openvibers/openvibe.sdk Commits

v0.20.1: openvibe-sdk/db runs one query at a time per transaction connection, so Promise.all over db calls inside a transaction no longer queues queries on a busy node-postgres client (deprecated; pg@9 refuses it); outside a transaction calls still run concurrently on the pool. Also fixes 0.20.0's release: core.SDK_VERSION said 0.19.0 and the browser bundle was stale (CI was red). tools/asyncify: the codemod awaits a helper's calls of an async function it was passed (waitFor(fn), conflictGuard(fn), route wrappers) and leaves a promise started early and awaited later alone; gen-migration --ensure captures modules' ensureSchema() SQL and --no-outbox/--no-inbox; the schema converter turns a bare INTEGER PRIMARY KEY into an identity by default and drops WITHOUT ROWID; sqlfix rewrites db.transaction(fn)() and db.inTransaction. npm test 41/41 with the containers.

OP OpenVibers committed 28 Sept 2026 · e155d193e1 · 12 files, +96 −10


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 49817fd..7521dc5 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,10 @@ release may change an API and says so here.
++## 0.20.1 (2026-09-28)
++
++- **`openvibe-sdk/db`: one query at a time per transaction connection.** `await Promise.all(xs.map((x) => db.….get(x)))` inside a transaction used to put several queries on the transaction's client at once. node-postgres queues them, but warns that pg@9 will refuse. Such calls are now chained per connection, in call order. Outside a transaction, each call still takes its own pool connection and runs concurrently. `test/db-ambient.test.js` checks this on PGlite and through PgBouncer.
++
## 0.20.0 (2026-09-28)
- **`db.afterCommit(fn)`** (and `t.afterCommit(fn)` on a transaction handle) runs `fn` once the running transaction commits. It never runs after a rollback, and a savepoint that rolls back drops the hooks added inside it. Hooks run in order, outside the transaction, with the connection already back in the pool; one that throws is logged and the rest still run. Outside a transaction, `fn` runs on the next turn. It replaces the better-sqlite3 pattern of a `setImmediate` after a synchronous transaction, for calls to other services that must see committed state (News' comment-thread visibility was the first user).
ddiff --git a/STATUS.json b/STATUS.json
iindex f653f72..a1e9cb0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.20.0",
++ "version": "0.20.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 d857473..7daa743 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.19.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.20.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.19.0';
++const SDK_VERSION = '0.20.1';
module.exports = {
createClient,
ddiff --git a/package-lock.json b/package-lock.json
iindex efd4342..ee21607 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.20.0",
++ "version": "0.20.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.20.0",
++ "version": "0.20.1",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 563baa9..e9537e6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.20.0",
++ "version": "0.20.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 d14090c..e5261c9 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.19.0';
++const SDK_VERSION = '0.20.1';
module.exports = {
createClient,
ddiff --git a/src/db/index.js b/src/db/index.js
iindex a789976..f235cea 100644
---- a/src/db/index.js
++++ b/src/db/index.js
@@@ -108,7 +108,19 @@ function createDb(o = {}) {
if (o.pglite) adapter = pgliteAdapter(o.pglite);
else adapter = pgAdapter({ url: o.url || process.env.DATABASE_URL, max: o.max || 10, service, queryTimeoutMs: o.queryTimeoutMs || 15000, log });
-- async function timed(conn, text, values) {
++ // One query at a time per transaction connection: a transaction's statements are ordered anyway, and node-postgres
++ // deprecates queuing a query on a busy client (pg@9 refuses it). Promise.all over db calls inside a transaction
++ // (a list read concurrently) is therefore safe; outside one, each call takes its own pool connection.
++ const lanes = new WeakMap();
++ function timed(conn, text, values) {
++ if (!conn || typeof conn !== 'object') return timedRun(conn, text, values);
++ const prev = lanes.get(conn) || Promise.resolve();
++ const next = prev.then(() => timedRun(conn, text, values));
++ lanes.set(conn, next.then(() => {}, () => {}));
++ return next;
++ }
++
++ async function timedRun(conn, text, values) {
const t0 = process.hrtime.bigint();
try {
const r = await adapter.run(conn, text, values);
ddiff --git a/test/db-ambient.test.js b/test/db-ambient.test.js
iindex 1e68ba0..04a7506 100644
---- a/test/db-ambient.test.js
++++ b/test/db-ambient.test.js
@@@ -57,6 +57,20 @@ function cases(label, open) {
assert.deepEqual(seen, [['outside', false, 0, 1]]);
} finally { await done(); }
}],
++ [`${label}: Promise.all over db calls inside a transaction runs them one at a time on its connection`, async () => {
++ const { db, done } = await open();
++ const warnings = [];
++ const onWarning = (w) => warnings.push(String(w && w.message));
++ process.on('warning', onWarning);
++ try {
++ const ins = db.prepare('INSERT INTO items (name, qty) VALUES (?, ?) RETURNING id');
++ const ids = await db.tx(async () => Promise.all([1, 2, 3, 4, 5].map(async (n) => (await ins.run(`p${n}`, n)).lastInsertRowid)));
++ assert.equal(new Set(ids).size, 5);
++ assert.equal(await db.prepare('SELECT count(*)::int FROM items').pluck().get(), 5);
++ await new Promise((r) => setImmediate(r));
++ assert.deepEqual(warnings.filter((w) => /already executing a query/.test(w)), [], 'no queued query on a busy client');
++ } finally { process.off('warning', onWarning); await done(); }
++ }],
[`${label}: a db.tx inside is a savepoint; its failure undoes only itself`, async () => {
const { db, done } = await open();
try {
ddiff --git a/tools/asyncify/asyncify.js b/tools/asyncify/asyncify.js
iindex fed858b..8cf31dd 100644
---- a/tools/asyncify/asyncify.js
++++ b/tools/asyncify/asyncify.js
@@@ -71,6 +71,25 @@ function analyse(file, src, cfg, asyncNames) {
if (n.type === 'Property' && n.key && isPrepare(n.value)) stmtNames.add(n.key.name || n.key.value);
});
++ // Higher-order helpers (conflictGuard(fn), waitFor(fn), once(id, fn), a route wrapper): when a local function is
++ // passed an async function, its calls of that parameter return promises, so they are awaited too.
++ const localFns = new Map();
++ walk.full(ast, (n) => {
++ if (n.type === 'FunctionDeclaration' && n.id) localFns.set(n.id.name, n);
++ if (n.type === 'VariableDeclarator' && n.id.type === 'Identifier' && n.init && ['FunctionExpression', 'ArrowFunctionExpression'].includes(n.init.type)) localFns.set(n.id.name, n.init);
++ });
++ const asyncParamCalls = new Set();
++ walk.full(ast, (n) => {
++ if (n.type !== 'CallExpression' || n.callee.type !== 'Identifier' || !localFns.has(n.callee.name)) return;
++ const target = localFns.get(n.callee.name);
++ n.arguments.forEach((arg, i) => {
++ const isAsyncFn = ['FunctionExpression', 'ArrowFunctionExpression'].includes(arg.type) && arg.async;
++ const p = target.params[i];
++ if (!isAsyncFn || !p || p.type !== 'Identifier') return;
++ walk.full(target.body, (c) => { if (c.type === 'CallExpression' && c.callee.type === 'Identifier' && c.callee.name === p.name) asyncParamCalls.add(c); });
++ });
++ });
++
function asyncCall(call) {
const c = call.callee;
if (c.type === 'MemberExpression' && !c.computed) {
@@@ -95,6 +114,7 @@ function analyse(file, src, cfg, asyncNames) {
}
if (p.length && (p[p.length - 1] === 'api' || p[p.length - 1] === 'svc' || p[p.length - 1] === 'self') && localAsync.has(m)) return true;
}
++ if (asyncParamCalls.has(call)) return true;
if (c.type === 'Identifier') {
if ((cfg.asyncGlobals || []).includes(c.name)) return true;
if (localAsync.has(c.name) && !syncNames.has(c.name)) return true;
@@@ -159,6 +179,9 @@ function analyse(file, src, cfg, asyncNames) {
}
manual.push(`${file}:${n.loc.start.line}: ${what} passed to .${n.callee.property.name}()`);
});
++ // Names awaited as a whole somewhere in the file (await first, await Promise.all([a, b]) excluded).
++ const awaitedLater = new Set();
++ walk.full(ast, (n) => { if (n.type === 'AwaitExpression' && n.argument && n.argument.type === 'Identifier') awaitedLater.add(n.argument.name); });
walk.fullAncestor(ast, (node, _s, anc) => {
if (node.type !== 'CallExpression' || !asyncCall(node)) return;
const parents = anc.slice(0, -1);
@@@ -171,6 +194,8 @@ function analyse(file, src, cfg, asyncNames) {
if (gp && gp.type === 'CallExpression' && gp.callee.type === 'MemberExpression' && gp.callee.object.name === 'Promise') return;
}
if (parent && parent.type === 'CallExpression' && parent.callee.type === 'MemberExpression' && parent.callee.object.name === 'Promise') return;
++ // const p = asyncCall(); … await p: the promise is started now on purpose and awaited later.
++ if (parent && parent.type === 'VariableDeclarator' && parent.init === node && parent.id.type === 'Identifier' && awaitedLater.has(parent.id.name)) return;
// assert.rejects(promise) / doesNotReject(promise) take the promise itself.
if (parent && parent.type === 'CallExpression' && parent.arguments[0] === node && parent.callee.type === 'MemberExpression' && ['rejects', 'doesNotReject'].includes(parent.callee.property.name)) return;
// Returned directly from an arrow body or a return statement of an async fn is fine too, but awaiting is harmless.
ddiff --git a/tools/asyncify/gen-migration.js b/tools/asyncify/gen-migration.js
iindex 3450be3..25b7670 100644
---- a/tools/asyncify/gen-migration.js
++++ b/tools/asyncify/gen-migration.js
@@@ -6,7 +6,7 @@
* and outbox. Run in the service's checkout, after `npm install` of openvibe-publishing ≥ 1.0 and openvibe-sdk ≥ 0.18:
*
* 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-inbox] [--outbox-table t] [--inbox-table t] [--no-outbox] [--ensure a.js,b.js]
* → migrations/0001_initial.sql
*
* It lists every `rowid` the server code still orders by: those tables need a `seq bigint GENERATED ALWAYS AS
@@@ -43,6 +43,18 @@ let out = `-- phase: expand
-- 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')}`;
++// --ensure a.js,b.js: modules whose ensureSchema(db) created more tables at boot; their SQL is captured and converted too.
++{
++ const i = args.indexOf('--ensure');
++ if (i >= 0) {
++ for (const f of args[i + 1].split(',')) {
++ const sqls = [];
++ const fake = { exec: (q) => sqls.push(q), prepare: () => ({ run() {}, get() {}, all: () => [] }), pragma() {}, function() {} };
++ req(path.resolve(f)).ensureSchema(fake);
++ out += `\n-- ${f} ensureSchema()\n${toPg(sqls.join('\n').replace(/^ {4}/gm, '')).trim()}\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) {
@@@ -53,7 +65,7 @@ const ev = req('openvibe-sdk/events');
const inboxTable = opt('--inbox-table', 'idempotency_receipts');
const outboxTable = opt('--outbox-table', 'event_outbox');
if (!args.includes('--no-inbox')) out += `\n-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled\n${ev.inboxSchema(inboxTable).trim()}\n`;
--out += `\n-- openvibe-sdk/events PostgreSQL outbox\n${ev.outboxSchema(outboxTable).trim()}\n`;
++if (!args.includes('--no-outbox')) out += `\n-- openvibe-sdk/events PostgreSQL outbox\n${ev.outboxSchema(outboxTable).trim()}\n`;
// SQLite's own copies of the tables above (created by the packages at boot) would be defined twice.
for (const t of [outboxTable, inboxTable]) {
const re = new RegExp(`\\nCREATE TABLE ${t} \\([\\s\\S]*?\\n\\);\\n`, 'g');
ddiff --git a/tools/asyncify/sqlfix.py b/tools/asyncify/sqlfix.py
iindex 34aaa35..ae5a345 100644
---- a/tools/asyncify/sqlfix.py
++++ b/tools/asyncify/sqlfix.py
@@@ -9,6 +9,7 @@ Rewrites:
LIKE / NOT LIKE → ILIKE / NOT ILIKE (SQLite's LIKE ignores ASCII case)
json_each(?) → jsonb_array_elements_text(?::jsonb) (a JSON text parameter; column "value")
IFNULL( → COALESCE(
++ db.transaction(fn)() → db.tx(fn); db.inTransaction → db.inTransaction()
sqlite_master table lists → information_schema.tables of the current schema
pragma_table_info('t') → information_schema.columns of t
sequencer.stamp(doc) → sequencer.stamp(store.db, doc) (openvibe-publishing 1.0)
@@@ -46,6 +47,21 @@ for p in files:
s = re.sub(r'(?<![IN\w])LIKE (?=[@?:$\'(]|[a-z])', 'ILIKE ', s)
s = s.replace('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
s = re.sub(r'\bIFNULL\(', 'COALESCE(', s)
++ # db.transaction(fn)() → db.tx(fn) (balanced parentheses; the codemod then awaits it and makes fn async).
++ out, i = [], 0
++ while True:
++ j = s.find('.transaction(', i)
++ if j < 0: out.append(s[i:]); break
++ k, depth = j + len('.transaction('), 1
++ while k < len(s) and depth:
++ depth += {'(': 1, ')': -1}.get(s[k], 0); k += 1
++ if s[k:k + 2] == '()':
++ out.append(s[i:j] + '.tx(' + s[j + len('.transaction('):k]); i = k + 2
++ else:
++ out.append(s[i:k]); i = k
++ s = ''.join(out)
++ # 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)
# ON CONFLICT … DO UPDATE SET x = x + 1 / COALESCE(excluded.x, x): a bare column on the right is ambiguous in
# PostgreSQL; qualify it with the table (columns of the INSERT list only; excluded.x and the targets stay).
def qualify(m):
ddiff --git a/tools/asyncify/sqlite-schema-to-pg.js b/tools/asyncify/sqlite-schema-to-pg.js
iindex c6ea611..09e6ef7 100644
---- a/tools/asyncify/sqlite-schema-to-pg.js
++++ b/tools/asyncify/sqlite-schema-to-pg.js
@@@ -28,8 +28,11 @@ function toPg(sql) {
sql = sql.replace(/^(\s*\w+\s+TEXT\b[^\n]*?) COLLATE NOCASE/gm, '$1');
return sql
.replace(/\bINTEGER PRIMARY KEY AUTOINCREMENT\b/g, 'bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY')
++ // A bare INTEGER PRIMARY KEY is SQLite's rowid: code may insert without it or with an explicit value.
++ .replace(/\bINTEGER PRIMARY KEY\b(?! AUTOINCREMENT)/g, 'bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
.replace(/CREATE (UNIQUE )?INDEX IF NOT EXISTS/g, (m, u) => `CREATE ${u || ''}INDEX`)
.replace(/CREATE TABLE IF NOT EXISTS/g, 'CREATE TABLE')
++ .replace(/\)\s*WITHOUT ROWID\s*;/g, ');')
.replace(/CREATE VIEW IF NOT EXISTS/g, 'CREATE VIEW')
.replace(/^(\s*\w+\s+)TEXT\b/gm, '$1text COLLATE "C"')
.replace(/^(\s*\w+\s+)INTEGER\b/gm, '$1bigint')