openvibers/openvibe.sdk Commits
SDK 0.50.1: savepoint scopes end; outbox timers never join a caller's transaction (#56)
Changes
@@@ -3,6 +3,16 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.50.1 (2026-10-17)++++- `openvibe-sdk/db`: a nested `db.tx` (a savepoint) marks its scope done when it ends, like the outermost one. Async++ work started inside a savepoint and still running afterwards (a timer, a fire-and-forget call) no longer keeps++ using the transaction's connection after it goes back to the pool.++- Outbox: the relay's timers run in the async context the outbox was created in. A `kick()` from inside a++ transaction that stays open (an inbox handler that goes on fanning out) used to make the relay's ticks run inside++ that transaction, on its connection. OpenVibe.Network logged "Client was closed and is not queryable" for about a++ minute after each go-live notice.++## 0.50.0 (2026-10-16)- `openvibe-sdk/play`: **cloud saves** — `play.saves.put(title, player, slot, { data, version })`, `get` (null when the@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.50.0",++ "version": "0.50.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.50.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.50.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, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,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.50.0';++const SDK_VERSION = '0.50.1';module.exports = {createClient,@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.50.0",++ "version": "0.50.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.50.0",++ "version": "0.50.1","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.50.0",++ "version": "0.50.1","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, 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.50.0';++const SDK_VERSION = '0.50.1';module.exports = {createClient,@@@ -154,11 +154,16 @@ function createDb(o = {}) {const name = `sp_${depth}`;const mark = hooks.length;await timed(conn, `SAVEPOINT ${name}`, []);++ // Marked done when the savepoint ends, like the outermost scope: async work started inside it and still++ // running afterwards (a timer, a fire-and-forget call) must not keep using this connection, which goes++ // back to the pool when the transaction ends.++ const scope = { conn, depth: depth + 1, done: false, hooks };try {-- const out = await inScope({ conn, depth: depth + 1, done: false, hooks }, () => fn(txApi(conn, depth + 1, hooks)));++ const out = await inScope(scope, () => fn(txApi(conn, depth + 1, hooks)));++ scope.done = true;await timed(conn, `RELEASE SAVEPOINT ${name}`, []);return out;-- } catch (err) { hooks.length = mark; await timed(conn, `ROLLBACK TO SAVEPOINT ${name}`, []).catch(() => {}); throw err; }++ } catch (err) { scope.done = true; hooks.length = mark; await timed(conn, `ROLLBACK TO SAVEPOINT ${name}`, []).catch(() => {}); throw err; }}const iso = ISOLATION[opts.isolation || 'read committed'];if (!iso) throw new TypeError(`openvibe-sdk/db: isolation must be one of ${Object.keys(ISOLATION).join(', ')}`);@@@ -1,4 +1,5 @@'use strict';++const { AsyncResource } = require('node:async_hooks');/*** openvibe-sdk/events (server): transactional outbox and inbox (ADR-004) on the service's own PostgreSQL database* (an openvibe-sdk/db handle, ADR-035): createPgOutbox / createPgInbox, with outboxSchema / inboxSchema for the@@@ -165,14 +166,21 @@ function createPgOutbox(db, {return total;}const flush = serialFlush(doFlush);++ // The relay's timers run in the async context the outbox was made in (a service's boot), never the caller's.++ // kick() is often called inside a transaction that stays open (an inbox handler that goes on fanning out): a++ // timer made there inherited it through openvibe-sdk/db's AsyncLocalStorage, so its ticks ran inside that++ // transaction on its connection, and when the connection died mid-transaction every tick failed until the++ // handler finished ("Client was closed and is not queryable" on OpenVibe.Network, 2026-10-16, for about a++ // minute after each go-live notice).++ const home = new AsyncResource('openvibe-outbox');function schedule(ms) {if (!running) return;clearTimeout(timer);-- timer = setTimeout(async () => {++ timer = home.runInAsyncScope(() => setTimeout(async () => {timer = null;try { await flush(); } catch (err) { if (onError) { try { onError(err); } catch { /* ignore */ } } }schedule(intervalMs);-- }, ms);++ }, ms));if (timer.unref) timer.unref();}return {@@@ -31,6 +31,32 @@ function cases(label, open) {assert.equal(await count.get(), 1);} finally { await done(); }}],++ [`${label}: work started inside a savepoint and still running after the transaction is outside it`, async () => {++ const { db, done } = await open();++ try {++ // A timer (an outbox kick, a fire-and-forget call) started inside a nested db.tx keeps its async++ // context. The savepoint's scope used to stay "running" forever, so that work kept the outer++ // transaction's connection after it went back to the pool (OpenVibe.Network, 2026-10-16).++ let later = null;++ await db.tx(async () => {++ await db.tx(async () => {++ later = new Promise((resolve) => setTimeout(() => resolve(db.inTransaction()), 30));++ assert.equal(db.inTransaction(), true);++ });++ assert.equal(db.inTransaction(), true);++ });++ assert.equal(await later, false, 'after the transaction, the savepoint\'s timer is outside it');++ const failed = [];++ await assert.rejects(db.tx(async () => {++ await assert.rejects(db.tx(async () => {++ failed.push(new Promise((resolve) => setTimeout(() => resolve(db.inTransaction()), 30)));++ throw new Error('inner');++ }), /inner/);++ throw new Error('outer');++ }), /outer/);++ assert.equal(await failed[0], false, 'a rolled-back savepoint too');++ } finally { await done(); }++ }],[`${label}: afterCommit runs after the commit, in order, never after a rollback; a failed savepoint drops its hooks`, async () => {const { db, done } = await open();try {@@@ -26,6 +26,35 @@ function eventsClient() {function cases(label, open) {return [++ [`${label}: a kick() from inside a transaction never hands the relay that transaction`, async () => {++ const { db, done } = await open();++ try {++ const { platform, events } = eventsClient();++ // Every claim the relay makes records whether it ran inside an ambient transaction.++ const inTx = [];++ const watched = new Proxy(db, {++ get(target, key) {++ if (key === 'many') return (...args) => { inTx.push(target.inTransaction()); return target.many(...args); };++ const v = target[key];++ return typeof v === 'function' ? v.bind(target) : v;++ },++ });++ const outbox = createPgOutbox(watched, { events, intervalMs: 15 });++ outbox.start();++ // An inbox handler: the event's row and the wake-up happen inside the handler's transaction, which++ // stays open a while after (Network's go-live notice fans out to every follower before it commits).++ await db.tx(async (t) => {++ await outbox.enqueue(t, { event_type: 'live.stream.started', actor, subject });++ outbox.kick();++ await new Promise((r) => setTimeout(r, 80));++ });++ await new Promise((r) => setTimeout(r, 120));++ await outbox.stop();++ assert.equal(platform.state.events.length, 1, 'the event went out');++ assert.ok(inTx.length >= 3, `the relay kept ticking (${inTx.length} claims)`);++ assert.ok(inTx.every((x) => x === false), 'no tick ran inside the handler\'s transaction');++ } finally { await done(); }++ }],[`${label}: enqueue needs the transaction; a rollback leaves no event; a commit publishes once`, async () => {const { db, done } = await open();try {
@@ -3,6 +3,16 @@
@@ -3,6 +3,16 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.50.1 (2026-10-17)
+
+- `openvibe-sdk/db`: a nested `db.tx` (a savepoint) marks its scope done when it ends, like the outermost one. Async
+ work started inside a savepoint and still running afterwards (a timer, a fire-and-forget call) no longer keeps
+ using the transaction's connection after it goes back to the pool.
+- Outbox: the relay's timers run in the async context the outbox was created in. A `kick()` from inside a
+ transaction that stays open (an inbox handler that goes on fanning out) used to make the relay's ticks run inside
+ that transaction, on its connection. OpenVibe.Network logged "Client was closed and is not queryable" for about a
+ minute after each go-live notice.
+
## 0.50.0 (2026-10-16)
## 0.50.0 (2026-10-16)
- `openvibe-sdk/play`: **cloud saves** — `play.saves.put(title, player, slot, { data, version })`, `get` (null when the
- `openvibe-sdk/play`: **cloud saves** — `play.saves.put(title, player, slot, { data, version })`, `get` (null when the
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.50.0",
+ "version": "0.50.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.50.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.50.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, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,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.50.0';
+const SDK_VERSION = '0.50.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.50.0",
+ "version": "0.50.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.50.0",
+ "version": "0.50.1",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.50.0",
+ "version": "0.50.1",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, 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, Bot robots, 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.50.0';
+const SDK_VERSION = '0.50.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -154,11 +154,16 @@ function createDb(o = {}) {
@@ -154,11 +154,16 @@ function createDb(o = {}) {
const name = `sp_${depth}`;
const name = `sp_${depth}`;
const mark = hooks.length;
const mark = hooks.length;
await timed(conn, `SAVEPOINT ${name}`, []);
await timed(conn, `SAVEPOINT ${name}`, []);
+ // Marked done when the savepoint ends, like the outermost scope: async work started inside it and still
+ // running afterwards (a timer, a fire-and-forget call) must not keep using this connection, which goes
+ // back to the pool when the transaction ends.
+ const scope = { conn, depth: depth + 1, done: false, hooks };
try {
try {
- const out = await inScope({ conn, depth: depth + 1, done: false, hooks }, () => fn(txApi(conn, depth + 1, hooks)));
+ const out = await inScope(scope, () => fn(txApi(conn, depth + 1, hooks)));
+ scope.done = true;
await timed(conn, `RELEASE SAVEPOINT ${name}`, []);
await timed(conn, `RELEASE SAVEPOINT ${name}`, []);
return out;
return out;
- } catch (err) { hooks.length = mark; await timed(conn, `ROLLBACK TO SAVEPOINT ${name}`, []).catch(() => {}); throw err; }
+ } catch (err) { scope.done = true; hooks.length = mark; await timed(conn, `ROLLBACK TO SAVEPOINT ${name}`, []).catch(() => {}); throw err; }
}
}
const iso = ISOLATION[opts.isolation || 'read committed'];
const iso = ISOLATION[opts.isolation || 'read committed'];
if (!iso) throw new TypeError(`openvibe-sdk/db: isolation must be one of ${Object.keys(ISOLATION).join(', ')}`);
if (!iso) throw new TypeError(`openvibe-sdk/db: isolation must be one of ${Object.keys(ISOLATION).join(', ')}`);
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const { AsyncResource } = require('node:async_hooks');
/**
/**
* openvibe-sdk/events (server): transactional outbox and inbox (ADR-004) on the service's own PostgreSQL database
* openvibe-sdk/events (server): transactional outbox and inbox (ADR-004) on the service's own PostgreSQL database
* (an openvibe-sdk/db handle, ADR-035): createPgOutbox / createPgInbox, with outboxSchema / inboxSchema for the
* (an openvibe-sdk/db handle, ADR-035): createPgOutbox / createPgInbox, with outboxSchema / inboxSchema for the
@@ -165,14 +166,21 @@ function createPgOutbox(db, {
@@ -165,14 +166,21 @@ function createPgOutbox(db, {
return total;
return total;
}
}
const flush = serialFlush(doFlush);
const flush = serialFlush(doFlush);
+ // The relay's timers run in the async context the outbox was made in (a service's boot), never the caller's.
+ // kick() is often called inside a transaction that stays open (an inbox handler that goes on fanning out): a
+ // timer made there inherited it through openvibe-sdk/db's AsyncLocalStorage, so its ticks ran inside that
+ // transaction on its connection, and when the connection died mid-transaction every tick failed until the
+ // handler finished ("Client was closed and is not queryable" on OpenVibe.Network, 2026-10-16, for about a
+ // minute after each go-live notice).
+ const home = new AsyncResource('openvibe-outbox');
function schedule(ms) {
function schedule(ms) {
if (!running) return;
if (!running) return;
clearTimeout(timer);
clearTimeout(timer);
- timer = setTimeout(async () => {
+ timer = home.runInAsyncScope(() => setTimeout(async () => {
timer = null;
timer = null;
try { await flush(); } catch (err) { if (onError) { try { onError(err); } catch { /* ignore */ } } }
try { await flush(); } catch (err) { if (onError) { try { onError(err); } catch { /* ignore */ } } }
schedule(intervalMs);
schedule(intervalMs);
- }, ms);
+ }, ms));
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
}
}
return {
return {
@@ -31,6 +31,32 @@ function cases(label, open) {
@@ -31,6 +31,32 @@ function cases(label, open) {
assert.equal(await count.get(), 1);
assert.equal(await count.get(), 1);
} finally { await done(); }
} finally { await done(); }
}],
}],
+ [`${label}: work started inside a savepoint and still running after the transaction is outside it`, async () => {
+ const { db, done } = await open();
+ try {
+ // A timer (an outbox kick, a fire-and-forget call) started inside a nested db.tx keeps its async
+ // context. The savepoint's scope used to stay "running" forever, so that work kept the outer
+ // transaction's connection after it went back to the pool (OpenVibe.Network, 2026-10-16).
+ let later = null;
+ await db.tx(async () => {
+ await db.tx(async () => {
+ later = new Promise((resolve) => setTimeout(() => resolve(db.inTransaction()), 30));
+ assert.equal(db.inTransaction(), true);
+ });
+ assert.equal(db.inTransaction(), true);
+ });
+ assert.equal(await later, false, 'after the transaction, the savepoint\'s timer is outside it');
+ const failed = [];
+ await assert.rejects(db.tx(async () => {
+ await assert.rejects(db.tx(async () => {
+ failed.push(new Promise((resolve) => setTimeout(() => resolve(db.inTransaction()), 30)));
+ throw new Error('inner');
+ }), /inner/);
+ throw new Error('outer');
+ }), /outer/);
+ assert.equal(await failed[0], false, 'a rolled-back savepoint too');
+ } finally { await done(); }
+ }],
[`${label}: afterCommit runs after the commit, in order, never after a rollback; a failed savepoint drops its hooks`, async () => {
[`${label}: afterCommit runs after the commit, in order, never after a rollback; a failed savepoint drops its hooks`, async () => {
const { db, done } = await open();
const { db, done } = await open();
try {
try {
@@ -26,6 +26,35 @@ function eventsClient() {
@@ -26,6 +26,35 @@ function eventsClient() {
function cases(label, open) {
function cases(label, open) {
return [
return [
+ [`${label}: a kick() from inside a transaction never hands the relay that transaction`, async () => {
+ const { db, done } = await open();
+ try {
+ const { platform, events } = eventsClient();
+ // Every claim the relay makes records whether it ran inside an ambient transaction.
+ const inTx = [];
+ const watched = new Proxy(db, {
+ get(target, key) {
+ if (key === 'many') return (...args) => { inTx.push(target.inTransaction()); return target.many(...args); };
+ const v = target[key];
+ return typeof v === 'function' ? v.bind(target) : v;
+ },
+ });
+ const outbox = createPgOutbox(watched, { events, intervalMs: 15 });
+ outbox.start();
+ // An inbox handler: the event's row and the wake-up happen inside the handler's transaction, which
+ // stays open a while after (Network's go-live notice fans out to every follower before it commits).
+ await db.tx(async (t) => {
+ await outbox.enqueue(t, { event_type: 'live.stream.started', actor, subject });
+ outbox.kick();
+ await new Promise((r) => setTimeout(r, 80));
+ });
+ await new Promise((r) => setTimeout(r, 120));
+ await outbox.stop();
+ assert.equal(platform.state.events.length, 1, 'the event went out');
+ assert.ok(inTx.length >= 3, `the relay kept ticking (${inTx.length} claims)`);
+ assert.ok(inTx.every((x) => x === false), 'no tick ran inside the handler\'s transaction');
+ } finally { await done(); }
+ }],
[`${label}: enqueue needs the transaction; a rollback leaves no event; a commit publishes once`, async () => {
[`${label}: enqueue needs the transaction; a rollback leaves no event; a commit publishes once`, async () => {
const { db, done } = await open();
const { db, done } = await open();
try {
try {