@cynodia/axiom-server 0.12.0-alpha.1 → 0.12.1-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/dist/server.d.ts CHANGED
@@ -201,10 +201,25 @@ export interface AxiomServer {
201
201
  start(): Promise<void>;
202
202
  /** The one entry point. Every transport funnels through here. */
203
203
  handle(request: ServerRequest): Promise<ServerResponse>;
204
- /** Authoritative values of every observable state. */
204
+ /**
205
+ * This authority's **local view** of every observable state — the state as of the most
206
+ * recently handled request (spec12.1 §6, §39, §40). It is a synchronous read of the
207
+ * authority-local cache and does not itself reconcile with persistence. In a multi-authority
208
+ * deployment, read authoritative state through `handle(SnapshotRequest)` or an action, both
209
+ * of which reconcile to the durable revision first; or call {@link coherentSnapshot}.
210
+ */
205
211
  snapshot(): StateSnapshot;
212
+ /** A single state's value from the authority-local view. See {@link snapshot} for the coherence contract. */
206
213
  getState(id: NodeId): unknown;
214
+ /** The durable store revision this authority's local view is at (spec12.1 §22, monotonic). */
207
215
  revision(): number;
216
+ /**
217
+ * Reconcile to the durable revision (reloading persisted state if another authority has
218
+ * committed) and return a revision-coherent snapshot (spec12.1 §7, §38, §40). This is the
219
+ * async counterpart of {@link snapshot}; `handle(SnapshotRequest)` performs the same
220
+ * reconciliation for the protocol path.
221
+ */
222
+ coherentSnapshot(): Promise<StateSnapshot>;
208
223
  /** Every mutation this authority has applied, with its outcome. */
209
224
  mutationLog(): MutationLogEntry[];
210
225
  /**
@@ -295,6 +310,8 @@ export interface AxiomServer {
295
310
  export interface DistributedWorkInspection {
296
311
  authority: AuthorityInfo;
297
312
  effects: import('./durable-work.js').DurableWorkItemView[];
313
+ /** Logical scheduled firings this authority knows about, with their lease liveness (spec12.1 §53). */
314
+ schedules: import('./durable-work.js').DurableWorkItemView[];
298
315
  incompatibleEffects: import('./durable-work.js').DurableWorkItem[];
299
316
  }
300
317
  export declare function createAxiomServer(options: AxiomServerOptions): AxiomServer;
package/dist/server.js CHANGED
@@ -458,6 +458,54 @@ export function createAxiomServer(options) {
458
458
  }
459
459
  return { revision: storeRevision, states, ...(incremental ? { partial: true } : {}) };
460
460
  }
461
+ /**
462
+ * Distributed state coherence (spec12.1 §6-§9, §11, §17-§22).
463
+ *
464
+ * The in-memory `StateDef` representation inside a running authority is an **authority-local
465
+ * cache** of persisted authoritative state — never an independent store. Before any
466
+ * operation whose semantic result depends on authoritative `StateDef` state, this
467
+ * re-observes the durable revision; if another authority has committed since, it reloads
468
+ * the persisted state so execution proceeds from a coherent revision.
469
+ *
470
+ * - The correctness mechanism is durable-revision observation, never a broadcast: a lost
471
+ * notification cannot leave this authority indefinitely stale (§17).
472
+ * - A refresh always corresponds to one coherent persisted revision — if the store moves
473
+ * while loading, it repeats (bounded) rather than mixing revisions (§19, §20).
474
+ * - `storeRevision` is monotonic: this never publishes local state at a revision below one
475
+ * already observed (§22).
476
+ *
477
+ * Everything that calls this runs inside the server's single serialized queue, so there is
478
+ * never a concurrent refresh within one authority.
479
+ */
480
+ async function ensureStateCoherent() {
481
+ let observed = await persistence.revision();
482
+ if (observed <= storeRevision) {
483
+ return;
484
+ }
485
+ for (let attempt = 0; attempt < 8; attempt += 1) {
486
+ const rows = await persistence.load();
487
+ const after = await persistence.revision();
488
+ if (after !== observed && attempt < 7) {
489
+ observed = after;
490
+ continue;
491
+ }
492
+ for (const row of rows) {
493
+ const state = statesById.get(row.stateId);
494
+ if (!state || state.derivation) {
495
+ continue;
496
+ }
497
+ runtime.hydrateState(row.stateId, row.value);
498
+ revisions.set(row.stateId, row.revision);
499
+ }
500
+ if (observed > storeRevision) {
501
+ storeRevision = observed;
502
+ }
503
+ // A reload may change any cached query result (a principal/policy-scoped entry
504
+ // included); the query cache already invalidates blindly on any commit (spec 0.10 §72).
505
+ invalidateQueryCache();
506
+ return;
507
+ }
508
+ }
461
509
  /**
462
510
  * The key an idempotency record is filed under.
463
511
  *
@@ -858,6 +906,12 @@ export function createAxiomServer(options) {
858
906
  */
