@rulvar/store-sqlite 1.44.0 → 1.45.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/dist/index.d.ts CHANGED
@@ -19,20 +19,48 @@ interface SqliteStoreOptions {
19
19
  now?: () => number;
20
20
  }
21
21
  declare class SqliteStore implements MetaLookupStore, LeasableStore {
22
+ /**
23
+ * The fenced writes promise (fenced run state RFC, phase 2): every
24
+ * lease-carrying mutation of this store (append, putMeta, delete)
25
+ * verifies the lease is the current holder FOR THE MUTATED RUN,
26
+ * atomically with the mutation, and rejects stale or mismatched
27
+ * holders with the typed LeaseHeldError leaving nothing changed.
28
+ */
29
+ readonly fencedWrites = true;
22
30
  private readonly db;
23
31
  private readonly ttlMs;
24
32
  private readonly now;
25
33
  constructor(options: SqliteStoreOptions);
26
34
  close(): void;
27
35
  private liveLease;
36
+ /**
37
+ * A lease fences exactly the run it names: guarding a mutation of a
38
+ * DIFFERENT run with it would pass the holder check while touching
39
+ * state the lease never protected, so the mismatch rejects typed
40
+ * before any check runs.
41
+ */
42
+ private requireRunMatch;
28
43
  /** Rejects unless `lease` is the CURRENT live lease for its run. */
29
44
  private assertFencing;
45
+ /**
46
+ * Runs the fence check and the guarded mutation as ONE immediate
47
+ * transaction, the same shape acquire already uses: BEGIN IMMEDIATE
48
+ * takes the write lock BEFORE the check reads the lease row, so a
49
+ * competing takeover cannot land between the check and the mutation
50
+ * (it serializes behind the commit and the loser sees the final rows).
51
+ * As two autocommit statements, a takeover in that window let a
52
+ * superseded holder mutate live state (fenced-run-state RFC, F3).
53
+ */
54
+ private fenced;
55
+ private insertEntry;
30
56
  append(runId: string, e: JournalEntry, lease?: Lease): Promise<void>;
31
57
  load(runId: string): Promise<JournalEntry[]>;
32
- putMeta(m: RunMeta): Promise<void>;
58
+ private upsertMeta;
59
+ putMeta(m: RunMeta, lease?: Lease): Promise<void>;
33
60
  getMeta(runId: string): Promise<RunMeta | undefined>;
34
61
  listRuns(f?: RunFilter): Promise<RunMeta[]>;
35
- delete(runId: string): Promise<void>;
62
+ private deleteRows;
63
+ delete(runId: string, lease?: Lease): Promise<void>;
36
64
  /**
37
65
  * TTL introspection (the LeasableStore optional capability): lets
38
66
  * createWorker verify at construction that its renew cadence matches
package/dist/index.js CHANGED
@@ -14,7 +14,19 @@ import { ConfigError, JournalOrderViolation, LeaseHeldError, metaMatchesFilter }
14
14
  * through untouched.
15
15
  * - Fencing: the epoch is monotonic per run for the store's lifetime;
16
16
  * an append carrying a stale or released lease rejects with the typed
17
- * LeaseHeldError and the entry never becomes visible.
17
+ * LeaseHeldError and the entry never becomes visible. The fence check
18
+ * and the guarded mutation (append's insert, renew's extension,
19
+ * release's deletion) commit as ONE immediate transaction: checking in
20
+ * one autocommit statement and mutating in the next left a
21
+ * cross-process window where a takeover landing between them let the
22
+ * superseded holder append a visible entry, extend the successor's
23
+ * lease, or delete it outright (the fenced-run-state RFC, finding F3).
24
+ * - Fenced writes (`fencedWrites: true`, the RFC's phase 2): putMeta
25
+ * and delete accept the same optional lease under the same atomic
26
+ * rule, and every lease-guarded mutation additionally requires the
27
+ * lease's runId to BE the mutated run, so a superseded worker can
28
+ * neither strand a run through a stale terminal meta write (F1) nor
29
+ * delete live run state (F4).
18
30
  * - acquire on a held, unexpired lease rejects with LeaseHeldError; the
19
31
  * holder MUST renew at an interval of at most ttl/3; an unrenewed
20
32
  * lease is reclaimable after ttl and reclaiming advances the epoch.
@@ -25,6 +37,14 @@ import { ConfigError, JournalOrderViolation, LeaseHeldError, metaMatchesFilter }
25
37
  const DEFAULT_LEASE_TTL_MS = 6e4;
26
38
  const wallClock = Date.now.bind(globalThis);
27
39
  var SqliteStore = class {
40
+ /**
41
+ * The fenced writes promise (fenced run state RFC, phase 2): every
42
+ * lease-carrying mutation of this store (append, putMeta, delete)
43
+ * verifies the lease is the current holder FOR THE MUTATED RUN,
44
+ * atomically with the mutation, and rejects stale or mismatched
45
+ * holders with the typed LeaseHeldError leaving nothing changed.
46
+ */
47
+ fencedWrites = true;
28
48
  db;
29
49
  ttlMs;
30
50
  now;
@@ -72,25 +92,73 @@ var SqliteStore = class {
72
92
  if (row === void 0 || row.expires_at <= this.now()) return;
73
93
  return row;
74
94
  }
95
+ /**
96
+ * A lease fences exactly the run it names: guarding a mutation of a
97
+ * DIFFERENT run with it would pass the holder check while touching
98
+ * state the lease never protected, so the mismatch rejects typed
99
+ * before any check runs.
100
+ */
101
+ requireRunMatch(lease, runId, mutation) {
102
+ if (lease.runId !== runId) throw new LeaseHeldError(`lease for run '${lease.runId}' (owner ${lease.owner}, epoch ${lease.epoch}) cannot guard a ${mutation} of run '${runId}'; the mutation is rejected and nothing changed`);
103
+ }
75
104
  /** Rejects unless `lease` is the CURRENT live lease for its run. */
76
105
  assertFencing(lease) {
77
106
  const live = this.liveLease(lease.runId);
78
107
  if (live === void 0 || live.owner !== lease.owner || live.epoch !== lease.epoch) throw new LeaseHeldError(`stale fencing epoch for run '${lease.runId}': lease (owner ${lease.owner}, epoch ${lease.epoch}) is not the current holder; the append or renew is rejected and nothing became visible`);
79
108
  }
80
- async append(runId, e, lease) {
81
- if (lease !== void 0) this.assertFencing(lease);
109
+ /**
110
+ * Runs the fence check and the guarded mutation as ONE immediate
111
+ * transaction, the same shape acquire already uses: BEGIN IMMEDIATE
112
+ * takes the write lock BEFORE the check reads the lease row, so a
113
+ * competing takeover cannot land between the check and the mutation
114
+ * (it serializes behind the commit and the loser sees the final rows).
115
+ * As two autocommit statements, a takeover in that window let a
116
+ * superseded holder mutate live state (fenced-run-state RFC, F3).
117
+ */
118
+ fenced(lease, mutate) {
119
+ this.db.exec("BEGIN IMMEDIATE");
120
+ try {
121
+ this.assertFencing(lease);
122
+ mutate();
123
+ this.db.exec("COMMIT");
124
+ } catch (thrown) {
125
+ this.db.exec("ROLLBACK");
126
+ throw thrown;
127
+ }
128
+ }
129
+ insertEntry(runId, e) {
82
130
  if (!Number.isFinite(e.seq)) {
83
131
  this.db.prepare("INSERT INTO entries (run_id, payload) VALUES (?, ?)").run(runId, JSON.stringify(e));
84
132
  return;
85
133
  }
86
134
  if (this.db.prepare("INSERT INTO entries (run_id, payload) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM entries WHERE run_id = ? AND CAST(json_extract(payload, '$.seq') AS INTEGER) >= ?)").run(runId, JSON.stringify(e), runId, e.seq).changes === 0) throw new JournalOrderViolation(`SqliteStore: append of seq ${e.seq} to run '${runId}' is not after the stored tail seq; a concurrent writer raced this journal from a stale tail`);
87
135
  }
136
+ async append(runId, e, lease) {
137
+ if (lease !== void 0) {
138
+ this.requireRunMatch(lease, runId, "journal append");
139
+ this.fenced(lease, () => {
140
+ this.insertEntry(runId, e);
141
+ });
142
+ return;
143
+ }
144
+ this.insertEntry(runId, e);
145
+ }
88
146
  async load(runId) {
89
147
  return this.db.prepare("SELECT payload FROM entries WHERE run_id = ? ORDER BY id").all(runId).map((row) => JSON.parse(row.payload));
90
148
  }
91
- async putMeta(m) {
149
+ upsertMeta(m) {
92
150
  this.db.prepare("INSERT INTO meta (run_id, payload) VALUES (?, ?) ON CONFLICT(run_id) DO UPDATE SET payload = excluded.payload").run(m.runId, JSON.stringify(m));
93
151
  }
152
+ async putMeta(m, lease) {
153
+ if (lease !== void 0) {
154
+ this.requireRunMatch(lease, m.runId, "meta write");
155
+ this.fenced(lease, () => {
156
+ this.upsertMeta(m);
157
+ });
158
+ return;
159
+ }
160
+ this.upsertMeta(m);
161
+ }
94
162
  async getMeta(runId) {
95
163
  const row = this.db.prepare("SELECT payload FROM meta WHERE run_id = ?").get(runId);
96
164
  return row === void 0 ? void 0 : JSON.parse(row.payload);
@@ -113,13 +181,23 @@ var SqliteStore = class {
113
181
  const sql = "SELECT payload FROM meta" + (where.length === 0 ? "" : ` WHERE ${where.join(" AND ")}`) + " ORDER BY run_id";
114
182
  return this.db.prepare(sql).all(...params).map((row) => JSON.parse(row.payload)).filter((meta) => metaMatchesFilter(meta, f));
115
183
  }
116
- async delete(runId) {
184
+ deleteRows(runId) {
185
+ this.db.prepare("DELETE FROM entries WHERE run_id = ?").run(runId);
186
+ this.db.prepare("DELETE FROM meta WHERE run_id = ?").run(runId);
187
+ this.db.prepare("DELETE FROM leases WHERE run_id = ?").run(runId);
188
+ this.db.prepare("DELETE FROM epochs WHERE run_id = ?").run(runId);
189
+ }
190
+ async delete(runId, lease) {
191
+ if (lease !== void 0) {
192
+ this.requireRunMatch(lease, runId, "run deletion");
193
+ this.fenced(lease, () => {
194
+ this.deleteRows(runId);
195
+ });
196
+ return;
197
+ }
117
198
  this.db.exec("BEGIN IMMEDIATE");
118
199
  try {
119
- this.db.prepare("DELETE FROM entries WHERE run_id = ?").run(runId);
120
- this.db.prepare("DELETE FROM meta WHERE run_id = ?").run(runId);
121
- this.db.prepare("DELETE FROM leases WHERE run_id = ?").run(runId);
122
- this.db.prepare("DELETE FROM epochs WHERE run_id = ?").run(runId);
200
+ this.deleteRows(runId);
123
201
  this.db.exec("COMMIT");
124
202
  } catch (thrown) {
125
203
  this.db.exec("ROLLBACK");
@@ -154,12 +232,14 @@ var SqliteStore = class {
154
232
  }
155
233
  }
156
234
  async renew(l) {
157
- this.assertFencing(l);
158
- this.db.prepare("UPDATE leases SET expires_at = ? WHERE run_id = ?").run(this.now() + this.ttlMs, l.runId);
235
+ this.fenced(l, () => {
236
+ this.db.prepare("UPDATE leases SET expires_at = ? WHERE run_id = ? AND owner = ? AND epoch = ?").run(this.now() + this.ttlMs, l.runId, l.owner, l.epoch);
237
+ });
159
238
  }
160
239
  async release(l) {
161
- this.assertFencing(l);
162
- this.db.prepare("DELETE FROM leases WHERE run_id = ?").run(l.runId);
240
+ this.fenced(l, () => {
241
+ this.db.prepare("DELETE FROM leases WHERE run_id = ? AND owner = ? AND epoch = ?").run(l.runId, l.owner, l.epoch);
242
+ });
163
243
  }
164
244
  };
165
245
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/store-sqlite",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "Rulvar SQLite store implementing JournalStore and LeasableStore with a fencing epoch.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,13 +22,13 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.44.0"
25
+ "@rulvar/core": "1.45.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.20.0",
29
29
  "tsdown": "^0.22.3",
30
30
  "typescript": "~6.0.3",
31
- "@rulvar/store-conformance": "1.44.0"
31
+ "@rulvar/store-conformance": "1.45.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",