@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.
- package/README.md +3 -1
- package/conformance/migrations/add-optional-field.json +1 -1
- package/conformance/migrations/add-required-field-with-default.json +1 -1
- package/conformance/migrations/crash-and-resume.json +1 -1
- package/conformance/migrations/destructive-removal-approved.json +1 -1
- package/conformance/migrations/destructive-removal-refused.json +1 -1
- package/conformance/migrations/idempotent-rerun.json +1 -1
- package/conformance/migrations/invalid-target-record.json +1 -1
- package/conformance/migrations/large-batched-transformation.json +1 -1
- package/conformance/migrations/manifest.json +1 -1
- package/conformance/migrations/metadata-only-change.json +1 -1
- package/conformance/migrations/migration-lock.json +1 -1
- package/conformance/migrations/missing-migration-path.json +1 -1
- package/conformance/migrations/record-transformation.json +1 -1
- package/conformance/migrations/relationship-addition.json +1 -1
- package/conformance/migrations/remove-empty-field.json +1 -1
- package/conformance/migrations/schema-fingerprint-mismatch.json +1 -1
- package/conformance/migrations/transform-field.json +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/migration-execute.js +53 -6
- package/dist/migration-executor.d.ts +24 -0
- package/dist/migration-executor.js +120 -32
- package/dist/migration-gate.js +32 -9
- package/dist/server.js +37 -10
- package/dist/sqlite-contention.d.ts +80 -0
- package/dist/sqlite-contention.js +125 -0
- package/dist/sqlite-migration.d.ts +14 -0
- package/dist/sqlite-migration.js +193 -116
- package/package.json +3 -3
- package/schema/protocol.v1.schema.json +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
phase: '
|
|
114
|
-
|
|
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:
|
|
135
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/migration-gate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
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) {
|