openvibers/openvibe.live Commits
Drop the legacy chat tables with foreign-key enforcement off: the implicit per-row delete over chat_messages' self-reference kept the release from becoming ready (#35)
Changes
@@@ -137,6 +137,11 @@ const MIGRATIONS = [},{id: '007_drop_chat_tables',++ // With foreign_keys ON, a table drop first runs an implicit DELETE of every row, and chat_messages' unindexed++ // self-reference (reply_to_id ON DELETE SET NULL) makes that a full scan per deleted row: quadratic, minutes++ // of CPU on production's table (the first deploy of this migration never became ready). Dropping with++ // enforcement off just frees the pages; nothing outside the twelve references them.++ foreignKeys: false,// T3 final step (2026-10-05). OpenVibe.Chat owns these twelve tables and has been their only// writer since the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept);// Live's readers and writers are gone (#28-#32 and the release that carries this migration,@@@ -256,11 +261,19 @@ function run(db, list = MIGRATIONS) {try {let adopted = false;let deferred = false;-- db.transaction(() => {-- if (m.adopt && m.adopt(db)) { adopted = true; }-- else if (m.up(db) === DEFER) { deferred = true; return; }-- record.run(m.id, adopted ? 'adopted' : 'applied', Date.now() - started);-- })();++ // `foreignKeys: false` runs the migration with enforcement off (SQLite only changes the pragma outside a++ // transaction): for drops of large tables, whose implicit per-row DELETE would otherwise run first.++ const fkOff = m.foreignKeys === false;++ if (fkOff) db.pragma('foreign_keys = OFF');++ try {++ db.transaction(() => {++ if (m.adopt && m.adopt(db)) { adopted = true; }++ else if (m.up(db) === DEFER) { deferred = true; return; }++ record.run(m.id, adopted ? 'adopted' : 'applied', Date.now() - started);++ })();++ } finally {++ if (fkOff) db.pragma('foreign_keys = ON');++ }failures.delete(m.id);const outcome = deferred ? 'deferred' : adopted ? 'adopted' : 'applied';results.push({ id: m.id, outcome });@@@ -108,5 +108,22 @@ const res = migrations.run(mem, migrations.MIGRATIONS.filter((m) => m.id === DROassert.strictEqual(res[0].outcome, 'adopted', 'a fresh database adopts the drop');mem.close();++// 5. Fast on a large table: chat_messages references itself (reply_to_id ON DELETE SET NULL, no index), so a drop++// with foreign_keys ON scans the table once per row. The migration runs with enforcement off, and turns it back on.++const big = new Database(':memory:');++big.pragma('foreign_keys = ON');++big.exec(`CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT NOT NULL,++ reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL)`);++const ins = big.prepare('INSERT INTO chat_messages (message, reply_to_id) VALUES (?, ?)');++big.transaction(() => { for (let i = 1; i <= 30000; i++) ins.run('line', i > 1 ? i - 1 : null); })();++const t0 = Date.now();++const out = migrations.run(big, migrations.MIGRATIONS.filter((m) => m.id === DROP_ID));++const took = Date.now() - t0;++assert.strictEqual(out[0].outcome, 'applied', JSON.stringify(out));++assert.ok(took < 3000, `30,000 self-referencing rows drop in well under the boot budget (took ${took} ms)`);++assert.strictEqual(big.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'chat_messages'").get().n, 0);++assert.strictEqual(big.pragma('foreign_keys', { simple: true }), 1, 'foreign keys are back on after the migration');++big.close();++console.log(`chat tables dropped: no server/ code names the ${TABLES.length} outside ${DROP_ID}; fresh schema none; a boot drops legacy copies and adopts a fresh database`);process.exit(0);
@@ -137,6 +137,11 @@ const MIGRATIONS = [
@@ -137,6 +137,11 @@ const MIGRATIONS = [
},
},
{
{
id: '007_drop_chat_tables',
id: '007_drop_chat_tables',
+ // With foreign_keys ON, a table drop first runs an implicit DELETE of every row, and chat_messages' unindexed
+ // self-reference (reply_to_id ON DELETE SET NULL) makes that a full scan per deleted row: quadratic, minutes
+ // of CPU on production's table (the first deploy of this migration never became ready). Dropping with
+ // enforcement off just frees the pages; nothing outside the twelve references them.
+ foreignKeys: false,
// T3 final step (2026-10-05). OpenVibe.Chat owns these twelve tables and has been their only
// T3 final step (2026-10-05). OpenVibe.Chat owns these twelve tables and has been their only
// writer since the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept);
// writer since the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept);
// Live's readers and writers are gone (#28-#32 and the release that carries this migration,
// Live's readers and writers are gone (#28-#32 and the release that carries this migration,
@@ -256,11 +261,19 @@ function run(db, list = MIGRATIONS) {
@@ -256,11 +261,19 @@ function run(db, list = MIGRATIONS) {
try {
try {
let adopted = false;
let adopted = false;
let deferred = false;
let deferred = false;
- db.transaction(() => {
- if (m.adopt && m.adopt(db)) { adopted = true; }
- else if (m.up(db) === DEFER) { deferred = true; return; }
- record.run(m.id, adopted ? 'adopted' : 'applied', Date.now() - started);
- })();
+ // `foreignKeys: false` runs the migration with enforcement off (SQLite only changes the pragma outside a
+ // transaction): for drops of large tables, whose implicit per-row DELETE would otherwise run first.
+ const fkOff = m.foreignKeys === false;
+ if (fkOff) db.pragma('foreign_keys = OFF');
+ try {
+ db.transaction(() => {
+ if (m.adopt && m.adopt(db)) { adopted = true; }
+ else if (m.up(db) === DEFER) { deferred = true; return; }
+ record.run(m.id, adopted ? 'adopted' : 'applied', Date.now() - started);
+ })();
+ } finally {
+ if (fkOff) db.pragma('foreign_keys = ON');
+ }
failures.delete(m.id);
failures.delete(m.id);
const outcome = deferred ? 'deferred' : adopted ? 'adopted' : 'applied';
const outcome = deferred ? 'deferred' : adopted ? 'adopted' : 'applied';
results.push({ id: m.id, outcome });
results.push({ id: m.id, outcome });
@@ -108,5 +108,22 @@ const res = migrations.run(mem, migrations.MIGRATIONS.filter((m) => m.id === DRO
@@ -108,5 +108,22 @@ const res = migrations.run(mem, migrations.MIGRATIONS.filter((m) => m.id === DRO
assert.strictEqual(res[0].outcome, 'adopted', 'a fresh database adopts the drop');
assert.strictEqual(res[0].outcome, 'adopted', 'a fresh database adopts the drop');
mem.close();
mem.close();
+// 5. Fast on a large table: chat_messages references itself (reply_to_id ON DELETE SET NULL, no index), so a drop
+// with foreign_keys ON scans the table once per row. The migration runs with enforcement off, and turns it back on.
+const big = new Database(':memory:');
+big.pragma('foreign_keys = ON');
+big.exec(`CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT NOT NULL,
+ reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL)`);
+const ins = big.prepare('INSERT INTO chat_messages (message, reply_to_id) VALUES (?, ?)');
+big.transaction(() => { for (let i = 1; i <= 30000; i++) ins.run('line', i > 1 ? i - 1 : null); })();
+const t0 = Date.now();
+const out = migrations.run(big, migrations.MIGRATIONS.filter((m) => m.id === DROP_ID));
+const took = Date.now() - t0;
+assert.strictEqual(out[0].outcome, 'applied', JSON.stringify(out));
+assert.ok(took < 3000, `30,000 self-referencing rows drop in well under the boot budget (took ${took} ms)`);
+assert.strictEqual(big.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'chat_messages'").get().n, 0);
+assert.strictEqual(big.pragma('foreign_keys', { simple: true }), 1, 'foreign keys are back on after the migration');
+big.close();
+
console.log(`chat tables dropped: no server/ code names the ${TABLES.length} outside ${DROP_ID}; fresh schema none; a boot drops legacy copies and adopts a fresh database`);
console.log(`chat tables dropped: no server/ code names the ${TABLES.length} outside ${DROP_ID}; fresh schema none; a boot drops legacy copies and adopts a fresh database`);
process.exit(0);
process.exit(0);