@stigmer/server 3.25.0 → 3.26.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.
Files changed (142) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +11 -8
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/boot/list-indexes.d.ts +3 -0
  5. package/dist/boot/list-indexes.d.ts.map +1 -0
  6. package/dist/boot/list-indexes.js +24 -0
  7. package/dist/boot/list-indexes.js.map +1 -0
  8. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  9. package/dist/domain/agentexecution/controller.js +6 -8
  10. package/dist/domain/agentexecution/controller.js.map +1 -1
  11. package/dist/domain/agentexecution/list-index.d.ts +2 -0
  12. package/dist/domain/agentexecution/list-index.d.ts.map +1 -0
  13. package/dist/domain/agentexecution/list-index.js +23 -0
  14. package/dist/domain/agentexecution/list-index.js.map +1 -0
  15. package/dist/domain/agentexecution/steps.d.ts +17 -33
  16. package/dist/domain/agentexecution/steps.d.ts.map +1 -1
  17. package/dist/domain/agentexecution/steps.js +94 -95
  18. package/dist/domain/agentexecution/steps.js.map +1 -1
  19. package/dist/domain/agentexecution/usage.d.ts.map +1 -1
  20. package/dist/domain/agentexecution/usage.js +37 -14
  21. package/dist/domain/agentexecution/usage.js.map +1 -1
  22. package/dist/domain/artifact/controller.d.ts.map +1 -1
  23. package/dist/domain/artifact/controller.js +27 -15
  24. package/dist/domain/artifact/controller.js.map +1 -1
  25. package/dist/domain/artifact/list-index.d.ts +2 -0
  26. package/dist/domain/artifact/list-index.d.ts.map +1 -0
  27. package/dist/domain/artifact/list-index.js +21 -0
  28. package/dist/domain/artifact/list-index.js.map +1 -0
  29. package/dist/domain/session/list-index.d.ts +4 -0
  30. package/dist/domain/session/list-index.d.ts.map +1 -0
  31. package/dist/domain/session/list-index.js +26 -0
  32. package/dist/domain/session/list-index.js.map +1 -0
  33. package/dist/domain/session/steps.d.ts +7 -8
  34. package/dist/domain/session/steps.d.ts.map +1 -1
  35. package/dist/domain/session/steps.js +76 -78
  36. package/dist/domain/session/steps.js.map +1 -1
  37. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  38. package/dist/domain/workflowexecution/controller.js +53 -55
  39. package/dist/domain/workflowexecution/controller.js.map +1 -1
  40. package/dist/domain/workflowexecution/execution-filter.d.ts +4 -0
  41. package/dist/domain/workflowexecution/execution-filter.d.ts.map +1 -1
  42. package/dist/domain/workflowexecution/execution-filter.js +11 -5
  43. package/dist/domain/workflowexecution/execution-filter.js.map +1 -1
  44. package/dist/domain/workflowexecution/get-execution-summary.d.ts.map +1 -1
  45. package/dist/domain/workflowexecution/get-execution-summary.js +23 -17
  46. package/dist/domain/workflowexecution/get-execution-summary.js.map +1 -1
  47. package/dist/domain/workflowexecution/list-index.d.ts +2 -0
  48. package/dist/domain/workflowexecution/list-index.d.ts.map +1 -0
  49. package/dist/domain/workflowexecution/list-index.js +21 -0
  50. package/dist/domain/workflowexecution/list-index.js.map +1 -0
  51. package/dist/domain/workflowexecution/list-pending-approvals.d.ts.map +1 -1
  52. package/dist/domain/workflowexecution/list-pending-approvals.js +69 -32
  53. package/dist/domain/workflowexecution/list-pending-approvals.js.map +1 -1
  54. package/dist/domain/workflowexecution/queries.d.ts +30 -4
  55. package/dist/domain/workflowexecution/queries.d.ts.map +1 -1
  56. package/dist/domain/workflowexecution/queries.js +76 -22
  57. package/dist/domain/workflowexecution/queries.js.map +1 -1
  58. package/dist/extensions/list-read-scope.d.ts +9 -12
  59. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  60. package/dist/extensions/list-read-scope.js.map +1 -1
  61. package/dist/index.d.ts +2 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/pipeline/steps/list-page.d.ts +63 -0
  65. package/dist/pipeline/steps/list-page.d.ts.map +1 -0
  66. package/dist/pipeline/steps/list-page.js +187 -0
  67. package/dist/pipeline/steps/list-page.js.map +1 -0
  68. package/dist/query/activity/handler.d.ts +6 -7
  69. package/dist/query/activity/handler.d.ts.map +1 -1
  70. package/dist/query/activity/handler.js +42 -59
  71. package/dist/query/activity/handler.js.map +1 -1
  72. package/dist/store/interface.d.ts +29 -0
  73. package/dist/store/interface.d.ts.map +1 -1
  74. package/dist/store/interface.js.map +1 -1
  75. package/dist/store/list-index.d.ts +123 -0
  76. package/dist/store/list-index.d.ts.map +1 -0
  77. package/dist/store/list-index.js +277 -0
  78. package/dist/store/list-index.js.map +1 -0
  79. package/dist/store/logger.d.ts +3 -0
  80. package/dist/store/logger.d.ts.map +1 -1
  81. package/dist/store/logger.js +5 -1
  82. package/dist/store/logger.js.map +1 -1
  83. package/dist/store/postgres/migrations.d.ts +10 -6
  84. package/dist/store/postgres/migrations.d.ts.map +1 -1
  85. package/dist/store/postgres/migrations.js +64 -6
  86. package/dist/store/postgres/migrations.js.map +1 -1
  87. package/dist/store/postgres/store.d.ts +26 -5
  88. package/dist/store/postgres/store.d.ts.map +1 -1
  89. package/dist/store/postgres/store.js +377 -22
  90. package/dist/store/postgres/store.js.map +1 -1
  91. package/dist/store/sqlite/migrations.d.ts +11 -5
  92. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  93. package/dist/store/sqlite/migrations.js +50 -4
  94. package/dist/store/sqlite/migrations.js.map +1 -1
  95. package/dist/store/sqlite/store.d.ts +18 -3
  96. package/dist/store/sqlite/store.d.ts.map +1 -1
  97. package/dist/store/sqlite/store.js +321 -15
  98. package/dist/store/sqlite/store.js.map +1 -1
  99. package/package.json +6 -6
  100. package/src/authorization/__tests__/drivers.ts +6 -1
  101. package/src/boot/__tests__/list-indexes.test.ts +65 -0
  102. package/src/boot/compose.ts +23 -13
  103. package/src/boot/list-indexes.ts +25 -0
  104. package/src/domain/agentexecution/__tests__/agentexecution.test.ts +85 -3
  105. package/src/domain/agentexecution/controller.ts +8 -10
  106. package/src/domain/agentexecution/list-index.ts +24 -0
  107. package/src/domain/agentexecution/steps.ts +118 -115
  108. package/src/domain/agentexecution/usage.ts +38 -17
  109. package/src/domain/artifact/controller.ts +28 -18
  110. package/src/domain/artifact/list-index.ts +22 -0
  111. package/src/domain/session/list-index.ts +28 -0
  112. package/src/domain/session/steps.ts +108 -123
  113. package/src/domain/workflowexecution/__tests__/list-read-scope-summaries.test.ts +155 -99
  114. package/src/domain/workflowexecution/__tests__/workflowexecution.test.ts +193 -31
  115. package/src/domain/workflowexecution/controller.ts +54 -82
  116. package/src/domain/workflowexecution/execution-filter.ts +20 -7
  117. package/src/domain/workflowexecution/get-execution-summary.ts +31 -24
  118. package/src/domain/workflowexecution/list-index.ts +22 -0
  119. package/src/domain/workflowexecution/list-pending-approvals.ts +82 -25
  120. package/src/domain/workflowexecution/queries.ts +134 -23
  121. package/src/extensions/list-read-scope.ts +9 -12
  122. package/src/index.ts +10 -1
  123. package/src/pipeline/steps/__tests__/list-page.test.ts +186 -0
  124. package/src/pipeline/steps/list-page.ts +284 -0
  125. package/src/query/activity/handler.ts +57 -78
  126. package/src/store/README.md +51 -32
  127. package/src/store/__tests__/list-index.measure.test.ts +293 -0
  128. package/src/store/__tests__/list-index.test.ts +328 -0
  129. package/src/store/__tests__/store-contract.ts +346 -3
  130. package/src/store/interface.ts +39 -0
  131. package/src/store/list-index.ts +465 -0
  132. package/src/store/logger.ts +8 -1
  133. package/src/store/postgres/__tests__/list-index-repair.test.ts +133 -0
  134. package/src/store/postgres/__tests__/migrations.test.ts +27 -19
  135. package/src/store/postgres/__tests__/store-contract.test.ts +50 -3
  136. package/src/store/postgres/migrations.ts +69 -7
  137. package/src/store/postgres/store.ts +485 -29
  138. package/src/store/sqlite/__tests__/migrations.test.ts +51 -7
  139. package/src/store/sqlite/__tests__/store-contract.test.ts +70 -16
  140. package/src/store/sqlite/__tests__/support.ts +12 -3
  141. package/src/store/sqlite/migrations.ts +57 -6
  142. package/src/store/sqlite/store.ts +462 -21
