@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,284 @@
1
+ /**
2
+ * One page of a list lane read through the list index — the one
3
+ * implementation of the page loop every paged lane calls, so the rules
4
+ * below hold everywhere by construction:
5
+ *
6
+ * - The store narrows by the request's own predicates (organization, key,
7
+ * creation window) and orders newest first (store/list-index.ts). The
8
+ * lane's per-row filter runs on each batch, then the read scope, which
9
+ * stays the last per-row predicate (extensions/list-read-scope.ts);
10
+ * neither reorders, so a page is in the store's order.
11
+ * - `page_size` 0 reads every matching row and returns no token, the
12
+ * behaviour every list lane had before it paged; a positive size is
13
+ * capped at `LIST_PAGE_MAX_SIZE`.
14
+ * - A page fills batch by batch until it is full, the rows run out, or
15
+ * `examineBudgetOf(size)` rows have been examined. A caller who may see
16
+ * few of the organization's rows gets a short page, possibly empty, with
17
+ * a token (the timeline's cursor contract, agentchannel/v1
18
+ * conversation_io.proto), never an unbounded walk.
19
+ * - The token's cursor is the last row RETURNED when the page was cut,
20
+ * else the last row EXAMINED, so no row is skipped and no refused row is
21
+ * read twice. The token is opaque and versioned, carries a fingerprint
22
+ * of the request's other fields (a mismatch is refused), and carries no
23
+ * authority: every page re-runs the scope.
24
+ *
25
+ * Proven by __tests__/list-page.test.ts (the loop, the budget, the cut,
26
+ * the token) and end-to-end by the paging arms of the conformance suites.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+
30
+ import type { Store } from "../../store/interface.js";
31
+ import type {
32
+ ListIndexCursor,
33
+ ListIndexDeclaration,
34
+ ListIndexQuery,
35
+ ListIndexRow,
36
+ } from "../../store/list-index.js";
37
+ import { internalError, invalidArgumentError } from "../errors.js";
38
+
39
+ /** The most entries one page returns, the contract's word on every paged request. */
40
+ export const LIST_PAGE_MAX_SIZE = 100;
41
+
42
+ /**
43
+ * The most rows one request examines for a page of `size`: five pages'
44
+ * worth, at least 100, at most 500. It bounds what one request asks of a
45
+ * composed read scope (on the hosted edition one authorization key per
46
+ * row, 50 per round trip) when the caller can see few of the rows.
47
+ */
48
+ export function examineBudgetOf(size: number): number {
49
+ return Math.min(500, Math.max(100, size * 5));
50
+ }
51
+
52
+ /** The request fields every paged lane carries. */
53
+ export interface ListPageRequest {
54
+ readonly pageSize: number;
55
+ readonly pageToken: string;
56
+ }
57
+
58
+ export interface ListPageParams<T, K extends string> {
59
+ readonly store: Store;
60
+ readonly declaration: ListIndexDeclaration<K>;
61
+ /** The request's own predicates, pushed into the store's indexed read. */
62
+ readonly query: Omit<ListIndexQuery<K>, "after" | "limit">;
63
+ readonly request: ListPageRequest;
64
+ /**
65
+ * The request's other fields, as text: a token is valid only for the
66
+ * request it was issued to.
67
+ */
68
+ readonly fingerprint: string;
69
+ /** Decodes a row; undefined skips it (the lane logs, as it always has). */
70
+ readonly decode: (data: Uint8Array) => T | undefined;
71
+ /** The lane's per-row filter beyond the indexed predicates. */
72
+ readonly keep?: (row: T) => boolean;
73
+ /** The read scope, bound by the lane to its caller and kind. */
74
+ readonly scope: (rows: T[]) => Promise<T[]>;
75
+ /** The failure copy for a store fault. */
76
+ readonly failure: string;
77
+ }
78
+
79
+ export interface ListPage<T> {
80
+ readonly entries: T[];
81
+ /** "" when the list is complete. */
82
+ readonly nextPageToken: string;
83
+ }
84
+
85
+ /** Reads one page (or, at `page_size` 0, the whole list) through the list index. */
86
+ export async function readListPage<T, K extends string>(
87
+ params: ListPageParams<T, K>,
88
+ ): Promise<ListPage<T>> {
89
+ const { request } = params;
90
+ if (request.pageSize < 0) {
91
+ throw invalidArgumentError("page_size must not be negative");
92
+ }
93
+ let after =
94
+ request.pageToken === ""
95
+ ? undefined
96
+ : decodeListPageToken(request.pageToken, params.fingerprint).cursor;
97
+
98
+ if (request.pageSize === 0) {
99
+ const rows = await queryOrFail(params, after, undefined);
100
+ return {
101
+ entries: await admit(params, rows).then((a) => a.map((e) => e.row)),
102
+ nextPageToken: "",
103
+ };
104
+ }
105
+
106
+ const size = Math.min(request.pageSize, LIST_PAGE_MAX_SIZE);
107
+ const budget = examineBudgetOf(size);
108
+ const kept: Array<Admitted<T>> = [];
109
+ let examined = 0;
110
+ for (;;) {
111
+ // One row past the batch is read only to learn whether more follow,
112
+ // so a list that ends exactly on a page carries no token (and no
113
+ // empty page behind it); the next batch reads that row again.
114
+ const batchSize = Math.min(size, budget - examined);
115
+ const read = await queryOrFail(params, after, batchSize + 1);
116
+ const more = read.length > batchSize;
117
+ const rows = more ? read.slice(0, batchSize) : read;
118
+ examined += rows.length;
119
+ kept.push(...(await admit(params, rows)));
120
+ const last = rows[rows.length - 1];
121
+ if (last !== undefined) {
122
+ after = last.cursor;
123
+ }
124
+
125
+ if (kept.length >= size) {
126
+ const page = kept.slice(0, size);
127
+ const exhausted = !more && kept.length === size;
128
+ return {
129
+ entries: page.map((e) => e.row),
130
+ nextPageToken: exhausted
131
+ ? ""
132
+ : encodeListPageToken(
133
+ page[page.length - 1]!.cursor,
134
+ params.fingerprint,
135
+ ),
136
+ };
137
+ }
138
+ if (!more) {
139
+ return { entries: kept.map((e) => e.row), nextPageToken: "" };
140
+ }
141
+ if (examined >= budget) {
142
+ return {
143
+ entries: kept.map((e) => e.row),
144
+ nextPageToken: encodeListPageToken(after!, params.fingerprint),
145
+ };
146
+ }
147
+ }
148
+ }
149
+
150
+ interface Admitted<T> {
151
+ readonly row: T;
152
+ readonly cursor: ListIndexCursor;
153
+ }
154
+
155
+ async function queryOrFail<T, K extends string>(
156
+ params: ListPageParams<T, K>,
157
+ after: ListIndexCursor | undefined,
158
+ limit: number | undefined,
159
+ ): Promise<ListIndexRow[]> {
160
+ try {
161
+ return await params.store.queryResources(params.declaration, {
162
+ ...params.query,
163
+ ...(after === undefined ? {} : { after }),
164
+ ...(limit === undefined ? {} : { limit }),
165
+ });
166
+ } catch (error) {
167
+ throw internalError(error, params.failure);
168
+ }
169
+ }
170
+
171
+ /** A batch through the lane's filter and the scope, each row keeping its cursor. */
172
+ async function admit<T, K extends string>(
173
+ params: ListPageParams<T, K>,
174
+ rows: ReadonlyArray<ListIndexRow>,
175
+ ): Promise<Array<Admitted<T>>> {
176
+ const decoded: Array<Admitted<T>> = [];
177
+ for (const row of rows) {
178
+ const value = params.decode(row.data);
179
+ if (
180
+ value !== undefined &&
181
+ (params.keep === undefined || params.keep(value))
182
+ ) {
183
+ decoded.push({ row: value, cursor: row.cursor });
184
+ }
185
+ }
186
+ if (decoded.length === 0) {
187
+ return [];
188
+ }
189
+ const allowed = new Set(await params.scope(decoded.map((d) => d.row)));
190
+ return decoded.filter((d) => allowed.has(d.row));
191
+ }
192
+
193
+ // =============================================================================
194
+ // The token
195
+ // =============================================================================
196
+
197
+ const TOKEN_VERSION = 1;
198
+
199
+ /**
200
+ * The position a token carries: the list index's cursor, plus, for a lane
201
+ * whose entries are finer than rows (the approvals of one execution), the
202
+ * entry's position within its row.
203
+ */
204
+ export interface ListPageTokenCursor {
205
+ readonly cursor: ListIndexCursor;
206
+ readonly position?: number;
207
+ }
208
+
209
+ function fingerprintHash(fingerprint: string): string {
210
+ return createHash("sha256")
211
+ .update(fingerprint)
212
+ .digest("base64url")
213
+ .slice(0, 16);
214
+ }
215
+
216
+ /** An opaque, versioned token for the position after `cursor`. */
217
+ export function encodeListPageToken(
218
+ cursor: ListIndexCursor,
219
+ fingerprint: string,
220
+ position?: number,
221
+ ): string {
222
+ const body = {
223
+ v: TOKEN_VERSION,
224
+ c: cursor.createdAt,
225
+ i: cursor.id,
226
+ f: fingerprintHash(fingerprint),
227
+ ...(position === undefined ? {} : { p: position }),
228
+ };
229
+ return Buffer.from(JSON.stringify(body)).toString("base64url");
230
+ }
231
+
232
+ /**
233
+ * The position a token carries, refused as `invalid page_token` (the copy
234
+ * every paged lane already answers with) when it is malformed, of a
235
+ * version this server never issued, or issued for another request.
236
+ */
237
+ export function decodeListPageToken(
238
+ token: string,
239
+ fingerprint: string,
240
+ ): ListPageTokenCursor {
241
+ let body: unknown;
242
+ try {
243
+ const bytes = Buffer.from(token, "base64url");
244
+ if (bytes.toString("base64url") !== token) {
245
+ throw new Error("not canonical base64url");
246
+ }
247
+ body = JSON.parse(bytes.toString("utf8"));
248
+ } catch {
249
+ throw invalidArgumentError("invalid page_token");
250
+ }
251
+ if (
252
+ typeof body !== "object" ||
253
+ body === null ||
254
+ (body as { v?: unknown }).v !== TOKEN_VERSION ||
255
+ typeof (body as { c?: unknown }).c !== "string" ||
256
+ typeof (body as { i?: unknown }).i !== "string" ||
257
+ typeof (body as { f?: unknown }).f !== "string"
258
+ ) {
259
+ throw invalidArgumentError("invalid page_token");
260
+ }
261
+ const parsed = body as { c: string; i: string; f: string; p?: unknown };
262
+ if (parsed.f !== fingerprintHash(fingerprint)) {
263
+ throw invalidArgumentError("page_token was issued for a different request");
264
+ }
265
+ if (
266
+ parsed.p !== undefined &&
267
+ !(Number.isSafeInteger(parsed.p) && (parsed.p as number) >= 0)
268
+ ) {
269
+ throw invalidArgumentError("invalid page_token");
270
+ }
271
+ return {
272
+ cursor: { createdAt: parsed.c, id: parsed.i },
273
+ ...(parsed.p === undefined ? {} : { position: parsed.p as number }),
274
+ };
275
+ }
276
+
277
+ /** A request's other fields as the text its token is bound to. */
278
+ export function listPageFingerprint(
279
+ fields: Readonly<Record<string, unknown>>,
280
+ ): string {
281
+ return JSON.stringify(fields, (_key, value: unknown) =>
282
+ typeof value === "bigint" ? value.toString() : value,
283
+ );
284
+ }
@@ -5,18 +5,15 @@
5
5
  *