859
907
  async function invokeCore(actionId, args, context, requestId, replayKey, depth) {
860
908
  const startedAt = Date.now();
909
+ // spec12.1 §9, §41-§44: an ActionDef transaction MUST begin from a StateDef snapshot
910
+ // corresponding to the persistence revision it will attempt to commit against — for a
911
+ // client request, a trigger, a scheduled firing, an event-invoked action or an
912
+ // effect-outcome action alike. Reconcile to the durable revision before any
913
+ // state-dependent semantics (guards, authorization, constraints, operations) run.
914
+ await ensureStateCoherent();
861
915
  // The caller is bound for the whole invocation, not only for the authorization check.
862
916
  // An operation that records who acted — `field(ref(PRINCIPAL), F_USER_ID)` — must resolve
863
917
  // whether or not the action also happens to carry an authorization rule. It is also what
@@ -991,6 +1045,13 @@ export function createAxiomServer(options) {
991
1045
  for (const stateId of writes) {
992
1046
  runtime.hydrateState(stateId, before.get(stateId));
993
1047
  }
1048
+ // spec12.1 §11, §14, §15: this authority lost an optimistic concurrency race. The
1049
+ // losing invocation returns CONCURRENCY_CONFLICT (no silent replay, §12) — but the
1050
+ // authority itself MUST recover: its local StateDef is now known invalid, so reload
1051
+ // the winning durable state before it processes any subsequent request. Without this,
1052
+ // the base revision stays stale and every future commit fails the same way (the F1
1053
+ // wedge).
1054
+ await ensureStateCoherent();
994
1055
  const diagnostics = [
995
1056
  diagnostic(SERVER_DIAGNOSTIC_CODES.CONCURRENCY_CONFLICT, `${action.name ?? action.id} was not committed: ${outcome.conflicts.join(', ')} changed while it ran`, { actionId: action.id, conflicts: outcome.conflicts }),
996
1057
  ];
@@ -1367,13 +1428,27 @@ export function createAxiomServer(options) {
1367
1428
  const gate = schemaGateWithoutStore(ir);
1368
1429
  throw new Error(`${gate.code}: ${gate.message}`);
1369
1430
  }
1370
- for (const entry of persisted) {
1431
+ // spec12.1 §37: publish a coherent (state, revision) pair. `persisted` above may be a
1432
+ // read behind by now (the migration gate did async work); re-load so the hydrated
1433
+ // state and `storeRevision` identify the same durable revision, repeating if a
1434
+ // concurrent authority commits during the load (§19, §20).
1435
+ let coherent;
1436
+ let coherentAt;
1437
+ for (let attempt = 0;; attempt += 1) {
1438
+ coherentAt = await persistence.revision();
1439
+ coherent = await persistence.load();
1440
+ const after = await persistence.revision();
1441
+ if (after === coherentAt || attempt >= 7) {
1442
+ break;
1443
+ }
1444
+ }
1445
+ for (const entry of coherent) {
1371
1446
  if (statesById.has(entry.stateId)) {
1372
1447
  runtime.hydrateState(entry.stateId, entry.value);
1373
1448
  revisions.set(entry.stateId, entry.revision);
1374
1449
  }
1375
1450
  }
1376
- storeRevision = await persistence.revision();
1451
+ storeRevision = coherentAt;
1377
1452
  // A crash between an effect intent's commit and its dispatch must not lose it: every
1378
1453
  // pending intent found on restart resumes dispatch here (spec §19,96,140).
1379
1454
  const pending = (await persistence.loadPendingEffects?.()) ?? [];
@@ -1491,6 +1566,12 @@ export function createAxiomServer(options) {
1491
1566
  ],
1492
1567
  };
1493
1568
  }
1569
+ // spec12.1 §8.1, §13, §38, §64: a SnapshotRequest arriving after another
1570
+ // authority's commit MUST observe state at least as new as that commit. Reconcile
1571
+ // to the durable revision before answering — this is the protocol path a real
1572
+ // consumer reads authoritative state through, and it is a permanent regression
1573
+ // requirement.
1574
+ await ensureStateCoherent();
1494
1575
  report({ kind: 'snapshot', revision: storeRevision });
1495
1576
  const response = {
1496
1577
  kind: 'snapshot',
@@ -1524,9 +1605,15 @@ export function createAxiomServer(options) {
1524
1605
  };
1525
1606
  });
