openvibers/openvibe.sdk Commits

v0.21.1: the PostgreSQL outbox publishes in write order; migration tools and lessons from the Media pass.

OP OpenVibers committed 28 Sept 2026 · faa355062e · 12 files, +91 −23

createPgOutbox's relay sorts the rows it claimed by id before publishing (UPDATE … RETURNING has no order, so a batch
could reach Events out of order); a test writes a dozen and checks the order. Tools: the schema converter turns any
SQLite row trigger (BEFORE/AFTER INSERT/UPDATE/DELETE, WHEN, a body of statements) into PL/pgSQL; SQLITE_DATE_FUNCTIONS
gains ov_now_iso(modifier); sqlfix.py rewrites date()/strftime with a modifier, quotes camelCase aliases after a
lowercase 'as', never quotes a named parameter (@user), and reports COALESCE(?, …); asyncify.js awaits x.tx() on any
handle, resolves lazy accessors (function objects() { return require(…) }) and holders assigned from them, and never
stacks a second await. docs/migrating-to-postgresql.md §7 gains the Media lessons.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0b95984..51d8faa 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,10 @@ release may change an API and says so here.
++## 0.21.1 (2026-09-28)
++
++- `openvibe-sdk/events` `createPgOutbox`: the relay publishes the rows it claimed in the order they were written. `UPDATE … RETURNING` gives no order, so a batch could reach Events out of order (a `media.clip.failed` before the `media.vod.ready` written before it). Found converting OpenVibe.Media.
++
## 0.21.0 (2026-09-28)
- `openvibe-sdk/auth`: `createPgRevocationStore(db, { table })` keeps Network's per-person token cutoffs on PostgreSQL. `await load()` reads them into memory at boot, so `isRevoked()` and `cutoffFor()` stay synchronous on the request path; `apply()` and `record()` are async and write through before memory moves, never backwards (a later cutoff stored by another process wins). `revocationSchema(table)` is its table, for the service's migration (the SQLite store created its table at first use, which a PostgreSQL runtime role cannot do).
ddiff --git a/STATUS.json b/STATUS.json
iindex 89ee394..e9d92df 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.21.0",
++ "version": "0.21.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 303f0dc..e1052a2 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.21.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.21.1, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.21.0';
++const SDK_VERSION = '0.21.1';
module.exports = {
createClient,
ddiff --git a/docs/migrating-to-postgresql.md b/docs/migrating-to-postgresql.md
iindex ee91edc..d41d1b6 100644
---- a/docs/migrating-to-postgresql.md
++++ b/docs/migrating-to-postgresql.md
@@@ -177,3 +177,15 @@ It changes nothing in the SQLite file.
- **Event handlers that became async** (a WebSocket dispatch) can overtake each other: queue them in arrival order.
- **Fire-and-forget helpers** (`tell(fn) { try { fn(x) } catch {} }`) catch nothing once `fn` is async: await it.
- **Tests**: `const x = createFoo(…)` instances, `require('./x').fn()` calls and a test's own `rejects(promise)` helper are handled by the codemod now; a boot helper that re-requires the server gets its database passed in (`createApp({ db })`); SQLite-only upgrade tests (a `migrate()` rebuilding an old table) go, the migration ledger replaces them.
++- **Media (2026-09-28)** added these:
++ - **SQLite functions the code calls**: `json_valid`, `json_extract`, `json_type`, `instr`, `date('now', …)`, `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', …)`: the migration defines SQLite-compatible `json_valid`/`json_extract`/`json_type`/`instr`, `ov_now_iso(modifier)`, and `sqlfix.py` rewrites `date(…)` to `substr(datetime(…), 1, 10)`.
++ - **`SUM()` over bigint is `numeric`**, and the SDK returns numeric as exact text on purpose (money). Cast byte and count sums (`::bigint`); leave double-precision sums (durations) alone.
++ - **Integers as booleans**: `WHERE 0`, `CASE WHEN is_public THEN`: write `FALSE`, `is_public = 1`.
++ - **Your own `BEGIN`/`COMMIT` through `db.exec`** runs on whichever pooled connection answers: use `db.tx` (a nested `tx` is a savepoint; throw a marker to roll a dry run back).
++ - **A generator that awaits** becomes `async function*`: its loops need `for await`.
++ - **Callbacks a helper takes in an object** (`announce(app, event, { change, payload })`, `onRow`, `alsoInTx`) are awaited by hand; so are `x.start()` calls whose `start` became async (a fire-and-forget one needs `.catch`, or the rejection ends the process).
++ - **Lazy accessors** (`function objects() { return require('../objects/model'); }`, `const m = objectsModel()`), inline `require('./x').fn()`, factory instances and `db` passed as a parameter (config `fileApis`) are resolved by the codemod now.
++ - **Stores whose creation became async** (the Shared config store) get an `init()` at boot and keep a synchronous `get()`; anything they must answer synchronously (explicit keys) is cached on activation.
++ - **The SQLite outbox refused to enqueue outside a transaction**; the PostgreSQL one takes any handle. Keep the guard in the service (`db.inTransaction()`), or an event can be written after the change committed.
++ - **Tests**: one migrated database per test process through `node --import` (a preload), a script runner that runs operator scripts in the test process (a child process cannot reach an in-process PGlite), identities started at 100000 so tests' explicit small ids never collide, fault injection in JavaScript instead of `TEMP TRIGGER`s (the runtime role has no DDL under `test:pg`), and SQLite upgrade tests removed.
++ - **Read-only reports** run in a `SET TRANSACTION READ ONLY` transaction instead of opening a read-only SQLite file.
ddiff --git a/package-lock.json b/package-lock.json
iindex cca4165..03b4f16 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.21.0",
++ "version": "0.21.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.21.0",
++ "version": "0.21.1",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 64dc02b..d100fb6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.21.0",
++ "version": "0.21.1",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex d91a7f4..110ecc0 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.21.0';
++const SDK_VERSION = '0.21.1';
module.exports = {
createClient,
ddiff --git a/src/outbox.js b/src/outbox.js
iindex 8609310..5cc7255 100644
---- a/src/outbox.js
++++ b/src/outbox.js
@@@ -277,10 +277,12 @@ function createPgOutbox(db, {
async function claim() {
const t = now();
-- return db.many(`UPDATE ${table} SET next_attempt_at = $1
++ const rows = await db.many(`UPDATE ${table} SET next_attempt_at = $1
WHERE id IN (SELECT id FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= $2
ORDER BY id LIMIT $3 FOR UPDATE SKIP LOCKED)
RETURNING id, event_id, envelope, traceparent, attempts`, [t + leaseMs, t, batchSize]);
++ // UPDATE … RETURNING has no order: publish in the order the rows were written.
++ return rows.sort((a, b) => Number(a.id) - Number(b.id));
}
async function markFailure(row, err) {
ddiff --git a/test/pg-outbox.test.js b/test/pg-outbox.test.js
iindex 0ffcc6f..104fe68 100644
---- a/test/pg-outbox.test.js
++++ b/test/pg-outbox.test.js
@@@ -50,6 +50,14 @@ function cases(label, open) {
await outbox.flush();
assert.equal(platform.state.events.length, 1, 'nothing republished');
assert.ok((await db.value(sql`SELECT seq FROM event_outbox WHERE event_id = ${env.event_id}`)) >= 1, 'the seq Events assigned is kept');
++ // Published in the order written (UPDATE … RETURNING alone has no order).
++ const written = [];
++ for (let i = 0; i < 12; i++) {
++ written.push((await db.tx(async (t) => outbox.enqueue(t, { event_type: 'live.stream.started', actor, subject, payload: { stream_id: 100 + i } }))).event_id);
++ }
++ await db.exec(sql`UPDATE event_outbox SET attempts = attempts WHERE event_id = ${written[3]}`); // a later row version for one of them
++ await outbox.flush();
++ assert.deepEqual(platform.state.events.slice(1).map((e) => e.event.event_id), written);
} finally { await done(); }
}],
[`${label}: transient failures back off; a permanent refusal rejects only its row`, async () => {
ddiff --git a/tools/asyncify/asyncify.js b/tools/asyncify/asyncify.js
iindex 6006cba..fde04a4 100644
---- a/tools/asyncify/asyncify.js
++++ b/tools/asyncify/asyncify.js
@@@ -153,6 +153,30 @@ function analyse(file, src, cfg, asyncNames) {
});
});
++ // Lazy accessors: function objects() { return require('../objects/model'); } / const model = () => require('./model').
++ const lazyAccessors = new Map();
++ {
++ const resolveRel = (lit) => {
++ const base = path.relative(process.cwd(), path.resolve(path.dirname(file), lit));
++ return base.endsWith('.js') ? base : fs.existsSync(`${base}.js`) ? `${base}.js` : path.join(base, 'index.js');
++ };
++ const reqOf = (n) => (n && n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require' && n.arguments[0]
++ && typeof n.arguments[0].value === 'string' && n.arguments[0].value.startsWith('.') ? resolveRel(n.arguments[0].value) : null);
++ const bodyReq = (f) => {
++ if (!f || f.params.length) return null;
++ if (f.body.type !== 'BlockStatement') return reqOf(f.body);
++ const st = f.body.body;
++ return st.length === 1 && st[0].type === 'ReturnStatement' ? reqOf(st[0].argument) : null;
++ };
++ for (const [name, f] of localFns) { const t = bodyReq(f); if (t && fs.existsSync(t)) lazyAccessors.set(name, t); }
++ // const m = objectsModel(): m is that module's API here.
++ walk.full(ast, (n) => {
++ if (n.type !== 'VariableDeclarator' || n.id.type !== 'Identifier' || !n.init || n.init.type !== 'CallExpression') return;
++ const c = n.init;
++ if (c.callee.type === 'Identifier' && lazyAccessors.has(c.callee.name) && !c.arguments.length) apis[n.id.name] = lazyAccessors.get(c.callee.name);
++ });
++ }
++
// Local functions that await a parameter themselves: rejects(p, …) → 'rejects:0'.
const promiseTakers = new Set();
for (const [name, f] of localFns) {
@@@ -173,7 +197,14 @@ function analyse(file, src, cfg, asyncNames) {
if (p.length >= 2 && (p[p.length - 2] === 'q' || p[p.length - 2] === 'stmts' || p[p.length - 2] === 'Q')) return true;
if (p.length && stmtNames.has(p[p.length - 1])) return true;
}
-- if ((m === 'tx' || m === 'transaction') && p.length && ['store', 'db', 'this'].includes(p[p.length - 1])) return m === 'tx';
++ // x.tx(fn): openvibe-sdk/db's transaction on any handle (db, store.db, db.getDb(), a destructured one).
++ if (m === 'tx' && !syncNames.has('tx')) return true;
++ if (m === 'transaction' && p.length && ['store', 'db', 'this'].includes(p[p.length - 1])) return false;
++ // objects().safeSync(: a local lazy accessor (function objects() { return require('../objects/model'); }).
++ if (obj.type === 'CallExpression' && obj.callee.type === 'Identifier' && lazyAccessors.has(obj.callee.name) && obj.arguments.length === 0) {
++ const set = asyncNames.api.get(lazyAccessors.get(obj.callee.name));
++ if (set && set.has(m) && !syncNames.has(m)) return true;
++ }
// require('./events').pasteCreated(: a module's function reached through an inline require.
if (obj.type === 'CallExpression' && obj.callee.type === 'Identifier' && obj.callee.name === 'require' && obj.arguments[0] && typeof obj.arguments[0].value === 'string' && obj.arguments[0].value.startsWith('.')) {
const base = path.relative(process.cwd(), path.resolve(path.dirname(file), obj.arguments[0].value));
ddiff --git a/tools/asyncify/sqlfix.py b/tools/asyncify/sqlfix.py
iindex ddef00a..e0518e7 100644
---- a/tools/asyncify/sqlfix.py
++++ b/tools/asyncify/sqlfix.py
@@@ -36,6 +36,7 @@ REPORT = [
(r'\b(db|this\.db)\.exec\(', 'DDL at boot on the serving handle: the serving role cannot create tables; the table belongs in a migration'),
(r'\b(SUM|AVG)\((?![^)]*\bCASE\b)[^)]*\)(?!\s*::)(?!, *0\)::)', 'SUM/AVG of a bigint column is numeric, returned as text: cast it (COALESCE(SUM(x), 0)::bigint) unless the column is double precision'),
(r'(@\w+|[^\w]\?) IS (NOT )?NULL(?!::)', 'a parameter tested with IS NULL may need a cast (@p::bigint IS NULL) when PostgreSQL cannot infer its type'),
++ (r'COALESCE\(\?,', 'COALESCE(?, col): cast the parameter (?::text) when PostgreSQL cannot infer its type from the other arguments'),
(r'UNIQUE constraint|constraint failed|SQLITE_[A-Z]', "SQLite error text → err.code ('23505' unique, '23503' foreign key, '23514' check) with err.table/err.constraint/err.detail"),
]
changed = []
@@@ -62,6 +63,9 @@ for p in files:
if SQLITE_DATES:
s = re.sub(r'\bCURRENT_TIMESTAMP\b', 'ov_now()', s)
s = re.sub(r"strftime\('%Y-%m-%dT%H:%M:%fZ', ?'now'\)", 'ov_now_iso()', s)
++ s = re.sub(r"strftime\('%Y-%m-%dT%H:%M:%fZ', ?'now', ?('[^']*'|\?)\)", r'ov_now_iso(\1)', s)
++ s = re.sub(r"\bdate\('now', ?('[^']*'|\?)\)", r"substr(datetime('now', \1), 1, 10)", s)
++ s = re.sub(r"(?<![\w.])date\(([\w.]+)\)(?= AS | >| <| =|,|\))", r'substr(datetime(\1), 1, 10)', s)
while 'INSERT OR IGNORE INTO' in s:
i = s.index('INSERT OR IGNORE INTO'); q = s[i - 1] if s[i - 1] in '\'"`' else '`'
j = s.index(q, i)
@@@ -100,14 +104,14 @@ for p in files:
body = lit.group(0)
if not re.search(r'\bSELECT\b', body): return body
q = '\\"' if body[0] == '"' else '"'
-- return re.sub(r'\bAS ([a-z]+[A-Z]\w*)\b', lambda m: f'AS {q}{m.group(1)}{q}', body)
++ return re.sub(r'\b(AS|as) ([a-z]+[A-Z]\w*)\b', lambda m: f'{m.group(1)} {q}{m.group(2)}{q}', body)
s = re.sub(r'`[^`]*`|\'(?:[^\'\\\n]|\\.)*\'|"(?:[^"\\\n]|\\.)*"', quote_aliases, s)
# Column names PostgreSQL reserves (SQLite does not): quoted inside SQL string literals ("window", "user").
def quote_reserved(lit):
body = lit.group(0)
if not re.search(r'\b(SELECT|INSERT|UPDATE|DELETE|CREATE)\b', body): return body
q = '\\"' if body[0] == '"' else '"' # inside a double-quoted JS string the quotes are escaped
-- return re.sub(r"(?<![.\w\"$\\'])(window|user)(?![\w\"(\\'])", lambda m: q + m.group(1) + q, body)
++ return re.sub(r"(?<![.\w\"$\\'@:])(window|user)(?![\w\"(\\'])", lambda m: q + m.group(1) + q, body)
s = re.sub(r'`[^`]*`|\'(?:[^\'\\\n]|\\.)*\'|"(?:[^"\\\n]|\\.)*"', quote_reserved, s)
# better-sqlite3's db.inTransaction was a property; openvibe-sdk/db's is a function.
s = re.sub(r'\b(db|this\.db|store\.db)\.inTransaction\b(?!\s*\()', r'\1.inTransaction()', s)
@@@ -147,6 +151,9 @@ for p in files:
s = re.sub(r"(SELECT name FROM sqlite_master WHERE type (?:= 'table'|IN \('table', ?'view'\))) AND name NOT (?:I)?LIKE 'sqlite_%'", r"\1", s)
s = re.sub(r"SELECT name FROM sqlite_master WHERE type (?:= 'table'|IN \('table', ?'view'\))",
"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()", s)
++ # SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? / 'x' → the table in the current schema
++ s = re.sub(r"SELECT 1( AS \w+)? FROM sqlite_master WHERE type = 'table' AND name = (\?|'\w+')",
++ r"SELECT 1\1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = \2", s)
s = re.sub(r"SELECT name FROM pragma_table_info\('(\w+)'\)",
r"SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = '\1' ORDER BY ordinal_position", s)
if s != o: open(p, 'w').write(s); changed.append(p)
ddiff --git a/tools/asyncify/sqlite-schema-to-pg.js b/tools/asyncify/sqlite-schema-to-pg.js
iindex d7aa1c3..ad5ebf2 100644
---- a/tools/asyncify/sqlite-schema-to-pg.js
++++ b/tools/asyncify/sqlite-schema-to-pg.js
@@@ -14,18 +14,21 @@
* const { toPg } = require('openvibe-sdk/tools/asyncify/sqlite-schema-to-pg'); (from a checkout)
*/
function toPg(sql) {
-- // The one trigger shape services use: a row that must never change (BEFORE UPDATE/DELETE … RAISE(ABORT, msg)).
-- // The condition (SQLite's WHEN, which may hold a subquery PostgreSQL does not allow in a trigger's WHEN) goes inside
-- // the function: IF cond THEN RAISE; the row passes otherwise (RETURN NEW for an update, OLD for a delete).
-- sql = sql.replace(/CREATE TRIGGER(?: IF NOT EXISTS)?\s+(\w+)\s+BEFORE\s+(UPDATE(?:\s+OF\s+[\w\s,]+?)?|DELETE)\s+ON\s+(\w+)(?:\s+WHEN\s+([\s\S]+?))?\s+BEGIN SELECT RAISE\(ABORT, '([^']*)'\); END;/g,
-- (m, name, op, table, when, msg) => {
-- const cond = when ? when.replace(/\s+/g, ' ').replace(/ IS NOT (OLD|NEW)\./g, ' IS DISTINCT FROM $1.') : 'TRUE';
-- const row = /^DELETE/.test(op) ? 'OLD' : 'NEW';
-- return `CREATE FUNCTION ${name}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF ${cond} THEN RAISE EXCEPTION '${msg}'; END IF; RETURN ${row}; END $$;\n`
-- + `CREATE TRIGGER ${name} BEFORE ${op.replace(/\s+/g, ' ')} ON ${table} FOR EACH ROW EXECUTE FUNCTION ${name}();`;
++ // A SQLite row trigger → a PL/pgSQL function and a trigger. Its WHEN condition (which may hold a subquery, not
++ // allowed in a PostgreSQL trigger's WHEN) goes inside the function as IF cond THEN body END IF; RAISE(ABORT, msg)
++ // becomes RAISE EXCEPTION; a BEFORE trigger lets the row pass (NEW, or OLD for a delete), an AFTER one returns NULL.
++ const cond = (c) => c.replace(/\s+/g, ' ').trim().replace(/ IS NOT (OLD|NEW)\./g, ' IS DISTINCT FROM $1.').replace(/(\b(?:OLD|NEW)\.\w+) IS (OLD|NEW)\./g, '$1 IS NOT DISTINCT FROM $2.');
++ sql = sql.replace(/CREATE TRIGGER(?: IF NOT EXISTS)?\s+(\w+)\s+(BEFORE|AFTER)\s+(INSERT|UPDATE(?:\s+OF\s+[\w\s,]+?)?|DELETE)\s+ON\s+(\w+)(?:\s+FOR EACH ROW)?(?:\s+WHEN\s+([\s\S]+?))?\s+BEGIN\s+([\s\S]+?)\s*END;/g,
++ (m, name, when, op, table, whenCond, body) => {
++ const stmts = body.replace(/SELECT RAISE\(ABORT, '([^']*)'\);?/g, "RAISE EXCEPTION '$1';")
++ .replace(/CURRENT_TIMESTAMP/g, 'ov_now()').trim().replace(/;?$/, ';');
++ const row = when === 'AFTER' ? 'NULL' : /^DELETE/.test(op) ? 'OLD' : 'NEW';
++ const inner = whenCond ? `IF ${cond(whenCond)} THEN ${stmts} END IF;` : stmts;
++ return `CREATE FUNCTION ${name}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN ${inner.replace(/\s+/g, ' ')} RETURN ${row}; END $$;\n`
++ + `CREATE TRIGGER ${name} ${when} ${op.replace(/\s+/g, ' ')} ON ${table} FOR EACH ROW EXECUTE FUNCTION ${name}();`;
});
-- if (sql.split(';').some((st) => /CREATE\s+TRIGGER/i.test(st) && !/FOR EACH ROW[\s\S]*EXECUTE FUNCTION/.test(st))) {
-- throw new Error('sqlite-schema-to-pg: a trigger other than the immutable-row shape needs writing as PL/pgSQL by hand');
++ if (sql.split(';').some((st) => /CREATE\s+TRIGGER/i.test(st) && !/FOR EACH ROW[\s\S]*EXECUTE FUNCTION/.test(st) && !/\$\$/.test(st))) {
++ throw new Error('sqlite-schema-to-pg: a trigger this converter does not read needs writing as PL/pgSQL by hand');
}
// COLLATE NOCASE: in an index, the index is on lower(col) (queries compare lower(col) too: sqlfix.py); on a column
// definition it goes (a case-insensitive UNIQUE there needs a lower() unique index, written by hand).
@@@ -58,7 +61,7 @@ function toPg(sql) {
/**
* SQLite's text timestamps and date functions on PostgreSQL, for a service whose columns hold 'YYYY-MM-DD HH:MM:SS'
* (UTC) text: the stored values, their comparisons and what the API returns stay exactly as they were. ov_now() is
-- * CURRENT_TIMESTAMP's text (per statement, as SQLite), ov_now_iso() strftime('%Y-%m-%dT%H:%M:%fZ', 'now');
++ * CURRENT_TIMESTAMP's text (per statement, as SQLite), ov_now_iso([modifier]) strftime('%Y-%m-%dT%H:%M:%fZ', 'now'[, modifier]);
* datetime(t [, modifier]) and julianday(t) read 'now', SQLite text and ISO 8601 (a zone, or none for UTC) and give
* NULL for what they cannot read, as SQLite does. A modifier is an interval ('-7 days', '+1 hour').
*/
@@@ -73,6 +76,7 @@ EXCEPTION WHEN others THEN RETURN NULL;
END $$;
CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;
CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
++CREATE FUNCTION ov_now_iso(modifier text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char((statement_timestamp() AT TIME ZONE 'UTC') + modifier::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$
DECLARE ts timestamp := ov_ts(t);
BEGIN