openvibers/openvibe.sdk Commits

openvibe-sdk 0.38.1: a fresh database applies its contract migrations (#34)

OP OpenVibers committed 10 Oct 2026 · 964c297295 · 10 files, +66 −22

`migrate`: a **fresh database applies its contract migrations at once.**

When a run starts with no row in `ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be running against it, so the N-1 window (ADR-016/028) has nothing to protect. Before, a fresh database stopped at its first contract for `windowDays`. A held contract holds everything after it, so every migration past that point went untested. Found on 2026-10-10: OpenVibe.Chat's test databases have stopped at 0004 since its 0005 contract, so its 0006 and 0007 never ran in a test. A new expand (Chat's SDK outbox) could not be tested at all.

**Existing databases are unchanged.** A contract waits until its expand has been applied for the window, and holds what follows. That includes an expand and its contract arriving in the same deploy, because the previous release may be running against the expanded schema.

Only three repositories have contract migrations today (Chat, Codes, Tips), so only their test databases change: they now carry the final schema, as production does after the window.

- `src/db/migrate.js`: `fresh = done.size === 0` and contracts are not held on a fresh run; the header explains why.
- `test/db.test.js`: a fresh database applies the contract with the rest. An existing database holds a new contract and the expand after it until the window passes. An expand plus contract in one deploy holds the contract.
- `test/testing-db.test.js`: a snapshot built fresh carries its contract, and a hit is that schema. `src/testing/db.js` comment updated.

0.38.1: version, changelog, STATUS and browser bundle updated. `ov test`: 40 files reached, green.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9655527..1a11726 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.38.1 (2026-10-10)
++
++- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in
++ `ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be
++ running against it, so the N-1 window has nothing to protect. Before, a fresh database stopped at its first contract
++ for `windowDays`, and because a held contract holds everything after it, every migration past that point went
++ untested: OpenVibe.Chat's test databases had stopped at 0004 since its 0005 contract. An existing database is
++ unchanged: a contract waits for its expand's window, also when both arrive in the same deploy, and holds what follows.
++- `createTestDb` snapshots are built on a fresh database, so they carry the contracts too.
++
## 0.38.0 (2026-10-10)
- **Removed: the one-time SQLite → PostgreSQL migration tooling** (plan T1, "Delete the migration era"). Every
ddiff --git a/STATUS.json b/STATUS.json
iindex 644bef0..fc6d6d6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.38.0",
++ "version": "0.38.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 20b541a..e0de004 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.38.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.38.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.38.0';
++const SDK_VERSION = '0.38.1';
module.exports = {
createClient,
ddiff --git a/package-lock.json b/package-lock.json
iindex 9aba265..bf28f31 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.38.0",
++ "version": "0.38.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.38.0",
++ "version": "0.38.1",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 6991fe3..063f81c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.38.0",
++ "version": "0.38.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 8849cc5..b16f481 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.38.0';
++const SDK_VERSION = '0.38.1';
module.exports = {
createClient,
ddiff --git a/src/db/migrate.js b/src/db/migrate.js
iindex 571e0c8..1db61d4 100644
---- a/src/db/migrate.js
++++ b/src/db/migrate.js
@@@ -13,7 +13,11 @@
* with its checksum; editing an applied file is refused. Runs are serialised by an advisory lock, so several
* processes starting together apply each migration once. A contract migration is refused until the expand it
* names has been applied for `windowDays` (default 7, the N-1 window of ADR-016), so a rollback to the previous
-- * release still finds what it expects. Run it with the owner role (DATABASE_DIRECT_URL): the runtime role
++ * release still finds what it expects; a held contract holds every later migration too (order is kept). A fresh
++ * database (no row in ov_migrations when the run starts: a new install, a test database, a restore drill into an
++ * empty schema) applies its contracts at once: no previous release can be running against it, and holding them
++ * would stop every fresh database at its first contract for the length of the window, with everything after it
++ * untested. Run it with the owner role (DATABASE_DIRECT_URL): the runtime role
* behind PgBouncer may not change the schema, and advisory locks need a session.
*/
const crypto = require('crypto');
@@@ -63,6 +67,8 @@ async function migrate({ db, dir, windowDays = 7, dryRun = false, now = () => Da
id text PRIMARY KEY, name text NOT NULL, phase text NOT NULL CHECK (phase IN ('expand','migrate','contract')),
checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now(), duration_ms integer NOT NULL DEFAULT 0)`);
const done = new Map((await t.many('SELECT id, checksum, applied_at FROM ov_migrations')).map((r) => [r.id, r]));
++ // A database with no applied migration has no previous release to protect: its contracts apply at once.
++ const fresh = done.size === 0;
for (const m of all) {
const prev = done.get(m.id);
if (prev) {
@@@ -71,7 +77,7 @@ async function migrate({ db, dir, windowDays = 7, dryRun = false, now = () => Da
}
const later = [...done.keys()].find((id) => id > m.id);
if (later) throw new Error(`migrate: ${m.file} is older than applied migration ${later}; number new migrations after the last one`);
-- if (m.phase === 'contract') {
++ if (m.phase === 'contract' && !fresh) {
const exp = done.get(m.after);
const ageDays = exp ? (now() - Date.parse(exp.applied_at)) / 86400000 : -1;
if (!exp || ageDays < windowDays) {
ddiff --git a/src/testing/db.js b/src/testing/db.js
iindex c64a789..a1421cf 100644
---- a/src/testing/db.js
++++ b/src/testing/db.js
@@@ -180,10 +180,10 @@ async function pgliteTestDb({ migrations, seed, seedKey, service, log }) {
await inst.waitReady;
const db = createDb({ pglite: inst, service: `${service}-test`, log });
if (migrations) {
-- // The snapshot froze ov_migrations.applied_at at build time. An ADR-028 contract migration is
-- // held until its expand has been applied for windowDays, so a reused snapshot would let it run
-- // ~7 days after it was built, while a fresh migrate holds it. Refresh the ages so the loaded
-- // database looks freshly migrated, as it did before snapshots.
++ // The snapshot froze ov_migrations.applied_at at build time. It was built on a fresh database,
++ // so its contracts already ran (sdk/db applies them on a fresh database); refreshing the ages keeps
++ // a contract added after the build (the key covers the migrations, so that is a stale snapshot)
++ // held for the window, as on any existing database.
await db.query('UPDATE ov_migrations SET applied_at = now()').catch(() => {});
// The key covers the migrations, so this is a check: migrate only if the snapshot is behind.
const applied = new Set((await db.many('SELECT id FROM ov_migrations').catch(() => [])).map((r) => r.id));
ddiff --git a/test/db.test.js b/test/db.test.js
iindex fa85636..76796ea 100644
---- a/test/db.test.js
++++ b/test/db.test.js
@@@ -103,7 +103,7 @@ async function pgOpen() {
const tests = [
...suite('pglite', async () => { const db = createDb({ pglite: true, service: 'sdk-test' }); return { db, migrate: (dir) => db.migrate({ dir, log: { log() {} } }) }; }),
-- ['migration rules: phases, edits refused, order kept, contract held in the N-1 window, no-transaction', async () => {
++ ['migration rules: phases, edits refused, order kept, contract held in the N-1 window (not on a fresh database), no-transaction', async () => {
assert.throws(() => parse(mdir('bad1', { 'x.sql': '-- phase: expand\nSELECT 1;' })), /NNNN_description/);
assert.throws(() => parse(mdir('bad2', { '0001_a.sql': 'SELECT 1;' })), /phase/);
assert.throws(() => parse(mdir('bad3', { '0001_a.sql': '-- phase: contract\nSELECT 1;' })), /must name its expand/);
@@@ -116,14 +116,42 @@ const tests = [
'0003_drop.sql': '-- phase: contract\n-- after: 0001\nALTER TABLE t DROP COLUMN old;',
'0004_idx.sql': '-- phase: expand\n-- no-transaction\nCREATE INDEX t_new ON t (new);',
});
-- let r = await db.migrate({ dir: d, log: quiet });
-- assert.deepEqual(r.applied.map((a) => a.id), ['0001', '0002']);
-- assert.match(r.held[0].reason, /N-1 window is 7/);
-- r = await db.migrate({ dir: d, log: quiet, now: () => Date.now() + 8 * 86400000 });
-- assert.deepEqual(r.applied.map((a) => a.id), ['0003', '0004'], 'after the window the contract runs, then the rest');
++ // A fresh database: no previous release can exist, so the contract applies with the rest.
++ const r0 = await db.migrate({ dir: d, log: quiet });
++ assert.deepEqual(r0.applied.map((a) => a.id), ['0001', '0002', '0003', '0004'], 'a fresh database applies its contracts at once');
++ assert.deepEqual(r0.held, []);
fs.writeFileSync(path.join(d, '0002_fill.sql'), '-- phase: migrate\nUPDATE t SET new = upper(old);');
await assert.rejects(db.migrate({ dir: d, log: quiet }), /changed after it was applied/);
} finally { await db.close(); }
++ // An existing database (the expand ran in an earlier deploy): the contract waits out the N-1 window, and
++ // what comes after it waits too.
++ const dbw = createDb({ pglite: true });
++ try {
++ const dw = mdir('window', {
++ '0001_add.sql': '-- phase: expand\nCREATE TABLE t (id int PRIMARY KEY, old text, new text);',
++ '0002_fill.sql': '-- phase: migrate\nUPDATE t SET new = old;',
++ });
++ await dbw.migrate({ dir: dw, log: quiet });
++ fs.writeFileSync(path.join(dw, '0003_drop.sql'), '-- phase: contract\n-- after: 0001\nALTER TABLE t DROP COLUMN old;');
++ fs.writeFileSync(path.join(dw, '0004_idx.sql'), '-- phase: expand\n-- no-transaction\nCREATE INDEX t_new ON t (new);');
++ let r = await dbw.migrate({ dir: dw, log: quiet });
++ assert.deepEqual(r.applied.map((a) => a.id), [], 'held, and the expand after it waits');
++ assert.match(r.held[0].reason, /N-1 window is 7/);
++ r = await dbw.migrate({ dir: dw, log: quiet, now: () => Date.now() + 8 * 86400000 });
++ assert.deepEqual(r.applied.map((a) => a.id), ['0003', '0004'], 'after the window the contract runs, then the rest');
++ } finally { await dbw.close(); }
++ // An existing database that gets an expand and its contract in the same deploy still holds the contract:
++ // the previous release may be running against the expanded schema.
++ const dbs = createDb({ pglite: true });
++ try {
++ const ds = mdir('same-deploy', { '0001_base.sql': '-- phase: expand\nCREATE TABLE base (id int);' });
++ await dbs.migrate({ dir: ds, log: quiet });
++ fs.writeFileSync(path.join(ds, '0002_add.sql'), '-- phase: expand\nCREATE TABLE v (id int, old text);');
++ fs.writeFileSync(path.join(ds, '0003_drop.sql'), '-- phase: contract\n-- after: 0002\nALTER TABLE v DROP COLUMN old;');
++ const r = await dbs.migrate({ dir: ds, log: quiet });
++ assert.deepEqual(r.applied.map((a) => a.id), ['0002']);
++ assert.strictEqual(r.held[0].id, '0003');
++ } finally { await dbs.close(); }
const db2 = createDb({ pglite: true });
try {
const d2 = mdir('order', { '0001_add.sql': '-- phase: expand\nCREATE TABLE u (id int);', '0005_b.sql': '-- phase: expand\nSELECT 1;' });
ddiff --git a/test/testing-db.test.js b/test/testing-db.test.js
iindex 351b921..2e36fd3 100644
---- a/test/testing-db.test.js
++++ b/test/testing-db.test.js
@@@ -186,7 +186,7 @@ const { createTestDb } = require(${JSON.stringify(path.join(__dirname, '..', 'sr
assert.equal(await seeded.db.value('SELECT count(*)::int FROM keyed'), 1, 'the seed ran');
} finally { await seeded.close(); }
}],
-- ['snapshot: a hit refreshes ov_migrations ages, so an ADR-028 contract stays held as on a fresh migrate', async () => {
++ ['snapshot: a fresh build applies its ADR-028 contract (no previous release exists), and a hit is that same schema', async () => {
const m = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-testdb-'));
fs.writeFileSync(path.join(m, '0001_expand.sql'), '-- phase: expand\nCREATE TABLE legacy (id int);\n');
fs.writeFileSync(path.join(m, '0002_contract.sql'), '-- phase: contract\n-- after: 0001\nCREATE TABLE contracted (id int);\n');
@@@ -197,8 +197,8 @@ const { createTestDb } = require(${JSON.stringify(path.join(__dirname, '..', 'sr
const b = await createTestDb({ migrations: m, seed, service: 'sdk' });
try {
assert.equal(b.snapshot, 'hit');
-- assert.equal(await b.db.value("SELECT to_regclass('contracted')"), null, 'the contract is still held');
-- assert.equal(await b.db.value("SELECT count(*)::int FROM ov_migrations WHERE id = '0002'"), 0, 'the contract is not recorded');
++ assert.equal(await b.db.value("SELECT to_regclass('contracted')::text"), 'contracted', 'the contract ran when the snapshot was built');
++ assert.equal(await b.db.value("SELECT count(*)::int FROM ov_migrations WHERE id = '0002'"), 1, 'the contract is recorded');
} finally { await b.close(); }
}],
['a run\'s schema name carries when it was made (so a killed run\'s leftovers can be swept); an older name has none', async () => {