@@ -59,8 +59,23 @@ import type {
59
59
  SignalDedupeStatus,
60
60
  SignalDedupeStore,
61
61
  Store,
62
+ StoreOpenOptions,
62
63
  WorkflowExecutionEventRecord,
63
64
  } from "../interface.js";
65
+ import {
66
+ ListIndexRegistry,
67
+ assertListIndexLimit,
68
+ listIndexFactsOf,
69
+ matchesListIndexQuery,
70
+ mergeListIndexRows,
71
+ sameListKeyRows,
72
+ } from "../list-index.js";
73
+ import type {
74
+ ListIndexDeclaration,
75
+ ListIndexFacts,
76
+ ListIndexQuery,
77
+ ListIndexRow,
78
+ } from "../list-index.js";
64
79
  import { NOOP_STORE_LOGGER } from "../logger.js";
65
80
  import type { StoreLogger } from "../logger.js";
66
81
  import {
@@ -77,6 +92,67 @@ import { runMigrations } from "./migrations.js";
77
92
  // import path stays stable for existing consumers.
78
93
  export type { StoreLogger } from "../logger.js";
79
94
 
95
+ /**
96
+ * `updated_at` and the list index's stamp, to the millisecond. Evaluated
97
+ * twice in one statement it yields one value (sqlite fixes 'now' per
98
+ * statement step), so a row this driver writes is stamped with exactly its
99
+ * `updated_at`; a writer that does not know the index writes
100
+ * `datetime('now')`, whole seconds, which never equals a stamp.
101
+ */
102
+ const NOW = `strftime('%Y-%m-%d %H:%M:%f', 'now')`;
103
+
104
+ /** The one upsert every resource write goes through (postgres/store.ts's twin). */
105
+ const UPSERT_RESOURCE_SQL = `INSERT OR REPLACE INTO resources
106
+ (kind, id, data, updated_at, list_org, list_created_at, list_index_revision, list_indexed_at)
107
+ VALUES (?, ?, ?, ${NOW}, ?, ?, ?, ${NOW})`;
108
+
109
+ /**
110
+ * The rows of a kind whose facts are not proven current under a revision
111
+ * (list-index.ts; postgres/store.ts's twin). A row's list columns sit
112
+ * after its blob in the record and sqlite reaches them only by walking the
113
+ * blob's overflow pages, so any predicate checked per row of the kind
114
+ * costs a read of every blob (measured: 11 ms, and 72 ms for an OR over
115
+ * the revision, across 1,279 rows of 37 KB). Every arm is therefore one
116
+ * index range that fetches a row only when it matches, and names its
117
+ * index: without statistics the planner prefers the primary key for
118
+ * `kind = ?`, and `INDEXED BY` fails the statement rather than fall back
119
+ * to a scan. The arms overlap (an unstamped row has no revision either),
120
+ * so the caller keeps one row per id. Parameters: kind, kind, kind,
121
+ * revision, kind, revision.
122
+ */
123
+ const UNPROVEN_ROWS_SQL = `
124
+ SELECT id, data FROM resources INDEXED BY idx_resources_list_unproven
125
+ WHERE kind = ? AND list_indexed_at IS NOT updated_at
126
+ UNION ALL
127
+ SELECT id, data FROM resources INDEXED BY idx_resources_list_revision
128
+ WHERE kind = ? AND list_index_revision IS NULL
129
+ UNION ALL
130
+ SELECT id, data FROM resources INDEXED BY idx_resources_list_revision
131
+ WHERE kind = ? AND list_index_revision < ?
132
+ UNION ALL
133
+ SELECT id, data FROM resources INDEXED BY idx_resources_list_revision
134
+ WHERE kind = ? AND list_index_revision > ?`;
135
+
136
+ function unprovenRowsParams(
137
+ kindName: string,
138
+ revision: number,
139
+ ): Array<string | number> {
140
+ return [kindName, kindName, kindName, revision, kindName, revision];
141
+ }
142
+
143
+ /** Keeps the first row per id; the unproven arms overlap. */
144
+ function oneRowPerId<T extends { readonly id: string }>(
145
+ rows: ReadonlyArray<T>,
146
+ ): T[] {
147
+ const seen = new Set<string>();
148
+ return rows.filter((row) =>
149
+ seen.has(row.id) ? false : (seen.add(row.id), true),
150
+ );
151
+ }
152
+
153
+ /** How many unproven rows the reconciliation at open decodes per statement. */
154
+ const RECONCILE_BATCH = 500;
155
+
80
156
  export class SqliteStore implements Store {
81
157
  readonly bootstrapState: BootstrapStateStore;
82
158
  readonly signalDedupe: SignalDedupeStore;
@@ -86,11 +162,18 @@ export class SqliteStore implements Store {
86
162
  private db: DatabaseSync | undefined;
87
163
  private readonly dbPath: string;
88
164
  private readonly logger: StoreLogger;
165
+ private readonly listIndexes: ListIndexRegistry;
89
166
 
90
- private constructor(db: DatabaseSync, dbPath: string, logger: StoreLogger) {
167
+ private constructor(
168
+ db: DatabaseSync,
169
+ dbPath: string,
170
+ logger: StoreLogger,
171
+ listIndexes: ListIndexRegistry,
172
+ ) {
91
173
  this.db = db;
92
174
  this.dbPath = dbPath;
93
175
  this.logger = logger;
176
+ this.listIndexes = listIndexes;
94
177
  this.bootstrapState = new SqliteBootstrapStateStore(() => this.open());
95
178
  this.signalDedupe = new SqliteSignalDedupeStore(() => this.open(), logger);
96
179
  this.oauthGrants = new SqliteOAuthGrantStore(() => this.open());
@@ -101,12 +184,15 @@ export class SqliteStore implements Store {
101
184
 
102
185
  /**
103
186
  * Opens (or creates) the database at dbPath, applies the pragmas in Go's
104
- * order, and runs migrations — Go NewStore.
187
+ * order, runs migrations — Go NewStore — and reconciles the list index
188
+ * (derives the facts of every unproven row of a declared kind).
105
189
  */
106
190
  static open(
107
191
  dbPath: string,
108
192
  logger: StoreLogger = NOOP_STORE_LOGGER,
193
+ options: StoreOpenOptions = {},
109
194
  ): SqliteStore {
195
+ const listIndexes = new ListIndexRegistry(options.listIndexes ?? []);
110
196
  const dir = path.dirname(dbPath);
111
197
  if (dir !== "" && dir !== ".") {
112
198
  mkdirSync(dir, { recursive: true });
@@ -129,7 +215,14 @@ export class SqliteStore implements Store {
129
215
  throw error;
130
216
  }
131
217
 
132
- return new SqliteStore(db, dbPath, logger);
218
+ const store = new SqliteStore(db, dbPath, logger, listIndexes);
219
+ try {
220
+ store.reconcileListIndexes();
221
+ } catch (error) {
222
+ db.close();
223
+ throw error;
224
+ }
225
+ return store;
133
226
  }
134
227
 
135
228
  /** Filesystem path of the database file (Go Store.Path()). */
@@ -149,9 +242,28 @@ export class SqliteStore implements Store {
149
242
  ): Promise<void> {
150
243
  const db = this.open();
151
244
  const data = toBinary(schema, msg);
152
- db.prepare(
153
- `INSERT OR REPLACE INTO resources (kind, id, data, updated_at) VALUES (?, ?, ?, datetime('now'))`,
154
- ).run(apiResourceKindName(kind), id, data);
245
+ const kindName = apiResourceKindName(kind);
246
+ const facts = this.listIndexes.factsOf(kind, msg);
247
+ if (facts === undefined) {
248
+ db.prepare(UPSERT_RESOURCE_SQL).run(kindName, id, data, null, null, null);
249
+ return;
250
+ }
251
+ db.exec("BEGIN");
252
+ try {
253
+ db.prepare(UPSERT_RESOURCE_SQL).run(
254
+ kindName,
255
+ id,
256
+ data,
257
+ facts.org,
258
+ facts.createdAt,
259
+ facts.revision,
260
+ );
261
+ replaceListKeys(db, kindName, id, facts);
262
+ db.exec("COMMIT");
263
+ } catch (error) {
264
+ db.exec("ROLLBACK");
265
+ throw error;
266
+ }
155
267
  }
156
268
 
157
269
  async getResource<Desc extends DescMessage>(
@@ -187,19 +299,44 @@ export class SqliteStore implements Store {
187
299
  db.exec("BEGIN IMMEDIATE");
188
300
  try {
189
301
  const row = db
190
- .prepare(`SELECT data FROM resources WHERE kind = ? AND id = ?`)
191
- .get(kindName, id) as { data: Uint8Array } | undefined;
302
+ .prepare(
303
+ `SELECT data, list_indexed_at IS updated_at AS stamped, list_index_revision
304
+ FROM resources WHERE kind = ? AND id = ?`,
305
+ )
306
+ .get(kindName, id) as
307
+ | {
308
+ data: Uint8Array;
309
+ stamped: number;
310
+ list_index_revision: number | null;
311
+ }
312
+ | undefined;
192
313
  if (row === undefined) {
193
314
  throw new ResourceNotFoundError(`${kindName}/${id}`);
194
315
  }
195
316
 
196
317
  const msg = fromBinary(schema, row.data);
318
+ const before = this.listIndexes.factsOf(kind, msg);
197
319
  modify(msg);
320
+ const after = this.listIndexes.factsOf(kind, msg);
198
321
 
199
322
  const data = toBinary(schema, msg);
200
- db.prepare(
201
- `INSERT OR REPLACE INTO resources (kind, id, data, updated_at) VALUES (?, ?, ?, datetime('now'))`,
202
- ).run(kindName, id, data);
323
+ db.prepare(UPSERT_RESOURCE_SQL).run(
324
+ kindName,
325
+ id,
326
+ data,
327
+ after?.org ?? null,
328
+ after?.createdAt ?? null,
329
+ after?.revision ?? null,
330
+ );
331
+ if (
332
+ after !== undefined &&
333
+ (row.stamped !== 1 ||
334
+ row.list_index_revision !== after.revision ||
335
+ before === undefined ||
336
+ !sameListKeyRows(before, after))
337
+ ) {
338
+ replaceListKeys(db, kindName, id, after);
339
+ }
203
340
 
204
341
  db.exec("COMMIT");
205
342
  return msg;
@@ -219,10 +356,74 @@ export class SqliteStore implements Store {
219
356
  return rows.map((row) => row.data);
220
357
  }
221
358
 
359
+ async queryResources<K extends string>(
360
+ declaration: ListIndexDeclaration<K>,
361
+ query: ListIndexQuery<K>,
362
+ ): Promise<ListIndexRow[]> {
363
+ this.listIndexes.require(declaration);
364
+ assertListIndexLimit(query.limit);
365
+ if (query.anyKey !== undefined && query.anyKey.length === 0) {
366
+ return [];
367
+ }
368
+ const db = this.open();
369
+ const kindName = apiResourceKindName(declaration.kind);
370
+
371
+ // A key read is driven from the key table (CROSS JOIN fixes sqlite's
372
+ // join order), so only the rows the key names are ever read; several
373
+ // keys are one such read each, merged below (postgres/store.ts's twin).
374
+ const reads =
375
+ query.anyKey === undefined
376
+ ? [provenReadSql(kindName, declaration.revision, query, undefined)]
377
+ : query.anyKey.map((key) =>
378
+ provenReadSql(kindName, declaration.revision, query, key),
379
+ );
380
+ const proven: ListIndexRow[] = [];
381
+ for (const read of reads) {
382
+ const rows = db.prepare(read.sql).all(...read.params) as Array<{
383
+ id: string;
384
+ data: Uint8Array;
385
+ created_at: string;
386
+ }>;
387
+ for (const row of rows) {
388
+ proven.push({
389
+ id: row.id,
390
+ data: row.data,
391
+ cursor: { createdAt: row.created_at, id: row.id },
392
+ });
393
+ }
394
+ }
395
+
396
+ const unproven = oneRowPerId(
397
+ db
398
+ .prepare(UNPROVEN_ROWS_SQL)
399
+ .all(...unprovenRowsParams(kindName, declaration.revision)) as Array<{
400
+ id: string;
401
+ data: Uint8Array;
402
+ }>,
403
+ );
404
+ const unprovenRows: ListIndexRow[] = [];
405
+ for (const row of unproven) {
406
+ const facts = this.deriveAndRepair(declaration, row.id, row.data);
407
+ if (facts !== undefined && matchesListIndexQuery(row.id, facts, query)) {
408
+ unprovenRows.push({
409
+ id: row.id,
410
+ data: row.data,
411
+ cursor: { createdAt: facts.createdAt, id: row.id },
412
+ });
413
+ }
414
+ }
415
+ return mergeListIndexRows(proven, unprovenRows, query.limit);
416
+ }
417
+
222
418
  async deleteResource(kind: ApiResourceKind, id: string): Promise<void> {
223
419
  const db = this.open();
420
+ const kindName = apiResourceKindName(kind);
224
421
  db.prepare(`DELETE FROM resources WHERE kind = ? AND id = ?`).run(
225
- apiResourceKindName(kind),
422
+ kindName,
423
+ id,
424
+ );
425
+ db.prepare(`DELETE FROM resource_list_keys WHERE kind = ? AND id = ?`).run(
426
+ kindName,
226
427
  id,
227
428
  );
228
429
  }
@@ -315,20 +516,59 @@ export class SqliteStore implements Store {
315
516
  const db = this.open();
316
517
  // BLOB equality in the WHERE clause makes the compare-and-swap one
317
518
  // atomic statement: zero rows changed means the row moved on (or was
318
- // deleted) since the read — a lost swap, never an upsert.
319
- const result = db
320
- .prepare(
321
- `UPDATE resources SET data = ?, updated_at = datetime('now') WHERE kind = ? AND id = ? AND data = ?`,
322
- )
323
- .run(newData, apiResourceKindName(kind), id, expectedData);
324
- return Number(result.changes) === 1;
519
+ // deleted) since the read — a lost swap, never an upsert. The new bytes'
520
+ // list facts ride the same statement; bytes a declared kind's schema
521
+ // cannot decode leave the row unproven (stamp NULL), for a read to skip.
522
+ const kindName = apiResourceKindName(kind);
523
+ const declaration = this.listIndexes.declarationOf(kind);
524
+ const facts =
525
+ declaration === undefined
526
+ ? undefined
527
+ : factsOfBytes(declaration, newData);
528
+ const swap = (): boolean =>
529
+ Number(
530
+ db
531
+ .prepare(
532
+ `UPDATE resources SET data = ?, updated_at = ${NOW},
533
+ list_org = ?, list_created_at = ?, list_index_revision = ?,
534
+ list_indexed_at = CASE WHEN ? THEN ${NOW} ELSE NULL END
535
+ WHERE kind = ? AND id = ? AND data = ?`,
536
+ )
537
+ .run(
538
+ newData,
539
+ facts?.org ?? null,
540
+ facts?.createdAt ?? null,
541
+ facts?.revision ?? null,
542
+ declaration === undefined || facts !== undefined ? 1 : 0,
543
+ kindName,
544
+ id,
545
+ expectedData,
546
+ ).changes,
547
+ ) === 1;
548
+ if (facts === undefined) {
549
+ return swap();
550
+ }
551
+ db.exec("BEGIN");
552
+ try {
553
+ const swapped = swap();
554
+ if (swapped) {
555
+ replaceListKeys(db, kindName, id, facts);
556
+ }
557
+ db.exec("COMMIT");
558
+ return swapped;
559
+ } catch (error) {
560
+ db.exec("ROLLBACK");
561
+ throw error;
562
+ }
325
563
  }
326
564
 
327
565
  async deleteResourcesByKind(kind: ApiResourceKind): Promise<number> {
328
566
  const db = this.open();
567
+ const kindName = apiResourceKindName(kind);
329
568
  const result = db
330
569
  .prepare(`DELETE FROM resources WHERE kind = ?`)
331
- .run(apiResourceKindName(kind));
570
+ .run(kindName);
571
+ db.prepare(`DELETE FROM resource_list_keys WHERE kind = ?`).run(kindName);
332
572
  return Number(result.changes);
333
573
  }
334
574
 
@@ -337,13 +577,123 @@ export class SqliteStore implements Store {
337
577
  idPrefix: string,
338
578
  ): Promise<number> {
339
579
  const db = this.open();
580
+ const kindName = apiResourceKindName(kind);
340
581
  // GLOB 'prefix*' uses the (kind, id) index where LIKE would not (Go).
341
582
  const result = db
342
583
  .prepare(`DELETE FROM resources WHERE kind = ? AND id GLOB ?`)
343
- .run(apiResourceKindName(kind), `${idPrefix}*`);
584
+ .run(kindName, `${idPrefix}*`);
585
+ db.prepare(
586
+ `DELETE FROM resource_list_keys WHERE kind = ? AND id GLOB ?`,
587
+ ).run(kindName, `${idPrefix}*`);
344
588
  return Number(result.changes);
345
589
  }
346
590
 
591
+ // ---------------------------------------------------------------------------
592
+ // The list index's repair (list-index.ts; postgres/store.ts's twin)
593
+ // ---------------------------------------------------------------------------
594
+
595
+ /**
596
+ * An unproven row's facts, derived from its bytes, and the row repaired
597
+ * when those bytes are still the stored ones. Undefined when the bytes
598
+ * do not decode; the row stays unproven and is skipped.
599
+ */
600
+ private deriveAndRepair(
601
+ declaration: ListIndexDeclaration,
602
+ id: string,
603
+ data: Uint8Array,
604
+ ): ListIndexFacts | undefined {
605
+ const kindName = apiResourceKindName(declaration.kind);
606
+ const facts = factsOfBytes(declaration, data);
607
+ if (facts === undefined) {
608
+ this.logger.warn(
609
+ "list index: an unproven row does not decode, skipping",
610
+ {
611
+ kind: kindName,
612
+ id,
613
+ },
614
+ );
615
+ return undefined;
616
+ }
617
+ const db = this.open();
618
+ db.exec("BEGIN");
619
+ try {
620
+ const repaired = db
621
+ .prepare(
622
+ `UPDATE resources SET list_org = ?, list_created_at = ?,
623
+ list_index_revision = ?, list_indexed_at = updated_at
624
+ WHERE kind = ? AND id = ? AND data = ?`,
625
+ )
626
+ .run(facts.org, facts.createdAt, facts.revision, kindName, id, data);
627
+ if (Number(repaired.changes) === 1) {
628
+ replaceListKeys(db, kindName, id, facts);
629
+ }
630
+ db.exec("COMMIT");
631
+ } catch (error) {
632
+ db.exec("ROLLBACK");
633
+ this.logger.warn("list index: repairing a row failed", {
634
+ kind: kindName,
635
+ id,
636
+ error: error instanceof Error ? error.message : String(error),
637
+ });
638
+ }
639
+ return facts;
640
+ }
641
+
642
+ /**
643
+ * Derives every unproven row of each declared kind and stamps every other
644
+ * kind's unstamped rows, so the unproven set is empty once open returns.
645
+ */
646
+ private reconcileListIndexes(): void {
647
+ const db = this.open();
648
+ const declarations = this.listIndexes.declarations();
649
+ for (const declaration of declarations) {
650
+ const kindName = apiResourceKindName(declaration.kind);
651
+ let after = "";
652
+ let derived = 0;
653
+ for (;;) {
654
+ // Duplicates from overlapping arms sit side by side in id order and
655
+ // the next batch starts after the last id, so none is repaired twice
656
+ // across batches and the count still ends the loop.
657
+ const rows = db
658
+ .prepare(
659
+ `SELECT id, data FROM (${UNPROVEN_ROWS_SQL}) WHERE id > ?
660
+ ORDER BY id LIMIT ?`,
661
+ )
662
+ .all(
663
+ ...unprovenRowsParams(kindName, declaration.revision),
664
+ after,
665
+ RECONCILE_BATCH,
666
+ ) as Array<{
667
+ id: string;
668
+ data: Uint8Array;
669
+ }>;
670
+ for (const row of oneRowPerId(rows)) {
671
+ if (
672
+ this.deriveAndRepair(declaration, row.id, row.data) !== undefined
673
+ ) {
674
+ derived += 1;
675
+ }
676
+ after = row.id;
677
+ }
678
+ if (rows.length < RECONCILE_BATCH) {
679
+ break;
680
+ }
681
+ }
682
+ if (derived > 0) {
683
+ this.logger.info("list index reconciled", {
684
+ kind: kindName,
685
+ rows: derived,
686
+ });
687
+ }
688
+ }
689
+ const declared = declarations.map((d) => apiResourceKindName(d.kind));
690
+ db.prepare(
691
+ `UPDATE resources SET list_indexed_at = updated_at
692
+ WHERE list_indexed_at IS NOT updated_at
693
+ AND kind NOT IN (${declared.map(() => "?").join(", ") || "''"})`,
694
+ ).run(...declared);
695
+ }
696
+
347
697
  // ---------------------------------------------------------------------------
348
698
  // Audit operations
349
699
  // ---------------------------------------------------------------------------
@@ -1145,6 +1495,97 @@ class SqliteSignalDedupeStore implements SignalDedupeStore {
1145
1495
  }
1146
1496
 
1147
1497
  /** Composite dedupe key: "{org}:{idempotency_key}" (Go buildDedupeKey). */
1498
+ /**
1499
+ * A row's key rows made equal to its facts, on the caller's open
1500
+ * transaction (postgres/store.ts's twin).
1501
+ */
1502
+ function replaceListKeys(
1503
+ db: DatabaseSync,
1504
+ kindName: string,
1505
+ id: string,
1506
+ facts: ListIndexFacts,
1507
+ ): void {
1508
+ db.prepare(`DELETE FROM resource_list_keys WHERE kind = ? AND id = ?`).run(
1509
+ kindName,
1510
+ id,
1511
+ );
1512
+ const insert = db.prepare(
1513
+ `INSERT INTO resource_list_keys (kind, id, key, value, created_at) VALUES (?, ?, ?, ?, ?)`,
1514
+ );
1515
+ for (const entry of facts.keys) {
1516
+ insert.run(kindName, id, entry.key, entry.value, facts.createdAt);
1517
+ }
1518
+ }
1519
+
1520
+ /**
1521
+ * One proven read of `queryResources` (postgres/store.ts's twin): through
1522
+ * one key's index range when `key` is given, else through the organization
1523
+ * index; only rows whose facts are proven current.
1524
+ */
1525
+ function provenReadSql(
1526
+ kindName: string,
1527
+ revision: number,
1528
+ query: ListIndexQuery,
1529
+ key: { readonly name: string; readonly value: string } | undefined,
1530
+ ): { sql: string; params: Array<string | number> } {
1531
+ const params: Array<string | number> = [];
1532
+ const created = key === undefined ? "r.list_created_at" : "k.created_at";
1533
+ const id = key === undefined ? "r.id" : "k.id";
1534
+ const where: string[] = [];
1535
+ let from = `resources AS r`;
1536
+ if (key === undefined) {
1537
+ where.push(`r.kind = ?`);
1538
+ params.push(kindName);
1539
+ } else {
1540
+ from = `resource_list_keys AS k CROSS JOIN resources AS r`;
1541
+ where.push(
1542
+ `k.kind = ?`,
1543
+ `k.key = ?`,
1544
+ `k.value = ?`,
1545
+ `r.kind = k.kind`,
1546
+ `r.id = k.id`,
1547
+ );
1548
+ params.push(kindName, key.name, key.value);
1549
+ }
1550
+ where.push(`r.list_indexed_at = r.updated_at`, `r.list_index_revision = ?`);
1551
+ params.push(revision);
1552
+ if (query.org !== undefined && query.org !== "") {
1553
+ where.push(`r.list_org = ?`);
1554
+ params.push(query.org);
1555
+ }
1556
+ if (query.createdAtOrAfter !== undefined) {
1557
+ where.push(`(${created} >= ? OR ${created} = '')`);
1558
+ params.push(query.createdAtOrAfter);
1559
+ }
1560
+ if (query.after !== undefined) {
1561
+ where.push(`(${created} < ? OR (${created} = ? AND ${id} < ?))`);
1562
+ params.push(query.after.createdAt, query.after.createdAt, query.after.id);
1563
+ }
1564
+ let limit = "";
1565
+ if (query.limit !== undefined) {
1566
+ limit = ` LIMIT ?`;
1567
+ params.push(query.limit);
1568
+ }
1569
+ return {
1570
+ sql: `SELECT r.id, r.data, ${created} AS created_at FROM ${from}
1571
+ WHERE ${where.join(" AND ")}
1572
+ ORDER BY ${created} DESC, ${id} DESC${limit}`,
1573
+ params,
1574
+ };
1575
+ }
1576
+
1577
+ /** A declared kind's facts from stored bytes; undefined when they do not decode. */
1578
+ function factsOfBytes(
1579
+ declaration: ListIndexDeclaration,
1580
+ data: Uint8Array,
1581
+ ): ListIndexFacts | undefined {
1582
+ try {
1583
+ return listIndexFactsOf(declaration, fromBinary(declaration.schema, data));
1584
+ } catch {
1585
+ return undefined;
1586
+ }
1587
+ }
1588
+
1148
1589
  function buildDedupeKey(org: string, idempotencyKey: string): string {
1149
1590
  return `${org}:${idempotencyKey}`;
1150
1591
  }