@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.cjs CHANGED
@@ -97,13 +97,11 @@ function streamPatternToGlob(input) {
97
97
  }
98
98
  var SqliteStore = class {
99
99
  client;
100
- /**
101
- * Memoized key resolver for the optional `pii_encryption` envelope.
102
- * Initialized in the constructor when encryption is configured;
103
- * `undefined` otherwise. The resolver caches the operator's key on
104
- * first use — rotation means restarting the store with a fresh
105
- * provider.
106
- */
100
+ // Memoized key resolver for the optional `pii_encryption` envelope.
101
+ // Initialized in the constructor when encryption is configured;
102
+ // `undefined` otherwise. The resolver caches the operator's key on
103
+ // first use — rotation means restarting the store with a fresh
104
+ // provider.
107
105
  _resolve_pii_key;
108
106
  constructor(config) {
109
107
  this.client = (0, import_client.createClient)({
@@ -112,14 +110,10 @@ var SqliteStore = class {
112
110
  });
113
111
  this._resolve_pii_key = config.pii_encryption ? (0, import_act_crypto.makeKeyResolver)(config.pii_encryption) : void 0;
114
112
  }
115
- /**
116
- * Write-side pii encoder: encrypts when `pii_encryption` is configured,
117
- * stringifies for the TEXT column either way. `null` passes through so
118
- * `forget_pii` semantics survive intact (the row format skips the
119
- * column entirely on a `NULL` write).
120
- *
121
- * @internal
122
- */
113
+ // Write-side pii encoder: encrypts when `pii_encryption` is configured,
114
+ // stringifies for the TEXT column either way. `null` passes through so
115
+ // `forget_pii` semantics survive intact (the row format skips the
116
+ // column entirely on a `NULL` write).
123
117
  async _stringify_pii_for_write(pii) {
124
118
  if (pii == null) return null;
125
119
  if (this._resolve_pii_key) {
@@ -127,15 +121,11 @@ var SqliteStore = class {
127
121
  }
128
122
  return JSON.stringify(pii);
129
123
  }
130
- /**
131
- * Read-side pii decoder: parses the TEXT column, and when
132
- * `pii_encryption` is configured, transparently decrypts any value
133
- * that came back as a JSON string. Plaintext writes deserialize to
134
- * objects and pass through — which is what makes mixed-data
135
- * rollouts (some pre-encryption rows, some post-) read cleanly.
136
- *
137
- * @internal
138
- */
124
+ // Read-side pii decoder: parses the TEXT column, and when
125
+ // `pii_encryption` is configured, transparently decrypts any value
126
+ // that came back as a JSON string. Plaintext writes deserialize to
127
+ // objects and pass through — which is what makes mixed-data
128
+ // rollouts (some pre-encryption rows, some post-) read cleanly.
139
129
  async _parse_pii_from_read(raw) {
140
130
  if (raw == null) return null;
141
131
  const parsed = parse_json(raw);
@@ -145,8 +135,8 @@ var SqliteStore = class {
145
135
  return parsed;
146
136
  }
147
137
  async seed() {
148
- await this.client.execute("PRAGMA journal_mode=WAL");
149
- await this.client.execute(`
138
+ await this._exec("PRAGMA journal_mode=WAL");
139
+ await this._exec(`
150
140
  CREATE TABLE IF NOT EXISTS events (
151
141
  id INTEGER PRIMARY KEY AUTOINCREMENT,
152
142
  stream TEXT NOT NULL,
@@ -160,22 +150,22 @@ var SqliteStore = class {
160
150
  )
161
151
  `);
162
152
  try {
163
- await this.client.execute("ALTER TABLE events ADD COLUMN pii TEXT");
153
+ await this._exec("ALTER TABLE events ADD COLUMN pii TEXT");
164
154
  } catch {
165
155
  }
166
- await this.client.execute(
156
+ await this._exec(
167
157
  "CREATE INDEX IF NOT EXISTS idx_events_stream ON events(stream)"
168
158
  );
169
- await this.client.execute(
159
+ await this._exec(
170
160
  "CREATE INDEX IF NOT EXISTS idx_events_name ON events(name)"
171
161
  );
172
- await this.client.execute(
162
+ await this._exec(
173
163
  "CREATE INDEX IF NOT EXISTS idx_events_snapshot ON events(stream, id) WHERE name = '__snapshot__'"
174
164
  );
175
- await this.client.execute(
165
+ await this._exec(
176
166
  "CREATE INDEX IF NOT EXISTS idx_events_stream_id ON events(stream, id) WHERE name <> '__snapshot__'"
177
167
  );
178
- await this.client.execute(`
168
+ await this._exec(`
179
169
  CREATE TABLE IF NOT EXISTS streams (
180
170
  stream TEXT PRIMARY KEY,
181
171
  source TEXT,
@@ -192,40 +182,36 @@ var SqliteStore = class {
192
182
  )
193
183
  `);
194
184
  try {
195
- await this.client.execute(
185
+ await this._exec(
196
186
  "ALTER TABLE streams ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"
197
187
  );
198
188
  } catch {
199
189
  }
200
190
  try {
201
- await this.client.execute(
202
- "ALTER TABLE streams ADD COLUMN deferred_at TEXT"
203
- );
191
+ await this._exec("ALTER TABLE streams ADD COLUMN deferred_at TEXT");
204
192
  } catch {
205
193
  }
206
194
  try {
207
- await this.client.execute(
195
+ await this._exec(
208
196
  "ALTER TABLE streams ADD COLUMN lane TEXT NOT NULL DEFAULT 'default'"
209
197
  );
210
198
  } catch {
211
199
  }
212
200
  try {
213
- await this.client.execute(
214
- "ALTER TABLE streams ADD COLUMN correlated_at INTEGER"
215
- );
201
+ await this._exec("ALTER TABLE streams ADD COLUMN correlated_at INTEGER");
216
202
  } catch {
217
203
  }
218
- await this.client.execute(
204
+ await this._exec(
219
205
  `UPDATE streams
220
206
  SET correlated_at = (SELECT COALESCE(MAX(id), -1) FROM events)
221
207
  WHERE correlated_at IS NULL`
222
208
  );
223
- const correlated_columns = await this.client.execute(
209
+ const correlated_columns = await this._exec(
224
210
  "PRAGMA table_info(correlated)"
225
211
  );
226
212
  const legacy = correlated_columns.rows.some((r) => r.name === "id");
227
213
  if (legacy) {
228
- await this.client.execute(`
214
+ await this._exec(`
229
215
  CREATE TABLE IF NOT EXISTS correlated_keyed (
230
216
  key TEXT PRIMARY KEY,
231
217
  at INTEGER NOT NULL DEFAULT -1,
@@ -233,15 +219,13 @@ var SqliteStore = class {
233
219
  leased_until INTEGER
234
220
  )
235
221
  `);
236
- await this.client.execute(
222
+ await this._exec(
237
223
  "INSERT OR IGNORE INTO correlated_keyed (key, at) SELECT '', at FROM correlated WHERE id = 0"
238
224
  );
239
- await this.client.execute("DROP TABLE correlated");
240
- await this.client.execute(
241
- "ALTER TABLE correlated_keyed RENAME TO correlated"
242
- );
225
+ await this._exec("DROP TABLE correlated");
226
+ await this._exec("ALTER TABLE correlated_keyed RENAME TO correlated");
243
227
  } else {
244
- await this.client.execute(`
228
+ await this._exec(`
245
229
  CREATE TABLE IF NOT EXISTS correlated (
246
230
  key TEXT PRIMARY KEY,
247
231
  at INTEGER NOT NULL DEFAULT -1,
@@ -250,104 +234,121 @@ var SqliteStore = class {
250
234
  )
251
235
  `);
252
236
  }
253
- await this.client.execute(
254
- "INSERT OR IGNORE INTO correlated (key) VALUES ('')"
255
- );
256
- await this.client.execute(
237
+ await this._exec("INSERT OR IGNORE INTO correlated (key) VALUES ('')");
238
+ await this._exec(
257
239
  "CREATE INDEX IF NOT EXISTS idx_streams_claim ON streams(blocked, priority DESC, at)"
258
240
  );
259
- await this.client.execute(
241
+ await this._exec(
260
242
  "CREATE INDEX IF NOT EXISTS idx_streams_correlated_at ON streams(lane, priority DESC, at) WHERE blocked = 0 AND at < correlated_at"
261
243
  );
262
- await this.client.execute(
244
+ await this._exec(
263
245
  "CREATE INDEX IF NOT EXISTS idx_streams_correlated_at_any_lane ON streams(priority DESC, at) WHERE blocked = 0 AND at < correlated_at"
264
246
  );
265
- await this.client.execute(
247
+ await this._exec(
266
248
  "CREATE INDEX IF NOT EXISTS idx_streams_lane ON streams(lane)"
267
249
  );
268
250
  }
269
251
  async drop() {
270
- await this.client.execute("DROP TABLE IF EXISTS events");
271
- await this.client.execute("DROP TABLE IF EXISTS streams");
272
- await this.client.execute("DROP TABLE IF EXISTS correlated");
252
+ await this._exec("DROP TABLE IF EXISTS events");
253
+ await this._exec("DROP TABLE IF EXISTS streams");
254
+ await this._exec("DROP TABLE IF EXISTS correlated");
273
255
  }
274
- /** One write transaction: any failure rolls back and throws a {@link StoreError} naming the operation. */
275
- async _tx(operation, work) {
276
- const tx = await this.client.transaction("write");
277
- try {
278
- const result = await work(tx);
279
- await tx.commit();
280
- return result;
281
- } catch (e) {
282
- await tx.rollback();
283
- throw new import_act.StoreError(operation, { cause: e });
284
- }
256
+ // One connection serves the whole store, and it can't run a statement
257
+ // while a write transaction is open on it: a second one fails at once
258
+ // (`TRANSACTION_ACTIVE` in memory, `SQLITE_BUSY` on a file) instead of
259
+ // waiting. So every statement and every transaction takes its turn here.
260
+ // A turn covers only the database work, never a caller's callback, so a
261
+ // callback that writes to the store can't wait on itself.
262
+ _turn = Promise.resolve();
263
+ _serial(work) {
264
+ const run = this._turn.then(work);
265
+ this._turn = run.catch(() => void 0);
266
+ return run;
267
+ }
268
+ // One statement, in its turn.
269
+ _exec(stmt) {
270
+ return this._serial(() => this.client.execute(stmt));
271
+ }
272
+ // One write transaction, in its turn: any failure rolls back and throws a {@link StoreError} naming the operation.
273
+ _tx(operation, work) {
274
+ return this._serial(async () => {
275
+ const tx = await this.client.transaction("write");
276
+ try {
277
+ const result = await work(tx);
278
+ await tx.commit();
279
+ return result;
280
+ } catch (e) {
281
+ await tx.rollback();
282
+ throw new import_act.StoreError(operation, { cause: e });
283
+ }
284
+ });
285
285
  }
286
286
  async dispose() {
287
- await Promise.resolve();
288
- this.client.close();
287
+ await this._serial(async () => this.client.close());
289
288
  }
290
289
  // --- commit: transaction + optimistic concurrency ---
291
290
  async commit(stream, msgs, meta, expectedVersion) {
292
- const tx = await this.client.transaction("write");
293
- let current_version = -1;
294
- try {
295
- const version_row = await tx.execute({
296
- sql: "SELECT COALESCE(MAX(version), -1) as v FROM events WHERE stream = ?",
297
- args: [stream]
298
- });
299
- current_version = Number(version_row.rows[0].v);
300
- if (typeof expectedVersion === "number" && current_version !== expectedVersion) {
301
- throw new import_act.ConcurrencyError(
302
- stream,
303
- current_version,
304
- msgs,
305
- expectedVersion
306
- );
307
- }
308
- const now = (/* @__PURE__ */ new Date()).toISOString();
309
- const committed = [];
310
- let version = current_version + 1;
311
- for (const { name, data, pii } of msgs) {
312
- const pii_for_write = await this._stringify_pii_for_write(pii);
313
- const result = await tx.execute({
314
- sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
315
- args: [
291
+ return this._serial(async () => {
292
+ const tx = await this.client.transaction("write");
293
+ let current_version = -1;
294
+ try {
295
+ const version_row = await tx.execute({
296
+ sql: "SELECT COALESCE(MAX(version), -1) as v FROM events WHERE stream = ?",
297
+ args: [stream]
298
+ });
299
+ current_version = Number(version_row.rows[0].v);
300
+ if (typeof expectedVersion === "number" && current_version !== expectedVersion) {
301
+ throw new import_act.ConcurrencyError(
302
+ stream,
303
+ current_version,
304
+ msgs,
305
+ expectedVersion
306
+ );
307
+ }
308
+ const now = (/* @__PURE__ */ new Date()).toISOString();
309
+ const committed = [];
310
+ let version = current_version + 1;
311
+ for (const { name, data, pii } of msgs) {
312
+ const pii_for_write = await this._stringify_pii_for_write(pii);
313
+ const result = await tx.execute({
314
+ sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
315
+ args: [
316
+ stream,
317
+ version,
318
+ name,
319
+ JSON.stringify(data),
320
+ JSON.stringify(meta),
321
+ now,
322
+ pii_for_write
323
+ ]
324
+ });
325
+ committed.push({
326
+ id: Number(result.lastInsertRowid),
316
327
  stream,
317
328
  version,
329
+ created: new Date(now),
318
330
  name,
319
- JSON.stringify(data),
320
- JSON.stringify(meta),
321
- now,
322
- pii_for_write
323
- ]
324
- });
325
- committed.push({
326
- id: Number(result.lastInsertRowid),
327
- stream,
328
- version,
329
- created: new Date(now),
330
- name,
331
- data,
332
- meta,
333
- ...pii == null ? {} : { pii }
334
- });
335
- version++;
331
+ data,
332
+ meta,
333
+ ...pii == null ? {} : { pii }
334
+ });
335
+ version++;
336
+ }
337
+ await tx.commit();
338
+ return committed;
339
+ } catch (e) {
340
+ await tx.rollback();
341
+ if (e instanceof import_act.ConcurrencyError) throw e;
342
+ if (is_unique_violation(e))
343
+ throw new import_act.ConcurrencyError(
344
+ stream,
345
+ current_version,
346
+ msgs,
347
+ expectedVersion ?? -1
348
+ );
349
+ throw new import_act.StoreError("commit", { cause: e });
336
350
  }
337
- await tx.commit();
338
- return committed;
339
- } catch (e) {
340
- await tx.rollback();
341
- if (e instanceof import_act.ConcurrencyError) throw e;
342
- if (is_unique_violation(e))
343
- throw new import_act.ConcurrencyError(
344
- stream,
345
- current_version,
346
- msgs,
347
- expectedVersion ?? -1
348
- );
349
- throw new import_act.StoreError("commit", { cause: e });
350
- }
351
+ });
351
352
  }
352
353
  // --- query: read-only, no transaction needed ---
353
354
  async query(callback, query) {
@@ -397,7 +398,7 @@ var SqliteStore = class {
397
398
  sql += " LIMIT ?";
398
399
  args.push(query.limit);
399
400
  }
400
- const result = await this.client.execute({ sql, args });
401
+ const result = await this._exec({ sql, args });
401
402
  let count = 0;
402
403
  for (const row of result.rows) {
403
404
  const pii_value = query?.with_pii === false ? null : await this._parse_pii_from_read(row.pii);
@@ -671,17 +672,15 @@ var SqliteStore = class {
671
672
  at_iso
672
673
  ]);
673
674
  }
674
- /**
675
- * Runs `UPDATE streams <set>` on the streams named by `input`, in one
676
- * write transaction (`_tx`) that rolls back on failure. An explicit list is
677
- * de-duplicated, so a repeated name counts once (matching PG's set-based
678
- * `stream = ANY(...)`), and updated one name per statement, so a long list
679
- * never meets SQLite's bound-parameter limit. `set` and `where` bind
680
- * `params` in order; libSQL `?` placeholders are positional and can't be
681
- * reused, so a value used twice is passed twice.
682
- *
683
- * @returns Count of rows updated.
684
- */
675
+ // Runs `UPDATE streams <set>` on the streams named by `input`, in one
676
+ // write transaction (`_tx`) that rolls back on failure. An explicit list is
677
+ // de-duplicated, so a repeated name counts once (matching PG's set-based
678
+ // `stream = ANY(...)`), and updated one name per statement, so a long list
679
+ // never meets SQLite's bound-parameter limit. `set` and `where` bind
680
+ // `params` in order; libSQL `?` placeholders are positional and can't be
681
+ // reused, so a value used twice is passed twice.
682
+ //
683
+ // @returns Count of rows updated.
685
684
  _update(operation, input, set, params = [], where) {
686
685
  const selections = Array.isArray(input) ? [...new Set(input)].map((stream) => ({
687
686
  clause: "stream = ?",
@@ -699,11 +698,9 @@ var SqliteStore = class {
699
698
  return count;
700
699
  });
701
700
  }
702
- /**
703
- * Translate a {@link StreamFilter} to a SQLite `WHERE` clause fragment
704
- * plus positional args. Returns `"1"` (always true) when empty so
705
- * callers can compose it unconditionally.
706
- */
701
+ // Translate a {@link StreamFilter} to a SQLite `WHERE` clause fragment
702
+ // plus positional args. Returns `"1"` (always true) when empty so
703
+ // callers can compose it unconditionally.
707
704
  _filter_clause(filter) {
708
705
  const conditions = [];
709
706
  const args = [];
@@ -772,8 +769,8 @@ var SqliteStore = class {
772
769
  sql += " ORDER BY stream LIMIT ?";
773
770
  args.push(limit);
774
771
  const [streamsResult, maxResult] = await Promise.all([
775
- this.client.execute({ sql, args }),
776
- this.client.execute("SELECT COALESCE(MAX(id), -1) AS m FROM events")
772
+ this._exec({ sql, args }),
773
+ this._exec("SELECT COALESCE(MAX(id), -1) AS m FROM events")
777
774
  ]);
778
775
  let count = 0;
779
776
  for (const row of streamsResult.rows) {
@@ -880,10 +877,8 @@ var SqliteStore = class {
880
877
  limit
881
878
  );
882
879
  }
883
- /**
884
- * Cheap path — head (and optional tail) via ROW_NUMBER() over the
885
- * `(stream, version)` unique index. Parallel queries when tail set.
886
- */
880
+ // Cheap path — head (and optional tail) via ROW_NUMBER() over the
881
+ // `(stream, version)` unique index. Parallel queries when tail set.
887
882
  async _query_stats_heads_only(from_clause, where_clause, args, want_tail, limit) {
888
883
  const cols = `e.id, e.stream, e.version, e.name, e.data, e.created, e.meta`;
889
884
  const limit_clause = limit !== void 0 ? " LIMIT ?" : "";
@@ -907,8 +902,8 @@ var SqliteStore = class {
907
902
  )` : null;
908
903
  const tail_args = limit !== void 0 ? [...args, ...args, limit] : [...args, ...args];
909
904
  const [headRes, tailRes] = await Promise.all([
910
- this.client.execute({ sql: head_sql, args: head_args }),
911
- tail_sql ? this.client.execute({ sql: tail_sql, args: tail_args }) : null
905
+ this._exec({ sql: head_sql, args: head_args }),
906
+ tail_sql ? this._exec({ sql: tail_sql, args: tail_args }) : null
912
907
  ]);
913
908
  const out = /* @__PURE__ */ new Map();
914
909
  for (const row of headRes.rows) {
@@ -923,11 +918,9 @@ var SqliteStore = class {
923
918
  }
924
919
  return out;
925
920
  }
926
- /**
927
- * Full-scan path — one CTE-based query with per-stream `COUNT(*)` and
928
- * `json_group_object(name, n)`. Heads (and optional tails) ride free
929
- * on the same scan.
930
- */
921
+ // Full-scan path — one CTE-based query with per-stream `COUNT(*)` and
922
+ // `json_group_object(name, n)`. Heads (and optional tails) ride free
923
+ // on the same scan.
931
924
  async _query_stats_full_scan(from_clause, where_clause, args, want_tail, want_count, want_names, limit) {
932
925
  const tail_cte = want_tail ? `, tails AS (
933
926
  SELECT * FROM (
@@ -972,7 +965,7 @@ var SqliteStore = class {
972
965
  ORDER BY h.stream
973
966
  `;
974
967
  const full_args = limit !== void 0 ? [...args, limit] : args;
975
- const res = await this.client.execute({ sql, args: full_args });
968
+ const res = await this._exec({ sql, args: full_args });
976
969
  const out = /* @__PURE__ */ new Map();
977
970
  for (const row of res.rows) {
978
971
  const r = row;
@@ -1082,33 +1075,35 @@ var SqliteStore = class {
1082
1075
  * from the source.
1083
1076
  */
1084
1077
  async restore(driver) {
1085
- const tx = await this.client.transaction("write");
1086
- try {
1087
- await tx.execute("DELETE FROM events");
1088
- await tx.execute("DELETE FROM streams");
1089
- await tx.execute("UPDATE correlated SET at = -1");
1090
- await tx.execute("DELETE FROM sqlite_sequence WHERE name = 'events'");
1091
- await driver(async (event) => {
1092
- const pii_for_write = await this._stringify_pii_for_write(event.pii);
1093
- const ins = await tx.execute({
1094
- sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
1095
- args: [
1096
- event.stream,
1097
- event.version,
1098
- event.name,
1099
- JSON.stringify(event.data),
1100
- JSON.stringify(event.meta),
1101
- event.created.toISOString(),
1102
- pii_for_write
1103
- ]
1078
+ return this._serial(async () => {
1079
+ const tx = await this.client.transaction("write");
1080
+ try {
1081
+ await tx.execute("DELETE FROM events");
1082
+ await tx.execute("DELETE FROM streams");
1083
+ await tx.execute("UPDATE correlated SET at = -1");
1084
+ await tx.execute("DELETE FROM sqlite_sequence WHERE name = 'events'");
1085
+ await driver(async (event) => {
1086
+ const pii_for_write = await this._stringify_pii_for_write(event.pii);
1087
+ const ins = await tx.execute({
1088
+ sql: "INSERT INTO events (stream, version, name, data, meta, created, pii) VALUES (?, ?, ?, ?, ?, ?, ?)",
1089
+ args: [
1090
+ event.stream,
1091
+ event.version,
1092
+ event.name,
1093
+ JSON.stringify(event.data),
1094
+ JSON.stringify(event.meta),
1095
+ event.created.toISOString(),
1096
+ pii_for_write
1097
+ ]
1098
+ });
1099
+ return Number(ins.lastInsertRowid);
1104
1100
  });
1105
- return Number(ins.lastInsertRowid);
1106
- });
1107
- await tx.commit();
1108
- } catch (error) {
1109
- await tx.rollback();
1110
- throw error;
1111
- }
1101
+ await tx.commit();
1102
+ } catch (error) {
1103
+ await tx.rollback();
1104
+ throw error;
1105
+ }
1106
+ });
1112
1107
  }
1113
1108
  /**
1114
1109
  * Wipe the sensitive-data payload for every event on the stream — the
@@ -1128,7 +1123,7 @@ var SqliteStore = class {
1128
1123
  * @returns Count of events whose `pii` was set to `NULL`
1129
1124
  */
1130
1125
  async forget_pii(stream) {
1131
- const r = await this.client.execute({
1126
+ const r = await this._exec({
1132
1127
  sql: "UPDATE events SET pii = NULL WHERE stream = ? AND pii IS NOT NULL",
1133
1128
  args: [stream]
1134
1129
  });