openvibers/openvibe.sdk Commits
openvibe-sdk 0.38.1: a fresh database applies its contract migrations (#34)
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.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@@@ -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",@@@ -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,@@@ -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",@@@ -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",@@@ -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,@@@ -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 = () => Daid 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) {@@@ -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));@@@ -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;' });@@@ -186,7 +186,7 @@ const { createTestDb } = require(${JSON.stringify(path.join(__dirname, '..', 'srassert.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, '..', 'srconst 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 () => {
@@ -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.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)
## 0.38.0 (2026-10-10)
- **Removed: the one-time SQLite → PostgreSQL migration tooling** (plan T1, "Delete the migration era"). Every
- **Removed: the one-time SQLite → PostgreSQL migration tooling** (plan T1, "Delete the migration era"). Every
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"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.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
// 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.38.0';
+const SDK_VERSION = '0.38.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.38.0",
+ "version": "0.38.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.38.0",
+ "version": "0.38.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.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.",
"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.38.0';
+const SDK_VERSION = '0.38.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -13,7 +13,11 @@
@@ -13,7 +13,11 @@
* with its checksum; editing an applied file is refused. Runs are serialised by an advisory lock, so several
* 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
* 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
* 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.
* behind PgBouncer may not change the schema, and advisory locks need a session.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -63,6 +67,8 @@ async function migrate({ db, dir, windowDays = 7, dryRun = false, now = () => Da
@@ -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')),
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)`);
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]));
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) {
for (const m of all) {
const prev = done.get(m.id);
const prev = done.get(m.id);
if (prev) {
if (prev) {
@@ -71,7 +77,7 @@ async function migrate({ db, dir, windowDays = 7, dryRun = false, now = () => Da
@@ -71,7 +77,7 @@ async function migrate({ db, dir, windowDays = 7, dryRun = false, now = () => Da
}
}
const later = [...done.keys()].find((id) => id > m.id);
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 (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 exp = done.get(m.after);
const ageDays = exp ? (now() - Date.parse(exp.applied_at)) / 86400000 : -1;
const ageDays = exp ? (now() - Date.parse(exp.applied_at)) / 86400000 : -1;
if (!exp || ageDays < windowDays) {
if (!exp || ageDays < windowDays) {
@@ -180,10 +180,10 @@ async function pgliteTestDb({ migrations, seed, seedKey, service, log }) {
@@ -180,10 +180,10 @@ async function pgliteTestDb({ migrations, seed, seedKey, service, log }) {
await inst.waitReady;
await inst.waitReady;
const db = createDb({ pglite: inst, service: `${service}-test`, log });
const db = createDb({ pglite: inst, service: `${service}-test`, log });
if (migrations) {
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(() => {});
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.
// 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));
const applied = new Set((await db.many('SELECT id FROM ov_migrations').catch(() => [])).map((r) => r.id));
@@ -103,7 +103,7 @@ async function pgOpen() {
@@ -103,7 +103,7 @@ async function pgOpen() {
const tests = [
const tests = [
...suite('pglite', async () => { const db = createDb({ pglite: true, service: 'sdk-test' }); return { db, migrate: (dir) => db.migrate({ dir, log: { log() {} } }) }; }),
...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('bad1', { 'x.sql': '-- phase: expand\nSELECT 1;' })), /NNNN_description/);
assert.throws(() => parse(mdir('bad2', { '0001_a.sql': 'SELECT 1;' })), /phase/);
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/);
assert.throws(() => parse(mdir('bad3', { '0001_a.sql': '-- phase: contract\nSELECT 1;' })), /must name its expand/);
@@ -116,14 +116,42 @@ const tests = [
@@ -116,14 +116,42 @@ const tests = [
'0003_drop.sql': '-- phase: contract\n-- after: 0001\nALTER TABLE t DROP COLUMN old;',
'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);',
'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);');
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/);
await assert.rejects(db.migrate({ dir: d, log: quiet }), /changed after it was applied/);
} finally { await db.close(); }
} 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 });
const db2 = createDb({ pglite: true });
try {
try {
const d2 = mdir('order', { '0001_add.sql': '-- phase: expand\nCREATE TABLE u (id int);', '0005_b.sql': '-- phase: expand\nSELECT 1;' });
const d2 = mdir('order', { '0001_add.sql': '-- phase: expand\nCREATE TABLE u (id int);', '0005_b.sql': '-- phase: expand\nSELECT 1;' });
@@ -186,7 +186,7 @@ const { createTestDb } = require(${JSON.stringify(path.join(__dirname, '..', 'sr
@@ -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');
assert.equal(await seeded.db.value('SELECT count(*)::int FROM keyed'), 1, 'the seed ran');
} finally { await seeded.close(); }
} 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-'));
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, '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');
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
@@ -197,8 +197,8 @@ const { createTestDb } = require(${JSON.stringify(path.join(__dirname, '..', 'sr
const b = await createTestDb({ migrations: m, seed, service: 'sdk' });
const b = await createTestDb({ migrations: m, seed, service: 'sdk' });
try {
try {
assert.equal(b.snapshot, 'hit');
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(); }
} 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 () => {
['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 () => {