openvibers/openvibe.sdk Commits

SDK 0.50.1: savepoint scopes end; outbox timers never join a caller's transaction (#56)

OP OpenVibers committed 27 days ago · 47658d24cf · 10 files, +89 −11

* SDK 0.50.1: savepoint scopes end with the savepoint; the outbox's timers never run inside a caller's transaction

* SDK 0.50.1: version stamped in core, STATUS, lock and the browser bundle

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 12e7d29..fcb9c47 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,16 @@
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.
++## 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
ddiff --git a/STATUS.json b/STATUS.json
iindex 5daa90b..bd04bf5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 7104df3..d185642 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.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,
ddiff --git a/package-lock.json b/package-lock.json
iindex 1f59350..8aab2ef 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 3870f31..0cf30d9 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex d03cd78..90f8b8e 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.50.0';
++const SDK_VERSION = '0.50.1';
module.exports = {
createClient,
ddiff --git a/src/db/index.js b/src/db/index.js
iindex 06dc254..1b03956 100644
---- a/src/db/index.js
++++ b/src/db/index.js
@@@ -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(', ')}`);
ddiff --git a/src/outbox.js b/src/outbox.js
iindex 960d969..1791645 100644
---- a/src/outbox.js
++++ b/src/outbox.js
@@@ -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 {
ddiff --git a/test/db-ambient.test.js b/test/db-ambient.test.js
iindex 324da22..8047fda 100644
---- a/test/db-ambient.test.js
++++ b/test/db-ambient.test.js
@@@ -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 {
ddiff --git a/test/pg-outbox.test.js b/test/pg-outbox.test.js
iindex 3d10442..0b5ae23 100644
---- a/test/pg-outbox.test.js
++++ b/test/pg-outbox.test.js
@@@ -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 {