@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.
- package/dist/docs/export-document.js +322 -139
- package/dist/docs/export-document.js.map +2 -2
- package/dist/llm/plugin-facade.js +414 -248
- package/dist/llm/plugin-facade.js.map +2 -2
- package/dist/store/agent-registry-store.d.ts.map +1 -1
- package/dist/store/agent-registry-store.js +4 -2
- package/dist/store/agent-registry-store.js.map +1 -1
- package/dist/store/conduit-sqlite.d.ts.map +1 -1
- package/dist/store/conduit-sqlite.js +4 -2
- package/dist/store/conduit-sqlite.js.map +1 -1
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +71 -6
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/migration-manager.d.ts +125 -1
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/migration-manager.js +427 -27
- package/dist/store/migration-manager.js.map +1 -1
- package/dist/store/nexus-sqlite.d.ts.map +1 -1
- package/dist/store/nexus-sqlite.js +4 -2
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/resolve-migrations-folder.d.ts +41 -0
- package/dist/store/resolve-migrations-folder.d.ts.map +1 -1
- package/dist/store/resolve-migrations-folder.js +53 -0
- package/dist/store/resolve-migrations-folder.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +5 -3
- package/dist/store/sqlite.js.map +1 -1
- package/package.json +12 -12
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
// Delete them and re-probe
|
|
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
|
|
366
|
-
// then check if any
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
556
|
-
//
|
|
557
|
-
// "CREATE TABLE IF NOT EXISTS ensures it exists", which would
|
|
558
|
-
// the CREATE-TABLE regex capture bogus "table" names
|
|
559
|
-
//
|
|
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 &&
|
|
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
|
|
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
|
|
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
|