@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
@@ -0,0 +1,293 @@
1
+ /**
2
+ * What a list read costs at production's shape, measured on both drivers.
3
+ * Gated on `STORE_MEASURE=1` and SKIPPED otherwise (the
4
+ * `AUTHORIZATION_MEASURE` idiom of authorization/__tests__/
5
+ * list-read-scope.measure.test.ts): seeding tens of megabytes takes a
6
+ * while, and the numbers are recorded beside the change that moves them,
7
+ * not asserted — no budget is ruled for open source.
8
+ *
9
+ * The seed is the hosted edition's population as read on 2026-09-23:
10
+ * 1,279 agent executions averaging about 37 KB each (the kind's rows are
11
+ * fat: every message, tool call and todo of a run rides the row), 865
12
+ * sessions of about half a kilobyte, twelve organizations with one third
13
+ * of every kind in the busiest. The payload is uniform where production's
14
+ * is skewed (its largest execution is 4 MB), so these numbers understate
15
+ * the tail, never the median.
16
+ *
17
+ * Each shape is timed as the lane pays for it — the store read, the
18
+ * decode, the lane's own predicate — as the median of five runs after one
19
+ * warm-up. The "today" shapes read the whole kind the way every lane did
20
+ * before the list index; the "indexed" shapes read the same answer through
21
+ * `queryResources`. The status-write shape times the run's hottest write,
22
+ * an `updateResource` that changes no list key, which the index must not
23
+ * slow; it uses only the store API that predates the index, so the same
24
+ * file measures the code before and after.
25
+ */
26
+ import { create, fromBinary } from "@bufbuild/protobuf";
27
+ import { afterAll, describe, expect, it } from "vitest";
28
+
29
+ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
30
+ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
31
+ import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
32
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
33
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
34
+
35
+ import { LIST_INDEXES } from "../../boot/list-indexes.js";
36
+ import { agentExecutionListIndex } from "../../domain/agentexecution/list-index.js";
37
+ import { sessionListIndex } from "../../domain/session/list-index.js";
38
+ import type { Store } from "../interface.js";
39
+ import { PostgresStore } from "../postgres/store.js";
40
+ import {
41
+ createTestDatabase,
42
+ testDatabaseAdminUrl,
43
+ } from "../postgres/__tests__/support.js";
44
+ import type { TestDatabase } from "../postgres/__tests__/support.js";
45
+ import { tempStore } from "../sqlite/__tests__/support.js";
46
+
47
+ const MEASURE = process.env["STORE_MEASURE"] === "1";
48
+
49
+ const ORGS = 12;
50
+ const EXECUTIONS = 1_279;
51
+ const SESSIONS = 865;
52
+ const MESSAGES_PER_EXECUTION = 40;
53
+ const MESSAGE_CHARS = 900;
54
+ const BUSY_ORG = "org-00";
55
+ const RUNS = 5;
56
+
57
+ function orgOf(index: number): string {
58
+ // One third of every kind in the busiest organization, the rest spread.
59
+ return index % 3 === 0
60
+ ? BUSY_ORG
61
+ : `org-${String((index % (ORGS - 1)) + 1).padStart(2, "0")}`;
62
+ }
63
+
64
+ function sessionIdOf(index: number): string {
65
+ return `ses_${String(index).padStart(26, "0")}`;
66
+ }
67
+
68
+ function createdAt(index: number): { seconds: bigint; nanos: number } {
69
+ return { seconds: BigInt(1_790_000_000 + index), nanos: 0 };
70
+ }
71
+
72
+ async function seed(store: Store): Promise<void> {
73
+ const content = "x".repeat(MESSAGE_CHARS);
74
+ for (let i = 0; i < SESSIONS; i++) {
75
+ const id = sessionIdOf(i);
76
+ await store.saveResource(
77
+ ApiResourceKind.session,
78
+ id,
79
+ SessionSchema,
80
+ create(SessionSchema, {
81
+ metadata: { id, org: orgOf(i), name: `session ${i}` },
82
+ spec: { subject: `subject ${i}` },
83
+ status: {
84
+ audit: { specAudit: { createdAt: createdAt(i) } },
85
+ },
86
+ }),
87
+ );
88
+ }
89
+ for (let i = 0; i < EXECUTIONS; i++) {
90
+ const id = `aex_${String(i).padStart(26, "0")}`;
91
+ const session = i % SESSIONS;
92
+ await store.saveResource(
93
+ ApiResourceKind.agent_execution,
94
+ id,
95
+ AgentExecutionSchema,
96
+ create(AgentExecutionSchema, {
97
+ metadata: { id, org: orgOf(session) },
98
+ spec: { sessionId: sessionIdOf(session), message: "run it" },
99
+ status: {
100
+ messages: Array.from({ length: MESSAGES_PER_EXECUTION }, (_, m) => ({
101
+ type:
102
+ m % 2 === 0 ? MessageType.MESSAGE_HUMAN : MessageType.MESSAGE_AI,
103
+ content,
104
+ timestamp: "2026-09-23T10:00:00Z",
105
+ })),
106
+ audit: { specAudit: { createdAt: createdAt(i) } },
107
+ },
108
+ }),
109
+ );
110
+ }
111
+ }
112
+
113
+ async function median(
114
+ work: () => Promise<number>,
115
+ ): Promise<{ ms: number; rows: number }> {
116
+ await work();
117
+ const samples: number[] = [];
118
+ let rows = 0;
119
+ for (let run = 0; run < RUNS; run++) {
120
+ const started = performance.now();
121
+ rows = await work();
122
+ samples.push(performance.now() - started);
123
+ }
124
+ samples.sort((a, b) => a - b);
125
+ return { ms: Math.round(samples[Math.floor(RUNS / 2)] ?? 0), rows };
126
+ }
127
+
128
+ function decodeExecutions(rows: ReadonlyArray<Uint8Array>): AgentExecution[] {
129
+ return rows.map((row) => fromBinary(AgentExecutionSchema, row));
130
+ }
131
+
132
+ interface Shape {
133
+ readonly name: string;
134
+ run(store: Store): Promise<number>;
135
+ }
136
+
137
+ /** What each lane pays today: the whole kind, decoded, then its predicate. */
138
+ const TODAY: ReadonlyArray<Shape> = [
139
+ {
140
+ name: "agentExecution.list(org) — whole kind, org in memory",
141
+ async run(store) {
142
+ const rows = await store.listResources(ApiResourceKind.agent_execution);
143
+ return decodeExecutions(rows).filter((e) => e.metadata?.org === BUSY_ORG)
144
+ .length;
145
+ },
146
+ },
147
+ {
148
+ name: "agentExecution.listBySession — whole kind, session in memory",
149
+ async run(store) {
150
+ const rows = await store.listResources(ApiResourceKind.agent_execution);
151
+ return decodeExecutions(rows).filter(
152
+ (e) => e.spec?.sessionId === sessionIdOf(0),
153
+ ).length;
154
+ },
155
+ },
156
+ {
157
+ name: "session.list — whole kind",
158
+ async run(store) {
159
+ const rows = await store.listResources(ApiResourceKind.session);
160
+ return rows.map((row) => fromBinary(SessionSchema, row)).length;
161
+ },
162
+ },
163
+ ];
164
+
165
+ /** The same answers read through the list index. */
166
+ const INDEXED: ReadonlyArray<Shape> = [
167
+ {
168
+ name: "agentExecution.list(org) — the organization's rows, every one",
169
+ async run(store) {
170
+ const rows = await store.queryResources(agentExecutionListIndex, {
171
+ org: BUSY_ORG,
172
+ });
173
+ return decodeExecutions(rows.map((row) => row.data)).length;
174
+ },
175
+ },
176
+ {
177
+ name: "agentExecution.list(org) — one page of 50",
178
+ async run(store) {
179
+ const rows = await store.queryResources(agentExecutionListIndex, {
180
+ org: BUSY_ORG,
181
+ limit: 50,
182
+ });
183
+ return decodeExecutions(rows.map((row) => row.data)).length;
184
+ },
185
+ },
186
+ {
187
+ name: "agentExecution.listBySession — the session key",
188
+ async run(store) {
189
+ const rows = await store.queryResources(agentExecutionListIndex, {
190
+ anyKey: [{ name: "session", value: sessionIdOf(0) }],
191
+ });
192
+ return decodeExecutions(rows.map((row) => row.data)).length;
193
+ },
194
+ },
195
+ {
196
+ name: "session.list(org) — one page of 50",
197
+ async run(store) {
198
+ const rows = await store.queryResources(sessionListIndex, {
199
+ org: BUSY_ORG,
200
+ limit: 50,
201
+ });
202
+ return rows.map((row) => fromBinary(SessionSchema, row.data)).length;
203
+ },
204
+ },
205
+ ];
206
+
207
+ const STATUS_WRITES = 200;
208
+
209
+ /** The run's hottest write: a status persist that changes no list key. */
210
+ async function timeStatusWrites(store: Store): Promise<number> {
211
+ const id = `aex_${String(1).padStart(26, "0")}`;
212
+ const started = performance.now();
213
+ for (let i = 0; i < STATUS_WRITES; i++) {
214
+ await store.updateResource(
215
+ ApiResourceKind.agent_execution,
216
+ id,
217
+ AgentExecutionSchema,
218
+ (execution) => {
219
+ execution.status!.error = `write ${i}`;
220
+ },
221
+ );
222
+ }
223
+ return (performance.now() - started) / STATUS_WRITES;
224
+ }
225
+
226
+ let postgresDatabase: TestDatabase | undefined;
227
+
228
+ const DRIVERS: ReadonlyArray<{
229
+ readonly name: string;
230
+ readonly skip: boolean;
231
+ open(): Promise<{ store: Store; close(): Promise<void> }>;
232
+ }> = [
233
+ {
234
+ name: "sqlite",
235
+ skip: false,
236
+ async open() {
237
+ const temp = tempStore();
238
+ return { store: temp.store, close: () => temp.cleanup() };
239
+ },
240
+ },
241
+ {
242
+ name: "postgres",
243
+ skip: testDatabaseAdminUrl() === undefined,
244
+ async open() {
245
+ postgresDatabase = await createTestDatabase();
246
+ const store = await PostgresStore.open(
247
+ postgresDatabase.databaseUrl,
248
+ undefined,
249
+ {
250
+ listIndexes: LIST_INDEXES,
251
+ },
252
+ );
253
+ return { store, close: () => store.close() };
254
+ },
255
+ },
256
+ ];
257
+
258
+ afterAll(async () => {
259
+ await postgresDatabase?.drop();
260
+ });
261
+
262
+ describe.skipIf(!MEASURE)("list reads at production's shape", () => {
263
+ describe.each(DRIVERS)("$name", (driver) => {
264
+ it.skipIf(driver.skip)(
265
+ "times every shape",
266
+ async () => {
267
+ const opened = await driver.open();
268
+ try {
269
+ await seed(opened.store);
270
+ for (const [label, shapes] of [
271
+ ["today", TODAY],
272
+ ["indexed", INDEXED],
273
+ ] as const) {
274
+ for (const shape of shapes) {
275
+ const { ms, rows } = await median(() => shape.run(opened.store));
276
+ console.log(
277
+ `[measure] ${driver.name} ${label} ${shape.name}: ${ms} ms (${rows} rows kept)`,
278
+ );
279
+ expect(rows).toBeGreaterThan(0);
280
+ }
281
+ }
282
+ const perWrite = await timeStatusWrites(opened.store);
283
+ console.log(
284
+ `[measure] ${driver.name} status write (updateResource, no key change): ${perWrite.toFixed(2)} ms per write over ${STATUS_WRITES}`,
285
+ );
286
+ } finally {
287
+ await opened.close();
288
+ }
289
+ },
290
+ 600_000,
291
+ );
292
+ });
293
+ });
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Pins the list index's one derivation (../list-index.ts), the pieces
3
+ * both drivers share: a declaration refuses a key its schema cannot
4
+ * answer at load; a row's facts are its organization, its creation
5
+ * instant as fixed-width text and every key it names; the instant sorts
6
+ * as text exactly as `compareCreatedAtDesc` orders timestamps (absent
7
+ * last); the query predicate and the merge the drivers finish with; and
8
+ * the registry's refusals. The drivers' SQL is pinned per driver by the
9
+ * list-index arms of store-contract.ts.
10
+ */
11
+ import { create } from "@bufbuild/protobuf";
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
15
+ import { ArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/api_pb";
16
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
17
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
18
+
19
+ import { compareCreatedAtDesc } from "../../pipeline/steps/helpers.js";
20
+ import {
21
+ ListIndexRegistry,
22
+ compareListIndexOrder,
23
+ declareListIndex,
24
+ field,
25
+ label,
26
+ listIndexFactsOf,
27
+ listIndexInstant,
28
+ listIndexInstantOfMillis,
29
+ matchesListIndexQuery,
30
+ mergeListIndexRows,
31
+ sameListKeyRows,
32
+ } from "../list-index.js";
33
+ import type { ListIndexFacts, ListIndexRow } from "../list-index.js";
34
+
35
+ const sessions = declareListIndex({
36
+ kind: ApiResourceKind.session,
37
+ schema: SessionSchema,
38
+ revision: 1,
39
+ keys: {
40
+ agent_instance: field("spec.agent_instance_id"),
41
+ channel: label("stigmer.ai/channel-id"),
42
+ },
43
+ });
44
+
45
+ function row(createdAt: string, id: string): ListIndexRow {
46
+ return { id, data: new Uint8Array(), cursor: { createdAt, id } };
47
+ }
48
+
49
+ function facts(overrides: Partial<ListIndexFacts> = {}): ListIndexFacts {
50
+ return {
51
+ org: "acme",
52
+ createdAt: "2026-09-23T10:00:00.000000000Z",
53
+ revision: 1,
54
+ keys: [],
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ describe("declareListIndex", () => {
60
+ it("refuses a path with no such field", () => {
61
+ expect(() =>
62
+ declareListIndex({
63
+ kind: ApiResourceKind.session,
64
+ schema: SessionSchema,
65
+ revision: 1,
66
+ keys: { wrong: field("spec.agent_instance") },
67
+ }),
68
+ ).toThrow(
69
+ "list index for session, key 'wrong': 'spec.agent_instance' has no field 'agent_instance'",
70
+ );
71
+ });
72
+
73
+ it("refuses a path that ends on a message, not a string", () => {
74
+ expect(() =>
75
+ declareListIndex({
76
+ kind: ApiResourceKind.session,
77
+ schema: SessionSchema,
78
+ revision: 1,
79
+ keys: { spec: field("spec") },
80
+ }),
81
+ ).toThrow("'spec' is not a string field");
82
+ });
83
+
84
+ it("refuses a path that walks through a scalar", () => {
85
+ expect(() =>
86
+ declareListIndex({
87
+ kind: ApiResourceKind.session,
88
+ schema: SessionSchema,
89
+ revision: 1,
90
+ keys: { deep: field("spec.agent_instance_id.nested") },
91
+ }),
92
+ ).toThrow(
93
+ "'agent_instance_id' in 'spec.agent_instance_id.nested' is not a message",
94
+ );
95
+ });
96
+
97
+ it("refuses a revision that is not a positive integer", () => {
98
+ expect(() =>
99
+ declareListIndex({
100
+ kind: ApiResourceKind.session,
101
+ schema: SessionSchema,
102
+ revision: 0,
103
+ keys: {},
104
+ }),
105
+ ).toThrow("revision must be a positive integer");
106
+ });
107
+ });
108
+
109
+ describe("listIndexFactsOf", () => {
110
+ it("reads the organization, the creation instant and every named key", () => {
111
+ const session = create(SessionSchema, {
112
+ metadata: {
113
+ id: "ses_1",
114
+ org: "acme",
115
+ labels: { "stigmer.ai/channel-id": "ach_1" },
116
+ },
117
+ spec: { agentInstanceId: "ain_1" },
118
+ status: {
119
+ audit: {
120
+ specAudit: { createdAt: { seconds: 1_790_000_000n, nanos: 5 } },
121
+ },
122
+ },
123
+ });
124
+ expect(listIndexFactsOf(sessions, session)).toEqual({
125
+ org: "acme",
126
+ createdAt: "2026-09-21T14:13:20.000000005Z",
127
+ revision: 1,
128
+ keys: [
129
+ { key: "agent_instance", value: "ain_1" },
130
+ { key: "channel", value: "ach_1" },
131
+ ],
132
+ });
133
+ });
134
+
135
+ it("leaves out a key the row does not name, and reads absences as empty", () => {
136
+ expect(listIndexFactsOf(sessions, create(SessionSchema, {}))).toEqual({
137
+ org: "",
138
+ createdAt: "",
139
+ revision: 1,
140
+ keys: [],
141
+ });
142
+ });
143
+
144
+ it("follows a nested field path", () => {
145
+ const artifacts = declareListIndex({
146
+ kind: ApiResourceKind.artifact,
147
+ schema: ArtifactSchema,
148
+ revision: 1,
149
+ keys: { agent_execution: field("spec.source.agent_execution_id") },
150
+ });
151
+ const artifact = create(ArtifactSchema, {
152
+ spec: { source: { agentExecutionId: "aex_1" } },
153
+ });
154
+ expect(listIndexFactsOf(artifacts, artifact).keys).toEqual([
155
+ { key: "agent_execution", value: "aex_1" },
156
+ ]);
157
+ });
158
+
159
+ it("refuses a declaration that was not made by declareListIndex", () => {
160
+ expect(() =>
161
+ listIndexFactsOf(
162
+ {
163
+ kind: ApiResourceKind.agent_execution,
164
+ schema: AgentExecutionSchema,
165
+ revision: 1,
166
+ keys: {},
167
+ },
168
+ create(AgentExecutionSchema, {}),
169
+ ),
170
+ ).toThrow("was not made by declareListIndex");
171
+ });
172
+ });
173
+
174
+ describe("listIndexInstant", () => {
175
+ it("is fixed-width UTC text to the nanosecond", () => {
176
+ expect(listIndexInstant({ seconds: 0n, nanos: 0 })).toBe(
177
+ "1970-01-01T00:00:00.000000000Z",
178
+ );
179
+ expect(listIndexInstantOfMillis(1_500)).toBe(
180
+ "1970-01-01T00:00:01.500000000Z",
181
+ );
182
+ });
183
+
184
+ it("is empty for an absent or unrepresentable stamp", () => {
185
+ expect(listIndexInstant(undefined)).toBe("");
186
+ expect(listIndexInstant({ seconds: 300_000_000_000n, nanos: 0 })).toBe("");
187
+ });
188
+
189
+ it("sorts as text exactly as compareCreatedAtDesc orders the timestamps", () => {
190
+ const stamps = [
191
+ { seconds: 10n, nanos: 2 },
192
+ undefined,
193
+ { seconds: 10n, nanos: 10 },
194
+ { seconds: 9n, nanos: 999_999_999 },
195
+ { seconds: 1_790_000_000n, nanos: 0 },
196
+ ];
197
+ const byTimestamp = [...stamps].sort((a, b) =>
198
+ compareCreatedAtDesc(a as never, b as never),
199
+ );
200
+ const byText = [...stamps].sort((a, b) =>
201
+ compareListIndexOrder(
202
+ { createdAt: listIndexInstant(a), id: "" },
203
+ { createdAt: listIndexInstant(b), id: "" },
204
+ ),
205
+ );
206
+ expect(byText).toEqual(byTimestamp);
207
+ });
208
+ });
209
+
210
+ describe("matchesListIndexQuery", () => {
211
+ const withChannel = facts({ keys: [{ key: "channel", value: "ach_1" }] });
212
+
213
+ it("matches an organization only exactly, and every organization when absent or empty", () => {
214
+ expect(matchesListIndexQuery("x", withChannel, { org: "acme" })).toBe(true);
215
+ expect(matchesListIndexQuery("x", withChannel, { org: "other" })).toBe(
216
+ false,
217
+ );
218
+ expect(matchesListIndexQuery("x", withChannel, { org: "" })).toBe(true);
219
+ });
220
+
221
+ it("matches any one of the listed keys", () => {
222
+ expect(
223
+ matchesListIndexQuery("x", withChannel, {
224
+ anyKey: [
225
+ { name: "agent_instance", value: "ain_9" },
226
+ { name: "channel", value: "ach_1" },
227
+ ],
228
+ }),
229
+ ).toBe(true);
230
+ expect(
231
+ matchesListIndexQuery("x", withChannel, {
232
+ anyKey: [{ name: "channel", value: "ach_2" }],
233
+ }),
234
+ ).toBe(false);
235
+ });
236
+
237
+ it("keeps rows created at or after the bound, and rows with no stamp", () => {
238
+ const bound = "2026-09-23T10:00:00.000000000Z";
239
+ expect(
240
+ matchesListIndexQuery("x", facts(), { createdAtOrAfter: bound }),
241
+ ).toBe(true);
242
+ expect(
243
+ matchesListIndexQuery(
244
+ "x",
245
+ facts({ createdAt: "2026-09-22T00:00:00.000000000Z" }),
246
+ {
247
+ createdAtOrAfter: bound,
248
+ },
249
+ ),
250
+ ).toBe(false);
251
+ expect(
252
+ matchesListIndexQuery("x", facts({ createdAt: "" }), {
253
+ createdAtOrAfter: bound,
254
+ }),
255
+ ).toBe(true);
256
+ });
257
+
258
+ it("keeps only rows strictly after the cursor in newest-first order", () => {
259
+ const at = "2026-09-23T10:00:00.000000000Z";
260
+ const after = { createdAt: at, id: "m" };
261
+ expect(
262
+ matchesListIndexQuery("a", facts({ createdAt: at }), { after }),
263
+ ).toBe(true);
264
+ expect(
265
+ matchesListIndexQuery("m", facts({ createdAt: at }), { after }),
266
+ ).toBe(false);
267
+ expect(
268
+ matchesListIndexQuery("z", facts({ createdAt: at }), { after }),
269
+ ).toBe(false);
270
+ expect(
271
+ matchesListIndexQuery(
272
+ "z",
273
+ facts({ createdAt: "2026-09-22T00:00:00.000000000Z" }),
274
+ { after },
275
+ ),
276
+ ).toBe(true);
277
+ });
278
+ });
279
+
280
+ describe("mergeListIndexRows", () => {
281
+ it("merges in order, keeps one row per id and cuts to the limit", () => {
282
+ const merged = mergeListIndexRows(
283
+ [row("2026-09-23", "b"), row("2026-09-21", "d")],
284
+ [row("2026-09-22", "c"), row("2026-09-23", "b"), row("", "e")],
285
+ 3,
286
+ );
287
+ expect(merged.map((r) => r.id)).toEqual(["b", "c", "d"]);
288
+ expect(
289
+ mergeListIndexRows(
290
+ [row("", "e")],
291
+ [row("2026-09-23", "a")],
292
+ undefined,
293
+ ).map((r) => r.id),
294
+ ).toEqual(["a", "e"]);
295
+ });
296
+ });
297
+
298
+ describe("sameListKeyRows", () => {
299
+ it("is equal only when the instant and every key agree", () => {
300
+ const a = facts({ keys: [{ key: "channel", value: "ach_1" }] });
301
+ expect(
302
+ sameListKeyRows(a, facts({ keys: [{ key: "channel", value: "ach_1" }] })),
303
+ ).toBe(true);
304
+ expect(
305
+ sameListKeyRows(a, facts({ keys: [{ key: "channel", value: "ach_2" }] })),
306
+ ).toBe(false);
307
+ expect(sameListKeyRows(a, facts({ createdAt: "", keys: a.keys }))).toBe(
308
+ false,
309
+ );
310
+ });
311
+ });
312
+
313
+ describe("ListIndexRegistry", () => {
314
+ it("refuses a kind declared twice", () => {
315
+ expect(() => new ListIndexRegistry([sessions, sessions])).toThrow(
316
+ "list index for session is declared twice",
317
+ );
318
+ });
319
+
320
+ it("refuses a read through a declaration it was not opened with", () => {
321
+ const other = declareListIndex({ ...sessions, keys: {} });
322
+ const registry = new ListIndexRegistry([sessions]);
323
+ expect(() => registry.require(sessions)).not.toThrow();
324
+ expect(() => registry.require(other)).toThrow(
325
+ "list index for session is not registered with this store",
326
+ );
327
+ });
328
+ });