@abelxiaoxing/cadence 1.2.3 → 1.3.0
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/README.md +40 -0
- package/package.json +2 -2
- package/src/design-control.ts +55 -22
- package/src/design-journal.ts +20 -7
- package/src/index.ts +203 -130
- package/src/openspec-cli.ts +494 -0
- package/src/run-store.ts +221 -25
package/src/run-store.ts
CHANGED
|
@@ -33,6 +33,17 @@ export class RunStoreFormatError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export class RunStoreMigrationError extends Error {
|
|
37
|
+
readonly code = "run-store-migration-failed" as const;
|
|
38
|
+
readonly schemaVersion: number;
|
|
39
|
+
|
|
40
|
+
constructor(schemaVersion: number, cause: unknown) {
|
|
41
|
+
super("run-store-migration-failed", { cause });
|
|
42
|
+
this.name = "RunStoreMigrationError";
|
|
43
|
+
this.schemaVersion = schemaVersion;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
|
|
37
48
|
const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
|
|
38
49
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
@@ -67,6 +78,7 @@ export interface StartRunInput {
|
|
|
67
78
|
stage: RunStage;
|
|
68
79
|
change?: string;
|
|
69
80
|
provisionalKey?: string;
|
|
81
|
+
provisionalAliases?: readonly string[];
|
|
70
82
|
operationId: string;
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -236,6 +248,7 @@ const REQUIRED_DELIVERY_BINDING_COLUMNS = [
|
|
|
236
248
|
"record_hash",
|
|
237
249
|
"operation_id",
|
|
238
250
|
] as const;
|
|
251
|
+
const LEGACY_SCHEMA_VERSION = 4;
|
|
239
252
|
|
|
240
253
|
function applicationTables(database: DatabaseSync): string[] {
|
|
241
254
|
return (
|
|
@@ -257,11 +270,12 @@ function tableColumns(database: DatabaseSync, table: string): string[] {
|
|
|
257
270
|
).map(({ name }) => name);
|
|
258
271
|
}
|
|
259
272
|
|
|
260
|
-
function
|
|
261
|
-
|
|
273
|
+
function hasRequiredCoreSchema(
|
|
274
|
+
database: DatabaseSync,
|
|
275
|
+
tables: readonly string[],
|
|
276
|
+
): boolean {
|
|
262
277
|
const deliveryBindingColumns = tableColumns(database, "delivery_bindings");
|
|
263
278
|
return (
|
|
264
|
-
!tables.includes("schema_meta") &&
|
|
265
279
|
REQUIRED_TABLES.every((table) => tables.includes(table)) &&
|
|
266
280
|
REQUIRED_DELIVERY_BINDING_COLUMNS.every((column) =>
|
|
267
281
|
deliveryBindingColumns.includes(column),
|
|
@@ -269,6 +283,110 @@ function hasCurrentSchema(database: DatabaseSync): boolean {
|
|
|
269
283
|
);
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
function hasCurrentSchema(
|
|
287
|
+
database: DatabaseSync,
|
|
288
|
+
tables: readonly string[],
|
|
289
|
+
): boolean {
|
|
290
|
+
return (
|
|
291
|
+
!tables.includes("schema_meta") && hasRequiredCoreSchema(database, tables)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isSupportedLegacySchema(
|
|
296
|
+
database: DatabaseSync,
|
|
297
|
+
tables: readonly string[],
|
|
298
|
+
): boolean {
|
|
299
|
+
if (
|
|
300
|
+
!tables.includes("schema_meta") ||
|
|
301
|
+
!hasRequiredCoreSchema(database, tables) ||
|
|
302
|
+
tableColumns(database, "schema_meta").join("\0") !== "version"
|
|
303
|
+
) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const rows = database
|
|
307
|
+
.prepare("SELECT version FROM schema_meta")
|
|
308
|
+
.all() as unknown as Array<{
|
|
309
|
+
version: number;
|
|
310
|
+
}>;
|
|
311
|
+
return (
|
|
312
|
+
rows.length === 1 && Number(rows[0]?.version) === LEGACY_SCHEMA_VERSION
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isSqliteLocked(error: unknown): boolean {
|
|
317
|
+
return (
|
|
318
|
+
typeof error === "object" &&
|
|
319
|
+
error !== null &&
|
|
320
|
+
"errcode" in error &&
|
|
321
|
+
((error as { errcode?: unknown }).errcode === 5 ||
|
|
322
|
+
(error as { errcode?: unknown }).errcode === 6)
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isStoreUnavailable(error: unknown): boolean {
|
|
327
|
+
if (typeof error !== "object" || error === null) return false;
|
|
328
|
+
const errcode = (error as { errcode?: unknown }).errcode;
|
|
329
|
+
if ([8, 10, 13, 14, 23, 24].includes(Number(errcode))) return true;
|
|
330
|
+
const code = (error as { code?: unknown }).code;
|
|
331
|
+
return (
|
|
332
|
+
typeof code === "string" &&
|
|
333
|
+
[
|
|
334
|
+
"EACCES",
|
|
335
|
+
"EBUSY",
|
|
336
|
+
"EMFILE",
|
|
337
|
+
"ENFILE",
|
|
338
|
+
"ENOSPC",
|
|
339
|
+
"EPERM",
|
|
340
|
+
"EROFS",
|
|
341
|
+
].includes(code)
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function ensureCurrentSchema(
|
|
346
|
+
database: DatabaseSync,
|
|
347
|
+
databasePath: string,
|
|
348
|
+
): void {
|
|
349
|
+
const initialTables = applicationTables(database);
|
|
350
|
+
if (hasCurrentSchema(database, initialTables)) return;
|
|
351
|
+
let transactionStarted = false;
|
|
352
|
+
let migrating = false;
|
|
353
|
+
try {
|
|
354
|
+
database.exec("BEGIN IMMEDIATE");
|
|
355
|
+
transactionStarted = true;
|
|
356
|
+
const tables = applicationTables(database);
|
|
357
|
+
if (tables.length === 0) {
|
|
358
|
+
database.exec(SCHEMA);
|
|
359
|
+
} else if (hasCurrentSchema(database, tables)) {
|
|
360
|
+
// Another opener may already have completed the same migration.
|
|
361
|
+
} else if (isSupportedLegacySchema(database, tables)) {
|
|
362
|
+
migrating = true;
|
|
363
|
+
database.exec("DROP TABLE schema_meta");
|
|
364
|
+
} else {
|
|
365
|
+
throw new RunStoreFormatError(databasePath);
|
|
366
|
+
}
|
|
367
|
+
database.exec("COMMIT");
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (transactionStarted) {
|
|
370
|
+
try {
|
|
371
|
+
database.exec("ROLLBACK");
|
|
372
|
+
} catch {
|
|
373
|
+
// The original schema or commit failure remains authoritative.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (
|
|
377
|
+
error instanceof RunStoreFormatError ||
|
|
378
|
+
error instanceof RunStoreMigrationError ||
|
|
379
|
+
isSqliteLocked(error)
|
|
380
|
+
) {
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
if (migrating) {
|
|
384
|
+
throw new RunStoreMigrationError(LEGACY_SCHEMA_VERSION, error);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
272
390
|
function parseProjection(value: string): RunProjection {
|
|
273
391
|
const parsed = JSON.parse(value) as RunProjection;
|
|
274
392
|
if (
|
|
@@ -281,22 +399,35 @@ function parseProjection(value: string): RunProjection {
|
|
|
281
399
|
return parsed;
|
|
282
400
|
}
|
|
283
401
|
|
|
284
|
-
function
|
|
402
|
+
function lookupKeys(input: StartRunInput): string[] {
|
|
285
403
|
const hasChange = typeof input.change === "string";
|
|
286
404
|
const hasProvisional = typeof input.provisionalKey === "string";
|
|
287
405
|
if (hasChange === hasProvisional) throw new Error("invalid-run-lookup-key");
|
|
288
406
|
if (hasChange) {
|
|
407
|
+
if (input.provisionalAliases !== undefined) {
|
|
408
|
+
throw new Error("invalid-run-lookup-key");
|
|
409
|
+
}
|
|
289
410
|
if (!CHANGE_NAME.test(input.change as string))
|
|
290
411
|
throw new Error("invalid-change");
|
|
291
|
-
return `change:${input.change}
|
|
412
|
+
return [`change:${input.change}`];
|
|
292
413
|
}
|
|
293
414
|
if (!SHA256.test(input.provisionalKey as string)) {
|
|
294
415
|
throw new Error("invalid-provisional-key");
|
|
295
416
|
}
|
|
417
|
+
if (
|
|
418
|
+
input.provisionalAliases !== undefined &&
|
|
419
|
+
(!Array.isArray(input.provisionalAliases) ||
|
|
420
|
+
input.provisionalAliases.length > 4 ||
|
|
421
|
+
input.provisionalAliases.some((alias) => !SHA256.test(alias)))
|
|
422
|
+
) {
|
|
423
|
+
throw new Error("invalid-provisional-key");
|
|
424
|
+
}
|
|
296
425
|
if (input.stage !== "abel-design") {
|
|
297
426
|
throw new Error("provisional-run-requires-design");
|
|
298
427
|
}
|
|
299
|
-
return
|
|
428
|
+
return [
|
|
429
|
+
...new Set([input.provisionalKey, ...(input.provisionalAliases ?? [])]),
|
|
430
|
+
].map((hash) => `provisional:${hash}`);
|
|
300
431
|
}
|
|
301
432
|
|
|
302
433
|
export class RunStore {
|
|
@@ -344,22 +475,37 @@ export class RunStore {
|
|
|
344
475
|
try {
|
|
345
476
|
database = new DatabaseSync(stateRoot.databasePath);
|
|
346
477
|
configureDatabase(database, options.busyTimeoutMs);
|
|
347
|
-
|
|
348
|
-
if (tables.length === 0) {
|
|
349
|
-
database.exec(SCHEMA);
|
|
350
|
-
} else if (!hasCurrentSchema(database)) {
|
|
351
|
-
throw new RunStoreFormatError(stateRoot.databasePath);
|
|
352
|
-
}
|
|
478
|
+
ensureCurrentSchema(database, stateRoot.databasePath);
|
|
353
479
|
} catch (error) {
|
|
354
|
-
|
|
355
|
-
|
|
480
|
+
try {
|
|
481
|
+
database?.close();
|
|
482
|
+
} catch {
|
|
483
|
+
// The run-store initialization failure remains authoritative.
|
|
484
|
+
}
|
|
485
|
+
if (
|
|
486
|
+
error instanceof RunStoreFormatError ||
|
|
487
|
+
error instanceof RunStoreMigrationError ||
|
|
488
|
+
isSqliteLocked(error) ||
|
|
489
|
+
isStoreUnavailable(error)
|
|
490
|
+
) {
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
356
493
|
throw new RunStoreFormatError(stateRoot.databasePath);
|
|
357
494
|
}
|
|
358
495
|
if (!database) throw new RunStoreFormatError(stateRoot.databasePath);
|
|
359
496
|
const store = new RunStore(stateRoot, database, options);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
497
|
+
try {
|
|
498
|
+
store.#secureDatabaseFiles();
|
|
499
|
+
store.recoverExpiredLeases();
|
|
500
|
+
return store;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
try {
|
|
503
|
+
database.close();
|
|
504
|
+
} catch {
|
|
505
|
+
// The permission or lease-recovery failure remains authoritative.
|
|
506
|
+
}
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
363
509
|
}
|
|
364
510
|
|
|
365
511
|
#assertOpen(): void {
|
|
@@ -374,7 +520,11 @@ export class RunStore {
|
|
|
374
520
|
value = work();
|
|
375
521
|
this.#database.exec("COMMIT");
|
|
376
522
|
} catch (error) {
|
|
377
|
-
|
|
523
|
+
try {
|
|
524
|
+
this.#database.exec("ROLLBACK");
|
|
525
|
+
} catch {
|
|
526
|
+
// The original operation or commit failure remains authoritative.
|
|
527
|
+
}
|
|
378
528
|
throw error;
|
|
379
529
|
}
|
|
380
530
|
this.#secureDatabaseFiles();
|
|
@@ -517,16 +667,62 @@ export class RunStore {
|
|
|
517
667
|
if (input.stage !== "abel-design" && input.stage !== "abel-implement") {
|
|
518
668
|
throw new Error("invalid-run-stage");
|
|
519
669
|
}
|
|
520
|
-
const
|
|
670
|
+
const keys = lookupKeys(input);
|
|
671
|
+
const key = keys[0];
|
|
672
|
+
if (!key) throw new Error("invalid-run-lookup-key");
|
|
521
673
|
return this.#transaction(() => {
|
|
522
|
-
|
|
674
|
+
const operationRows = this.#database
|
|
523
675
|
.prepare(
|
|
524
|
-
`SELECT run_id,
|
|
525
|
-
|
|
676
|
+
`SELECT operations.run_id, operations.kind, operations.state,
|
|
677
|
+
operations.outcome_json, runs.lookup_key
|
|
678
|
+
FROM operations
|
|
679
|
+
JOIN runs ON runs.run_id = operations.run_id
|
|
680
|
+
WHERE runs.root_hash = ? AND runs.stage = ?
|
|
681
|
+
AND operations.operation_id = ?`,
|
|
526
682
|
)
|
|
527
|
-
.
|
|
528
|
-
|
|
529
|
-
|
|
683
|
+
.all(
|
|
684
|
+
this.stateRoot.consumerRootHash,
|
|
685
|
+
input.stage,
|
|
686
|
+
input.operationId,
|
|
687
|
+
) as unknown as Array<{
|
|
688
|
+
run_id: string;
|
|
689
|
+
kind: string;
|
|
690
|
+
state: string;
|
|
691
|
+
outcome_json: string | null;
|
|
692
|
+
lookup_key: string;
|
|
693
|
+
}>;
|
|
694
|
+
if (operationRows.length > 0) {
|
|
695
|
+
const replay = operationRows.find(
|
|
696
|
+
(row) =>
|
|
697
|
+
row.kind === "start" &&
|
|
698
|
+
row.state === "committed" &&
|
|
699
|
+
row.outcome_json !== null &&
|
|
700
|
+
keys.includes(row.lookup_key),
|
|
701
|
+
);
|
|
702
|
+
if (operationRows.length !== 1 || !replay) {
|
|
703
|
+
throw new Error("operation-id-conflict");
|
|
704
|
+
}
|
|
705
|
+
const outcome = parseProjection(replay.outcome_json as string);
|
|
706
|
+
if (outcome.runId !== replay.run_id) {
|
|
707
|
+
throw new Error("operation-outcome-invalid");
|
|
708
|
+
}
|
|
709
|
+
return outcome;
|
|
710
|
+
}
|
|
711
|
+
let existing: RunRow | undefined;
|
|
712
|
+
for (const compatibleKey of keys) {
|
|
713
|
+
const candidate = this.#database
|
|
714
|
+
.prepare(
|
|
715
|
+
`SELECT run_id, projection_json FROM runs
|
|
716
|
+
WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
|
|
717
|
+
)
|
|
718
|
+
.get(this.stateRoot.consumerRootHash, input.stage, compatibleKey) as
|
|
719
|
+
| RunRow
|
|
720
|
+
| undefined;
|
|
721
|
+
if (candidate && existing && candidate.run_id !== existing.run_id) {
|
|
722
|
+
throw new Error("run-lookup-conflict");
|
|
723
|
+
}
|
|
724
|
+
existing ??= candidate;
|
|
725
|
+
}
|
|
530
726
|
if (existing) {
|
|
531
727
|
const projection = parseProjection(existing.projection_json);
|
|
532
728
|
if (
|