@rotorsoft/act-sqlite 1.22.3 → 1.22.5

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.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // src/sqlite-store.ts
2
- import { createClient } from "@libsql/client";
2
+ import {
3
+ createClient
4
+ } from "@libsql/client";
3
5
  import {
4
6
  ConcurrencyError,
5
7
  is_literal_source,
@@ -79,13 +81,11 @@ function streamPatternToGlob(input) {
79
81
  }
80
82
  var SqliteStore = class {
81
83
  client;
82
- /**
83
- * Memoized key resolver for the optional `pii_encryption` envelope.
84
- * Initialized in the constructor when encryption is configured;
85
- * `undefined` otherwise. The resolver caches the operator's key on
86
- * first use — rotation means restarting the store with a fresh
87
- * provider.
88
- */
84
+ // Memoized key resolver for the optional `pii_encryption` envelope.
85
+ // Initialized in the constructor when encryption is configured;
86
+ // `undefined` otherwise. The resolver caches the operator's key on
87
+ // first use — rotation means restarting the store with a fresh
88
+ // provider.
89
89
  _resolve_pii_key;
90
90
  constructor(config) {
91
91
  this.client = createClient({
@@ -94,14 +94,10 @@ var SqliteStore = class {
94
94
  });
95
95
  this._resolve_pii_key = config.pii_encryption ? makeKeyResolver(config.pii_encryption) : void 0;
96
96
  }
97
- /**
98
- * Write-side pii encoder: encrypts when `pii_encryption` is configured,
99
- * stringifies for the TEXT column either way. `null` passes through so
100
- * `forget_pii` semantics survive intact (the row format skips the
101
- * column entirely on a `NULL` write).
102
- *
103
- * @internal
104
- */
97
+ // Write-side pii encoder: encrypts when `pii_encryption` is configured,
98
+ // stringifies for the TEXT column either way. `null` passes through so
99
+ // `forget_pii` semantics survive intact (the row format skips the
100
+ // column entirely on a `NULL` write).
105
101
  async _stringify_pii_for_write(pii) {
106
102
  if (pii == null) return null;
107
103
  if (this._resolve_pii_key) {
@@ -109,15 +105,11 @@ var SqliteStore = class {
109
105
  }
110
106
  return JSON.stringify(pii);
111
107
  }
112
- /**
113
- * Read-side pii decoder: parses the TEXT column, and when
114
- * `pii_encryption` is configured, transparently decrypts any value
115
- * that came back as a JSON string. Plaintext writes deserialize to
116
- * objects and pass through — which is what makes mixed-data
117
- * rollouts (some pre-encryption rows, some post-) read cleanly.
118
- *
119
- * @internal
120
- */
108
+ // Read-side pii decoder: parses the TEXT column, and when
109
+ // `pii_encryption` is configured, transparently decrypts any value
110
+ // that came back as a JSON string. Plaintext writes deserialize to
111
+ // objects and pass through — which is what makes mixed-data
112
+ // rollouts (some pre-encryption rows, some post-) read cleanly.
121
113
  async _parse_pii_from_read(raw) {
122
114
  if (raw == null) return null;
123
115
  const parsed = parse_json(raw);
@@ -127,8 +119,8 @@ var SqliteStore = class {
127
119
  return parsed;
128
120
  }
129
121
  async seed() {
130
- await this.client.execute("PRAGMA journal_mode=WAL");
131
- await this.client.execute(`
122
+ await this._exec("PRAGMA journal_mode=WAL");
123
+ await this._exec(`
132
124
  CREATE TABLE IF NOT EXISTS events (
133
125
  id INTEGER PRIMARY KEY AUTOINCREMENT,
134
126
  stream TEXT NOT NULL,
@@ -142,22 +134,22 @@ var SqliteStore = class {
142
134
  )
143
135
  `);
144
136
  try {
145
- await this.client.execute("ALTER TABLE events ADD COLUMN pii TEXT");
137
+ await this._exec("ALTER TABLE events ADD COLUMN pii TEXT");
146
138
  } catch {
147
139
  }
148
- await this.client.execute(
140
+ await this._exec(
149
141
  "CREATE INDEX IF NOT EXISTS idx_events_stream ON events(stream)"
150
142
  );
151
- await this.client.execute(
143
+ await this._exec(
152
144
  "CREATE INDEX IF NOT EXISTS idx_events_name ON events(name)"
153
145
  );
154
- await this.client.execute(
146
+ await this._exec(
155
147
  "CREATE INDEX IF NOT EXISTS idx_events_snapshot ON events(stream, id) WHERE name = '__snapshot__'"
156
148
  );
157
- await this.client.execute(
149
+ await this._exec(
158
150
  "CREATE INDEX IF NOT EXISTS idx_events_stream_id ON events(stream, id) WHERE name <> '__snapshot__'"
159
151
  );
160
- await this.client.execute(`
152
+ await this._exec(`
161
153
  CREATE TABLE IF NOT EXISTS streams (
162
154
  stream TEXT PRIMARY KEY,
163
155
  source TEXT,
@@ -174,40 +166,36 @@ var SqliteStore = class {
174
166
  )
175
167
  `);
176
168
  try {
177
- await this.client.execute(
169
+ await this._exec(
178
170
  "ALTER TABLE streams ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"
179
171
  );
180
172
  } catch {
181
173
  }
182
174
  try {
183
- await this.client.execute(
184
- "ALTER TABLE streams ADD COLUMN deferred_at TEXT"
185
- );
175
+ await this._exec("ALTER TABLE streams ADD COLUMN deferred_at TEXT");
186
176
  } catch {
187
177
  }
188
178
  try {
189
- await this.client.execute(
179
+ await this._exec(
190
180
  "ALTER TABLE streams ADD COLUMN lane TEXT NOT NULL DEFAULT 'default'"
191
181
  );
192
182
  } catch {
193
183
  }
194
184
  try {
195
- await this.client.execute(
196
- "ALTER TABLE streams ADD COLUMN correlated_at INTEGER"
197
- );
185
+ await this._exec("ALTER TABLE streams ADD COLUMN correlated_at INTEGER");
198
186
  } catch {
199
187
  }
200
- await this.client.execute(
188
+ await this._exec(
201
189
  `UPDATE streams
202
190
  SET correlated_at = (SELECT COALESCE(MAX(id), -1) FROM events)
203
191
  WHERE correlated_at IS NULL`
204
192
  );
205
- const correlated_columns = await this.client.execute(
193
+ const correlated_columns = await this._exec(
206
194
  "PRAGMA table_info(correlated)"
207
195
  );
208
196
  const legacy = correlated_columns.rows.some((r) => r.name === "id");
209
197
  if (legacy) {
210
- await this.client.execute(`
198
+ await this._exec(`
211
199
  CREATE TABLE IF NOT EXISTS correlated_keyed (
212
200
  key TEXT PRIMARY KEY,
213
201
  at INTEGER NOT NULL DEFAULT -1,
@@ -215,15 +203,13 @@ var SqliteStore = class {
215
203
  leased_until INTEGER
216
204
  )
217
205
  `);
218
- await this.client.execute(
206
+ await this._exec(
219
207
  "INSERT OR IGNORE INTO correlated_keyed (key, at) SELECT '', at FROM correlated WHERE id = 0"
220
208
  );
221
- await this.client.execute("DROP TABLE correlated");
222
- await this.client.execute(
223
- "ALTER TABLE correlated_keyed RENAME TO correlated"
224
- );
209
+ await this._exec("DROP TABLE correlated");
210
+ await this._exec("ALTER TABLE correlated_keyed RENAME TO correlated");
225
211
  } else {
226
- await this.client.execute(`
212
+ await this._exec(`
227
213
  CREATE TABLE IF NOT EXISTS correlated (
228
214
  key TEXT PRIMARY KEY,
229
215
  at INTEGER NOT NULL DEFAULT -1,
@@ -232,104 +218,121 @@ var SqliteStore = class {
232
218
  )
233
219
  `);
234
220
  }
235
- await this.client.execute(
236
- "INSERT OR IGNORE INTO correlated (key) VALUES ('')"
237
- );
238
- await this.client.execute(
221
+ await this._exec("INSERT OR IGNORE INTO correlated (key) VALUES ('')");
222
+ await this._exec(
239
223
  "CREATE INDEX IF NOT EXISTS idx_streams_claim ON streams(blocked, priority DESC, at)"
240
224
  );
241
- await this.client.execute(
225
+ await this._exec(
242
226
  "CREATE INDEX IF NOT EXISTS idx_streams_correlated_at ON streams(lane, priority DESC, at) WHERE blocked = 0 AND at < correlated_at"
243
227
  );
244
- await this.client.execute(
228
+ await this._exec(
245
229
  "CREATE INDEX IF NOT EXISTS idx_streams_correlated_at_any_lane ON streams(priority DESC, at) WHERE blocked = 0 AND at < correlated_at"
246
230
  );
247
- await this.client.execute(
231
+ await this._exec(
248
232
  "CREATE INDEX IF NOT EXISTS idx_streams_lane ON streams(lane)"
249
233
  );
250
234
  }
251
235
  async drop() {
252
- await this.client.execute("DROP TABLE IF EXISTS events");
253
- await this.client.execute("DROP TABLE IF EXISTS streams");
254
- await this.client.execute("DROP TABLE IF EXISTS correlated");
236
+ await this._exec("DROP TABLE IF EXISTS events");
237
+ await this._exec("DROP TABLE IF EXISTS streams");
238
+ await this._exec("DROP TABLE IF EXISTS correlated");
255
239
  }
256
- /** One write transaction: any failure rolls back and throws a {@link StoreError} naming the operation. */
257
- async _tx(operation, work) {
258
- const tx = await this.client.transaction("write");
259
- try {
260
- const result = await work(tx);
261
- await tx.commit();
262
- return result;
263
- } catch (e) {
264
- await tx.rollback();
265
- throw new StoreError(operation, { cause: e });
266
- }
240
+ // One connection serves the whole store, and it can't run a statement
241
+ // while a write transaction is open on it: a second one fails at once
242
+ // (`TRANSACTION_ACTIVE` in memory, `SQLITE_BUSY` on a file) instead of
243
+ // waiting. So every statement and every transaction takes its turn here.
244
+ // A turn covers only the database work, never a caller's callback, so a
245
+ // callback that writes to the store can't wait on itself.
246
+ _turn = Promise.resolve();
247
+ _serial(work) {
248
+ const run = this._turn.then(work);
249
+ this._turn = run.catch(() => void 0);
250
+ return run;
251
+ }
252
+ // One statement, in its turn.
253
+ _exec(stmt) {
254
+ return this._serial(() => this.client.execute(stmt));
255
+ }
256
+ // One write transaction, in its turn: any failure rolls back and throws a {@link StoreError} naming the operation.
257
+ _tx(operation, work) {
258
+ return this._serial(async () => {
259
+ const tx = await this.client.transaction("write");
260
+ try {
261
+ const result = await work(tx);
262
+ await tx.commit();
263
+ return result;
264
+ } catch (e) {
265
+ await tx.rollback();
266
+ throw new StoreError(operation, { cause: e });
267
+ }
268
+ });
267
269
  }
268
270
  async dispose() {
269
- await Promise.resolve();
270
- this.client.close();
271
+ await this._serial(async () => this.client.close());
271
272
  }
272
273
  // --- commit: transaction + optimistic concurrency ---
273
274
  async commit(stream, msgs, meta, expectedVersion) {
274
- const tx = await this.client.transaction("write");
275
- let current_version = -1;
276
- try {
277
- const version_row = await tx.execute({
278
- sql: "SELECT COALESCE(MAX(version), -1) as v FROM events WHERE stream = ?",
279
- args: [stream]
280
- });
281
- current_version = Number(version_row.rows[0].v);
282
- if (typeof expectedVersion === "number" && current_version !== expectedVersion) {
283
- throw new ConcurrencyError(
284
- stream,
285
- current_version,
286
- msgs,
287
- expectedVersion
288
- );
289
- }
290
- const now = (/* @__PURE__ */ new Date()).toISOString();
291
- const committed = [];
292
- let version = current_version + 1;
293
- for (const { name, data, pii } of msgs) {
294
- const pii_for_write = await this._stringify_pii_for_write(pii);
295
- const result = await tx.execute({
296
- sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
297
- args: [
275
+ return this._serial(async () => {
276
+ const tx = await this.client.transaction("write");
277
+ let current_version = -1;
278
+ try {
279
+ const version_row = await tx.execute({
280
+ sql: "SELECT COALESCE(MAX(version), -1) as v FROM events WHERE stream = ?",
281
+ args: [stream]
282
+ });
283
+ current_version = Number(version_row.rows[0].v);
284
+ if (typeof expectedVersion === "number" && current_version !== expectedVersion) {
285
+ throw new ConcurrencyError(
286
+ stream,
287
+ current_version,
288
+ msgs,
289
+ expectedVersion
290
+ );
291
+ }
292
+ const now = (/* @__PURE__ */ new Date()).toISOString();
293
+ const committed = [];
294
+ let version = current_version + 1;
295
+ for (const { name, data, pii } of msgs) {
296
+ const pii_for_write = await this._stringify_pii_for_write(pii);
297
+ const result = await tx.execute({
298
+ sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
299
+ args: [
300
+ stream,
301
+ version,
302
+ name,
303
+ JSON.stringify(data),
304
+ JSON.stringify(meta),
305
+ now,
306
+ pii_for_write
307
+ ]
308
+ });
309
+ committed.push({
310
+ id: Number(result.lastInsertRowid),
298
311
  stream,
299
312
  version,
313
+ created: new Date(now),
300
314
  name,
301
- JSON.stringify(data),
302
- JSON.stringify(meta),
303
- now,
304
- pii_for_write
305
- ]
306
- });
307
- committed.push({
308
- id: Number(result.lastInsertRowid),
309
- stream,
310
- version,
311
- created: new Date(now),
312
- name,
313
- data,
314
- meta,
315
- ...pii == null ? {} : { pii }
316
- });
317
- version++;
315
+ data,
316
+ meta,
317
+ ...pii == null ? {} : { pii }
318
+ });
319
+ version++;
320
+ }
321
+ await tx.commit();
322
+ return committed;
323
+ } catch (e) {
324
+ await tx.rollback();
325
+ if (e instanceof ConcurrencyError) throw e;
326
+ if (is_unique_violation(e))
327
+ throw new ConcurrencyError(
328
+ stream,
329
+ current_version,
330
+ msgs,
331
+ expectedVersion ?? -1
332
+ );
333
+ throw new StoreError("commit", { cause: e });
318
334
  }
319
- await tx.commit();
320
- return committed;
321
- } catch (e) {
322
- await tx.rollback();
323
- if (e instanceof ConcurrencyError) throw e;
324
- if (is_unique_violation(e))
325
- throw new ConcurrencyError(
326
- stream,
327
- current_version,
328
- msgs,
329
- expectedVersion ?? -1
330
- );
331
- throw new StoreError("commit", { cause: e });
332
- }
335
+ });
333
336
  }
334
337
  // --- query: read-only, no transaction needed ---
335
338
  async query(callback, query) {
@@ -379,7 +382,7 @@ var SqliteStore = class {
379
382
  sql += " LIMIT ?";
380
383
  args.push(query.limit);
381
384
  }
382
- const result = await this.client.execute({ sql, args });
385
+ const result = await this._exec({ sql, args });
383
386
  let count = 0;
384
387
  for (const row of result.rows) {
385
388
  const pii_value = query?.with_pii === false ? null : await this._parse_pii_from_read(row.pii);
@@ -653,17 +656,15 @@ var SqliteStore = class {
653
656
  at_iso
654
657
  ]);
655
658
  }
656
- /**
657
- * Runs `UPDATE streams <set>` on the streams named by `input`, in one
658
- * write transaction (`_tx`) that rolls back on failure. An explicit list is
659
- * de-duplicated, so a repeated name counts once (matching PG's set-based
660
- * `stream = ANY(...)`), and updated one name per statement, so a long list
661
- * never meets SQLite's bound-parameter limit. `set` and `where` bind
662
- * `params` in order; libSQL `?` placeholders are positional and can't be
663
- * reused, so a value used twice is passed twice.
664
- *
665
- * @returns Count of rows updated.
666
- */
659
+ // Runs `UPDATE streams <set>` on the streams named by `input`, in one
660
+ // write transaction (`_tx`) that rolls back on failure. An explicit list is
661
+ // de-duplicated, so a repeated name counts once (matching PG's set-based
662
+ // `stream = ANY(...)`), and updated one name per statement, so a long list
663
+ // never meets SQLite's bound-parameter limit. `set` and `where` bind
664
+ // `params` in order; libSQL `?` placeholders are positional and can't be
665
+ // reused, so a value used twice is passed twice.
666
+ //
667
+ // @returns Count of rows updated.
667
668
  _update(operation, input, set, params = [], where) {
668
669
  const selections = Array.isArray(input) ? [...new Set(input)].map((stream) => ({
669
670
  clause: "stream = ?",
@@ -681,11 +682,9 @@ var SqliteStore = class {
681
682
  return count;
682
683
  });
683
684
  }
684
- /**
685
- * Translate a {@link StreamFilter} to a SQLite `WHERE` clause fragment
686
- * plus positional args. Returns `"1"` (always true) when empty so
687
- * callers can compose it unconditionally.
688
- */
685
+ // Translate a {@link StreamFilter} to a SQLite `WHERE` clause fragment
686
+ // plus positional args. Returns `"1"` (always true) when empty so
687
+ // callers can compose it unconditionally.
689
688
  _filter_clause(filter) {
690
689
  const conditions = [];
691
690
  const args = [];
@@ -754,8 +753,8 @@ var SqliteStore = class {
754
753
  sql += " ORDER BY stream LIMIT ?";
755
754
  args.push(limit);
756
755
  const [streamsResult, maxResult] = await Promise.all([
757
- this.client.execute({ sql, args }),
758
- this.client.execute("SELECT COALESCE(MAX(id), -1) AS m FROM events")
756
+ this._exec({ sql, args }),
757
+ this._exec("SELECT COALESCE(MAX(id), -1) AS m FROM events")
759
758
  ]);
760
759
  let count = 0;
761
760
  for (const row of streamsResult.rows) {
@@ -862,10 +861,8 @@ var SqliteStore = class {
862
861
  limit
863
862
  );
864
863
  }
865
- /**
866
- * Cheap path — head (and optional tail) via ROW_NUMBER() over the
867
- * `(stream, version)` unique index. Parallel queries when tail set.
868
- */
864
+ // Cheap path — head (and optional tail) via ROW_NUMBER() over the
865
+ // `(stream, version)` unique index. Parallel queries when tail set.
869
866
  async _query_stats_heads_only(from_clause, where_clause, args, want_tail, limit) {
870
867
  const cols = `e.id, e.stream, e.version, e.name, e.data, e.created, e.meta`;
871
868
  const limit_clause = limit !== void 0 ? " LIMIT ?" : "";
@@ -889,8 +886,8 @@ var SqliteStore = class {
889
886
  )` : null;
890
887
  const tail_args = limit !== void 0 ? [...args, ...args, limit] : [...args, ...args];
891
888
  const [headRes, tailRes] = await Promise.all([
892
- this.client.execute({ sql: head_sql, args: head_args }),
893
- tail_sql ? this.client.execute({ sql: tail_sql, args: tail_args }) : null
889
+ this._exec({ sql: head_sql, args: head_args }),
890
+ tail_sql ? this._exec({ sql: tail_sql, args: tail_args }) : null
894
891
  ]);
895
892
  const out = /* @__PURE__ */ new Map();
896
893
  for (const row of headRes.rows) {
@@ -905,11 +902,9 @@ var SqliteStore = class {
905
902
  }
906
903
  return out;
907
904
  }
908
- /**
909
- * Full-scan path — one CTE-based query with per-stream `COUNT(*)` and
910
- * `json_group_object(name, n)`. Heads (and optional tails) ride free
911
- * on the same scan.
912
- */
905
+ // Full-scan path — one CTE-based query with per-stream `COUNT(*)` and
906
+ // `json_group_object(name, n)`. Heads (and optional tails) ride free
907
+ // on the same scan.
913
908
  async _query_stats_full_scan(from_clause, where_clause, args, want_tail, want_count, want_names, limit) {
914
909
  const tail_cte = want_tail ? `, tails AS (
915
910
  SELECT * FROM (
@@ -954,7 +949,7 @@ var SqliteStore = class {
954
949
  ORDER BY h.stream
955
950
  `;
956
951
  const full_args = limit !== void 0 ? [...args, limit] : args;
957
- const res = await this.client.execute({ sql, args: full_args });
952
+ const res = await this._exec({ sql, args: full_args });
958
953
  const out = /* @__PURE__ */ new Map();
959
954
  for (const row of res.rows) {
960
955
  const r = row;
@@ -1064,33 +1059,35 @@ var SqliteStore = class {
1064
1059
  * from the source.
1065
1060
  */
1066
1061
  async restore(driver) {
1067
- const tx = await this.client.transaction("write");
1068
- try {
1069
- await tx.execute("DELETE FROM events");
1070
- await tx.execute("DELETE FROM streams");
1071
- await tx.execute("UPDATE correlated SET at = -1");
1072
- await tx.execute("DELETE FROM sqlite_sequence WHERE name = 'events'");
1073
- await driver(async (event) => {
1074
- const pii_for_write = await this._stringify_pii_for_write(event.pii);
1075
- const ins = await tx.execute({
1076
- sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
1077
- args: [
1078
- event.stream,
1079
- event.version,
1080
- event.name,
1081
- JSON.stringify(event.data),
1082
- JSON.stringify(event.meta),
1083
- event.created.toISOString(),
1084
- pii_for_write
1085
- ]
1062
+ return this._serial(async () => {
1063
+ const tx = await this.client.transaction("write");
1064
+ try {
1065
+ await tx.execute("DELETE FROM events");
1066
+ await tx.execute("DELETE FROM streams");
1067
+ await tx.execute("UPDATE correlated SET at = -1");
1068
+ await tx.execute("DELETE FROM sqlite_sequence WHERE name = 'events'");
1069
+ await driver(async (event) => {
1070
+ const pii_for_write = await this._stringify_pii_for_write(event.pii);
1071
+ const ins = await tx.execute({
1072
+ sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
1073
+ args: [
1074
+ event.stream,
1075
+ event.version,
1076
+ event.name,
1077
+ JSON.stringify(event.data),
1078
+ JSON.stringify(event.meta),
1079
+ event.created.toISOString(),
1080
+ pii_for_write
1081
+ ]
1082
+ });
1083
+ return Number(ins.lastInsertRowid);
1086
1084
  });
1087
- return Number(ins.lastInsertRowid);
1088
- });
1089
- await tx.commit();
1090
- } catch (error) {
1091
- await tx.rollback();
1092
- throw error;
1093
- }
1085
+ await tx.commit();
1086
+ } catch (error) {
1087
+ await tx.rollback();
1088
+ throw error;
1089
+ }
1090
+ });
1094
1091
  }
1095
1092
  /**
1096
1093
  * Wipe the sensitive-data payload for every event on the stream — the
@@ -1110,7 +1107,7 @@ var SqliteStore = class {
1110
1107
  * @returns Count of events whose `pii` was set to `NULL`
1111
1108
  */
1112
1109
  async forget_pii(stream) {
1113
- const r = await this.client.execute({
1110
+ const r = await this._exec({
1114
1111
  sql: "UPDATE events SET pii = NULL WHERE stream = ? AND pii IS NOT NULL",
1115
1112
  args: [stream]
1116
1113
  });