@cleocode/core 2026.6.9 → 2026.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@
11
11
  * @see https://github.com/anthropics/cleo/issues/65
12
12
  */
13
13
  import { copyFileSync, existsSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
14
15
  import { readMigrationFiles } from 'drizzle-orm/migrator';
15
16
  import { getLogger } from '../logger.js';
16
17
  import { isSqliteBusy } from './with-retry.js';
@@ -27,6 +28,178 @@ export { isSqliteBusy };
27
28
  const MAX_MIGRATION_RETRIES = 5;
28
29
  const MIGRATION_RETRY_BASE_DELAY_MS = 100;
29
30
  const MIGRATION_RETRY_MAX_DELAY_MS = 2000;
31
+ /**
32
+ * Strip SQL line (`-- …`) and block (`/* … *​/`) comments from a migration's SQL
33
+ * before scanning it for DDL targets.
34
+ *
35
+ * Migration files routinely carry prose comments describing the change — phrases
36
+ * like "the project-side CREATE TABLE half of that move" or "CREATE TABLE IF NOT
37
+ * EXISTS ensures it exists". A DDL-extraction regex run over the RAW SQL captures
38
+ * bogus "table" names from those comments (`half`, `IF`, `ensures`), making a
39
+ * fully-satisfied migration look un-applied. The journal then omits it, Drizzle
40
+ * re-runs its bare `CREATE TABLE …` against a DB where the table already exists,
41
+ * and the open fails (root cause of the "Task database not initialized" poison).
42
+ *
43
+ * Always strip comments via this helper BEFORE any `createTableRegex` /
44
+ * `createIndexRegex` / `alterColumnRegex` scan. The pair of replaces matches the
45
+ * idiom already used by {@link isExecutableStatement} and Scenario 3's
46
+ * comment-only baseline detection.
47
+ *
48
+ * @param sql - Raw migration SQL (one statement or many joined with `\n`)
49
+ * @returns The SQL with all `--` line comments and `/* … *​/` block comments removed
50
+ */
51
+ export function stripSqlComments(sql) {
52
+ return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
53
+ }
54
+ /**
55
+ * Compute the set of tables that a migration lineage CREATEs and a LATER
56
+ * migration permanently ELIMINATES (DROP TABLE with no subsequent recreate).
57
+ *
58
+ * ## Why (bug #2)
59
+ *
60
+ * The DDL probe ({@link probeAndMarkApplied}) marks a `CREATE TABLE foo`
61
+ * migration applied only if `foo` exists in the live schema. That breaks when a
62
+ * later migration DROPs `foo` for good: e.g. `t033`/`initial` create
63
+ * `release_manifests`, which `t9686b2` later DROPs (superseded by `releases`).
64
+ * On a DB that has run the WHOLE lineage, `release_manifests` is correctly
65
+ * absent — but the probe then refuses to mark `t033`/`initial` applied, so
66
+ * drizzle's `migrate()` re-runs their bare `CREATE TABLE release_manifests`
67
+ * against the live DB and throws ("table already exists" / cascade) — surfacing
68
+ * as the opaque E_NOT_INITIALIZED / E_INTERNAL on a consolidated `cleo.db`.
69
+ *
70
+ * Pre-computing the eliminated set lets the probe treat a CREATE-of-an-
71
+ * eliminated-table as already satisfied: the table is *supposed* to be gone, so
72
+ * its absence is evidence the lineage ran, not that it didn't.
73
+ *
74
+ * ## Disposition tracking
75
+ *
76
+ * Walks migrations in `folderMillis` order and, WITHIN each migration, processes
77
+ * statements IN ORDER so the SQLite rebuild/recreate idioms resolve correctly:
78
+ *
79
+ * - `CREATE TABLE x` (or `… RENAME TO x`) → `x` is present.
80
+ * - `DROP TABLE x` → `x` is dropped.
81
+ *
82
+ * The rebuild idiom (`CREATE __new_x; DROP x; ALTER __new_x RENAME TO x`) and the
83
+ * drop-then-recreate idiom (`DROP x; CREATE x`) both end with `x` PRESENT because
84
+ * the final in-order statement for `x` is a create/rename. A table is
85
+ * "eliminated" only if the LAST statement touching it across the whole lineage is
86
+ * a DROP. Transient intermediates (`__new_*`) are naturally classified by the
87
+ * same rule and are irrelevant to the probe (which already redirects renames to
88
+ * the final name).
89
+ *
90
+ * @param migrations - All local migrations of one lineage (from readMigrationFiles)
91
+ * @returns The set of permanently-eliminated final table names
92
+ * @task T11553
93
+ */
94
+ export function computeEliminatedTables(migrations) {
95
+ const createTableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/i;
96
+ const dropTableRegex = /DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?[`"]?(\w+)[`"]?/i;
97
+ const renameRegex = /ALTER\s+TABLE\s+[`"]?(\w+)[`"]?\s+RENAME\s+TO\s+[`"]?(\w+)[`"]?/i;
98
+ // Final disposition per table: true = present, false = dropped.
99
+ const disposition = new Map();
100
+ const ordered = [...migrations].sort((a, b) => a.folderMillis - b.folderMillis);
101
+ for (const migration of ordered) {
102
+ const statements = Array.isArray(migration.sql) ? migration.sql : [migration.sql ?? ''];
103
+ for (const rawStatement of statements) {
104
+ // Split on `;` so multiple DDL statements joined in one `.sql` array entry
105
+ // are still evaluated IN ORDER (drizzle usually splits on
106
+ // statement-breakpoint, but a migration may carry several `;`-separated
107
+ // statements in a single chunk).
108
+ const stripped = stripSqlComments(rawStatement);
109
+ for (const clause of stripped.split(';')) {
110
+ const create = createTableRegex.exec(clause);
111
+ if (create) {
112
+ disposition.set(create[1], true);
113
+ continue;
114
+ }
115
+ const rename = renameRegex.exec(clause);
116
+ if (rename) {
117
+ disposition.set(rename[2], true);
118
+ continue;
119
+ }
120
+ const drop = dropTableRegex.exec(clause);
121
+ if (drop) {
122
+ disposition.set(drop[1], false);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ const eliminated = new Set();
128
+ for (const [table, present] of disposition) {
129
+ if (!present)
130
+ eliminated.add(table);
131
+ }
132
+ return eliminated;
133
+ }
134
+ /**
135
+ * The timestamp-prefix of the consolidation cutover migration
136
+ * (`20260531000001_t11363-consolidation-cleo-{project,global}`), used as the
137
+ * fallback when the consolidation migration cannot be located on disk.
138
+ *
139
+ * Migration folder names start with a 14-char numeric timestamp prefix
140
+ * (`YYYYMMDDHHMMSS`). Comparing those prefixes lexicographically is equivalent
141
+ * to comparing the encoded timestamps, so this string is the cutover boundary.
142
+ *
143
+ * @task T11553
144
+ */
145
+ export const CONSOLIDATION_CUTOVER_PREFIX = '20260531000001';
146
+ /**
147
+ * Length of a drizzle migration folder's leading `YYYYMMDDHHMMSS` timestamp.
148
+ */
149
+ const MIGRATION_TIMESTAMP_PREFIX_LEN = 14;
150
+ /**
151
+ * Resolve the consolidation cutover timestamp-prefix for a lineage being
152
+ * reconciled, by locating the `*-consolidation-cleo-*` migration in the SIBLING
153
+ * consolidated folders (`drizzle-cleo-project` / `drizzle-cleo-global`) next to
154
+ * the supplied `migrationsFolder`.
155
+ *
156
+ * The legacy lineages (`drizzle-tasks` / `drizzle-brain`) do NOT themselves
157
+ * contain the consolidation migration — it lives in the consolidated folders —
158
+ * so we scan the siblings. Best-effort: if neither sibling is present/readable
159
+ * (partial install) the function returns {@link CONSOLIDATION_CUTOVER_PREFIX}.
160
+ *
161
+ * @param migrationsFolder - Absolute path to the lineage folder being reconciled
162
+ * @returns The 14-char timestamp prefix of the consolidation cutover migration
163
+ * @task T11553
164
+ */
165
+ export function resolveConsolidationCutoverPrefix(migrationsFolder) {
166
+ const parent = dirname(migrationsFolder);
167
+ for (const setName of ['drizzle-cleo-project', 'drizzle-cleo-global']) {
168
+ const folder = join(parent, setName);
169
+ if (!existsSync(folder))
170
+ continue;
171
+ try {
172
+ const consolidation = readMigrationFiles({ migrationsFolder: folder }).find((m) => /-consolidation-cleo-/.test(m.name ?? ''));
173
+ if (consolidation?.name) {
174
+ return consolidation.name.slice(0, MIGRATION_TIMESTAMP_PREFIX_LEN);
175
+ }
176
+ }
177
+ catch {
178
+ // Best-effort — fall through to the next sibling / the constant fallback.
179
+ }
180
+ }
181
+ return CONSOLIDATION_CUTOVER_PREFIX;
182
+ }
183
+ /**
184
+ * Whether a migration's `YYYYMMDDHHMMSS` timestamp prefix is at or before the
185
+ * consolidation cutover — i.e. it is a PRE-consolidation legacy migration
186
+ * subsumed by the consolidation snapshot (vs a NEW post-consolidation migration
187
+ * that must run normally).
188
+ *
189
+ * @param migrationName - The migration folder name (e.g. `20260321000000_t033-…`)
190
+ * @param cutoverPrefix - The cutover prefix from {@link resolveConsolidationCutoverPrefix}
191
+ * @returns true if at/before the cutover (pre-consolidation), false if after
192
+ * @task T11553
193
+ */
194
+ function isAtOrBeforeCutover(migrationName, cutoverPrefix) {
195
+ const prefix = (migrationName ?? '').slice(0, MIGRATION_TIMESTAMP_PREFIX_LEN);
196
+ // A name without a parseable prefix is treated as pre-cutover (legacy/unknown):
197
+ // safer to stamp-and-skip than to re-run an unknown legacy migration. Real
198
+ // post-consolidation migrations always carry a valid prefix.
199
+ if (prefix.length < MIGRATION_TIMESTAMP_PREFIX_LEN)
200
+ return true;
201
+ return prefix <= cutoverPrefix;
202
+ }
30
203
  /**
31
204
  * Check whether a table exists in a SQLite database.
32
205
  */
@@ -92,14 +265,56 @@ function insertJournalEntry(nativeDb, hash, createdAt, name) {
92
265
  * Replaces the broken "wholesale mark applied" pattern that was the root cause
93
266
  * of the ensureColumns band-aid sprawl (T632).
94
267
  *
268
+ * ## Eliminated-table tolerance (bug #2 · T11553)
269
+ *
270
+ * A `CREATE TABLE foo` whose `foo` is in `eliminatedTables` — i.e. a LATER
271
+ * migration in the same lineage permanently DROPs it (e.g. `release_manifests`,
272
+ * dropped by `t9686b2`) — is treated as ALREADY SATISFIED. On a DB that ran the
273
+ * whole lineage the table is *supposed* to be absent; without this tolerance the
274
+ * probe refuses to mark the creating migration applied, drizzle re-runs its bare
275
+ * `CREATE TABLE` and throws. See {@link computeEliminatedTables}.
276
+ *
277
+ * ## Zero-DDL discriminator (bug #2 follow-up · T11553)
278
+ *
279
+ * A migration with NO probe-able DDL targets (pure `INSERT`/`UPDATE`/`DELETE`
280
+ * backfill, `DROP TABLE`-only, `CREATE VIEW`, …) cannot be verified by schema
281
+ * inspection. The cutover decides:
282
+ *
283
+ * - **at/before** the consolidation cutover → a PRE-consolidation legacy
284
+ * migration, already subsumed and possibly NON-idempotent (`UPDATE`/plain
285
+ * `INSERT`) → STAMP applied (do NOT re-run).
286
+ * - **after** the cutover → a NEW post-consolidation migration whose effect has
287
+ * NOT run yet → do NOT stamp (return false) so drizzle `migrate()` executes
288
+ * it. New migrations are required to be idempotent (`INSERT OR IGNORE` /
289
+ * `DROP … IF EXISTS` / `CREATE … IF NOT EXISTS`), so running them is correct
290
+ * and harmless on re-run; they journal normally afterwards.
291
+ *
292
+ * Migrations WITH DDL targets keep the probe-tolerance behaviour unchanged
293
+ * (including post-cutover ones like t11538/t11649 that must be probe-stampable
294
+ * once already applied).
295
+ *
95
296
  * @param nativeDb - Native SQLite database handle
96
297
  * @param migration - One entry from drizzle's readMigrationFiles
97
298
  * @param logSubsystem - Logger subsystem name
299
+ * @param eliminatedTables - Final table names a later migration permanently DROPs
300
+ * (from {@link computeEliminatedTables}); their CREATE targets count as
301
+ * satisfied. Defaults to empty (no tolerance) for callers that do not supply it.
302
+ * @param consolidationCutoverPrefix - The cutover timestamp-prefix from
303
+ * {@link resolveConsolidationCutoverPrefix}; gates the zero-DDL stamp/run
304
+ * decision. Defaults to {@link CONSOLIDATION_CUTOVER_PREFIX}.
98
305
  * @returns true if the journal entry was inserted; false if migration must run
99
306
  */
100
- function probeAndMarkApplied(nativeDb, migration, logSubsystem) {
307
+ function probeAndMarkApplied(nativeDb, migration, logSubsystem, eliminatedTables = new Set(), consolidationCutoverPrefix = CONSOLIDATION_CUTOVER_PREFIX) {
101
308
  const sqlStatements = Array.isArray(migration.sql) ? migration.sql : [migration.sql ?? ''];
102
- const fullSql = sqlStatements.join('\n');
309
+ // Strip SQL line (`--`) and block (`/* */`) comments BEFORE any DDL-target
310
+ // extraction. Prose comments routinely contain phrases like "the project-side
311
+ // CREATE TABLE half of that move", which would otherwise make createTableRegex
312
+ // capture a phantom target (`half`) → tableExists(phantom) false → the
313
+ // migration is wrongly left un-journaled → Drizzle re-runs its bare CREATE
314
+ // TABLE against an existing table and crashes (root-cause of the
315
+ // "Task database not initialized" poison). Mirrors the same idiom already used
316
+ // by reconcileBrainMigrationsForConsolidatedDb and Scenario 3 below.
317
+ const fullSql = stripSqlComments(sqlStatements.join('\n'));
103
318
  // Extract DDL targets we can probe.
104
319
  const alterColumnRegex = /ALTER\s+TABLE\s+[`"]?(\w+)[`"]?\s+ADD\s+COLUMN\s+[`"]?(\w+)[`"]?/gi;
105
320
  const createTableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi;
@@ -154,10 +369,24 @@ function probeAndMarkApplied(nativeDb, migration, logSubsystem) {
154
369
  // `allCreatedTablesAreRenamed` gate was too narrow). Pure CREATE INDEX
155
370
  // migrations (no rename, no rebuild) still probe their indexes normally.
156
371
  const performsTableRebuild = renameMap.size > 0;
372
+ // Capture `CREATE INDEX <name> ON <table>` so the probe can skip indexes that
373
+ // live on an ELIMINATED table (bug #2): when `release_manifests` is dropped by
374
+ // a later migration, SQLite drops `idx_release_manifests_*` with it — probing
375
+ // for them would wrongly report the creating migration un-applied.
376
+ const createIndexWithTableRegex = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?\s+ON\s+[`"]?(\w+)[`"]?/gi;
377
+ const indexOnTable = new Map();
378
+ for (const m of fullSql.matchAll(createIndexWithTableRegex)) {
379
+ indexOnTable.set(m[1], m[2]);
380
+ }
157
381
  const indexTargets = [];
158
382
  if (!isRebuildOnlyMigration && !performsTableRebuild) {
159
383
  for (const m of fullSql.matchAll(createIndexRegex)) {
160
- indexTargets.push(m[1]);
384
+ const idx = m[1];
385
+ // Skip indexes on a permanently-eliminated table — they are expected to be
386
+ // gone once that table was dropped.
387
+ if (eliminatedTables.has(indexOnTable.get(idx) ?? ''))
388
+ continue;
389
+ indexTargets.push(idx);
161
390
  }
162
391
  }
163
392
  const triggerTargets = [];
@@ -166,7 +395,20 @@ function probeAndMarkApplied(nativeDb, migration, logSubsystem) {
166
395
  }
167
396
  const totalTargets = alterTargets.length + tableTargets.length + indexTargets.length + triggerTargets.length;
168
397
  if (totalTargets === 0) {
169
- // No probable DDL — could be UPDATE/INSERT/DELETE/etc. Leave for migrate().
398
+ // No probe-able DDL — pure DML (INSERT/UPDATE/DELETE), DROP-only, CREATE VIEW,
399
+ // etc. Schema inspection can't verify it, so the consolidation cutover decides
400
+ // (bug #2 follow-up · T11553):
401
+ if (isAtOrBeforeCutover(migration.name, consolidationCutoverPrefix)) {
402
+ // PRE-consolidation legacy migration — already subsumed by the consolidation
403
+ // snapshot and possibly NON-idempotent (UPDATE / plain INSERT). Re-running it
404
+ // would double-apply or fail, so STAMP applied without running.
405
+ insertJournalEntry(nativeDb, migration.hash, migration.folderMillis, migration.name ?? '');
406
+ getLogger(logSubsystem).debug({ migration: migration.name }, `Zero-DDL pre-consolidation migration ${migration.name} stamped applied (subsumed; not re-run).`);
407
+ return true;
408
+ }
409
+ // POST-consolidation migration (e.g. a new INSERT-OR-IGNORE backfill or
410
+ // DROP-IF-EXISTS) whose effect has NOT run yet — DO NOT stamp; let drizzle
411
+ // migrate() execute it. New migrations are required to be idempotent.
170
412
  return false;
171
413
  }
172
414
  // Probe each target.
@@ -176,7 +418,10 @@ function probeAndMarkApplied(nativeDb, migration, logSubsystem) {
176
418
  const cols = nativeDb.prepare(`PRAGMA table_info(${table})`).all();
177
419
  return cols.some((c) => c.name === column);
178
420
  });
179
- const allTablesPresent = tableTargets.every((t) => tableExists(nativeDb, t));
421
+ // A CREATE-TABLE target is satisfied if it exists OR if a later migration in
422
+ // the lineage permanently eliminated it (bug #2 — e.g. `release_manifests`,
423
+ // dropped by t9686b2). Its absence on a fully-migrated DB is expected.
424
+ const allTablesPresent = tableTargets.every((t) => tableExists(nativeDb, t) || eliminatedTables.has(t));
180
425
  const allIndexesPresent = indexTargets.every((idx) => {
181
426
  const rows = nativeDb
182
427
  .prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`)
@@ -204,6 +449,60 @@ function probeAndMarkApplied(nativeDb, migration, logSubsystem) {
204
449
  // At least one target missing — leave for drizzle migrate() to run.
205
450
  return false;
206
451
  }
452
+ /**
453
+ * Read the set of migration hashes for a SIBLING lineage folder (T11829).
454
+ *
455
+ * Used to build the cross-lineage orphan-deletion union in {@link reconcileJournal}.
456
+ * Defensive: a missing/unreadable/empty sibling folder yields an empty set rather
457
+ * than throwing — a sibling that does not exist in this install simply contributes
458
+ * no hashes to the union (the reconcile is conservative: fewer known hashes can
459
+ * only mean MORE deletions, so we never over-delete by failing safe to empty, but
460
+ * we also never crash the caller's open on a missing sibling).
461
+ *
462
+ * @param siblingFolder - Absolute path to a sibling drizzle migrations folder.
463
+ * @returns The set of migration hashes declared by that folder (empty on failure).
464
+ */
465
+ function readSiblingMigrationHashes(siblingFolder) {
466
+ try {
467
+ return new Set(readMigrationFiles({ migrationsFolder: siblingFolder }).map((m) => m.hash));
468
+ }
469
+ catch {
470
+ return new Set();
471
+ }
472
+ }
473
+ /**
474
+ * Ensure a UNIQUE index on `__drizzle_migrations(hash)` exists so the shared
475
+ * consolidated journal converges idempotently (T11829).
476
+ *
477
+ * With the UNIQUE index in place, the `INSERT OR IGNORE` emitted by
478
+ * {@link insertJournalEntry} becomes a true no-op on a re-probe (rather than
479
+ * appending a duplicate row), so any residual cross-lineage re-probe cannot grow
480
+ * the journal. Creation is guarded behind a one-time dedup: the live consolidated
481
+ * journal currently has no duplicate hashes, but a historically-thrashed journal
482
+ * could, and `CREATE UNIQUE INDEX` would fail on duplicates. We therefore collapse
483
+ * any duplicate-hash rows (keeping the lowest `id`) BEFORE creating the index.
484
+ *
485
+ * Idempotent and cheap: once the index exists, `CREATE UNIQUE INDEX IF NOT EXISTS`
486
+ * is a no-op and the dedup pass finds nothing.
487
+ *
488
+ * @param nativeDb - Native SQLite database handle.
489
+ */
490
+ function ensureJournalHashUnique(nativeDb) {
491
+ if (!tableExists(nativeDb, '__drizzle_migrations'))
492
+ return;
493
+ try {
494
+ // One-time dedup: keep the lowest id per hash, delete the rest. A no-op once
495
+ // the journal is already unique (the live journal has no dups today).
496
+ nativeDb.exec('DELETE FROM "__drizzle_migrations" WHERE id NOT IN ' +
497
+ '(SELECT MIN(id) FROM "__drizzle_migrations" GROUP BY hash)');
498
+ nativeDb.exec('CREATE UNIQUE INDEX IF NOT EXISTS "idx_drizzle_migrations_hash" ON "__drizzle_migrations" ("hash")');
499
+ }
500
+ catch {
501
+ // Best-effort convergence aid — never block an open on the index/dedup.
502
+ // INSERT OR IGNORE already guards duplicate inserts at the row level even
503
+ // without the index (it just cannot enforce uniqueness without it).
504
+ }
505
+ }
207
506
  /**
208
507
  * Bootstrap and reconcile the Drizzle migration journal.
209
508
  *
@@ -220,12 +519,50 @@ function probeAndMarkApplied(nativeDb, migration, logSubsystem) {
220
519
  * applied migrations to be re-run and fail with "duplicate column name". Backfills
221
520
  * the name from the local migration file matched by hash.
222
521
  *
522
+ * ## Cross-lineage orphan-deletion guard (T11829 — OOM root fix)
523
+ *
524
+ * The consolidated `cleo.db` carries ONE shared `__drizzle_migrations` journal but
525
+ * is reconciled by MULTIPLE physically-coexisting migration lineages
526
+ * (`drizzle-tasks`, `drizzle-cleo-project`, `drizzle-nexus`, `drizzle-brain`,
527
+ * `drizzle-agent-registry`, `drizzle-conduit`). Each lineage only knows its OWN
528
+ * folder, so a naive Sub-case B classified EVERY sibling lineage's journal rows as
529
+ * "true orphans" and DELETEd them — the next sibling open then deleted THIS
530
+ * lineage's rows, so the journal NEVER converged (it oscillated and re-ran
531
+ * BEGIN/COMMIT migrate transactions on every open, holding the WAL writer lock and
532
+ * stacking 300-550 MB-per-connection opens until the host OOM-killed).
533
+ *
534
+ * The fix: an entry is a TRUE orphan only when its hash belongs to NO lineage that
535
+ * physically shares this DB. Callers that share the journal pass
536
+ * `siblingMigrationsFolders` (the OTHER lineages' folders); the orphan-DELETE
537
+ * decision then uses the UNION of every sibling lineage's hashes plus this
538
+ * lineage's own. A hash present in any sibling is preserved (it is that sibling's
539
+ * legitimately-applied migration, not an orphan). Standalone DBs (a single
540
+ * lineage) pass no siblings and behave exactly as before.
541
+ *
223
542
  * @param nativeDb - Native SQLite database handle
224
543
  * @param migrationsFolder - Path to the drizzle migrations folder
225
544
  * @param existenceTable - A table name used to detect if the DB has data (e.g., 'tasks' or 'brain_decisions')
226
545
  * @param logSubsystem - Logger subsystem name for reconciliation warnings
546
+ * @param siblingMigrationsFolders - OTHER migration-lineage folders that share this
547
+ * `__drizzle_migrations` journal inside the same consolidated `cleo.db` (T11829).
548
+ * Their hashes are added to the orphan-deletion union so a sibling lineage's rows
549
+ * are never deleted as cross-lineage orphans. Omit (or pass `[]`) for a DB with a
550
+ * single lineage. Unreadable/empty sibling folders are skipped defensively.
227
551
  */
228
- export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, logSubsystem) {
552
+ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, logSubsystem, siblingMigrationsFolders = []) {
553
+ // bug #2 (T11553): pre-compute the tables this lineage CREATEs and a LATER
554
+ // migration permanently ELIMINATES (DROP TABLE, no recreate — e.g.
555
+ // `release_manifests`, dropped by t9686b2). The DDL probe treats a CREATE of an
556
+ // eliminated table (and its indexes) as already satisfied, so a fully-migrated
557
+ // DB — where that table is correctly absent — doesn't make drizzle re-run the
558
+ // creating migration's bare `CREATE TABLE` and crash.
559
+ const eliminatedTables = computeEliminatedTables(readMigrationFiles({ migrationsFolder }));
560
+ // bug #2 follow-up (T11553): the consolidation cutover timestamp-prefix gates
561
+ // the zero-DDL stamp/run decision in probeAndMarkApplied — pre-cutover legacy
562
+ // DML/DROP migrations are stamped (subsumed, possibly non-idempotent), new
563
+ // post-cutover ones are left for migrate() to RUN. Resolved from the sibling
564
+ // consolidation migration on disk.
565
+ const cutoverPrefix = resolveConsolidationCutoverPrefix(migrationsFolder);
229
566
  // Scenario 1: Tables exist but no migration journal — bootstrap baseline
230
567
  if (tableExists(nativeDb, existenceTable) && !tableExists(nativeDb, '__drizzle_migrations')) {
231
568
  const migrations = readMigrationFiles({ migrationsFolder });
@@ -243,6 +580,10 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
243
580
  insertJournalEntry(nativeDb, baseline.hash, baseline.folderMillis, baseline.name ?? '');
244
581
  }
245
582
  }
583
+ // T11829: enforce UNIQUE(hash) so the shared consolidated journal converges
584
+ // idempotently — any residual re-probe's INSERT OR IGNORE becomes a true no-op.
585
+ // Runs once the journal table exists (Scenario 1 / a prior open created it).
586
+ ensureJournalHashUnique(nativeDb);
246
587
  // Scenario 2: Journal has orphaned entries from a previous CLEO version
247
588
  //
248
589
  // Two distinct sub-cases require different handling:
@@ -271,10 +612,26 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
271
612
  if (tableExists(nativeDb, '__drizzle_migrations') && tableExists(nativeDb, existenceTable)) {
272
613
  const localMigrations = readMigrationFiles({ migrationsFolder });
273
614
  const localHashes = new Set(localMigrations.map((m) => m.hash));
615
+ // T11829 (OOM root fix): the orphan-DELETE decision must use the UNION of
616
+ // every migration lineage that physically shares this DB's `__drizzle_migrations`
617
+ // journal — NOT just this lineage's folder. A hash belonging to a sibling
618
+ // lineage (e.g. drizzle-cleo-project's rows seen from the drizzle-tasks open)
619
+ // is that sibling's legitimately-applied migration, not an orphan. Deleting it
620
+ // is what made the shared journal oscillate and never converge → host OOM.
621
+ const knownHashes = new Set(localHashes);
622
+ for (const siblingFolder of siblingMigrationsFolders) {
623
+ if (siblingFolder === migrationsFolder)
624
+ continue;
625
+ for (const m of readSiblingMigrationHashes(siblingFolder)) {
626
+ knownHashes.add(m);
627
+ }
628
+ }
274
629
  const dbEntries = nativeDb
275
630
  .prepare('SELECT id, hash FROM "__drizzle_migrations"')
276
631
  .all();
277
- const orphanedEntries = dbEntries.filter((e) => !localHashes.has(e.hash));
632
+ // A row is an orphan ONLY when its hash is unknown to THIS lineage AND every
633
+ // sibling lineage sharing this journal (T11829 cross-lineage guard).
634
+ const orphanedEntries = dbEntries.filter((e) => !knownHashes.has(e.hash));
278
635
  const hasOrphanedEntries = orphanedEntries.length > 0;
279
636
  if (hasOrphanedEntries) {
280
637
  const dbHashes = new Set(dbEntries.map((e) => e.hash));
@@ -286,8 +643,9 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
286
643
  log.debug({ extra: orphanedEntries.length }, `Migration journal has ${orphanedEntries.length} entries for migrations not known to this install (DB is ahead). Skipping reconciliation.`);
287
644
  }
288
645
  else {
289
- // Sub-case B: TRUE ORPHANS — entries whose hash has no local match.
290
- // Delete them and re-probe local migrations via DDL.
646
+ // Sub-case B: TRUE ORPHANS — entries whose hash matches NO known lineage
647
+ // (this one or any sibling sharing the journal). Delete them and re-probe
648
+ // local migrations via DDL.
291
649
  log.warn({ orphaned: orphanedEntries.length }, `Detected ${orphanedEntries.length} true-orphan journal entries from a previous CLEO lineage. Reconciling via DDL probe.`);
292
650
  const deleteStmt = nativeDb.prepare('DELETE FROM "__drizzle_migrations" WHERE id = ?');
293
651
  for (const e of orphanedEntries)
@@ -299,7 +657,7 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
299
657
  for (const m of localMigrations) {
300
658
  if (journaledHashesAfter.has(m.hash))
301
659
  continue;
302
- probeAndMarkApplied(nativeDb, m, logSubsystem);
660
+ probeAndMarkApplied(nativeDb, m, logSubsystem, eliminatedTables, cutoverPrefix);
303
661
  }
304
662
  }
305
663
  }
@@ -362,12 +720,10 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
362
720
  // than running the comment SQL. The "all DDL targets present" check reduces to:
363
721
  // strip comments → empty string → no targets at all → baseline already satisfied.
364
722
  if (alterMatches.length === 0) {
365
- // Check for comment-only baseline marker: strip SQL line comments and block comments,
366
- // then check if any non-whitespace DDL remains.
367
- const stripped = fullSql
368
- .replace(/--[^\n]*/g, '') // strip line comments
369
- .replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
370
- .trim();
723
+ // Check for comment-only baseline marker: strip SQL line + block comments
724
+ // (shared {@link stripSqlComments} helper), then check if any
725
+ // non-whitespace DDL remains.
726
+ const stripped = stripSqlComments(fullSql).trim();
371
727
  if (stripped === '') {
372
728
  // Comment-only baseline marker — mark applied immediately on existing DBs.
373
729
  const log = getLogger(logSubsystem);
@@ -379,7 +735,7 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
379
735
  const createTableRe = /CREATE\s+TABLE/i;
380
736
  const createTriggerRe = /CREATE\s+TRIGGER/i;
381
737
  if (renameRe.test(fullSql) && createTableRe.test(fullSql)) {
382
- probeAndMarkApplied(nativeDb, migration, logSubsystem);
738
+ probeAndMarkApplied(nativeDb, migration, logSubsystem, eliminatedTables, cutoverPrefix);
383
739
  }
384
740
  else if (createTableRe.test(fullSql) || createTriggerRe.test(fullSql)) {
385
741
  // Pure CREATE TABLE migration (no ALTER, no RENAME). Delegate to
@@ -388,7 +744,16 @@ export function reconcileJournal(nativeDb, migrationsFolder, existenceTable, log
388
744
  // This handles signaldock's initial migration (pure schema bootstrap) when
389
745
  // the DB has tables but the journal entry is missing (e.g., after a journal
390
746
  // reset or bare-SQL-to-drizzle migration path upgrade).
391
- probeAndMarkApplied(nativeDb, migration, logSubsystem);
747
+ probeAndMarkApplied(nativeDb, migration, logSubsystem, eliminatedTables, cutoverPrefix);
748
+ }
749
+ else {
750
+ // Zero-DDL migration (pure DML backfill, DROP-only, CREATE VIEW, …) with
751
+ // no journal entry. Delegate to probeAndMarkApplied, whose zero-target
752
+ // path applies the consolidation cutover (T11553): a PRE-cutover legacy
753
+ // migration is stamped (subsumed, possibly non-idempotent), while a NEW
754
+ // POST-cutover one is left un-stamped so drizzle migrate() RUNS its
755
+ // effect (e.g. the cutover agent's INSERT-OR-IGNORE backfill / DROP).
756
+ probeAndMarkApplied(nativeDb, migration, logSubsystem, eliminatedTables, cutoverPrefix);
392
757
  }
393
758
  continue;
394
759
  }
@@ -543,6 +908,11 @@ export function reconcileBrainMigrationsForConsolidatedDb(nativeDb, migrationsFo
543
908
  `);
544
909
  const localMigrations = sanitizeMigrationStatements(readMigrationFiles({ migrationsFolder }));
545
910
  const existingHashes = new Set(nativeDb.prepare('SELECT hash FROM "__drizzle_migrations"').all().map((r) => r.hash));
911
+ // bug #2 (T11553): tables this brain lineage CREATEs and a LATER migration
912
+ // permanently ELIMINATES — their absence is expected on a fully-migrated DB and
913
+ // must count as "satisfied" so the creating migration is journaled rather than
914
+ // re-run. (Mirrors the reconcileJournal eliminated-table tolerance.)
915
+ const eliminatedTables = computeEliminatedTables(localMigrations);
546
916
  const createTableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi;
547
917
  const renameRegex = /ALTER\s+TABLE\s+[`"]?(\w+)[`"]?\s+RENAME\s+TO\s+[`"]?(\w+)[`"]?/gi;
548
918
  const addColumnRegex = /ALTER\s+TABLE\s+[`"]?(\w+)[`"]?\s+ADD\s+COLUMN\s+[`"]?(\w+)[`"]?/gi;
@@ -552,12 +922,11 @@ export function reconcileBrainMigrationsForConsolidatedDb(nativeDb, migrationsFo
552
922
  const cols = nativeDb.prepare(`PRAGMA table_info("${table}")`).all();
553
923
  return cols.some((c) => c.name === column);
554
924
  };
555
- // Strip SQL line (`-- …`) and block (`/* … */`) comments before scanning for
556
- // DDL targets. A migration's prose comments routinely contain phrases like
557
- // "CREATE TABLE IF NOT EXISTS ensures it exists", which would otherwise make
558
- // the CREATE-TABLE regex capture bogus "table" names (`IF`, `ensures`) and
559
- // wrongly classify a satisfied migration as missing.
560
- const stripSqlComments = (sql) => sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
925
+ // Strip SQL comments before scanning for DDL targets (shared module helper —
926
+ // see {@link stripSqlComments}). A migration's prose comments routinely contain
927
+ // phrases like "CREATE TABLE IF NOT EXISTS ensures it exists", which would
928
+ // otherwise make the CREATE-TABLE regex capture bogus "table" names and wrongly
929
+ // classify a satisfied migration as missing.
561
930
  let marked = 0;
562
931
  let applied = 0;
563
932
  for (const m of localMigrations) {
@@ -582,7 +951,8 @@ export function reconcileBrainMigrationsForConsolidatedDb(nativeDb, migrationsFo
582
951
  for (const match of fullSql.matchAll(addColumnRegex)) {
583
952
  addColumns.push({ table: match[1], column: match[2] });
584
953
  }
585
- const tablesSatisfied = resultTables.size > 0 && [...resultTables].every((t) => tableExists(nativeDb, t));
954
+ const tablesSatisfied = resultTables.size > 0 &&
955
+ [...resultTables].every((t) => tableExists(nativeDb, t) || eliminatedTables.has(t));
586
956
  const altersSatisfied = resultTables.size === 0 &&
587
957
  addColumns.length > 0 &&
588
958
  addColumns.every(({ table, column }) => columnExists(table, column));
@@ -624,25 +994,55 @@ export function reconcileBrainMigrationsForConsolidatedDb(nativeDb, migrationsFo
624
994
  }
625
995
  return { marked, applied };
626
996
  }
997
+ /**
998
+ * Collect the `.message` of an Error and every nested `.cause` (recursively).
999
+ *
1000
+ * Drizzle wraps the underlying node:sqlite error in a `DrizzleError` whose own
1001
+ * `.message` is generic ("Failed to run the query …") while the real SQLite
1002
+ * message ("table nexus_nodes already exists") lives in `.cause.message`. Error
1003
+ * predicates that only test the top-level `.message` miss the wrapped case and
1004
+ * rethrow — defeating the migrateWithRetry reconcile path. Walking the cause
1005
+ * chain (with a depth guard against pathological cycles) lets the predicates see
1006
+ * the real message regardless of how many layers wrap it.
1007
+ *
1008
+ * @param err - The thrown value (Error, wrapped Error, or anything else)
1009
+ * @returns All messages found along the cause chain, joined with `\n`
1010
+ */
1011
+ function collectErrorMessages(err) {
1012
+ const messages = [];
1013
+ let current = err;
1014
+ // Depth guard: cap the walk so a self-referential cause cannot loop forever.
1015
+ for (let depth = 0; current instanceof Error && depth < 16; depth++) {
1016
+ messages.push(current.message);
1017
+ current = current.cause;
1018
+ }
1019
+ return messages.join('\n');
1020
+ }
627
1021
  /**
628
1022
  * Check whether an error is a SQLite "duplicate column name" error.
629
1023
  *
630
1024
  * These are thrown when an ALTER TABLE ADD COLUMN statement is re-executed
631
1025
  * after the column was already added (Scenario 3 in reconcileJournal).
1026
+ *
1027
+ * Inspects the full `.cause` chain so a Drizzle-wrapped error (whose real SQLite
1028
+ * message lives in `.cause.message`) is still recognised.
632
1029
  */
633
1030
  export function isDuplicateColumnError(err) {
634
1031
  if (!(err instanceof Error))
635
1032
  return false;
636
- return /duplicate column name/i.test(err.message);
1033
+ return /duplicate column name/i.test(collectErrorMessages(err));
637
1034
  }
638
1035
  /**
639
1036
  * T9174: Check for "table X already exists" SQLite error.
640
1037
  * Occurs when migration SQL lacks IF NOT EXISTS but startup DDL already ran.
1038
+ *
1039
+ * Inspects the full `.cause` chain so a Drizzle-wrapped error (whose real SQLite
1040
+ * message lives in `.cause.message`) is still recognised.
641
1041
  */
642
1042
  export function isTableAlreadyExistsError(err) {
643
1043
  if (!(err instanceof Error))
644
1044
  return false;
645
- return /table .+ already exists/i.test(err.message);
1045
+ return /table .+ already exists/i.test(collectErrorMessages(err));
646
1046
  }
647
1047
  /**
648
1048
  * Check whether a SQL statement is "executable" — i.e., contains something