openvibers/openvibe.media Commits

JSON times carry their zone (ISO 8601 Z) on the API edge (#425)

OP OpenVibers committed 1 days ago · f9d7305511 · 3 files, +110 −0

Changes

ddiff --git a/server/index.js b/server/index.js
iindex 61c71dc..b02a93e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -73,6 +73,8 @@ const ready = (async () => {
}
const app = express();
++ // Times leave with their zone (openvibe.design rule 148): the SQLite-era text columns are UTC.
++ app.use(require('./iso-times').isoJsonTimes);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
ddiff --git a/server/iso-times.js b/server/iso-times.js
nnew file mode 100644
iindex 0000000..9c16da7
---- /dev/null
++++ b/server/iso-times.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Times leave with their zone (openvibe.design rule 148).
++ *
++ * The tables created from SQLite (migrations/0001_initial.sql) keep timestamps as text in the
++ * SQLite shape `YYYY-MM-DD HH:MM:SS`, which ov_now() writes in UTC. A browser reads that string as
++ * local time, so west of UTC every event looked hours in the future. This is the edge: JSON fields
++ * whose name says they are a time are rendered as ISO 8601 with Z, and nothing else changes — the
++ * 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.
++ */
++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, …
++// and the bare `created` / `updated` / `started` / `ended` / `expires` some responses use.
++const TIME_KEY = /(^|_)(at|created|updated|started|ended|expires|timestamp)$|^last_|^(since|until)$/;
++
++/** One stored time as ISO 8601 with Z; anything not in the SQLite shape is returned untouched. */
++function isoTime(value) {
++ return typeof value === 'string' && SQL_TEXT_TIME.test(value) ? `${value.replace(' ', 'T')}Z` : value;
++}
++
++/** Copy `value`, rendering time-named fields; arrays and plain objects are walked, a Date is not. */
++function normalizeTimes(value, depth = 0) {
++ if (depth > 8 || value === null || typeof value !== 'object') return value;
++ if (Array.isArray(value)) return value.map((item) => normalizeTimes(item, depth + 1));
++ const proto = Object.getPrototypeOf(value);
++ if (proto !== Object.prototype && proto !== null) return value;
++ const out = {};
++ for (const [key, item] of Object.entries(value)) {
++ out[key] = TIME_KEY.test(key) ? isoTime(item) : normalizeTimes(item, depth + 1);
++ }
++ return out;
++}
++
++/** Express middleware: JSON answers carry ISO times. Internal service routes keep their SQL shape. */
++function isoJsonTimes(req, res, next) {
++ if (req.path.startsWith('/internal/')) return next();
++ const json = res.json.bind(res);
++ res.json = (body) => json(normalizeTimes(body));
++ next();
++}
++
++module.exports = { TIME_KEY, SQL_TEXT_TIME, isoTime, normalizeTimes, isoJsonTimes };
ddiff --git a/test/iso-times.test.js b/test/iso-times.test.js
nnew file mode 100644
iindex 0000000..79da4e6
---- /dev/null
++++ b/test/iso-times.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * Times leave with their zone (openvibe.design rule 148): Media's SQLite-era text timestamps are UTC
++ * but a browser reads `YYYY-MM-DD HH:MM:SS` as local time. The JSON edge renders time fields as ISO
++ * 8601 with Z; stored text, cursors and every query that compares it stay unchanged.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { isoTime, normalizeTimes, isoJsonTimes } = require('../server/iso-times');
++
++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+02:00'), '2026-11-12T05:01:02+02:00', 'ISO with an offset is left alone');
++assert.strictEqual(isoTime('2026-11-12'), '2026-11-12', 'a date is not a time');
++assert.strictEqual(isoTime(null), null);
++assert.deepStrictEqual(
++ normalizeTimes({ id: 1, created_at: '2026-11-12 05:01:02', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12 05:01:02' }, tags: [{ expires_at: '2026-11-12 05:01:02' }] }),
++ { id: 1, created_at: '2026-11-12T05:01:02Z', updated_at: null, when: '2026-11-12 05:01:02', meta: { last_seen: '2026-11-12T05:01:02Z' }, tags: [{ expires_at: '2026-11-12T05:01:02Z' }] },
++ 'time-named fields are rendered; body text that looks like a time is not',
++);
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-iso-times-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ Object.assign(process.env, {
++ VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ OBJECTS_PATH: dir('objects'), THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'),
++ MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'https://openvibe.network',
++ });
++ const db = require('../server/db/database');
++ const express = require('express');
++ await db.upsertApp({ app_id: 'games', api_key: 'games-key' });
++ const raw = db.getDb();
++ await raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, file_size, is_public, visibility, duration_seconds, is_recording, storage_provider, health_status, created_at) OVERRIDING SYSTEM VALUE
++ VALUES (91, 'games', 'Zoned', ?, 64, 1, 'public', 60, 0, 'local', 'ok', '2026-10-01 10:00:00')`).run(path.join(process.env.VOD_PATH, 'zoned.webm'));
++
++ const app = express();
++ app.use(express.json());
++ app.use(isoJsonTimes);
++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const get = (p) => new Promise((resolve, reject) => {
++ http.get({ host: '127.0.0.1', port: server.address().port, path: p, headers: { authorization: 'Bearer games-key' } }, (res) => {
++ let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, body, json: () => JSON.parse(body) }));
++ }).on('error', reject);
++ });
++
++ const r = await get('/api/v1/games/vods/91');
++ assert.strictEqual(r.status, 200, r.body);
++ assert.strictEqual(r.json().created_at, '2026-10-01T10:00:00Z', 'the VOD created_at is ISO with Z');
++ assert.match(r.json().created_at, /Z$/);
++ const stored = await raw.prepare('SELECT created_at FROM vods WHERE id = 91').get();
++ assert.strictEqual(stored.created_at, '2026-10-01 10:00:00', 'the stored text is untouched');
++
++ await new Promise((resolve) => server.close(resolve));
++ console.log('iso times: helper and GET /api/v1/games/vods/:id checked');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });