@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/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 hasCurrentSchema(database: DatabaseSync): boolean {
261
- const tables = applicationTables(database);
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 lookupKey(input: StartRunInput): string {
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 `provisional:${input.provisionalKey}`;
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
- const tables = applicationTables(database);
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
- database?.close();
355
- if (error instanceof RunStoreFormatError) throw error;
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
- store.#secureDatabaseFiles();
361
- store.recoverExpiredLeases();
362
- return store;
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
- this.#database.exec("ROLLBACK");
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 key = lookupKey(input);
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
- let existing = this.#database
674
+ const operationRows = this.#database
523
675
  .prepare(
524
- `SELECT run_id, projection_json FROM runs
525
- WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
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
- .get(this.stateRoot.consumerRootHash, input.stage, key) as
528
- | RunRow
529
- | undefined;
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 (