1526
1607
  },
1527
- snapshot: snapshotOf,
1608
+ snapshot: () => snapshotOf(),
1528
1609
  getState: (id) => runtime.getState(id),
1529
1610
  revision: () => storeRevision,
1611
+ async coherentSnapshot() {
1612
+ return serialize(async () => {
1613
+ await ensureStateCoherent();
1614
+ return snapshotOf();
1615
+ });
1616
+ },
1530
1617
  mutationLog: () => runtime.getMutationLog(),
1531
1618
  effectLog: () => [...effectRecords.values()].map((entry) => ({ ...entry })),
1532
1619
  subscriptionLog: () => subscriptionRuntime.status(),
@@ -1574,11 +1661,12 @@ export function createAxiomServer(options) {
1574
1661
  compatibilityKey,
1575
1662
  };
1576
1663
  if (!distributedWorkStore) {
1577
- return { authority: authorityInfo, effects: [], incompatibleEffects: [] };
1664
+ return { authority: authorityInfo, effects: [], schedules: [], incompatibleEffects: [] };
1578
1665
  }
1579
1666
  return {
1580
1667
  authority: authorityInfo,
1581
1668
  effects: await distributedWorkStore.list('effect'),
1669
+ schedules: await distributedWorkStore.list(SCHEDULE_FIRING_WORK_CLASS),
1582
1670
  incompatibleEffects: await distributedWorkStore.listIncompatible('effect'),
1583
1671
  };
1584
1672
  },
@@ -9,11 +9,29 @@ import type { PersistenceAdapter } from './persistence.js';
9
9
  *
10
10
  * The whole semantic transaction is written inside one SQL transaction, so a crash cannot
11
11
  * leave half of an action committed.
12
+ *
13
+ * **Multi-process contention (spec12.1 §27-§34).** This adapter is part of the supported
14
+ * SQLite multi-authority reference path: independent OS processes, independent connections,
15
+ * one database file. SQLite's single-writer file lock surfaces physical contention as
16
+ * `SQLITE_BUSY` / `SQLITE_LOCKED`; that MUST NOT escape as a semantic result during ordinary
17
+ * operation. Every statement runs behind a short `PRAGMA busy_timeout` plus the bounded
18
+ * {@link runWithBusyHandling} retry shared with the migration and coordination providers —
19
+ * physical contention is absorbed, a genuine constraint / corruption / IO / programmer error
20
+ * passes straight through, and a semantic optimistic `CONCURRENCY_CONFLICT` is never
21
+ * manufactured from a lock wait (§32). Waiting is always bounded (§28).
12
22
  */
13
23
  export interface SqlitePersistenceOptions {
14
24
  /** A file path, or `':memory:'`. */
15
25
  location: string;
16
26
  table?: string;
27
+ /**
28
+ * The SQLite native busy wait, in ms (spec12.1 §30). Infrastructure tuning, never
29
+ * application semantics. `0` disables the native wait; the bounded retry still applies.
30
+ * Defaults to the value the migration / coordination providers use.
31
+ */
32
+ busyTimeoutMs?: number;
33
+ /** Test seam — deterministic sleep for the bounded busy retry. */
34
+ sleep?: (ms: number) => Promise<void>;
17
35
  }
18
36
  /** Whether this Node build offers `node:sqlite` at all. */
19
37
  export declare function isSqliteAvailable(): Promise<boolean>;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_BUSY_TIMEOUT_MS, SqliteContentionError, runWithBusyHandling, } from './sqlite-contention.js';