6
6
  * This is the OSS twin of the cloud's ListRecentActivityHandler; the
7
7
  * merge, ordering, projection, and filtering semantics are deliberately
8
- * identical (stigmer#461). What differs is only what single-tenancy
9
- * removes: with NO ListReadScope composed there is no id enumeration
10
- * (the candidate set is every stored row) and the request's org is a
11
- * no-op (a recents filter stricter than the per-kind lists it summarizes
12
- * would hide locally-owned rows); load-all-then-sort-in-memory replaces
13
- * per-kind SQL LIMIT windows (each kind's newest page_size rows are a
14
- * superset of its contribution to the merged page — the identical final
15
- * list; the full scan is this store's contract, the same pattern every
16
- * OSS list handler uses). With a composed ListReadScope (20260830.01,
17
- * census lane 22) both loads narrow to the caller's authorized ids ∩
18
- * the request's org when non-blank — the Java handler's two
19
- * listAuthorizedResourceIds calls and its findRecentByIdsAndOrg org arm.
8
+ * identical (stigmer#461). Both loads read the request's org through the
9
+ * list index (every org when blank) — the same narrowing the per-kind
10
+ * lists it summarizes apply, so recents is never stricter than they are —
11
+ * and, with a composed ListReadScope (census lane 22), offer those rows to
12
+ * its restrict verb: one read per kind, exact, where the Java handler
13
+ * enumerated the caller's authorized ids and then scanned. Recents orders
14
+ * by the last update, not by creation, so each kind's org is read whole
15
+ * and merged in memory (each kind's newest page_size rows are a superset
16
+ * of its contribution to the merged page).
20
17
  *
21
18
  * Proven by __tests__/handler.test.ts (Go's handler_test.go arms) and
22
19
  * activity.conformance.test.ts on local.
@@ -35,7 +32,9 @@ import type {
35
32
  RecentActivityEntry,
36
33
  } from "@stigmer/protos/ai/stigmer/activity/v1/io_pb";
37
34
  import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
35
+ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
38
36
  import { WorkflowExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
37
+ import type { WorkflowExecution } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
39
38
  import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/enum_pb";
40
39
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
41
40
  import type { ApiResourceAudit } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
@@ -44,6 +43,9 @@ import type { ApiResourceMetadata } from "@stigmer/protos/ai/stigmer/commons/api
44
43
  import type { Logger } from "../../boot/logger.js";
45
44
  import type { CallerIdentity } from "../../extensions/identity.js";
46
45
  import type { ListReadScope } from "../../extensions/list-read-scope.js";
46
+ import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
47
+ import { sessionListIndex } from "../../domain/session/list-index.js";
48
+ import { workflowExecutionListIndex } from "../../domain/workflowexecution/list-index.js";
47
49
  import type { Store } from "../../store/interface.js";
48
50
 
49
51
  /**
@@ -91,10 +93,9 @@ export class ActivityHandler {
91
93
  ) {}
92
94
 
93
95
  /**
94
- * Go ListRecentActivity: load both kinds, project to sidebar entries,
95
- * merge-sort newest-first, trim to the page. With a composed scope the
96
- * per-kind loads narrow to the caller's authorized ids ∩ the request's
97
- * org (blank = permission-bounded across orgs, the repo convention).
96
+ * Go ListRecentActivity: load both kinds (the request's org, every org
97
+ * when blank; the scope's restrict verb when one is composed), project
98
+ * to sidebar entries, merge-sort newest-first, trim to the page.
98
99
  */
99
100
  async listRecentActivity(
100
101
  request: ListRecentActivityRequest,
@@ -102,21 +103,8 @@ export class ActivityHandler {
102
103
  ): Promise<ListRecentActivityResponse> {
103
104
  const pageSize = normalizePageSize(request.pageSize);
104
105
 
105
- let sessionScope: ReadonlySet<string> | undefined;
106
- let executionScope: ReadonlySet<string> | undefined;
107
- if (this.listReadScope !== undefined) {
108
- sessionScope = await this.listReadScope.authorizedResourceIds(
109
- identity,
110
- ApiResourceKind.session,
111
- );
112
- executionScope = await this.listReadScope.authorizedResourceIds(
113
- identity,
114
- ApiResourceKind.workflow_execution,
115
- );
116
- }
117
-
118
- const sessions = await this.loadSessions(sessionScope, request.org);
119
- const executions = await this.loadExecutions(executionScope, request.org);
106
+ const sessions = await this.loadSessions(identity, request.org);
107
+ const executions = await this.loadExecutions(identity, request.org);
120
108
 
121
109
  // Sessions before executions, then a stable sort: entries with equal
122
110
  // timestamps keep this insertion order — the same tie-break the
@@ -146,32 +134,36 @@ export class ActivityHandler {
146
134
  }
147
135
 
148
136
  /**
149
- * Go loadSessions: every stored personal session projected to a recents
150
- * entry; runtime-originated sessions excluded.
137
+ * Go loadSessions: the org's personal sessions the caller may see,
138
+ * projected to recents entries; runtime-originated sessions excluded.
151
139
  */
152
140
  private async loadSessions(
153
- authorizedIds: ReadonlySet<string> | undefined,
141
+ identity: CallerIdentity,
154
142
  org: string,
155
143
  ): Promise<RecentActivityEntry[]> {
156
- const rows = await this.store.listResources(ApiResourceKind.session);
157
- const entries: RecentActivityEntry[] = [];
144
+ const rows = await this.store.queryResources(sessionListIndex, { org });
145
+ const personal: Session[] = [];
158
146
  for (const row of rows) {
159
- let session;
147
+ let session: Session;
160
148
  try {
161
- session = fromBinary(SessionSchema, row);
149
+ session = fromBinary(SessionSchema, row.data);
162
150
  } catch {
163
151
  this.logger.warn("Skipping undecodable session row in recent activity");
164
152
  continue;
165
153
  }
166
- if (hasRuntimeOriginLabel(session.metadata)) {
167
- continue;
168
- }
169
- if (
170
- authorizedIds !== undefined &&
171
- !isVisibleUnderScope(authorizedIds, org, session.metadata)
172
- ) {
173
- continue;
154
+ if (!hasRuntimeOriginLabel(session.metadata)) {
155
+ personal.push(session);
174
156
  }
157
+ }
158
+ const visible = await restrictListByReadScope(
159
+ this.listReadScope,
160
+ identity,
161
+ ApiResourceKind.session,
162
+ personal,
163
+ "",
164
+ );
165
+ const entries: RecentActivityEntry[] = [];
166
+ for (const session of visible) {
175
167
  entries.push(
176
168
  create(RecentActivityEntrySchema, {
177
169
  id: session.metadata?.id ?? "",
@@ -184,31 +176,33 @@ export class ActivityHandler {
184
176
  return entries;
185
177
  }
186
178
 
187
- /** Go loadExecutions: every stored workflow execution projected. */
179
+ /** Go loadExecutions: the org's workflow executions the caller may see, projected. */
188
180
  private async loadExecutions(
189
- authorizedIds: ReadonlySet<string> | undefined,
181
+ identity: CallerIdentity,
190
182
  org: string,
191
183
  ): Promise<RecentActivityEntry[]> {
192
- const rows = await this.store.listResources(
193
- ApiResourceKind.workflow_execution,
194
- );
195
- const entries: RecentActivityEntry[] = [];
184
+ const rows = await this.store.queryResources(workflowExecutionListIndex, {
185
+ org,
186
+ });
187
+ const decoded: WorkflowExecution[] = [];
196
188
  for (const row of rows) {
197
- let execution;
198
189
  try {
199
- execution = fromBinary(WorkflowExecutionSchema, row);
190
+ decoded.push(fromBinary(WorkflowExecutionSchema, row.data));
200
191
  } catch {
201
192
  this.logger.warn(
202
193
  "Skipping undecodable workflow execution row in recent activity",
203
194
  );
204
- continue;
205
- }
206
- if (
207
- authorizedIds !== undefined &&
208
- !isVisibleUnderScope(authorizedIds, org, execution.metadata)
209
- ) {
210
- continue;
211
195
  }
196
+ }
197
+ const visible = await restrictListByReadScope(
198
+ this.listReadScope,
199
+ identity,
200
+ ApiResourceKind.workflow_execution,
201
+ decoded,
202
+ "",
203
+ );
204
+ const entries: RecentActivityEntry[] = [];
205
+ for (const execution of visible) {
212
206
  const name = execution.metadata?.name ?? "";
213
207
  entries.push(
214
208
  create(RecentActivityEntrySchema, {
@@ -217,7 +211,8 @@ export class ActivityHandler {
217
211
  subject: name === "" ? UNTITLED_EXECUTION_SUBJECT : name,
218
212
  updatedAt: extractUpdatedAt(execution.status?.audit),
219
213
  status: resolvePhase(
220
- execution.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED,
214
+ execution.status?.phase ??
215
+ ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED,
221
216
  ),
222
217
  }),
223
218
  );
@@ -226,22 +221,6 @@ export class ActivityHandler {
226
221
  }
227
222
  }
228
223
 
229
- /**
230
- * The scoped-visibility test both loads share when a scope is composed:
231
- * the row's id must be authorized and, when the request names an org, the
232
- * row must belong to it (the Java findRecentByIdsAndOrg arm).
233
- */
234
- function isVisibleUnderScope(
235
- authorizedIds: ReadonlySet<string>,
236
- org: string,
237
- metadata: ApiResourceMetadata | undefined,
238
- ): boolean {
239
- if (!authorizedIds.has(metadata?.id ?? "")) {
240
- return false;
241
- }
242
- return org === "" || (metadata?.org ?? "") === org;
243
- }
244
-
245
224
  /** Go normalizePageSize: ≤0 → default 30; >100 → cap 100. */
246
225
  export function normalizePageSize(requested: number): number {
247
226
  if (requested <= 0) {
@@ -1,40 +1,59 @@
1
1
  # store/ — the persistence layer
2
2
 
3
3
  Ports `backend/libs/go/store` (D2 §3, DD-003): `interface.ts` is the
4
- driver-agnostic contract (surface-for-surface with Go's `store.Store`, plus
5
- the consolidated members Go kept behind the `DB()` escape hatch — bootstrap
6
- state, signal dedupe, MCP OAuth). Two drivers implement it:
4
+ driver-agnostic contract (surface-for-surface with Go's `store.Store`, plus the
5
+ consolidated members Go kept behind the `DB()` escape hatch — bootstrap state,
6
+ signal dedupe, MCP OAuth). Two drivers implement it:
7
7
 
8
- - `sqlite/` — the laptop-tier default (`node:sqlite`, zero-config, one
9
- file) with the versioned migration chain (v1–v6 adopted from Go
10
- DDL-faithful, v7 = the OD-3 consolidation).
8
+ - `sqlite/` — the laptop-tier default (`node:sqlite`, zero-config, one file)
9
+ with its versioned migration chain (v1–v6 adopted from Go DDL-faithful, then
10
+ this port's own steps, each named at its constant in `sqlite/migrations.ts`).
11
11
  - `postgres/` — the self-host/team tier (DD-010: `pg` pool, row-level
12
- `FOR UPDATE` atomicity, its own independent migration chain v1 with real
13
- indexes, `tsvector`/`tsquery` search per DD-009).
12
+ `FOR UPDATE` atomicity, its own independent migration chain with real indexes,
13
+ `tsvector`/`tsquery` search per DD-009).
14
14
 
15
- Selection is boot config (`boot/config.ts`): `DATABASE_URL` set → Postgres
16
- (it wins when both are set — `DB_PATH` always has a default value); else
17
- sqlite on `DB_PATH`. Domain code depends on `interface.ts` only — never on
18
- a driver directory. Driver-neutral helpers shared by both drivers live
19
- here (`proto-fields.ts` reflection + scan semantics, `logger.ts`).
15
+ Selection is boot config (`boot/config.ts`): `DATABASE_URL` set → Postgres (it
16
+ wins when both are set — `DB_PATH` always has a default value); else sqlite on
17
+ `DB_PATH`. Domain code depends on `interface.ts` only — never on a driver
18
+ directory. Driver-neutral helpers shared by both drivers live here
19
+ (`proto-fields.ts` reflection + scan semantics, `list-index.ts`, `logger.ts`).
20
+
21
+ ## The list index
22
+
23
+ A list lane reads one organization's or one parent's rows in creation order
24
+ through `Store.queryResources`, never by decoding the whole kind.
25
+ `list-index.ts` is the one derivation: a kind's declaration (beside its lanes,
26
+ `domain/<kind>/list-index.ts`) names its keys as data, the composition root
27
+ opens the store with the one list of declarations (`boot/list-indexes.ts`), and
28
+ every driver keeps a declared row's facts (organization, creation instant, keys)
29
+ in the same statement as the row.
30
+
31
+ Reads are exact whoever wrote the rows. A row whose facts are not proven current
32
+ — written by a binary that does not know the index, as the old pod does while a
33
+ roll overlaps it with the new one, or under another revision of the declaration
34
+ — is evaluated from its bytes and repaired by a compare-and-set on those bytes.
35
+ The migrations that add the index are schema only; the reconciliation each
36
+ driver runs at open derives every unproven row, so the unproven set is empty in
37
+ steady state and reads stay one index range. Every read shape names or forces
38
+ its index, because a freshly written table has no planner statistics.
39
+
40
+ ## Contract and continuity
20
41
 
21
42
  The behavioral contract both drivers must satisfy identically is
22
- `__tests__/store-contract.ts`, invoked by each driver's
23
- `store-contract.test.ts` (sqlite always; Postgres under
24
- `TEST_DATABASE_URL` — visible skips locally, a real service container in
25
- CI). Driver-physical behavior stays in each driver's own tests. One
26
- deliberate semantic difference is recorded in DD-010: sqlite serializes
27
- ALL writes globally as a side effect of its single synchronous connection;
28
- Postgres guarantees per-resource atomicity only — the interface contract
29
- is the narrower one.
30
-
31
- Schema continuity across the Go cutover (any Go-created database adopts
32
- forward) is proven by `sqlite/__tests__/migrations.test.ts` against a real
33
- Go-created fixture. The fixture is FROZEN: the Go server retired
34
- (go-server-retirement, D4 #25), its schema can no longer change, and the
35
- committed v6 dump is the permanent record of what real pre-cutover
36
- databases look like. The generator script
37
- (`scripts/regen-go-db-fixture.sh`) lived until #25 and remains in git
38
- history should the fixture ever need forensic regeneration. The Postgres
39
- chain has no adoption story by construction — no Postgres database
40
- predates its driver.
43
+ `__tests__/store-contract.ts`, invoked by each driver's `store-contract.test.ts`
44
+ (sqlite always; Postgres under `TEST_DATABASE_URL` — visible skips locally, a
45
+ real service container in CI). Driver-physical behavior stays in each driver's
46
+ own tests. One deliberate semantic difference is recorded in DD-010: sqlite
47
+ serializes ALL writes globally as a side effect of its single synchronous
48
+ connection; Postgres guarantees per-resource atomicity only — the interface
49
+ contract is the narrower one.
50
+
51
+ Schema continuity across the Go cutover (any Go-created database adopts forward)
52
+ is proven by `sqlite/__tests__/migrations.test.ts` against a real Go-created
53
+ fixture. The fixture is FROZEN: the Go server retired (go-server-retirement, D4
54
+ #25), its schema can no longer change, and the committed v6 dump is the
55
+ permanent record of what real pre-cutover databases look like. The generator
56
+ script (`scripts/regen-go-db-fixture.sh`) lived until #25 and remains in git
57
+ history should the fixture ever need forensic regeneration. The Postgres chain
58
+ has no adoption story by construction — no Postgres database predates its
59
+ driver.