@cleocode/core 2026.4.18 → 2026.4.19
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/index.js +103 -5
- package/dist/index.js.map +2 -2
- package/dist/init.d.ts.map +1 -1
- package/dist/store/brain-sqlite.d.ts.map +1 -1
- package/dist/store/migration-manager.d.ts +22 -2
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/init.ts +67 -0
- package/src/store/__tests__/migration-reconcile.test.ts +351 -0
- package/src/store/brain-sqlite.ts +4 -2
- package/src/store/migration-manager.ts +103 -3
- package/src/store/sqlite.ts +4 -2
- package/src/tasks/add.ts +7 -1
|
@@ -73,7 +73,11 @@ export function createSafetyBackup(dbPath: string): void {
|
|
|
73
73
|
* Handles three scenarios:
|
|
74
74
|
* 1. Tables exist but no __drizzle_migrations — bootstrap baseline as applied
|
|
75
75
|
* 2. Journal has orphaned hashes (from older CLEO version) — clear and re-mark all as applied
|
|
76
|
-
* 3. Journal is
|
|
76
|
+
* 3. Journal exists but is missing entries for migrations whose DDL has already been applied
|
|
77
|
+
* (e.g., ALTER TABLE ADD COLUMN ran but journal entry was never written — happens when
|
|
78
|
+
* migrations are cherry-picked from worktrees or the process crashes mid-migration).
|
|
79
|
+
* Auto-inserts the missing journal entry so Drizzle skips the migration instead of
|
|
80
|
+
* re-running ALTER TABLE and crashing on "duplicate column name".
|
|
77
81
|
*
|
|
78
82
|
* @param nativeDb - Native SQLite database handle
|
|
79
83
|
* @param migrationsFolder - Path to the drizzle migrations folder
|
|
@@ -127,21 +131,117 @@ export function reconcileJournal(
|
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
}
|
|
134
|
+
|
|
135
|
+
// Scenario 3: Journal exists but is missing entries for already-applied migrations.
|
|
136
|
+
// Detects migrations whose DDL columns already exist in the database but whose
|
|
137
|
+
// journal entry was never written (e.g., cherry-picked from a worktree, or process
|
|
138
|
+
// crashed after the ALTER TABLE succeeded but before the journal INSERT committed).
|
|
139
|
+
if (tableExists(nativeDb, '__drizzle_migrations') && tableExists(nativeDb, existenceTable)) {
|
|
140
|
+
const localMigrations = readMigrationFiles({ migrationsFolder });
|
|
141
|
+
const journalEntries = nativeDb
|
|
142
|
+
.prepare('SELECT hash FROM "__drizzle_migrations"')
|
|
143
|
+
.all() as Array<{ hash: string }>;
|
|
144
|
+
const journaledHashes = new Set(journalEntries.map((e) => e.hash));
|
|
145
|
+
|
|
146
|
+
for (const migration of localMigrations) {
|
|
147
|
+
if (journaledHashes.has(migration.hash)) continue;
|
|
148
|
+
|
|
149
|
+
// Parse the migration SQL for ALTER TABLE ... ADD COLUMN statements.
|
|
150
|
+
// drizzle's readMigrationFiles returns sql as string[] (one entry per
|
|
151
|
+
// statement-breakpoint-separated statement), so join them for regex scanning.
|
|
152
|
+
const alterColumnRegex = /ALTER\s+TABLE\s+[`"]?(\w+)[`"]?\s+ADD\s+COLUMN\s+[`"]?(\w+)[`"]?/gi;
|
|
153
|
+
const alterMatches: Array<{ table: string; column: string }> = [];
|
|
154
|
+
const sqlStatements = Array.isArray(migration.sql) ? migration.sql : [migration.sql ?? ''];
|
|
155
|
+
const fullSql = sqlStatements.join('\n');
|
|
156
|
+
for (const m of fullSql.matchAll(alterColumnRegex)) {
|
|
157
|
+
alterMatches.push({ table: m[1] as string, column: m[2] as string });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Only auto-reconcile migrations that consist entirely of ALTER TABLE ADD COLUMN
|
|
161
|
+
// statements (and contain at least one). Pure CREATE INDEX / DROP INDEX migrations
|
|
162
|
+
// that have no journal entry are genuinely pending and must run normally.
|
|
163
|
+
if (alterMatches.length === 0) continue;
|
|
164
|
+
|
|
165
|
+
// Check whether all ADD COLUMN targets already exist in their tables.
|
|
166
|
+
const allColumnsExist = alterMatches.every(({ table, column }) => {
|
|
167
|
+
if (!tableExists(nativeDb, table)) return false;
|
|
168
|
+
const cols = nativeDb.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
|
169
|
+
name: string;
|
|
170
|
+
}>;
|
|
171
|
+
return cols.some((c) => c.name === column);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (allColumnsExist) {
|
|
175
|
+
const log = getLogger(logSubsystem);
|
|
176
|
+
log.warn(
|
|
177
|
+
{ migration: migration.name, columns: alterMatches },
|
|
178
|
+
`Detected partially-applied migration ${migration.name} — columns exist but journal entry missing. Auto-reconciling.`,
|
|
179
|
+
);
|
|
180
|
+
nativeDb.exec(
|
|
181
|
+
`INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES ('${migration.hash}', ${migration.folderMillis})`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check whether an error is a SQLite "duplicate column name" error.
|
|
190
|
+
*
|
|
191
|
+
* These are thrown when an ALTER TABLE ADD COLUMN statement is re-executed
|
|
192
|
+
* after the column was already added (Scenario 3 in reconcileJournal).
|
|
193
|
+
*/
|
|
194
|
+
export function isDuplicateColumnError(err: unknown): boolean {
|
|
195
|
+
if (!(err instanceof Error)) return false;
|
|
196
|
+
return /duplicate column name/i.test(err.message);
|
|
130
197
|
}
|
|
131
198
|
|
|
132
199
|
/**
|
|
133
200
|
* Run Drizzle migrations with SQLITE_BUSY retry and exponential backoff.
|
|
134
201
|
*
|
|
202
|
+
* Also handles "duplicate column name" errors (Scenario 3): if Drizzle tries to
|
|
203
|
+
* re-apply a migration whose DDL columns already exist (journal entry missing),
|
|
204
|
+
* this function calls reconcileJournal again to insert the missing entry and
|
|
205
|
+
* retries migrate() once more. This is the belt-and-suspenders safety net for
|
|
206
|
+
* any partial migration that slips through the proactive reconcileJournal check.
|
|
207
|
+
*
|
|
135
208
|
* @param db - Drizzle database instance
|
|
136
209
|
* @param migrationsFolder - Path to the drizzle migrations folder
|
|
210
|
+
* @param nativeDb - Optional native SQLite handle for duplicate-column auto-reconcile
|
|
211
|
+
* @param existenceTable - Optional existence-check table name for auto-reconcile
|
|
212
|
+
* @param logSubsystem - Optional logger subsystem name for auto-reconcile warnings
|
|
137
213
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
214
|
+
export function migrateWithRetry(
|
|
215
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle's NodeSQLiteDatabase is generic — accepting any schema avoids coupling to a specific schema type
|
|
216
|
+
db: NodeSQLiteDatabase<any>,
|
|
217
|
+
migrationsFolder: string,
|
|
218
|
+
nativeDb?: DatabaseSync,
|
|
219
|
+
existenceTable?: string,
|
|
220
|
+
logSubsystem?: string,
|
|
221
|
+
): void {
|
|
222
|
+
let duplicateColumnReconciled = false;
|
|
223
|
+
|
|
140
224
|
for (let attempt = 1; attempt <= MAX_MIGRATION_RETRIES; attempt++) {
|
|
141
225
|
try {
|
|
142
226
|
migrate(db, { migrationsFolder });
|
|
143
227
|
return;
|
|
144
228
|
} catch (err) {
|
|
229
|
+
// Belt-and-suspenders: if Drizzle hits a duplicate column name error on
|
|
230
|
+
// the first attempt and we have the native DB handle, run Scenario 3
|
|
231
|
+
// reconcileJournal and retry once. This catches any partial migration that
|
|
232
|
+
// slipped through the proactive check run before migrateWithRetry.
|
|
233
|
+
if (
|
|
234
|
+
isDuplicateColumnError(err) &&
|
|
235
|
+
!duplicateColumnReconciled &&
|
|
236
|
+
nativeDb !== undefined &&
|
|
237
|
+
existenceTable !== undefined &&
|
|
238
|
+
logSubsystem !== undefined
|
|
239
|
+
) {
|
|
240
|
+
duplicateColumnReconciled = true;
|
|
241
|
+
reconcileJournal(nativeDb, migrationsFolder, existenceTable, logSubsystem);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
145
245
|
if (!isSqliteBusy(err) || attempt === MAX_MIGRATION_RETRIES) {
|
|
146
246
|
throw err;
|
|
147
247
|
}
|
package/src/store/sqlite.ts
CHANGED
|
@@ -473,8 +473,10 @@ function runMigrations(nativeDb: DatabaseSync, db: NodeSQLiteDatabase<typeof sch
|
|
|
473
473
|
// @see https://github.com/anthropics/cleo/issues/83
|
|
474
474
|
ensureColumns(nativeDb, 'sessions', REQUIRED_SESSION_COLUMNS, 'sqlite');
|
|
475
475
|
|
|
476
|
-
// Run pending migrations with SQLITE_BUSY retry
|
|
477
|
-
migrateWithRetry
|
|
476
|
+
// Run pending migrations with SQLITE_BUSY retry.
|
|
477
|
+
// Pass nativeDb + existenceTable so migrateWithRetry can auto-reconcile any
|
|
478
|
+
// partial migration (Scenario 3) that slips through the proactive check above.
|
|
479
|
+
migrateWithRetry(db, migrationsFolder, nativeDb, 'tasks', 'sqlite');
|
|
478
480
|
|
|
479
481
|
// Defensive column safety net
|
|
480
482
|
ensureColumns(nativeDb, 'tasks', REQUIRED_TASK_COLUMNS, 'sqlite');
|
package/src/tasks/add.ts
CHANGED
|
@@ -502,9 +502,15 @@ export async function addTask(
|
|
|
502
502
|
if (lifecycleMode === 'strict') {
|
|
503
503
|
throw new CleoError(
|
|
504
504
|
ExitCode.VALIDATION_ERROR,
|
|
505
|
-
'Tasks must have a parent (epic or task) in strict mode. Use --parent <epicId
|
|
505
|
+
'Tasks must have a parent (epic or task) in strict mode. Use --parent <epicId>, --type epic for a root-level epic, or set lifecycle.mode to "advisory".',
|
|
506
506
|
{
|
|
507
507
|
fix: 'cleo add "Task title" --parent T### --acceptance "AC1|AC2|AC3"',
|
|
508
|
+
alternatives: [
|
|
509
|
+
{
|
|
510
|
+
action: 'Create as epic',
|
|
511
|
+
command: 'cleo add "Epic title" --type epic --priority high',
|
|
512
|
+
},
|
|
513
|
+
],
|
|
508
514
|
},
|
|
509
515
|
);
|
|
510
516
|
}
|