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.
Changes
@@@ -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).@@@ -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",@@@ -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,@@@ -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",@@@ -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",@@@ -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,@@@ -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);@@@ -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 {@@@ -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.@@@ -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');@@@ -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 schemapragma_table_info('t') → information_schema.columns of tsequencer.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):@@@ -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')
@@ -8,6 +8,10 @@ release may change an API and says so here.
@@ -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)
## 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).
- **`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).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"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.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
// 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.19.0';
+const SDK_VERSION = '0.20.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.20.0",
+ "version": "0.20.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.20.0",
+ "version": "0.20.1",
"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.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.",
"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",
@@ -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.19.0';
+const SDK_VERSION = '0.20.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -108,7 +108,19 @@ function createDb(o = {}) {
@@ -108,7 +108,19 @@ function createDb(o = {}) {
if (o.pglite) adapter = pgliteAdapter(o.pglite);
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 });
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();
const t0 = process.hrtime.bigint();
try {
try {
const r = await adapter.run(conn, text, values);
const r = await adapter.run(conn, text, values);
@@ -57,6 +57,20 @@ function cases(label, open) {
@@ -57,6 +57,20 @@ function cases(label, open) {
assert.deepEqual(seen, [['outside', false, 0, 1]]);
assert.deepEqual(seen, [['outside', false, 0, 1]]);
} finally { await done(); }
} 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 () => {
[`${label}: a db.tx inside is a savepoint; its failure undoes only itself`, async () => {
const { db, done } = await open();
const { db, done } = await open();
try {
try {
@@ -71,6 +71,25 @@ function analyse(file, src, cfg, asyncNames) {
@@ -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);
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) {
function asyncCall(call) {
const c = call.callee;
const c = call.callee;
if (c.type === 'MemberExpression' && !c.computed) {
if (c.type === 'MemberExpression' && !c.computed) {
@@ -95,6 +114,7 @@ function analyse(file, src, cfg, asyncNames) {
@@ -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 (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 (c.type === 'Identifier') {
if ((cfg.asyncGlobals || []).includes(c.name)) return true;
if ((cfg.asyncGlobals || []).includes(c.name)) return true;
if (localAsync.has(c.name) && !syncNames.has(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) {
@@ -159,6 +179,9 @@ function analyse(file, src, cfg, asyncNames) {
}
}
manual.push(`${file}:${n.loc.start.line}: ${what} passed to .${n.callee.property.name}()`);
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) => {
walk.fullAncestor(ast, (node, _s, anc) => {
if (node.type !== 'CallExpression' || !asyncCall(node)) return;
if (node.type !== 'CallExpression' || !asyncCall(node)) return;
const parents = anc.slice(0, -1);
const parents = anc.slice(0, -1);
@@ -171,6 +194,8 @@ function analyse(file, src, cfg, asyncNames) {
@@ -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 (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;
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.
// 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;
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.
// Returned directly from an arrow body or a return statement of an async fn is fine too, but awaiting is harmless.
@@ -6,7 +6,7 @@
@@ -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:
* 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,…]
* 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
* → migrations/0001_initial.sql
*
*
* 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
@@ -43,6 +43,18 @@ let out = `-- phase: expand
@@ -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),
-- 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')}`;
${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 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; };
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) {
@@ -53,7 +65,7 @@ const ev = req('openvibe-sdk/events');
@@ -53,7 +65,7 @@ const ev = req('openvibe-sdk/events');
const inboxTable = opt('--inbox-table', 'idempotency_receipts');
const inboxTable = opt('--inbox-table', 'idempotency_receipts');
const outboxTable = opt('--outbox-table', 'event_outbox');
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`;
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.
// SQLite's own copies of the tables above (created by the packages at boot) would be defined twice.
for (const t of [outboxTable, inboxTable]) {
for (const t of [outboxTable, inboxTable]) {
const re = new RegExp(`\\nCREATE TABLE ${t} \\([\\s\\S]*?\\n\\);\\n`, 'g');
const re = new RegExp(`\\nCREATE TABLE ${t} \\([\\s\\S]*?\\n\\);\\n`, 'g');
@@ -9,6 +9,7 @@ Rewrites:
@@ -9,6 +9,7 @@ Rewrites:
LIKE / NOT LIKE → ILIKE / NOT ILIKE (SQLite's LIKE ignores ASCII case)
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")
json_each(?) → jsonb_array_elements_text(?::jsonb) (a JSON text parameter; column "value")
IFNULL( → COALESCE(
IFNULL( → COALESCE(
+ db.transaction(fn)() → db.tx(fn); db.inTransaction → db.inTransaction()
sqlite_master table lists → information_schema.tables of the current schema
sqlite_master table lists → information_schema.tables of the current schema
pragma_table_info('t') → information_schema.columns of t
pragma_table_info('t') → information_schema.columns of t
sequencer.stamp(doc) → sequencer.stamp(store.db, doc) (openvibe-publishing 1.0)
sequencer.stamp(doc) → sequencer.stamp(store.db, doc) (openvibe-publishing 1.0)
@@ -46,6 +47,21 @@ for p in files:
@@ -46,6 +47,21 @@ for p in files:
s = re.sub(r'(?<![IN\w])LIKE (?=[@?:$\'(]|[a-z])', 'ILIKE ', s)
s = re.sub(r'(?<![IN\w])LIKE (?=[@?:$\'(]|[a-z])', 'ILIKE ', s)
s = s.replace('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
s = s.replace('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
s = re.sub(r'\bIFNULL\(', 'COALESCE(', s)
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
# 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).
# PostgreSQL; qualify it with the table (columns of the INSERT list only; excluded.x and the targets stay).
def qualify(m):
def qualify(m):
@@ -28,8 +28,11 @@ function toPg(sql) {
@@ -28,8 +28,11 @@ function toPg(sql) {
sql = sql.replace(/^(\s*\w+\s+TEXT\b[^\n]*?) COLLATE NOCASE/gm, '$1');
sql = sql.replace(/^(\s*\w+\s+TEXT\b[^\n]*?) COLLATE NOCASE/gm, '$1');
return sql
return sql
.replace(/\bINTEGER PRIMARY KEY AUTOINCREMENT\b/g, 'bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY')
.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 (UNIQUE )?INDEX IF NOT EXISTS/g, (m, u) => `CREATE ${u || ''}INDEX`)
.replace(/CREATE TABLE IF NOT EXISTS/g, 'CREATE TABLE')
.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(/CREATE VIEW IF NOT EXISTS/g, 'CREATE VIEW')
.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')