openvibers/openvibe.chat Commits
Chat history: an ISO ?before= cursor pages correctly (#544)
Changes
@@@ -591,7 +591,9 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {const streamId = positiveInt(req.params.streamId);if (streamId === null) return res.status(400).json({ error: 'Invalid stream ID' });const limit = Math.min(parseInt(req.query.limit || '500'), 500);-- const before = req.query.before; // ISO timestamp for pagination++ // A time cursor: the pages give times as ISO (#542) and the column keeps 'YYYY-MM-DD HH:MM:SS' UTC text.++ const before = req.query.before != null && req.query.before !== '' ? require('../iso-times').storedTime(String(req.query.before)) : null;++ if (req.query.before != null && req.query.before !== '' && !before) return res.status(400).json({ error: 'before is a time, like 2026-11-12T10:00:00Z' });// Resolve which broadcaster this stream belongs to.const stream = await ctx.ensureStream(streamId);@@@ -9,8 +9,8 @@* stored text, cursors built from it and every query that compares it stay exactly as they are.** Already-ISO values (with Z or an offset), dates (`YYYY-MM-DD`) and non-strings pass through.-- * `POST /api/v1/...` cursors echo their own stored text, not one of these fields, so pagination is-- * unaffected.++ * `POST /api/v1/...` cursors echo their own stored text, not one of these fields. A time cursor a page++ * documents as ISO (`/api/chat/history/:streamId?before=`) goes back through storedTime() first.*/const SQL_TEXT_TIME = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/;// created_at, updated_at, started_at, ended_at, expires_at, last_message_at, timestamp, …@@@ -43,4 +43,17 @@ function isoJsonTimes(req, res, next) {next();}--module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes };++/**++ * The other way: a time a client sends back (an ISO `?before=` echoed from a page) in the stored shape, so a text++ * comparison against the column orders correctly. ISO puts a 'T' where the stored text has a space, and 'T' sorts after++ * every digit and the space, so an ISO cursor compared raw let through every row of that same day. Null when it is not++ * a time.++ */++function storedTime(value) {++ if (typeof value !== 'string' || !value || value.length > 40) return null;++ const v = value.trim();++ const t = Date.parse(SQL_TEXT_TIME.test(v) ? `${v.replace(' ', 'T')}Z` : v);++ return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;++}++++module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes, storedTime };@@@ -7,7 +7,15 @@*/const assert = require('assert');const { boot, suite } = require('./helpers');--const { isoTime, normalizeTimes } = require('../server/iso-times');++const { isoTime, normalizeTimes, storedTime } = require('../server/iso-times');++++// A cursor sent back as ISO is compared in the stored shape (an ISO string sorts after every row of its own day).++assert.strictEqual(storedTime('2026-11-12T10:00:05.123Z'), '2026-11-12 10:00:05', 'ISO with Z → stored text');++assert.strictEqual(storedTime('2026-11-12T12:00:05+02:00'), '2026-11-12 10:00:05', 'an offset is moved to UTC');++assert.strictEqual(storedTime('2026-11-12 10:00:05'), '2026-11-12 10:00:05', 'the stored shape is read as UTC and kept');++assert.strictEqual(storedTime('not a time'), null, 'not a time');++assert.strictEqual(storedTime(''), null, 'empty');++assert.ok(storedTime('2026-11-12T10:00:05Z') < '2026-11-12 10:00:06' && storedTime('2026-11-12T10:00:05Z') > '2026-11-12 10:00:04', 'and it orders against the column');assert.strictEqual(isoTime('2026-11-12 05:01:02'), '2026-11-12T05:01:02Z');assert.strictEqual(isoTime('2026-11-12T05:01:02.123Z'), '2026-11-12T05:01:02.123Z', 'ISO with Z is left alone');
@@ -591,7 +591,9 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -591,7 +591,9 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
const streamId = positiveInt(req.params.streamId);
const streamId = positiveInt(req.params.streamId);
if (streamId === null) return res.status(400).json({ error: 'Invalid stream ID' });
if (streamId === null) return res.status(400).json({ error: 'Invalid stream ID' });
const limit = Math.min(parseInt(req.query.limit || '500'), 500);
const limit = Math.min(parseInt(req.query.limit || '500'), 500);
- const before = req.query.before; // ISO timestamp for pagination
+ // A time cursor: the pages give times as ISO (#542) and the column keeps 'YYYY-MM-DD HH:MM:SS' UTC text.
+ const before = req.query.before != null && req.query.before !== '' ? require('../iso-times').storedTime(String(req.query.before)) : null;
+ if (req.query.before != null && req.query.before !== '' && !before) return res.status(400).json({ error: 'before is a time, like 2026-11-12T10:00:00Z' });
// Resolve which broadcaster this stream belongs to.
// Resolve which broadcaster this stream belongs to.
const stream = await ctx.ensureStream(streamId);
const stream = await ctx.ensureStream(streamId);
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
* stored text, cursors built from it and every query that compares it stay exactly as they are.
* stored text, cursors built from it and every query that compares it stay exactly as they are.
*
*
* Already-ISO values (with Z or an offset), dates (`YYYY-MM-DD`) and non-strings pass through.
* Already-ISO values (with Z or an offset), dates (`YYYY-MM-DD`) and non-strings pass through.
- * `POST /api/v1/...` cursors echo their own stored text, not one of these fields, so pagination is
- * unaffected.
+ * `POST /api/v1/...` cursors echo their own stored text, not one of these fields. A time cursor a page
+ * documents as ISO (`/api/chat/history/:streamId?before=`) goes back through storedTime() first.
*/
*/
const SQL_TEXT_TIME = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/;
const SQL_TEXT_TIME = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/;
// created_at, updated_at, started_at, ended_at, expires_at, last_message_at, timestamp, …
// created_at, updated_at, started_at, ended_at, expires_at, last_message_at, timestamp, …
@@ -43,4 +43,17 @@ function isoJsonTimes(req, res, next) {
@@ -43,4 +43,17 @@ function isoJsonTimes(req, res, next) {
next();
next();
}
}
-module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes };
+/**
+ * The other way: a time a client sends back (an ISO `?before=` echoed from a page) in the stored shape, so a text
+ * comparison against the column orders correctly. ISO puts a 'T' where the stored text has a space, and 'T' sorts after
+ * every digit and the space, so an ISO cursor compared raw let through every row of that same day. Null when it is not
+ * a time.
+ */
+function storedTime(value) {
+ if (typeof value !== 'string' || !value || value.length > 40) return null;
+ const v = value.trim();
+ const t = Date.parse(SQL_TEXT_TIME.test(v) ? `${v.replace(' ', 'T')}Z` : v);
+ return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
+}
+
+module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes, storedTime };
@@ -7,7 +7,15 @@
@@ -7,7 +7,15 @@
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
-const { isoTime, normalizeTimes } = require('../server/iso-times');
+const { isoTime, normalizeTimes, storedTime } = require('../server/iso-times');
+
+// A cursor sent back as ISO is compared in the stored shape (an ISO string sorts after every row of its own day).
+assert.strictEqual(storedTime('2026-11-12T10:00:05.123Z'), '2026-11-12 10:00:05', 'ISO with Z → stored text');
+assert.strictEqual(storedTime('2026-11-12T12:00:05+02:00'), '2026-11-12 10:00:05', 'an offset is moved to UTC');
+assert.strictEqual(storedTime('2026-11-12 10:00:05'), '2026-11-12 10:00:05', 'the stored shape is read as UTC and kept');
+assert.strictEqual(storedTime('not a time'), null, 'not a time');
+assert.strictEqual(storedTime(''), null, 'empty');
+assert.ok(storedTime('2026-11-12T10:00:05Z') < '2026-11-12 10:00:06' && storedTime('2026-11-12T10:00:05Z') > '2026-11-12 10:00:04', 'and it orders against the column');
assert.strictEqual(isoTime('2026-11-12 05:01:02'), '2026-11-12T05:01:02Z');
assert.strictEqual(isoTime('2026-11-12 05:01:02'), '2026-11-12T05:01:02Z');
assert.strictEqual(isoTime('2026-11-12T05:01:02.123Z'), '2026-11-12T05:01:02.123Z', 'ISO with Z is left alone');
assert.strictEqual(isoTime('2026-11-12T05:01:02.123Z'), '2026-11-12T05:01:02.123Z', 'ISO with Z is left alone');