@cynodia/axiom-server 0.11.1-alpha.1 → 0.11.2-alpha.1

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.
Files changed (32) hide show
  1. package/README.md +3 -1
  2. package/conformance/migrations/add-optional-field.json +1 -1
  3. package/conformance/migrations/add-required-field-with-default.json +1 -1
  4. package/conformance/migrations/crash-and-resume.json +1 -1
  5. package/conformance/migrations/destructive-removal-approved.json +1 -1
  6. package/conformance/migrations/destructive-removal-refused.json +1 -1
  7. package/conformance/migrations/idempotent-rerun.json +1 -1
  8. package/conformance/migrations/invalid-target-record.json +1 -1
  9. package/conformance/migrations/large-batched-transformation.json +1 -1
  10. package/conformance/migrations/manifest.json +1 -1
  11. package/conformance/migrations/metadata-only-change.json +1 -1
  12. package/conformance/migrations/migration-lock.json +1 -1
  13. package/conformance/migrations/missing-migration-path.json +1 -1
  14. package/conformance/migrations/record-transformation.json +1 -1
  15. package/conformance/migrations/relationship-addition.json +1 -1
  16. package/conformance/migrations/remove-empty-field.json +1 -1
  17. package/conformance/migrations/schema-fingerprint-mismatch.json +1 -1
  18. package/conformance/migrations/transform-field.json +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +1 -0
  21. package/dist/migration-execute.js +53 -6
  22. package/dist/migration-executor.d.ts +24 -0
  23. package/dist/migration-executor.js +120 -32
  24. package/dist/migration-gate.js +32 -9
  25. package/dist/server.js +37 -10
  26. package/dist/sqlite-contention.d.ts +80 -0
  27. package/dist/sqlite-contention.js +125 -0
  28. package/dist/sqlite-migration.d.ts +14 -0
  29. package/dist/sqlite-migration.js +193 -116
  30. package/package.json +3 -3
  31. package/schema/protocol.v1.schema.json +1 -1
  32. package/schema/server-ir.v7.schema.json +1 -1
package/README.md CHANGED
@@ -41,7 +41,9 @@ This package also owns the parts of an application that only the authority can r
41
41
  - **Schema evolution** (0.11) — `planMigration` / `explainMigration`, the host-controlled
42
42
  `executeMigration`, the durable `MigrationMetadataStore` and lease lock, keyset-batched
43
43
  crash-resumable migration, memory and SQLite `MigrationRowStore`s, and the