1
2
  /** Whether this Node build offers `node:sqlite` at all. */
2
3
  export async function isSqliteAvailable() {
3
4
  try {
@@ -12,23 +13,33 @@ export async function createSqlitePersistence(options) {
12
13
  const module = (await import('node:sqlite'));
13
14
  const table = options.table ?? 'axiom_state';
14
15
  const database = new module.DatabaseSync(options.location);
16
+ const busyMs = Math.max(0, Math.floor(options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS));
17
+ const guard = (context, fn) => runWithBusyHandling(fn, {
18
+ context,
19
+ ...(options.sleep !== undefined ? { sleep: options.sleep } : {}),
20
+ });
15
21
  const effectsTable = `${table}_effects`;
16
- database.exec(`
17
- CREATE TABLE IF NOT EXISTS ${table} (
18
- state_id TEXT PRIMARY KEY,
19
- revision INTEGER NOT NULL,
20
- value TEXT NOT NULL
21
- );
22
- CREATE TABLE IF NOT EXISTS ${table}_meta (
23
- key TEXT PRIMARY KEY,
24
- value INTEGER NOT NULL
25
- );
26
- CREATE TABLE IF NOT EXISTS ${effectsTable} (
27
- effect_id TEXT PRIMARY KEY,
28
- record TEXT NOT NULL,
29
- status TEXT NOT NULL
30
- );
31
- `);
22
+ // Schema creation is itself a write and can hit a concurrent initializer's lock (spec12.1
23
+ // §33) — run it behind the same bounded handling as everything else.
24
+ await guard('sqlitePersistence.init', () => {
25
+ database.exec(`PRAGMA busy_timeout = ${busyMs};`);
26
+ database.exec(`
27
+ CREATE TABLE IF NOT EXISTS ${table} (
28
+ state_id TEXT PRIMARY KEY,
29
+ revision INTEGER NOT NULL,
30
+ value TEXT NOT NULL
31
+ );
32
+ CREATE TABLE IF NOT EXISTS ${table}_meta (
33
+ key TEXT PRIMARY KEY,
34
+ value INTEGER NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS ${effectsTable} (
37
+ effect_id TEXT PRIMARY KEY,
38
+ record TEXT NOT NULL,
39
+ status TEXT NOT NULL
40
+ );
41
+ `);
42
+ });
32
43
  const readAll = database.prepare(`SELECT state_id, revision, value FROM ${table}`);
33
44
  const readRevision = database.prepare(`SELECT value FROM ${table}_meta WHERE key = 'revision'`);
34
45
  const readOne = database.prepare(`SELECT revision FROM ${table} WHERE state_id = ?`);
@@ -43,54 +54,79 @@ export async function createSqlitePersistence(options) {
43
54
  const currentRevision = () => Number(readRevision.get()?.value ?? 0);
44
55
  return {
45
56
  async load() {
46
- return readAll.all().map((row) => ({
57
+ return guard('sqlitePersistence.load', () => readAll.all().map((row) => ({
47
58
  stateId: String(row.state_id),
48
59
  revision: Number(row.revision),
49
60
  value: JSON.parse(String(row.value)),
50
- }));
61
+ })));
51
62
  },
52
63
  async commit(commit) {
53
- const conflicts = commit.writes
54
- .map((write) => write.stateId)
55
- .filter((stateId) => Number(readOne.get(stateId)?.revision ?? 0) !== (commit.expected[stateId] ?? 0));
56
- if (conflicts.length > 0) {
57
- return { committed: false, revision: currentRevision(), conflicts };
58
- }
59
- const revision = currentRevision() + 1;
60
- // One SQL transaction for one semantic transaction — the state writes and the effect
61
- // intents this transaction recorded commit atomically, which is the outbox invariant
62
- // (spec §18): a crash right after this call cannot lose an intent that was here.
63
- database.exec('BEGIN IMMEDIATE');
64
+ // The whole commit — the conflict check and the atomic write — runs behind bounded
65
+ // busy handling. A physical lock we wait out then re-checks conflicts against the
66
+ // now-current revisions, so a concurrent writer's commit becomes a legitimate
67
+ // CONCURRENCY_CONFLICT rather than a leaked SQLITE_BUSY (spec12.1 §32).
64
68
  try {
65
- for (const write of commit.writes) {
66
- upsert.run(write.stateId, revision, JSON.stringify(write.value ?? null));
67
- }
68
- setRevision.run(revision);
69
- for (const effect of commit.effects ?? []) {
70
- insertEffect.run(effect.id, JSON.stringify(effect), effect.status);
71
- }
72
- database.exec('COMMIT');
69
+ return await guard('sqlitePersistence.commit', () => {
70
+ const conflicts = commit.writes
71
+ .map((write) => write.stateId)
72
+ .filter((stateId) => Number(readOne.get(stateId)?.revision ?? 0) !== (commit.expected[stateId] ?? 0));
73
+ if (conflicts.length > 0) {
74
+ return { committed: false, revision: currentRevision(), conflicts };
75
+ }
76
+ const revision = currentRevision() + 1;
77
+ database.exec('BEGIN IMMEDIATE');
78
+ try {
79
+ for (const write of commit.writes) {
80
+ upsert.run(write.stateId, revision, JSON.stringify(write.value ?? null));
81
+ }
82
+ setRevision.run(revision);
83
+ for (const effect of commit.effects ?? []) {
84
+ insertEffect.run(effect.id, JSON.stringify(effect), effect.status);
85
+ }
86
+ database.exec('COMMIT');
87
+ }
88
+ catch (error) {
89
+ try {
90
+ database.exec('ROLLBACK');
91
+ }
92
+ catch {
93
+ /* nothing open */
94
+ }
95
+ throw error;
96
+ }
97
+ return { committed: true, revision, conflicts: [] };
98
+ });
73
99
  }
74
100
  catch (error) {
75
- database.exec('ROLLBACK');
101
+ if (error instanceof SqliteContentionError) {
102
+ // Physical contention outlasted the bounded window. Report a refused commit (like
103
+ // an optimistic conflict) so the authority reconciles and the caller may retry —
104
+ // never a raw provider error (spec12.1 §27, §67).
105
+ return {
106
+ committed: false,
107
+ revision: await guard('sqlitePersistence.revision', currentRevision),
108
+ conflicts: commit.writes.map((write) => write.stateId),
109
+ };
110
+ }
76
111
  throw error;
77
112
  }
78
- return { committed: true, revision, conflicts: [] };
79
113
  },
80
114
  async revision() {
81
- return currentRevision();
115
+ return guard('sqlitePersistence.revision', currentRevision);
82
116
  },
83
117
  async loadPendingEffects() {
84
- return readPendingEffects.all().map((row) => JSON.parse(String(row.record)));
118
+ return guard('sqlitePersistence.loadPendingEffects', () => readPendingEffects.all().map((row) => JSON.parse(String(row.record))));
85
119
  },
86
120
  async recordEffectAttempt(id, update) {
87
- const row = readEffect.get(id);
88
- if (!row) {
89
- return;
90
- }
91
- const existing = JSON.parse(String(row.record));
92
- const updated = { ...existing, ...update };
93
- updateEffect.run(JSON.stringify(updated), updated.status, id);
121
+ await guard('sqlitePersistence.recordEffectAttempt', () => {
122
+ const row = readEffect.get(id);
123
+ if (!row) {
124
+ return;
125
+ }
126
+ const existing = JSON.parse(String(row.record));
127
+ const updated = { ...existing, ...update };
128
+ updateEffect.run(JSON.stringify(updated), updated.status, id);
129
+ });
94
130
  },
95
131
  async close() {
96
132
  database.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-server",
3
- "version": "0.12.0-alpha.1",
3
+ "version": "0.12.1-alpha.1",
4
4
  "description": "Authoritative server runtime that executes an Axiom Server IR.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",
@@ -37,8 +37,8 @@
37
37
  "./schema/*": "./schema/*"
38
38
  },
39
39
  "dependencies": {
40
- "@cynodia/axiom-core": "0.12.0-alpha.1",
41
- "@cynodia/axiom-runtime": "0.12.0-alpha.1"
40
+ "@cynodia/axiom-core": "0.12.1-alpha.1",
41
+ "@cynodia/axiom-runtime": "0.12.1-alpha.1"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "tsc -b tsconfig.json tsconfig.test.json",