openvibers/openvibe.sdk Commits
v0.21.0: createPgRevocationStore (token cutoffs on PostgreSQL), Valkey without the INFO ready check, and migration tools for SQLite text timestamps.
Changes
@@@ -8,6 +8,11 @@ release may change an API and says so here.++## 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).++- `openvibe-sdk/valkey`: the client no longer runs iovalkey's ready check (`INFO`), which a service's ACL user may not call; every connect logged NOPERM. `ready()` still pings.++## 0.20.4 (2026-09-28)- `openvibe-sdk/db`: `bytea` comes back as a `Buffer` from both adapters. PGlite returned a `Uint8Array`, so `Buffer.isBuffer` and `.toString('hex')` differed between tests and production. Found with openvibe-shared's config store on PostgreSQL. `test/db-ambient.test.js`.@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.20.4",++ "version": "0.21.0","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",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.20.4, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.21.0, 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.20.4';++const SDK_VERSION = '0.21.0';module.exports = {createClient,@@@ -4,6 +4,7 @@ import cjs from '../src/auth/index.js';export const {createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,-- verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER,++ verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema,++ TOKEN_VALID_AFTER,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.20.4",++ "version": "0.21.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.20.4",++ "version": "0.21.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.20.4",++ "version": "0.21.0","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",@@@ -7,6 +7,6 @@ const browser = require('./browser');const { createServiceTokenClient } = require('./tokens');const { verifyUserToken, verifyAppToken } = require('./jwt');const { exchangeCode, refreshUserToken } = require('./oauth');--const { createRevocationStore, TOKEN_VALID_AFTER } = require('./revocations');++const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER };++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };@@@ -81,4 +81,67 @@ function createRevocationStore(db = null, { table = 'ov_token_revocations', nowreturn { apply, record, isRevoked, cutoffFor, EVENT_TYPE };}--module.exports = { createRevocationStore, TOKEN_VALID_AFTER: EVENT_TYPE };++/** The PostgreSQL table createPgRevocationStore uses, for the service's migration. */++function revocationSchema(table = 'ov_token_revocations') {++ if (!TABLE_RE.test(table)) throw new TypeError(`revocationSchema: bad table name ${table}`);++ return `CREATE TABLE IF NOT EXISTS ${table} (++ subject_id text COLLATE "C" PRIMARY KEY,++ valid_after_ms bigint NOT NULL,++ reason text,++ updated_at bigint NOT NULL++);`;++}++++/**++ * The same cutoffs on PostgreSQL (an openvibe-sdk/db handle; ADR-035). isRevoked() and cutoffFor() stay synchronous++ * (they run on every authenticated request): `await load()` reads every cutoff into memory at boot (people with a++ * cutoff are few), and apply()/record() write through, awaited, before the memory moves. One process per service:++ * a cutoff another process records reaches this one on its next load().++ *++ * const cutoffs = createPgRevocationStore(db, { table: 'token_revocations' }); // table in the migration: revocationSchema()++ * await cutoffs.load();++ * await cutoffs.apply(envelope) → 'revoked' | 'unchanged' | 'ignored:<why>'++ * cutoffs.isRevoked(claims) → boolean (memory)++ */++function createPgRevocationStore(db, { table = 'ov_token_revocations', now = () => Date.now() } = {}) {++ if (!TABLE_RE.test(table)) throw new TypeError(`createPgRevocationStore: bad table name ${table}`);++ if (!db || typeof db.prepare !== 'function') throw new TypeError('createPgRevocationStore: an openvibe-sdk/db handle is required');++ const cutoffs = new Map();++ let loaded = false;++ async function load() {++ const rows = await db.prepare(`SELECT subject_id, valid_after_ms FROM ${table}`).all();++ cutoffs.clear();++ for (const r of rows) cutoffs.set(r.subject_id, Number(r.valid_after_ms) || 0);++ loaded = true;++ return cutoffs.size;++ }++ const cutoffFor = (subject) => (subject ? cutoffs.get(subject) || 0 : 0);++ async function record(subject, validAfterMs, reason = null) {++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(validAfterMs)) return false;++ if (!(validAfterMs > cutoffFor(subject))) return false;++ const row = await db.prepare(`INSERT INTO ${table} (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at++ WHERE excluded.valid_after_ms > ${table}.valid_after_ms++ RETURNING valid_after_ms`).get(subject, validAfterMs, reason, now());++ // No row back: the stored cutoff was already later (another process); read it.++ const ms = row ? Number(row.valid_after_ms) : Number((await db.prepare(`SELECT valid_after_ms FROM ${table} WHERE subject_id = ?`).get(subject) || {}).valid_after_ms) || 0;++ if (ms > cutoffFor(subject)) cutoffs.set(subject, ms);++ return Boolean(row);++ }++ async function apply(event) {++ if (!event || event.event_type !== EVENT_TYPE) return 'ignored:type';++ if (event.source !== 'network') return 'ignored:source';++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};++ const subject = p.subject && p.subject.type === 'user' ? p.subject.id : null;++ const ms = Date.parse(p.valid_after);++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';++ return (await record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null)) ? 'revoked' : 'unchanged';++ }++ function isRevoked(claims) {++ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;++ return claims.iat * 1000 < cutoffFor(claims.subject_id);++ }++ return { load, apply, record, isRevoked, cutoffFor, loaded: () => loaded, EVENT_TYPE };++}++++module.exports = { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER: EVENT_TYPE };@@@ -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.20.4';++const SDK_VERSION = '0.21.0';module.exports = {createClient,@@@ -19,7 +19,8 @@ function createValkey({ url = process.env.VALKEY_URL, prefix = process.env.VALKEif (!client) {try { Valkey = require('iovalkey'); } catch { throw new Error('openvibe-sdk/valkey: install `iovalkey` in the service (npm i iovalkey)'); }}-- const opts = { maxRetriesPerRequest: 3, enableAutoPipelining: true, connectTimeout: 5000, lazyConnect };++ // enableReadyCheck off: a service's ACL user has no INFO (the check logged NOPERM on every connect); ready() pings.++ const opts = { maxRetriesPerRequest: 3, enableAutoPipelining: true, connectTimeout: 5000, lazyConnect, enableReadyCheck: false };const c = client || new Valkey(url, opts);let lastError = null;c.on('error', (err) => { if (!lastError || lastError.message !== err.message) log.warn(`[valkey] ${err.message}`); lastError = err; });@@@ -2,7 +2,7 @@// openvibe-sdk/auth createRevocationStore: network.user.token_valid_after cutoffs (Contracts 0.39.0).const assert = require('assert');const Database = require('better-sqlite3');--const { createRevocationStore, TOKEN_VALID_AFTER } = require('../src/auth');++const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('../src/auth');const A = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';const B = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4';@@@ -31,4 +31,29 @@ const mem = createRevocationStore();assert.strictEqual(mem.apply(ev(B, new Date(T).toISOString())), 'revoked');assert.strictEqual(mem.isRevoked({ subject_id: B, iat: T / 1000 - 5 }), true);assert.throws(() => createRevocationStore(db, { table: 'x; DROP TABLE y' }), /bad table name/);--console.log('revocations: all checks passed');++++// createPgRevocationStore: the same rules on PostgreSQL (PGlite), cutoffs read into memory by load().++(async () => {++ const { createDb } = require('../src/db');++ const pg = createDb({ pglite: true, service: 'sdk-test' });++ await pg.exec(revocationSchema('token_revocations'));++ const p = createPgRevocationStore(pg, { table: 'token_revocations', now: () => T });++ assert.strictEqual(await p.load(), 0);++ assert.strictEqual(await p.apply(ev(A, new Date(T).toISOString())), 'revoked');++ assert.strictEqual(p.isRevoked({ subject_id: A, iat: T / 1000 - 1 }), true);++ assert.strictEqual(p.isRevoked({ subject_id: A, iat: T / 1000 }), false);++ assert.strictEqual(await p.apply(ev(A, new Date(T - 60000).toISOString())), 'unchanged', 'never moves back');++ assert.strictEqual(await p.apply(ev(A, new Date(T + 1).toISOString(), { source: 'live' })), 'ignored:source');++ // Another process moved it further: record() refuses to go back and takes the stored value.++ await pg.prepare('UPDATE token_revocations SET valid_after_ms = ? WHERE subject_id = ?').run(T + 5000, A);++ assert.strictEqual(await p.record(A, T + 1000), false);++ assert.strictEqual(p.cutoffFor(A), T + 5000);++ // A restart reads what is stored.++ const again = createPgRevocationStore(pg, { table: 'token_revocations' });++ assert.strictEqual(again.cutoffFor(A), 0, 'nothing before load()');++ assert.strictEqual(await again.load(), 1);++ assert.strictEqual(again.cutoffFor(A), T + 5000);++ assert.throws(() => createPgRevocationStore(pg, { table: 'x; DROP TABLE y' }), /bad table name/);++ await pg.close();++ console.log('revocations: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -8,15 +8,22 @@* node gen-migration.js <Name> [--publishing revisions:blog_post,citations:blog_post,index-hooks:blog,seo:blog_entity,…]* [--no-inbox] [--outbox-table t] [--inbox-table t] [--no-outbox] [--ensure a.js,b.js]* [--from server/store.js] (the module holding SCHEMA; default server/db.js)++ * [--open "require('./server/db').openDb(':memory:')"]* → migrations/0001_initial.sql*++ * --open: the schema as the service's own boot code leaves a fresh SQLite database (SCHEMA, then every migrate() ALTER++ * and seed, then the --ensure modules run on it), for a service whose migrate() changed tables after SCHEMA. Run it++ * before converting any code (the working copy is the SQLite release). Tables come out in dependency order, one column++ * per line; the rows the boot seeded become INSERTs; SQLite text timestamps (DATETIME, CURRENT_TIMESTAMP) bring++ * SQLITE_DATE_FUNCTIONS along.++ ** It lists every `rowid` the server code still orders by: those tables need a `seq bigint GENERATED ALWAYS AS* IDENTITY UNIQUE` column (a person adds it; see docs/migrating-to-postgresql.md §7).*/const fs = require('fs');const path = require('path');const { execFileSync } = require('child_process');--const { toPg } = require('./sqlite-schema-to-pg');++const { toPg, SQLITE_DATE_FUNCTIONS } = require('./sqlite-schema-to-pg');const args = process.argv.slice(2);const Name = args[0];@@@ -25,13 +32,97 @@ const pi = args.indexOf('--publishing');const pubs = pi >= 0 ? args[pi + 1].split(',').map((x) => x.split(':')) : [];const req = (m) => require(require.resolve(m, { paths: [process.cwd()] }));++const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };++const ensureList = opt('--ensure', '') ? opt('--ensure', '').split(',') : [];++++/** A fresh SQLite database opened by the service's code → PostgreSQL DDL (tables in dependency order) and seed rows. */++function fromOpenedDb(expr) {++ const { createRequire } = require('module');++ const creq = createRequire(path.resolve('package.json'));++ const sdb = new Function('require', `return (${expr});`)(creq);++ for (const f of ensureList) creq(path.resolve(f)).ensureSchema(sdb);++ const stripComments = (sql) => {++ let o = ''; let q = null;++ for (let i = 0; i < sql.length; i++) {++ const c = sql[i];++ if (q) { o += c; if (c === q) q = null; continue; }++ if (c === "'" || c === '"') { q = c; o += c; continue; }++ if (c === '-' && sql[i + 1] === '-') { while (i < sql.length && sql[i] !== '\n') i++; o += '\n'; continue; }++ o += c;++ }++ return o;++ };++ const splitTop = (body) => {++ const parts = []; let depth = 0; let q = null; let cur = '';++ for (const c of body) {++ if (q) { cur += c; if (c === q) q = null; continue; }++ if (c === "'" || c === '"') { q = c; cur += c; continue; }++ if (c === '(') depth++;++ if (c === ')') depth--;++ if (c === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; continue; }++ cur += c;++ }++ if (cur.trim()) parts.push(cur.trim());++ return parts;++ };++ const objs = sdb.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY rowid").all();++ const tables = objs.filter((o) => o.type === 'table' && !/CREATE VIRTUAL/i.test(o.sql));++ const byName = new Map(tables.map((t) => [t.name, t]));++ const order = []; const done = new Set();++ const visit = (t, stack) => {++ if (done.has(t.name) || stack.has(t.name)) return;++ stack.add(t.name);++ for (const m of t.sql.matchAll(/REFERENCES\s+"?(\w+)"?/gi)) { const d = byName.get(m[1]); if (d && d.name !== t.name) visit(d, stack); }++ done.add(t.name); order.push(t);++ };++ for (const t of tables) visit(t, new Set());++ let ddl = '';++ const tsDefault = new Set(); const pk = new Map();++ for (const t of order) {++ const sql = stripComments(t.sql);++ const i = sql.indexOf('('); const j = sql.lastIndexOf(')');++ const cols = splitTop(sql.slice(i + 1, j)).map((x) => x.replace(/\s+/g, ' '));++ for (const c of cols) {++ const m = /^"?(\w+)"? /.exec(c);++ if (!m) continue;++ if (/DEFAULT (CURRENT_TIMESTAMP|\((datetime|strftime)\()/i.test(c)) tsDefault.add(`${t.name}.${m[1]}`);++ if (/^"?\w+"? INTEGER PRIMARY KEY/i.test(c)) pk.set(t.name, { col: m[1], always: /AUTOINCREMENT/i.test(c) });++ }++ const tail = sql.slice(j + 1).trim();++ ddl += `CREATE TABLE ${t.name} (\n ${cols.join(',\n ')}\n)${tail ? ` ${tail}` : ''};\n\n`;++ }++ for (const o of objs) if (o.type === 'index') ddl += `${stripComments(o.sql).replace(/\s+/g, ' ').trim()};\n`;++ for (const o of objs) if (o.type === 'trigger') ddl += `\n${o.sql.trim()};\n`;++ for (const o of objs) if (o.type === 'view') ddl += `\n${o.sql.trim()};\n`;++ let out = toPg(ddl);++ // Seed rows the boot wrote (spaces, categories, …); timestamp defaults fill themselves in.++ const lit = (v) => (v === null ? 'NULL' : typeof v === 'number' || typeof v === 'bigint' ? String(v)++ : Buffer.isBuffer(v) ? `'\\x${v.toString('hex')}'` : `'${String(v).replace(/'/g, "''")}'`);++ let seeds = '';++ for (const t of order) {++ const rows = sdb.prepare(`SELECT * FROM "${t.name}"`).all();++ if (!rows.length) continue;++ const cols = Object.keys(rows[0]).filter((c) => !tsDefault.has(`${t.name}.${c}`));++ const k = pk.get(t.name);++ const q = (c) => (/^(window|user)$/.test(c) ? `"${c}"` : c);++ seeds += `INSERT INTO ${t.name} (${cols.map(q).join(', ')})${k && k.always && cols.includes(k.col) ? ' OVERRIDING SYSTEM VALUE' : ''} VALUES\n`++ + `${rows.map((r) => ` (${cols.map((c) => lit(r[c])).join(', ')})`).join(',\n')};\n`;++ if (k && cols.includes(k.col)) seeds += `SELECT setval(pg_get_serial_sequence('${t.name}', '${k.col}'), (SELECT MAX(${k.col}) FROM ${t.name}));\n`;++ }++ if (seeds) out += `\n-- Rows the SQLite boot seeded\n${seeds}`;++ if (/ov_now\(\)|ov_now_iso\(\)/.test(out)) out = `${SQLITE_DATE_FUNCTIONS}\n${out}`;++ try { sdb.close(); } catch { /* in-memory */ }++ return out;++}++// The schema as the SQLite release ran it: server/db.js at HEAD (the conversion edits the working copy). A literal with// ${…} interpolations is evaluated by loading that file and reading its SCHEMA export.const dbFile = (() => { const i = args.indexOf('--from'); return i >= 0 ? args[i + 1] : 'server/db.js'; })();--const src = execFileSync('git', ['show', `HEAD:${dbFile}`], { encoding: 'utf8' });--const m = /const SCHEMA = `([\s\S]*?)`;/.exec(src);++const openExpr = opt('--open', null);++const src = openExpr ? '' : execFileSync('git', ['show', `HEAD:${dbFile}`], { encoding: 'utf8' });++const m = openExpr ? [null, ''] : /const SCHEMA = `([\s\S]*?)`;/.exec(src);if (!m) { console.error(`${dbFile} (HEAD) has no const SCHEMA = \`…\`;`); process.exit(1); }--if (m[1].includes('${')) {++if (!openExpr && m[1].includes('${')) {const tmp = path.resolve(path.dirname(dbFile), '.db-head-schema.js');fs.writeFileSync(tmp, src);try {@@@ -44,9 +135,9 @@ let out = `-- phase: expand-- OpenVibe.${Name} on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on ${new Date().toISOString().slice(0, 10)}; never edited after it runs.--${toPg(m[1]).replace(/\n{3,}/g, '\n\n')}`;++${(openExpr ? fromOpenedDb(openExpr) : toPg(m[1])).replace(/\n{3,}/g, '\n\n')}`;// --ensure a.js,b.js: modules whose ensureSchema(db) created more tables at boot; their SQL is captured and converted too.--{++if (!openExpr) {const i = args.indexOf('--ensure');if (i >= 0) {for (const f of args[i + 1].split(',')) {@@@ -58,7 +149,6 @@ ${toPg(m[1]).replace(/\n{3,}/g, '\n\n')}`;}}const SCHEMA_OF = { 'index-hooks': (x, p) => x.sequencerSchema(p), seo: (x, p) => x.redirectsSchema(p) };--const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };for (const [store, prefix] of pubs) {const mod = req(`openvibe-publishing/${store}`);out += `\n-- openvibe-publishing/${store} (prefix ${prefix})\n${(SCHEMA_OF[store] ? SCHEMA_OF[store](mod, prefix) : mod.schema(prefix)).trim()}\n`;@@@ -26,7 +26,7 @@ REPORT = [(r'SELECT DISTINCT', 'SELECT DISTINCT with ORDER BY on an expression not selected fails; a DISTINCT over a LEFT JOIN is usually WHERE … OR EXISTS (…)'),(r'GROUP BY', 'GROUP BY: every selected bare column must be grouped (SQLite took a bare column from the MAX/MIN row: DISTINCT ON)'),(r'WITH RECURSIVE', 'recursive seed: cast ? to the column type and collation (?::text COLLATE "C")'),-- (r"datetime\(|strftime\(|julianday\(", 'SQLite date functions'),++ (r"datetime\(|strftime\(|julianday\(", 'SQLite date functions (with SQLITE_DATE_FUNCTIONS in the migration, datetime(t[, modifier]) and julianday(t) exist; strftime does not)'),(r'GROUP_CONCAT|json_extract|json_group_array|json_object\(', 'SQLite functions'),(r'lastInsertRowid', 'lastInsertRowid → RETURNING'),(r'\.transaction\(', 'db.transaction(fn)() → db.tx(fn)'),@@@ -50,8 +50,14 @@ for mf in sorted(_glob.glob('migrations/*.sql')):TABLE_COLS.setdefault(tm.group(1), set()).update(cols)for am in re.finditer(r'ALTER TABLE (\w+) ADD COLUMN (\w+)', ddl):TABLE_COLS.setdefault(am.group(1), set()).add(am.group(2))++# A migration carrying SQLITE_DATE_FUNCTIONS (text timestamps kept as SQLite wrote them): CURRENT_TIMESTAMP in code++# becomes ov_now() (PostgreSQL's would be a timestamptz, cast to other text), strftime's ISO 'now' ov_now_iso().++SQLITE_DATES = any('CREATE FUNCTION ov_now()' in open(mf).read() for mf in _glob.glob('migrations/*.sql'))for p in files:s = open(p).read(); o = s++ 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)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)@@@ -45,7 +45,41 @@ function toPg(sql) {.replace(/^(\s*"?\w+"?\s+)TEXT\b/gm, '$1text COLLATE "C"').replace(/^(\s*"?\w+"?\s+)INTEGER\b/gm, '$1bigint').replace(/^(\s*"?\w+"?\s+)REAL\b/gm, '$1double precision')-- .replace(/^(\s*"?\w+"?\s+)BLOB\b/gm, '$1bytea');++ .replace(/^(\s*"?\w+"?\s+)BLOB\b/gm, '$1bytea')++ // SQLite text timestamps stay text in the same format; their defaults use SQLITE_DATE_FUNCTIONS below.++ .replace(/^(\s*"?\w+"?\s+)DATETIME\b/gm, '$1text COLLATE "C"')++ .replace(/DEFAULT CURRENT_TIMESTAMP\b/g, 'DEFAULT ov_now()')++ .replace(/DEFAULT \(datetime\('now'\)\)/g, 'DEFAULT ov_now()')++ .replace(/DEFAULT \(strftime\('%Y-%m-%dT%H:%M:%fZ', ?'now'\)\)/g, 'DEFAULT ov_now_iso()');}--module.exports = { toPg };++/**++ * 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');++ * 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').++ */++const SQLITE_DATE_FUNCTIONS = `-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).++CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$++BEGIN++ IF t IS NULL THEN RETURN NULL; END IF;++ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;++ IF t ~ '\\d\\d:\\d\\d(:\\d\\d(\\.\\d+)?)?\\s*(Z|[+-]\\d\\d(:?\\d\\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;++ RETURN t::timestamp;++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 datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$++DECLARE ts timestamp := ov_ts(t);++BEGIN++ IF ts IS NULL THEN RETURN NULL; END IF;++ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;++ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');++EXCEPTION WHEN others THEN RETURN NULL;++END $$;++CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;++`;++++module.exports = { toPg, SQLITE_DATE_FUNCTIONS };@@@ -133,4 +133,20 @@ export interface RevocationStore {export interface RevocationStoreOptions { table?: string; now?: () => number; maxCache?: number }/** A better-sqlite3 handle keeps cutoffs across restarts; without one they live in memory. */export declare function createRevocationStore(db?: unknown, opts?: RevocationStoreOptions): RevocationStore;++/** The same cutoffs on PostgreSQL: load() reads them into memory; apply()/record() write through (async). */++export interface PgRevocationStore {++ /** Read every stored cutoff into memory (at boot); resolves to how many. */++ load(): Promise<number>;++ apply(event: unknown): Promise<'revoked' | 'unchanged' | `ignored:${string}`>;++ record(subject: string, validAfterMs: number, reason?: string | null): Promise<boolean>;++ /** From memory: true when claims.iat (seconds) is before claims.subject_id's cutoff. */++ isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean;++ cutoffFor(subject: string): number;++ loaded(): boolean;++ readonly EVENT_TYPE: 'network.user.token_valid_after';++}++/** An openvibe-sdk/db handle; the table comes from revocationSchema() in the service's migration. */++export declare function createPgRevocationStore(db: unknown, opts?: { table?: string; now?: () => number }): PgRevocationStore;++/** CREATE TABLE IF NOT EXISTS for createPgRevocationStore (default table ov_token_revocations). */++export declare function revocationSchema(table?: string): string;export declare const TOKEN_VALID_AFTER: 'network.user.token_valid_after';
@@ -8,6 +8,11 @@ release may change an API and says so here.
@@ -8,6 +8,11 @@ release may change an API and says so here.
+## 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).
+- `openvibe-sdk/valkey`: the client no longer runs iovalkey's ready check (`INFO`), which a service's ACL user may not call; every connect logged NOPERM. `ready()` still pings.
+
## 0.20.4 (2026-09-28)
## 0.20.4 (2026-09-28)
- `openvibe-sdk/db`: `bytea` comes back as a `Buffer` from both adapters. PGlite returned a `Uint8Array`, so `Buffer.isBuffer` and `.toString('hex')` differed between tests and production. Found with openvibe-shared's config store on PostgreSQL. `test/db-ambient.test.js`.
- `openvibe-sdk/db`: `bytea` comes back as a `Buffer` from both adapters. PGlite returned a `Uint8Array`, so `Buffer.isBuffer` and `.toString('hex')` differed between tests and production. Found with openvibe-shared's config store on PostgreSQL. `test/db-ambient.test.js`.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.20.4",
+ "version": "0.21.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.20.4, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.21.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// 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.
// (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 */
/* eslint-disable */
@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.20.4';
+const SDK_VERSION = '0.21.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -4,6 +4,7 @@ import cjs from '../src/auth/index.js';
@@ -4,6 +4,7 @@ import cjs from '../src/auth/index.js';
export const {
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
- verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER,
+ verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema,
+ TOKEN_VALID_AFTER,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.20.4",
+ "version": "0.21.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.20.4",
+ "version": "0.21.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.20.4",
+ "version": "0.21.0",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -7,6 +7,6 @@ const browser = require('./browser');
@@ -7,6 +7,6 @@ const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { exchangeCode, refreshUserToken } = require('./oauth');
-const { createRevocationStore, TOKEN_VALID_AFTER } = require('./revocations');
+const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');
-module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER };
+module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
@@ -81,4 +81,67 @@ function createRevocationStore(db = null, { table = 'ov_token_revocations', now
@@ -81,4 +81,67 @@ function createRevocationStore(db = null, { table = 'ov_token_revocations', now
return { apply, record, isRevoked, cutoffFor, EVENT_TYPE };
return { apply, record, isRevoked, cutoffFor, EVENT_TYPE };
}
}
-module.exports = { createRevocationStore, TOKEN_VALID_AFTER: EVENT_TYPE };
+/** The PostgreSQL table createPgRevocationStore uses, for the service's migration. */
+function revocationSchema(table = 'ov_token_revocations') {
+ if (!TABLE_RE.test(table)) throw new TypeError(`revocationSchema: bad table name ${table}`);
+ return `CREATE TABLE IF NOT EXISTS ${table} (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ valid_after_ms bigint NOT NULL,
+ reason text,
+ updated_at bigint NOT NULL
+);`;
+}
+
+/**
+ * The same cutoffs on PostgreSQL (an openvibe-sdk/db handle; ADR-035). isRevoked() and cutoffFor() stay synchronous
+ * (they run on every authenticated request): `await load()` reads every cutoff into memory at boot (people with a
+ * cutoff are few), and apply()/record() write through, awaited, before the memory moves. One process per service:
+ * a cutoff another process records reaches this one on its next load().
+ *
+ * const cutoffs = createPgRevocationStore(db, { table: 'token_revocations' }); // table in the migration: revocationSchema()
+ * await cutoffs.load();
+ * await cutoffs.apply(envelope) → 'revoked' | 'unchanged' | 'ignored:<why>'
+ * cutoffs.isRevoked(claims) → boolean (memory)
+ */
+function createPgRevocationStore(db, { table = 'ov_token_revocations', now = () => Date.now() } = {}) {
+ if (!TABLE_RE.test(table)) throw new TypeError(`createPgRevocationStore: bad table name ${table}`);
+ if (!db || typeof db.prepare !== 'function') throw new TypeError('createPgRevocationStore: an openvibe-sdk/db handle is required');
+ const cutoffs = new Map();
+ let loaded = false;
+ async function load() {
+ const rows = await db.prepare(`SELECT subject_id, valid_after_ms FROM ${table}`).all();
+ cutoffs.clear();
+ for (const r of rows) cutoffs.set(r.subject_id, Number(r.valid_after_ms) || 0);
+ loaded = true;
+ return cutoffs.size;
+ }
+ const cutoffFor = (subject) => (subject ? cutoffs.get(subject) || 0 : 0);
+ async function record(subject, validAfterMs, reason = null) {
+ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(validAfterMs)) return false;
+ if (!(validAfterMs > cutoffFor(subject))) return false;
+ const row = await db.prepare(`INSERT INTO ${table} (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at
+ WHERE excluded.valid_after_ms > ${table}.valid_after_ms
+ RETURNING valid_after_ms`).get(subject, validAfterMs, reason, now());
+ // No row back: the stored cutoff was already later (another process); read it.
+ const ms = row ? Number(row.valid_after_ms) : Number((await db.prepare(`SELECT valid_after_ms FROM ${table} WHERE subject_id = ?`).get(subject) || {}).valid_after_ms) || 0;
+ if (ms > cutoffFor(subject)) cutoffs.set(subject, ms);
+ return Boolean(row);
+ }
+ async function apply(event) {
+ if (!event || event.event_type !== EVENT_TYPE) return 'ignored:type';
+ if (event.source !== 'network') return 'ignored:source';
+ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ const subject = p.subject && p.subject.type === 'user' ? p.subject.id : null;
+ const ms = Date.parse(p.valid_after);
+ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
+ return (await record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null)) ? 'revoked' : 'unchanged';
+ }
+ function isRevoked(claims) {
+ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;
+ return claims.iat * 1000 < cutoffFor(claims.subject_id);
+ }
+ return { load, apply, record, isRevoked, cutoffFor, loaded: () => loaded, EVENT_TYPE };
+}
+
+module.exports = { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER: EVENT_TYPE };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.20.4';
+const SDK_VERSION = '0.21.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -19,7 +19,8 @@ function createValkey({ url = process.env.VALKEY_URL, prefix = process.env.VALKE
@@ -19,7 +19,8 @@ function createValkey({ url = process.env.VALKEY_URL, prefix = process.env.VALKE
if (!client) {
if (!client) {
try { Valkey = require('iovalkey'); } catch { throw new Error('openvibe-sdk/valkey: install `iovalkey` in the service (npm i iovalkey)'); }
try { Valkey = require('iovalkey'); } catch { throw new Error('openvibe-sdk/valkey: install `iovalkey` in the service (npm i iovalkey)'); }
}
}
- const opts = { maxRetriesPerRequest: 3, enableAutoPipelining: true, connectTimeout: 5000, lazyConnect };
+ // enableReadyCheck off: a service's ACL user has no INFO (the check logged NOPERM on every connect); ready() pings.
+ const opts = { maxRetriesPerRequest: 3, enableAutoPipelining: true, connectTimeout: 5000, lazyConnect, enableReadyCheck: false };
const c = client || new Valkey(url, opts);
const c = client || new Valkey(url, opts);
let lastError = null;
let lastError = null;
c.on('error', (err) => { if (!lastError || lastError.message !== err.message) log.warn(`[valkey] ${err.message}`); lastError = err; });
c.on('error', (err) => { if (!lastError || lastError.message !== err.message) log.warn(`[valkey] ${err.message}`); lastError = err; });
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// openvibe-sdk/auth createRevocationStore: network.user.token_valid_after cutoffs (Contracts 0.39.0).
// openvibe-sdk/auth createRevocationStore: network.user.token_valid_after cutoffs (Contracts 0.39.0).
const assert = require('assert');
const assert = require('assert');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
-const { createRevocationStore, TOKEN_VALID_AFTER } = require('../src/auth');
+const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('../src/auth');
const A = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
const A = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
const B = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4';
const B = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4';
@@ -31,4 +31,29 @@ const mem = createRevocationStore();
@@ -31,4 +31,29 @@ const mem = createRevocationStore();
assert.strictEqual(mem.apply(ev(B, new Date(T).toISOString())), 'revoked');
assert.strictEqual(mem.apply(ev(B, new Date(T).toISOString())), 'revoked');
assert.strictEqual(mem.isRevoked({ subject_id: B, iat: T / 1000 - 5 }), true);
assert.strictEqual(mem.isRevoked({ subject_id: B, iat: T / 1000 - 5 }), true);
assert.throws(() => createRevocationStore(db, { table: 'x; DROP TABLE y' }), /bad table name/);
assert.throws(() => createRevocationStore(db, { table: 'x; DROP TABLE y' }), /bad table name/);
-console.log('revocations: all checks passed');
+
+// createPgRevocationStore: the same rules on PostgreSQL (PGlite), cutoffs read into memory by load().
+(async () => {
+ const { createDb } = require('../src/db');
+ const pg = createDb({ pglite: true, service: 'sdk-test' });
+ await pg.exec(revocationSchema('token_revocations'));
+ const p = createPgRevocationStore(pg, { table: 'token_revocations', now: () => T });
+ assert.strictEqual(await p.load(), 0);
+ assert.strictEqual(await p.apply(ev(A, new Date(T).toISOString())), 'revoked');
+ assert.strictEqual(p.isRevoked({ subject_id: A, iat: T / 1000 - 1 }), true);
+ assert.strictEqual(p.isRevoked({ subject_id: A, iat: T / 1000 }), false);
+ assert.strictEqual(await p.apply(ev(A, new Date(T - 60000).toISOString())), 'unchanged', 'never moves back');
+ assert.strictEqual(await p.apply(ev(A, new Date(T + 1).toISOString(), { source: 'live' })), 'ignored:source');
+ // Another process moved it further: record() refuses to go back and takes the stored value.
+ await pg.prepare('UPDATE token_revocations SET valid_after_ms = ? WHERE subject_id = ?').run(T + 5000, A);
+ assert.strictEqual(await p.record(A, T + 1000), false);
+ assert.strictEqual(p.cutoffFor(A), T + 5000);
+ // A restart reads what is stored.
+ const again = createPgRevocationStore(pg, { table: 'token_revocations' });
+ assert.strictEqual(again.cutoffFor(A), 0, 'nothing before load()');
+ assert.strictEqual(await again.load(), 1);
+ assert.strictEqual(again.cutoffFor(A), T + 5000);
+ assert.throws(() => createPgRevocationStore(pg, { table: 'x; DROP TABLE y' }), /bad table name/);
+ await pg.close();
+ console.log('revocations: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -8,15 +8,22 @@
@@ -8,15 +8,22 @@
* node gen-migration.js <Name> [--publishing revisions:blog_post,citations:blog_post,index-hooks:blog,seo:blog_entity,…]
* node gen-migration.js <Name> [--publishing revisions:blog_post,citations:blog_post,index-hooks:blog,seo:blog_entity,…]
* [--no-inbox] [--outbox-table t] [--inbox-table t] [--no-outbox] [--ensure a.js,b.js]
* [--no-inbox] [--outbox-table t] [--inbox-table t] [--no-outbox] [--ensure a.js,b.js]
* [--from server/store.js] (the module holding SCHEMA; default server/db.js)
* [--from server/store.js] (the module holding SCHEMA; default server/db.js)
+ * [--open "require('./server/db').openDb(':memory:')"]
* → migrations/0001_initial.sql
* → migrations/0001_initial.sql
*
*
+ * --open: the schema as the service's own boot code leaves a fresh SQLite database (SCHEMA, then every migrate() ALTER
+ * and seed, then the --ensure modules run on it), for a service whose migrate() changed tables after SCHEMA. Run it
+ * before converting any code (the working copy is the SQLite release). Tables come out in dependency order, one column
+ * per line; the rows the boot seeded become INSERTs; SQLite text timestamps (DATETIME, CURRENT_TIMESTAMP) bring
+ * SQLITE_DATE_FUNCTIONS along.
+ *
* It lists every `rowid` the server code still orders by: those tables need a `seq bigint GENERATED ALWAYS AS
* It lists every `rowid` the server code still orders by: those tables need a `seq bigint GENERATED ALWAYS AS
* IDENTITY UNIQUE` column (a person adds it; see docs/migrating-to-postgresql.md §7).
* IDENTITY UNIQUE` column (a person adds it; see docs/migrating-to-postgresql.md §7).
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { execFileSync } = require('child_process');
const { execFileSync } = require('child_process');
-const { toPg } = require('./sqlite-schema-to-pg');
+const { toPg, SQLITE_DATE_FUNCTIONS } = require('./sqlite-schema-to-pg');
const args = process.argv.slice(2);
const args = process.argv.slice(2);
const Name = args[0];
const Name = args[0];
@@ -25,13 +32,97 @@ const pi = args.indexOf('--publishing');
@@ -25,13 +32,97 @@ const pi = args.indexOf('--publishing');
const pubs = pi >= 0 ? args[pi + 1].split(',').map((x) => x.split(':')) : [];
const pubs = pi >= 0 ? args[pi + 1].split(',').map((x) => x.split(':')) : [];
const req = (m) => require(require.resolve(m, { paths: [process.cwd()] }));
const req = (m) => require(require.resolve(m, { paths: [process.cwd()] }));
+const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
+const ensureList = opt('--ensure', '') ? opt('--ensure', '').split(',') : [];
+
+/** A fresh SQLite database opened by the service's code → PostgreSQL DDL (tables in dependency order) and seed rows. */
+function fromOpenedDb(expr) {
+ const { createRequire } = require('module');
+ const creq = createRequire(path.resolve('package.json'));
+ const sdb = new Function('require', `return (${expr});`)(creq);
+ for (const f of ensureList) creq(path.resolve(f)).ensureSchema(sdb);
+ const stripComments = (sql) => {
+ let o = ''; let q = null;
+ for (let i = 0; i < sql.length; i++) {
+ const c = sql[i];
+ if (q) { o += c; if (c === q) q = null; continue; }
+ if (c === "'" || c === '"') { q = c; o += c; continue; }
+ if (c === '-' && sql[i + 1] === '-') { while (i < sql.length && sql[i] !== '\n') i++; o += '\n'; continue; }
+ o += c;
+ }
+ return o;
+ };
+ const splitTop = (body) => {
+ const parts = []; let depth = 0; let q = null; let cur = '';
+ for (const c of body) {
+ if (q) { cur += c; if (c === q) q = null; continue; }
+ if (c === "'" || c === '"') { q = c; cur += c; continue; }
+ if (c === '(') depth++;
+ if (c === ')') depth--;
+ if (c === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; continue; }
+ cur += c;
+ }
+ if (cur.trim()) parts.push(cur.trim());
+ return parts;
+ };
+ const objs = sdb.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY rowid").all();
+ const tables = objs.filter((o) => o.type === 'table' && !/CREATE VIRTUAL/i.test(o.sql));
+ const byName = new Map(tables.map((t) => [t.name, t]));
+ const order = []; const done = new Set();
+ const visit = (t, stack) => {
+ if (done.has(t.name) || stack.has(t.name)) return;
+ stack.add(t.name);
+ for (const m of t.sql.matchAll(/REFERENCES\s+"?(\w+)"?/gi)) { const d = byName.get(m[1]); if (d && d.name !== t.name) visit(d, stack); }
+ done.add(t.name); order.push(t);
+ };
+ for (const t of tables) visit(t, new Set());
+ let ddl = '';
+ const tsDefault = new Set(); const pk = new Map();
+ for (const t of order) {
+ const sql = stripComments(t.sql);
+ const i = sql.indexOf('('); const j = sql.lastIndexOf(')');
+ const cols = splitTop(sql.slice(i + 1, j)).map((x) => x.replace(/\s+/g, ' '));
+ for (const c of cols) {
+ const m = /^"?(\w+)"? /.exec(c);
+ if (!m) continue;
+ if (/DEFAULT (CURRENT_TIMESTAMP|\((datetime|strftime)\()/i.test(c)) tsDefault.add(`${t.name}.${m[1]}`);
+ if (/^"?\w+"? INTEGER PRIMARY KEY/i.test(c)) pk.set(t.name, { col: m[1], always: /AUTOINCREMENT/i.test(c) });
+ }
+ const tail = sql.slice(j + 1).trim();
+ ddl += `CREATE TABLE ${t.name} (\n ${cols.join(',\n ')}\n)${tail ? ` ${tail}` : ''};\n\n`;
+ }
+ for (const o of objs) if (o.type === 'index') ddl += `${stripComments(o.sql).replace(/\s+/g, ' ').trim()};\n`;
+ for (const o of objs) if (o.type === 'trigger') ddl += `\n${o.sql.trim()};\n`;
+ for (const o of objs) if (o.type === 'view') ddl += `\n${o.sql.trim()};\n`;
+ let out = toPg(ddl);
+ // Seed rows the boot wrote (spaces, categories, …); timestamp defaults fill themselves in.
+ const lit = (v) => (v === null ? 'NULL' : typeof v === 'number' || typeof v === 'bigint' ? String(v)
+ : Buffer.isBuffer(v) ? `'\\x${v.toString('hex')}'` : `'${String(v).replace(/'/g, "''")}'`);
+ let seeds = '';
+ for (const t of order) {
+ const rows = sdb.prepare(`SELECT * FROM "${t.name}"`).all();
+ if (!rows.length) continue;
+ const cols = Object.keys(rows[0]).filter((c) => !tsDefault.has(`${t.name}.${c}`));
+ const k = pk.get(t.name);
+ const q = (c) => (/^(window|user)$/.test(c) ? `"${c}"` : c);
+ seeds += `INSERT INTO ${t.name} (${cols.map(q).join(', ')})${k && k.always && cols.includes(k.col) ? ' OVERRIDING SYSTEM VALUE' : ''} VALUES\n`
+ + `${rows.map((r) => ` (${cols.map((c) => lit(r[c])).join(', ')})`).join(',\n')};\n`;
+ if (k && cols.includes(k.col)) seeds += `SELECT setval(pg_get_serial_sequence('${t.name}', '${k.col}'), (SELECT MAX(${k.col}) FROM ${t.name}));\n`;
+ }
+ if (seeds) out += `\n-- Rows the SQLite boot seeded\n${seeds}`;
+ if (/ov_now\(\)|ov_now_iso\(\)/.test(out)) out = `${SQLITE_DATE_FUNCTIONS}\n${out}`;
+ try { sdb.close(); } catch { /* in-memory */ }
+ return out;
+}
+
// The schema as the SQLite release ran it: server/db.js at HEAD (the conversion edits the working copy). A literal with
// The schema as the SQLite release ran it: server/db.js at HEAD (the conversion edits the working copy). A literal with
// ${…} interpolations is evaluated by loading that file and reading its SCHEMA export.
// ${…} interpolations is evaluated by loading that file and reading its SCHEMA export.
const dbFile = (() => { const i = args.indexOf('--from'); return i >= 0 ? args[i + 1] : 'server/db.js'; })();
const dbFile = (() => { const i = args.indexOf('--from'); return i >= 0 ? args[i + 1] : 'server/db.js'; })();
-const src = execFileSync('git', ['show', `HEAD:${dbFile}`], { encoding: 'utf8' });
-const m = /const SCHEMA = `([\s\S]*?)`;/.exec(src);
+const openExpr = opt('--open', null);
+const src = openExpr ? '' : execFileSync('git', ['show', `HEAD:${dbFile}`], { encoding: 'utf8' });
+const m = openExpr ? [null, ''] : /const SCHEMA = `([\s\S]*?)`;/.exec(src);
if (!m) { console.error(`${dbFile} (HEAD) has no const SCHEMA = \`…\`;`); process.exit(1); }
if (!m) { console.error(`${dbFile} (HEAD) has no const SCHEMA = \`…\`;`); process.exit(1); }
-if (m[1].includes('${')) {
+if (!openExpr && m[1].includes('${')) {
const tmp = path.resolve(path.dirname(dbFile), '.db-head-schema.js');
const tmp = path.resolve(path.dirname(dbFile), '.db-head-schema.js');
fs.writeFileSync(tmp, src);
fs.writeFileSync(tmp, src);
try {
try {
@@ -44,9 +135,9 @@ let out = `-- phase: expand
@@ -44,9 +135,9 @@ let out = `-- phase: expand
-- OpenVibe.${Name} on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
-- OpenVibe.${Name} on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on ${new Date().toISOString().slice(0, 10)}; never edited after it runs.
-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on ${new Date().toISOString().slice(0, 10)}; never edited after it runs.
-${toPg(m[1]).replace(/\n{3,}/g, '\n\n')}`;
+${(openExpr ? fromOpenedDb(openExpr) : toPg(m[1])).replace(/\n{3,}/g, '\n\n')}`;
// --ensure a.js,b.js: modules whose ensureSchema(db) created more tables at boot; their SQL is captured and converted too.
// --ensure a.js,b.js: modules whose ensureSchema(db) created more tables at boot; their SQL is captured and converted too.
-{
+if (!openExpr) {
const i = args.indexOf('--ensure');
const i = args.indexOf('--ensure');
if (i >= 0) {
if (i >= 0) {
for (const f of args[i + 1].split(',')) {
for (const f of args[i + 1].split(',')) {
@@ -58,7 +149,6 @@ ${toPg(m[1]).replace(/\n{3,}/g, '\n\n')}`;
@@ -58,7 +149,6 @@ ${toPg(m[1]).replace(/\n{3,}/g, '\n\n')}`;
}
}
}
}
const SCHEMA_OF = { 'index-hooks': (x, p) => x.sequencerSchema(p), seo: (x, p) => x.redirectsSchema(p) };
const SCHEMA_OF = { 'index-hooks': (x, p) => x.sequencerSchema(p), seo: (x, p) => x.redirectsSchema(p) };
-const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
for (const [store, prefix] of pubs) {
for (const [store, prefix] of pubs) {
const mod = req(`openvibe-publishing/${store}`);
const mod = req(`openvibe-publishing/${store}`);
out += `\n-- openvibe-publishing/${store} (prefix ${prefix})\n${(SCHEMA_OF[store] ? SCHEMA_OF[store](mod, prefix) : mod.schema(prefix)).trim()}\n`;
out += `\n-- openvibe-publishing/${store} (prefix ${prefix})\n${(SCHEMA_OF[store] ? SCHEMA_OF[store](mod, prefix) : mod.schema(prefix)).trim()}\n`;
@@ -26,7 +26,7 @@ REPORT = [
@@ -26,7 +26,7 @@ REPORT = [
(r'SELECT DISTINCT', 'SELECT DISTINCT with ORDER BY on an expression not selected fails; a DISTINCT over a LEFT JOIN is usually WHERE … OR EXISTS (…)'),
(r'SELECT DISTINCT', 'SELECT DISTINCT with ORDER BY on an expression not selected fails; a DISTINCT over a LEFT JOIN is usually WHERE … OR EXISTS (…)'),
(r'GROUP BY', 'GROUP BY: every selected bare column must be grouped (SQLite took a bare column from the MAX/MIN row: DISTINCT ON)'),
(r'GROUP BY', 'GROUP BY: every selected bare column must be grouped (SQLite took a bare column from the MAX/MIN row: DISTINCT ON)'),
(r'WITH RECURSIVE', 'recursive seed: cast ? to the column type and collation (?::text COLLATE "C")'),
(r'WITH RECURSIVE', 'recursive seed: cast ? to the column type and collation (?::text COLLATE "C")'),
- (r"datetime\(|strftime\(|julianday\(", 'SQLite date functions'),
+ (r"datetime\(|strftime\(|julianday\(", 'SQLite date functions (with SQLITE_DATE_FUNCTIONS in the migration, datetime(t[, modifier]) and julianday(t) exist; strftime does not)'),
(r'GROUP_CONCAT|json_extract|json_group_array|json_object\(', 'SQLite functions'),
(r'GROUP_CONCAT|json_extract|json_group_array|json_object\(', 'SQLite functions'),
(r'lastInsertRowid', 'lastInsertRowid → RETURNING'),
(r'lastInsertRowid', 'lastInsertRowid → RETURNING'),
(r'\.transaction\(', 'db.transaction(fn)() → db.tx(fn)'),
(r'\.transaction\(', 'db.transaction(fn)() → db.tx(fn)'),
@@ -50,8 +50,14 @@ for mf in sorted(_glob.glob('migrations/*.sql')):
@@ -50,8 +50,14 @@ for mf in sorted(_glob.glob('migrations/*.sql')):
TABLE_COLS.setdefault(tm.group(1), set()).update(cols)
TABLE_COLS.setdefault(tm.group(1), set()).update(cols)
for am in re.finditer(r'ALTER TABLE (\w+) ADD COLUMN (\w+)', ddl):
for am in re.finditer(r'ALTER TABLE (\w+) ADD COLUMN (\w+)', ddl):
TABLE_COLS.setdefault(am.group(1), set()).add(am.group(2))
TABLE_COLS.setdefault(am.group(1), set()).add(am.group(2))
+# A migration carrying SQLITE_DATE_FUNCTIONS (text timestamps kept as SQLite wrote them): CURRENT_TIMESTAMP in code
+# becomes ov_now() (PostgreSQL's would be a timestamptz, cast to other text), strftime's ISO 'now' ov_now_iso().
+SQLITE_DATES = any('CREATE FUNCTION ov_now()' in open(mf).read() for mf in _glob.glob('migrations/*.sql'))
for p in files:
for p in files:
s = open(p).read(); o = s
s = open(p).read(); o = s
+ 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)
while 'INSERT OR IGNORE INTO' in 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 '`'
i = s.index('INSERT OR IGNORE INTO'); q = s[i - 1] if s[i - 1] in '\'"`' else '`'
j = s.index(q, i)
j = s.index(q, i)
@@ -45,7 +45,41 @@ function toPg(sql) {
@@ -45,7 +45,41 @@ function toPg(sql) {
.replace(/^(\s*"?\w+"?\s+)TEXT\b/gm, '$1text COLLATE "C"')
.replace(/^(\s*"?\w+"?\s+)TEXT\b/gm, '$1text COLLATE "C"')
.replace(/^(\s*"?\w+"?\s+)INTEGER\b/gm, '$1bigint')
.replace(/^(\s*"?\w+"?\s+)INTEGER\b/gm, '$1bigint')
.replace(/^(\s*"?\w+"?\s+)REAL\b/gm, '$1double precision')
.replace(/^(\s*"?\w+"?\s+)REAL\b/gm, '$1double precision')
- .replace(/^(\s*"?\w+"?\s+)BLOB\b/gm, '$1bytea');
+ .replace(/^(\s*"?\w+"?\s+)BLOB\b/gm, '$1bytea')
+ // SQLite text timestamps stay text in the same format; their defaults use SQLITE_DATE_FUNCTIONS below.
+ .replace(/^(\s*"?\w+"?\s+)DATETIME\b/gm, '$1text COLLATE "C"')
+ .replace(/DEFAULT CURRENT_TIMESTAMP\b/g, 'DEFAULT ov_now()')
+ .replace(/DEFAULT \(datetime\('now'\)\)/g, 'DEFAULT ov_now()')
+ .replace(/DEFAULT \(strftime\('%Y-%m-%dT%H:%M:%fZ', ?'now'\)\)/g, 'DEFAULT ov_now_iso()');
}
}
-module.exports = { toPg };
+/**
+ * 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');
+ * 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').
+ */
+const SQLITE_DATE_FUNCTIONS = `-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).
+CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$
+BEGIN
+ IF t IS NULL THEN RETURN NULL; END IF;
+ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;
+ IF t ~ '\\d\\d:\\d\\d(:\\d\\d(\\.\\d+)?)?\\s*(Z|[+-]\\d\\d(:?\\d\\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;
+ RETURN t::timestamp;
+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 datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$
+DECLARE ts timestamp := ov_ts(t);
+BEGIN
+ IF ts IS NULL THEN RETURN NULL; END IF;
+ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;
+ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');
+EXCEPTION WHEN others THEN RETURN NULL;
+END $$;
+CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;
+`;
+
+module.exports = { toPg, SQLITE_DATE_FUNCTIONS };
@@ -133,4 +133,20 @@ export interface RevocationStore {
@@ -133,4 +133,20 @@ export interface RevocationStore {
export interface RevocationStoreOptions { table?: string; now?: () => number; maxCache?: number }
export interface RevocationStoreOptions { table?: string; now?: () => number; maxCache?: number }
/** A better-sqlite3 handle keeps cutoffs across restarts; without one they live in memory. */
/** A better-sqlite3 handle keeps cutoffs across restarts; without one they live in memory. */
export declare function createRevocationStore(db?: unknown, opts?: RevocationStoreOptions): RevocationStore;
export declare function createRevocationStore(db?: unknown, opts?: RevocationStoreOptions): RevocationStore;
+/** The same cutoffs on PostgreSQL: load() reads them into memory; apply()/record() write through (async). */
+export interface PgRevocationStore {
+ /** Read every stored cutoff into memory (at boot); resolves to how many. */
+ load(): Promise<number>;
+ apply(event: unknown): Promise<'revoked' | 'unchanged' | `ignored:${string}`>;
+ record(subject: string, validAfterMs: number, reason?: string | null): Promise<boolean>;
+ /** From memory: true when claims.iat (seconds) is before claims.subject_id's cutoff. */
+ isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean;
+ cutoffFor(subject: string): number;
+ loaded(): boolean;
+ readonly EVENT_TYPE: 'network.user.token_valid_after';
+}
+/** An openvibe-sdk/db handle; the table comes from revocationSchema() in the service's migration. */
+export declare function createPgRevocationStore(db: unknown, opts?: { table?: string; now?: () => number }): PgRevocationStore;
+/** CREATE TABLE IF NOT EXISTS for createPgRevocationStore (default table ov_token_revocations). */
+export declare function revocationSchema(table?: string): string;
export declare const TOKEN_VALID_AFTER: 'network.user.token_valid_after';
export declare const TOKEN_VALID_AFTER: 'network.user.token_valid_after';