44
- `createAxiomServer` startup gate. See
44
+ `createAxiomServer` startup gate. Concurrent migrators — including two OS processes on one
45
+ SQLite file — resolve to `completed` / `MIGRATION_IN_PROGRESS` / `alreadyAtTarget`; the
46
+ SQLite provider absorbs physical `SQLITE_BUSY` contention internally (0.11.2). See
45
47
  [`docs/MIGRATIONS.md`](https://github.com/cynodia/axiom/blob/main/docs/MIGRATIONS.md).
46
48
 
47
49
  Main exports: `createAxiomServer`, `createMemoryPersistence`, `createSqlitePersistence`,
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "7e4d24d95c273ff084e7758508a02e35015d5db2ceda46ca61e7ee8a2c2a4929",
16
16
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
16
16
  "migrations": [
@@ -12,7 +12,7 @@
12
12
  "contract": "axiom.server.v7",
13
13
  "id": "migration-conformance",
14
14
  "name": "Migration Conformance",
15
- "version": "0.11.1-alpha.1",
15
+ "version": "0.11.2-alpha.1",
16
16
  "schemaVersion": 2,
17
17
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
18
18
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "f9f0f88b1d0cd25eb570c1401fe3f96ca899330d8c210e79ef50a10e91ad236d",
16
16
  "migrations": [
@@ -12,7 +12,7 @@
12
12
  "contract": "axiom.server.v7",
13
13
  "id": "migration-conformance",
14
14
  "name": "Migration Conformance",
15
- "version": "0.11.1-alpha.1",
15
+ "version": "0.11.2-alpha.1",
16
16
  "schemaVersion": 2,
17
17
  "schemaFingerprint": "f9f0f88b1d0cd25eb570c1401fe3f96ca899330d8c210e79ef50a10e91ad236d",
18
18
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
16
16
  "migrations": [
@@ -11,7 +11,7 @@
11
11
  "contract": "axiom.server.v7",
12
12
  "id": "migration-conformance",
13
13
  "name": "Migration Conformance",
14
- "version": "0.11.1-alpha.1",
14
+ "version": "0.11.2-alpha.1",
15
15
  "schemaVersion": 2,
16
16
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
17
17
  "migrations": [
@@ -11,7 +11,7 @@
11
11
  "contract": "axiom.server.v7",
12
12
  "id": "migration-conformance",
13
13
  "name": "Migration Conformance",
14
- "version": "0.11.1-alpha.1",
14
+ "version": "0.11.2-alpha.1",
15
15
  "schemaVersion": 2,
16
16
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
17
17
  "migrations": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "conformance": "axiom.conformance.v5",
3
3
  "baseContract": "axiom.server.v7",
4
- "release": "0.11.1-alpha.1",
4
+ "release": "0.11.2-alpha.1",
5
5
  "description": "Portable migration conformance fixtures (spec11 §84-86). Each file carries a compiled axiom.server.v7 target Server IR with its MigrationDef chain, the schema version the persisted data starts at, the source rows, any destructive approvals, and the exact expected outcome. Running one needs only a row store and the semantics in docs/MIGRATIONS.md + docs/AUTHORITY.md. Every fixture must produce equivalent target data on the memory and SQLite providers (spec11 §83).",
6
6
  "fixtures": [
7
7
  {
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "f9f0f88b1d0cd25eb570c1401fe3f96ca899330d8c210e79ef50a10e91ad236d",
16
16
  "migrations": [
@@ -11,7 +11,7 @@
11
11
  "contract": "axiom.server.v7",
12
12
  "id": "migration-conformance",
13
13
  "name": "Migration Conformance",
14
- "version": "0.11.1-alpha.1",
14
+ "version": "0.11.2-alpha.1",
15
15
  "schemaVersion": 2,
16
16
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
17
17
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 3,
15
15
  "schemaFingerprint": "5f584b6aacbe13facfb360b562bdfa020f96e5f4e6d7f63c3d0dc7edacc207eb",
16
16
  "migrations": [
@@ -11,7 +11,7 @@
11
11
  "contract": "axiom.server.v7",
12
12
  "id": "migration-conformance",
13
13
  "name": "Migration Conformance",
14
- "version": "0.11.1-alpha.1",
14
+ "version": "0.11.2-alpha.1",
15
15
  "schemaVersion": 2,
16
16
  "schemaFingerprint": "428fde4036f9c14ec63905ce893e907318542fd1a89e6f946b4400713c80a4de",
17
17
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "4b8e155b1b02f04b28cadd78f580b4ac35f5ba0149e4809e31bbabd145852af4",
16
16
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "f9f0f88b1d0cd25eb570c1401fe3f96ca899330d8c210e79ef50a10e91ad236d",
16
16
  "migrations": [
@@ -11,7 +11,7 @@
11
11
  "contract": "axiom.server.v7",
12
12
  "id": "migration-conformance",
13
13
  "name": "Migration Conformance",
14
- "version": "0.11.1-alpha.1",
14
+ "version": "0.11.2-alpha.1",
15
15
  "schemaVersion": 2,
16
16
  "schemaFingerprint": "2c478dc5301786b76c6990e38e1659d621d7712fdfa785bf115b1275b9804877",
17
17
  "migrations": [
@@ -10,7 +10,7 @@
10
10
  "contract": "axiom.server.v7",
11
11
  "id": "migration-conformance",
12
12
  "name": "Migration Conformance",
13
- "version": "0.11.1-alpha.1",
13
+ "version": "0.11.2-alpha.1",
14
14
  "schemaVersion": 2,
15
15
  "schemaFingerprint": "f9f0f88b1d0cd25eb570c1401fe3f96ca899330d8c210e79ef50a10e91ad236d",
16
16
  "migrations": [
package/dist/index.d.ts CHANGED
@@ -31,6 +31,8 @@ export * from './migration-eval.js';
31
31
  export * from './migration-executor.js';
32
32
  export * from './migration-execute.js';
33
33
  export * from './migration-conformance.js';
34
+ export { SqliteContentionError, isSqliteContentionError, runWithBusyHandling, DEFAULT_BUSY_TIMEOUT_MS, DEFAULT_BUSY_ATTEMPTS, DEFAULT_BUSY_BACKOFF_MS, } from './sqlite-contention.js';
35
+ export type { BusyHandlingOptions } from './sqlite-contention.js';
34
36
  export { createSqliteMigrationStore, createSqliteRowStore, isSqliteMigrationAvailable, } from './sqlite-migration.js';
35
37
  export type { SqliteMigrationStoreOptions, SqliteRowStoreOptions } from './sqlite-migration.js';
36
38
  export { createSqliteDataProvider, isSqliteAvailable as isSqliteDataProviderAvailable } from './sqlite-data-provider.js';
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ export * from './migration-eval.js';
22
22
  export * from './migration-executor.js';
23
23
  export * from './migration-execute.js';
24
24
  export * from './migration-conformance.js';
25
+ export { SqliteContentionError, isSqliteContentionError, runWithBusyHandling, DEFAULT_BUSY_TIMEOUT_MS, DEFAULT_BUSY_ATTEMPTS, DEFAULT_BUSY_BACKOFF_MS, } from './sqlite-contention.js';
25
26
  export { createSqliteMigrationStore, createSqliteRowStore, isSqliteMigrationAvailable, } from './sqlite-migration.js';
26
27
  export { createSqliteDataProvider, isSqliteAvailable as isSqliteDataProviderAvailable } from './sqlite-data-provider.js';
27
28
  export * from './effects.js';
@@ -1,7 +1,8 @@
1
1
  import { MIGRATION_DIAGNOSTIC_CODES } from './migration.js';
2
2
  import { planMigration } from './migration.js';
3
- import { runMigration } from './migration-executor.js';
3
+ import { classifyMigrationContention, runMigration } from './migration-executor.js';
4
4
  import { evaluateSchemaGate } from './migration-gate.js';
5
+ import { isSqliteContentionError } from './sqlite-contention.js';
5
6
  /** Process-private registry of genuine, host-minted migration capabilities. */
6
7
  const MINTED_AUTHORITIES = new WeakSet();
7
8
  /** Mint migration authority. Only a host calls this; the result cannot be reconstructed by shape. */
@@ -26,7 +27,28 @@ export async function executeMigration(options) {
26
27
  };
27
28
  }
28
29
  const requiredVersion = options.ir.schemaVersion ?? 1;
29
- const record = await options.metadata.readSchema();
30
+ // `executeMigration` itself never holds the migration lease — `runMigration` does — so a
31
+ // SQLite lock this orchestrator hits is always another runner establishing/holding
32
+ // ownership (spec11.2 §6, §15). Reconcile it to a semantic outcome, never a leaked
33
+ // provider error.
34
+ let record;
35
+ try {
36
+ record = await options.metadata.readSchema();
37
+ }
38
+ catch (error) {
39
+ if (!isSqliteContentionError(error))
40
+ throw error;
41
+ const resolution = await classifyMigrationContention(options.metadata, requiredVersion, undefined, error);
42
+ if (resolution.kind === 'in-progress') {
43
+ return { ok: false, code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_IN_PROGRESS, message: resolution.message };
44
+ }
45
+ if (resolution.kind === 'failed') {
46
+ return { ok: false, code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_FAILED, message: resolution.message };
47
+ }
48
+ // already-at-target: a competing runner won the race. Re-read once — it succeeds now
49
+ // that the winner has released the writer lock — and fall through to the no-op path.
50
+ record = await options.metadata.readSchema();
51
+ }
30
52
  const fromVersion = options.fromVersion ?? record?.schemaVersion ?? 1;
31
53
  if (fromVersion === requiredVersion) {
32
54
  // Nothing to do — surface it as success with an empty plan.
@@ -61,9 +83,34 @@ export async function executeMigration(options) {
61
83
  return { ok: true, plan: planned.plan, run, gate };
62
84
  }
63
85
  export async function getMigrationStatus(metadata) {
64
- const record = await metadata.readSchema();
65
- const lock = await metadata.readLock();
66
- const checkpoint = await metadata.readCheckpoint();
86
+ // Host inspection must stay usable during a concurrent migration — a consumer may not be
87
+ // required to catch SQLite-native errors to read migration state (spec11.2 §12, §32).
88
+ // After the store's own bounded busy handling, residual contention on the
89
+ // `_axiom_migration_*` tables means a migration is active, so a read that still contends
90
+ // is reported as a coherent in-progress status rather than a thrown provider error.
91
+ let contended = false;
92
+ const read = async (fn) => {
93
+ try {
94
+ return await fn();
95
+ }
96
+ catch (error) {
97
+ if (isSqliteContentionError(error)) {
98
+ contended = true;
99
+ return null;
100
+ }
101
+ throw error;
102
+ }
103
+ };
104
+ const record = await read(() => metadata.readSchema());
105
+ const lock = await read(() => metadata.readLock());
106
+ const checkpoint = await read(() => metadata.readCheckpoint());
107
+ const phase = lock
108
+ ? 'in-progress'
109
+ : contended
110
+ ? 'in-progress'
111
+ : checkpoint
112
+ ? 'checkpointed'
113
+ : 'idle';
67
114
  return {
68
115
  schemaVersion: record?.schemaVersion ?? null,
69
116
  schemaFingerprint: record?.schemaFingerprint ?? null,
@@ -82,6 +129,6 @@ export async function getMigrationStatus(metadata) {
82
129
  batchCursor: checkpoint.batchCursor,
83
130
  }
84
131
  : null,
85
- phase: lock ? 'in-progress' : checkpoint ? 'checkpointed' : 'idle',
132
+ phase,
86
133
  };
87
134
  }
@@ -57,5 +57,29 @@ export type RunMigrationResult = {
57
57
  code: MigrationDiagnosticCode;
58
58
  message: string;
59
59
  };
60
+ /**
61
+ * A physical SQLite lock is not a semantic result (spec11.2 §5, §15). When a metadata or
62
+ * row-store call raises {@link SqliteContentionError} during a migration, this re-reads the
63
+ * migration metadata and answers in Axiom terms:
64
+ *
65
+ * - the persisted schema is already at (or past) the target → the race is already won →
66
+ * `already-at-target`;
67
+ * - a valid lease is held by *someone else* → `in-progress`;
68
+ * - this runner holds the lease and SQLite is still contended → an unrelated writer, not a
69
+ * migration owner → `failed`, with the physical cause retained (spec11.2 §35);
70
+ * - the metadata itself cannot be observed → only migration runners contend those tables, so
71
+ * without a lease of our own that means another migration is active → `in-progress`
72
+ * (spec11.2 §8, §11).
73
+ */
74
+ export type ContentionResolution = {
75
+ kind: 'already-at-target';
76
+ } | {
77
+ kind: 'in-progress';
78
+ message: string;
79
+ } | {
80
+ kind: 'failed';
81
+ message: string;
82
+ };
83
+ export declare function classifyMigrationContention(metadata: MigrationMetadataStore, targetVersion: number, ownToken: string | undefined, error: unknown): Promise<ContentionResolution>;
60
84
  export declare function runMigration(ir: ServerIR, plan: SemanticMigrationPlan, metadata: MigrationMetadataStore, target: MigrationRowStore | MigrationDataset, options: RunMigrationOptions): Promise<RunMigrationResult>;
61
85
  //# sourceMappingURL=migration-executor.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { MigrationTransformError, evaluateMigrationExpression, migrationRowScope, } from './migration-eval.js';
2
2
  import { MIGRATION_DIAGNOSTIC_CODES } from './migration.js';
3
3
  import { createMemoryRowStore } from './migration-row-store.js';
4
+ import { SqliteContentionError, isSqliteContentionError } from './sqlite-contention.js';
4
5
  export { createMemoryRowStore } from './migration-row-store.js';
5
6
  /** Thrown when `crashAfter` fires. The checkpoint is already durable; resume is a re-run. */
6
7
  export class MigrationCrash extends Error {
@@ -95,6 +96,42 @@ async function applySchemaOperation(store, operation) {
95
96
  default:
96
97
  }
97
98
  }
99
+ export async function classifyMigrationContention(metadata, targetVersion, ownToken, error) {
100
+ const cause = error instanceof SqliteContentionError ? error.providerCause : 'sqlite: database is locked';
101
+ try {
102
+ const record = await metadata.readSchema();
103
+ if (record && record.schemaVersion >= targetVersion) {
104
+ return { kind: 'already-at-target' };
105
+ }
106
+ const lock = await metadata.readLock();
107
+ if (lock && lock.token !== ownToken) {
108
+ return { kind: 'in-progress', message: `a migration is already running, held by ${lock.holder}` };
109
+ }
110
+ if (ownToken !== undefined) {
111
+ return {
112
+ kind: 'failed',
113
+ message: `SQLite remained locked while this runner held the migration lease (${cause})`,
114
+ };
115
+ }
116
+ return lock
117
+ ? { kind: 'in-progress', message: `a migration is already running, held by ${lock.holder}` }
118
+ : { kind: 'failed', message: `unresolved SQLite lock contention with no migration owner (${cause})` };
119
+ }
120
+ catch (reReadError) {
121
+ if (isSqliteContentionError(reReadError)) {
122
+ return ownToken !== undefined
123
+ ? {
124
+ kind: 'failed',
125
+ message: `SQLite remained locked while this runner held the migration lease (${cause})`,
126
+ }
127
+ : {
128
+ kind: 'in-progress',
129
+ message: 'another migration appears to be running (migration metadata is locked)',
130
+ };
131
+ }
132
+ throw reReadError;
133
+ }
134
+ }
98
135
  export async function runMigration(ir, plan, metadata, target, options) {
99
136
  const store = 'readBatch' in target ? target : createMemoryRowStore(target);
100
137
  const now = options.now ?? (() => Date.now());
@@ -102,42 +139,78 @@ export async function runMigration(ir, plan, metadata, target, options) {
102
139
  const batchSize = Math.max(1, options.batchSize ?? 500);
103
140
  const approved = new Set(options.approveDestructive ?? []);
104
141
  const id = planId(plan);
105
- // --- Idempotency & compatibility, before any lock or write (spec11 §35) --------------
106
- const current = await metadata.readSchema();
107
- if (current && current.schemaVersion === plan.toVersion) {
108
- return { ok: true, phase: 'completed', rowsTransformed: 0, resumed: false, alreadyAtTarget: true };
109
- }
110
- if (current && current.schemaVersion !== plan.fromVersion) {
111
- return {
112
- ok: false,
113
- phase: 'failed',
114
- code: MIGRATION_DIAGNOSTIC_CODES.SCHEMA_INCOMPATIBLE,
115
- message: `persisted schema ${current.schemaVersion} is not the plan's origin ${plan.fromVersion}`,
116
- };
117
- }
118
- // --- Destructive approval, before any lock or write (spec11 §21, §106) ---------------
119
- const unapproved = plan.destructive.filter((change) => !approved.has(change.operationId));
120
- if (unapproved.length > 0) {
121
- return {
122
- ok: false,
123
- phase: 'failed',
124
- code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_APPROVAL_REQUIRED,
125
- message: `destructive operations not approved: ${unapproved.map((change) => change.operationId).join(', ')}`,
126
- };
127
- }
128
- // --- Take the migration lock (spec11 §66) -----------------------------------------
129
- const lockResult = await metadata.acquireLock(options.holder, leaseMs);
130
- if (!lockResult.ok || !lockResult.lock) {
142
+ // A physical SQLite lock this runner cannot wait out is reconciled to an Axiom outcome
143
+ // (spec11.2 §5, §15). `token` is `undefined` until this runner owns the lease, which is
144
+ // what lets `classifyMigrationContention` tell "someone else is migrating" from
145
+ // "I own the lease and an unrelated writer is blocking me".
146
+ let token;
147
+ const onContention = async (error) => {
148
+ const resolution = await classifyMigrationContention(metadata, plan.toVersion, token, error);
149
+ if (resolution.kind === 'already-at-target') {
150
+ return { ok: true, phase: 'completed', rowsTransformed: 0, resumed: false, alreadyAtTarget: true };
151
+ }
131
152
  return {
132
153
  ok: false,
133
154
  phase: 'failed',
134
- code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_IN_PROGRESS,
135
- message: `a migration is already running, held by ${lockResult.heldBy?.holder ?? 'another instance'}`,
155
+ code: resolution.kind === 'in-progress'
156
+ ? MIGRATION_DIAGNOSTIC_CODES.MIGRATION_IN_PROGRESS
157
+ : MIGRATION_DIAGNOSTIC_CODES.MIGRATION_FAILED,
158
+ message: resolution.message,
136
159
  };
137
- }
138
- const token = lockResult.lock.token;
139
- const operations = plan.steps.flatMap((step) => step.operations);
160
+ };
140
161
  try {
162
+ // --- Idempotency & compatibility, before any lock or write (spec11 §35) ------------
163
+ const current = await metadata.readSchema();
164
+ if (current && current.schemaVersion === plan.toVersion) {
165
+ return { ok: true, phase: 'completed', rowsTransformed: 0, resumed: false, alreadyAtTarget: true };
166
+ }
167
+ if (current && current.schemaVersion !== plan.fromVersion) {
168
+ return {
169
+ ok: false,
170
+ phase: 'failed',
171
+ code: MIGRATION_DIAGNOSTIC_CODES.SCHEMA_INCOMPATIBLE,
172
+ message: `persisted schema ${current.schemaVersion} is not the plan's origin ${plan.fromVersion}`,
173
+ };
174
+ }
175
+ // --- Destructive approval, before any lock or write (spec11 §21, §106) -------------
176
+ const unapproved = plan.destructive.filter((change) => !approved.has(change.operationId));
177
+ if (unapproved.length > 0) {
178
+ return {
179
+ ok: false,
180
+ phase: 'failed',
181
+ code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_APPROVAL_REQUIRED,
182
+ message: `destructive operations not approved: ${unapproved.map((change) => change.operationId).join(', ')}`,
183
+ };
184
+ }
185
+ // --- Take the migration lock (spec11 §66) ---------------------------------------
186
+ const lockResult = await metadata.acquireLock(options.holder, leaseMs);
187
+ if (!lockResult.ok || !lockResult.lock) {
188
+ return {
189
+ ok: false,
190
+ phase: 'failed',
191
+ code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_IN_PROGRESS,
192
+ message: `a migration is already running, held by ${lockResult.heldBy?.holder ?? 'another instance'}`,
193
+ };
194
+ }
195
+ token = lockResult.lock.token;
196
+ const operations = plan.steps.flatMap((step) => step.operations);
197
+ // Re-check the persisted version *under the lock*. A competing runner can complete the
198
+ // whole transition — commit `toVersion` and release its lease — between this runner's
199
+ // pre-lock read and its acquisition. Without this check that runner would re-run a
200
+ // non-idempotent transform on already-migrated rows (spec11.2 §17, §19). The lock is
201
+ // what makes "check the version, then transform" atomic across processes.
202
+ const afterLock = await metadata.readSchema();
203
+ if (afterLock && afterLock.schemaVersion === plan.toVersion) {
204
+ return { ok: true, phase: 'completed', rowsTransformed: 0, resumed: false, alreadyAtTarget: true };
205
+ }
206
+ if (afterLock && afterLock.schemaVersion !== plan.fromVersion) {
207
+ return {
208
+ ok: false,
209
+ phase: 'failed',
210
+ code: MIGRATION_DIAGNOSTIC_CODES.SCHEMA_INCOMPATIBLE,
211
+ message: `persisted schema ${afterLock.schemaVersion} is not the plan's origin ${plan.fromVersion}`,
212
+ };
213
+ }
141
214
  const checkpoint = await metadata.readCheckpoint();
142
215
  if (checkpoint && checkpoint.planId !== id) {
143
216
  return {
@@ -278,6 +351,11 @@ export async function runMigration(ir, plan, metadata, target, options) {
278
351
  message: error.message,
279
352
  };
280
353
  }
354
+ if (isSqliteContentionError(error)) {
355
+ // Ordinary cross-process SQLite contention resolves to an Axiom outcome, never a
356
+ // leaked provider error (spec11.2 §5, §29).
357
+ return onContention(error);
358
+ }
281
359
  return {
282
360
  ok: false,
283
361
  phase: 'failed',
@@ -286,6 +364,16 @@ export async function runMigration(ir, plan, metadata, target, options) {
286
364
  };
287
365
  }
288
366
  finally {
289
- await metadata.releaseLock(token);
367
+ if (token !== undefined) {
368
+ // Best-effort lease release. If SQLite is still contended here the lease simply
369
+ // expires on its own (spec11.2 §16); nothing else may mask the real result.
370
+ try {
371
+ await metadata.releaseLock(token);
372
+ }
373
+ catch (releaseError) {
374
+ if (!isSqliteContentionError(releaseError))
375
+ throw releaseError;
376
+ }
377
+ }
290
378
  }
291
379
  }
@@ -1,5 +1,6 @@
1
1
  import { migrationPath } from './deps.js';
2
2
  import { MIGRATION_DIAGNOSTIC_CODES } from './migration.js';
3
+ import { isSqliteContentionError } from './sqlite-contention.js';
3
4
  /** Every gate status, for enumeration in tests. */
4
5
  export const SCHEMA_GATE_STATUSES = [
5
6
  'compatible',
@@ -27,17 +28,39 @@ export async function evaluateSchemaGate(ir, metadata, context = {}) {
27
28
  const requiredVersion = ir.schemaVersion ?? 1;
28
29
  const requiredFingerprint = ir.schemaFingerprint ?? '';
29
30
  const identity = declaresSchemaIdentity(ir);
30
- const lock = await metadata.readLock();
31
+ // Ordinary cross-process contention on the migration-metadata tables means another
32
+ // migration runner is active (nothing else touches `_axiom_migration_*`). After the
33
+ // store's own bounded busy handling, a residual contention error is reported as
34
+ // `migration-in-progress` — a coherent Axiom verdict that also preserves the 0.11.1
35
+ // fail-closed startup (the authority does not start) (spec11.2 §11, §13, §33).
36
+ const inProgress = (holder) => ({
37
+ status: 'migration-in-progress',
38
+ code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_IN_PROGRESS,
39
+ message: `a migration is running${holder ? `, held by ${holder}` : ''}; the authority will not start until it completes`,
40
+ requiredVersion,
41
+ persistedVersion: null,
42
+ });
43
+ let lock;
44
+ try {
45
+ lock = await metadata.readLock();
46
+ }
47
+ catch (error) {
48
+ if (isSqliteContentionError(error))
49
+ return inProgress('another instance');
50
+ throw error;
51
+ }
31
52
  if (lock) {
32
- return {
33
- status: 'migration-in-progress',
34
- code: MIGRATION_DIAGNOSTIC_CODES.MIGRATION_IN_PROGRESS,
35
- message: `a migration is running, held by ${lock.holder}; the authority will not start until it completes`,
36
- requiredVersion,
37
- persistedVersion: null,
38
- };
53
+ return inProgress(lock.holder);
54
+ }
55
+ let record;
56
+ try {
57
+ record = await metadata.readSchema();
58
+ }
59
+ catch (error) {
60
+ if (isSqliteContentionError(error))
61
+ return inProgress('another instance');
62
+ throw error;
39
63
  }
40
- const record = await metadata.readSchema();
41
64
  // --- The graph declares no semantic schema identity ---------------------------------
42
65
  if (!identity) {
43
66
  if (record !== null && record.schemaVersion > 1) {
package/dist/server.js CHANGED
@@ -14,6 +14,7 @@ import { diffRows, identityValuesToLoad, providerEntitiesWritten, rewriteForStag
14
14
  import { cursorMatchesContext, fingerprint, openCursor, randomCursorSecret, sealCursor, } from './query-cursor.js';
15
15
  import { MIGRATION_DIAGNOSTIC_CODES } from './migration.js';
16
16
  import { evaluateSchemaGate, gateAllowsStart, schemaGateWithoutStore } from './migration-gate.js';
17
+ import { isSqliteContentionError } from './sqlite-contention.js';
17
18
  import { getMigrationStatus } from './migration-execute.js';
18
19
  /**
19
20
  * Diagnostic codes the authority adds to the runtime vocabulary. They describe failures of
@@ -1325,7 +1326,21 @@ export function createAxiomServer(options) {
1325
1326
  // While a migration is running, authoritative traffic is refused rather than
1326
1327
  // applied to data that is mid-transition (spec11 §68, spec11.1 §12). 0.11 has no
1327
1328
  // online migration; a host that wants zero-downtime must sequence it itself.
1328
- if (await migrationMetadata.readLock()) {
1329
+ //
1330
+ // A residual SQLite contention error here (after the store's bounded busy
1331
+ // handling) means another process is holding/establishing migration ownership on
1332
+ // the shared database — a request-time race with a migrator resolves to
1333
+ // `MIGRATION_IN_PROGRESS`, never a leaked provider error (spec11.2 §34).
1334
+ let migrationLockHeld;
1335
+ try {
1336
+ migrationLockHeld = (await migrationMetadata.readLock()) !== null;
1337
+ }
1338
+ catch (error) {
1339
+ if (!isSqliteContentionError(error))
1340
+ throw error;
1341
+ migrationLockHeld = true;
1342
+ }
1343
+ if (migrationLockHeld) {
1329
1344
  migrationLockSeen = true;
1330
1345
  return {
1331
1346
  kind: 'error',
@@ -1339,16 +1354,28 @@ export function createAxiomServer(options) {
1339
1354
  // A migration that ran under this process has just finished. Either the schema
1340
1355
  // still matches this build — invalidate the (now possibly stale) query cache
1341
1356
  // (spec11 §45) — or it moved past this build and this authority must stop
1342
- // serving until it is redeployed (spec11 §103).
1343
- migrationLockSeen = false;
1344
- const record = await migrationMetadata.readSchema();
1345
- if (record &&
1346
- (record.schemaVersion !== (ir.schemaVersion ?? 1) ||
1347
- record.schemaFingerprint !== (ir.schemaFingerprint ?? ''))) {
1348
- schemaOutdated = true;
1357
+ // serving until it is redeployed (spec11 §103). If the post-migration schema
1358
+ // read still contends, defer the decision to the next request rather than
1359
+ // leaking the error.
1360
+ let record;
1361
+ try {
1362
+ record = await migrationMetadata.readSchema();
1363
+ }
1364
+ catch (error) {
1365
+ if (!isSqliteContentionError(error))
1366
+ throw error;
1367
+ record = undefined;
1349
1368
  }
1350
- else {
1351
- invalidateQueryCache();
1369
+ if (record !== undefined) {
1370
+ migrationLockSeen = false;
1371
+ if (record &&
1372
+ (record.schemaVersion !== (ir.schemaVersion ?? 1) ||
1373
+ record.schemaFingerprint !== (ir.schemaFingerprint ?? ''))) {
1374
+ schemaOutdated = true;
1375
+ }
1376
+ else {
1377
+ invalidateQueryCache();
1378
+ }
1352
1379
  }
1353
1380
  }
1354
1381
  if (schemaOutdated) {