@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
@@ -1,48 +1,159 @@
1
1
  /**
2
- * Shared full-scan load for the workflowexecution read surfaces (list,
3
- * listByWorkflow, getExecutionSummary, listPendingApprovals) — Go's
4
- * store.ListResources + proto.Unmarshal-continue loop, where malformed
5
- * rows are skipped rather than failing the read.
2
+ * The workflowexecution read surfaces' shared reads (list,
3
+ * listByWorkflow, getExecutionSummary, listPendingApprovals), all through
4
+ * the list index (list-index.ts beside this file; the store's contract in
5
+ * store/interface.ts) — the request's org or workflow key narrows in the
6
+ * store, newest created first. Malformed rows are skipped rather than
7
+ * failing the read, Go's proto.Unmarshal-continue loop.
8
+ *
9
+ * The two lists share one reader: the default order pages
10
+ * (pipeline/steps/list-page.ts), with the lane's filters and then the read
11
+ * scope on each batch; another sort field sorts the whole matching set and
12
+ * returns its first page_size entries with no token, because only the
13
+ * creation order is an index order.
6
14
  */
7
- import { fromBinary } from "@bufbuild/protobuf";
15
+ import { create, fromBinary } from "@bufbuild/protobuf";
8
16
 
9
17
  import type { WorkflowExecution } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
10
18
  import { WorkflowExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
19
+ import {
20
+ ExecutionSortField,
21
+ WorkflowExecutionListSchema,
22
+ } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
23
+ import type { WorkflowExecutionList } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
11
24
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
12
25
 
13
26
  import type { Logger } from "../../boot/logger.js";
14
- import { internalError } from "../../pipeline/errors.js";
27
+ import type { CallerIdentity } from "../../extensions/identity.js";
28
+ import type { ListReadScope } from "../../extensions/list-read-scope.js";
29
+ import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
30
+ import { internalError, invalidArgumentError } from "../../pipeline/errors.js";
31
+ import {
32
+ LIST_PAGE_MAX_SIZE,
33
+ listPageFingerprint,
34
+ readListPage,
35
+ } from "../../pipeline/steps/list-page.js";
36
+ import type { ListPageRequest } from "../../pipeline/steps/list-page.js";
15
37
  import type { Store } from "../../store/interface.js";
38
+ import type { ListIndexQuery, ListIndexRow } from "../../store/list-index.js";
39
+ import { applySortField } from "./execution-filter.js";
40
+ import { workflowExecutionListIndex } from "./list-index.js";
41
+
42
+ /** The keys the workflow-execution index declares. */
43
+ export type WorkflowExecutionListKey = "workflow" | "workflow_instance";
44
+
45
+ /** A stored row, or undefined (logged) when it does not decode. */
46
+ export function decodeWorkflowExecution(
47
+ data: Uint8Array,
48
+ logger: Logger,
49
+ ): WorkflowExecution | undefined {
50
+ try {
51
+ return fromBinary(WorkflowExecutionSchema, data);
52
+ } catch (error) {
53
+ logger.warn("Failed to unmarshal workflow execution, skipping", {
54
+ error: error instanceof Error ? error.message : String(error),
55
+ });
56
+ return undefined;
57
+ }
58
+ }
16
59
 
17
60
  /**
18
- * Loads every workflow execution. The Internal message varies by caller in
19
- * Go (each handler wraps the same store error with its own text), so the
20
- * caller supplies it.
61
+ * Every workflow execution the query matches, newest created first. The
62
+ * Internal message varies by caller in Go (each handler wraps the same
63
+ * store error with its own text), so the caller supplies it.
21
64
  */
22
- export async function loadAllWorkflowExecutions(
65
+ export async function loadWorkflowExecutions(
23
66
  store: Store,
24
67
  logger: Logger,
68
+ query: ListIndexQuery<WorkflowExecutionListKey>,
25
69
  internalMessage: string,
26
70
  ): Promise<WorkflowExecution[]> {
27
- let rows: Uint8Array[];
71
+ let rows: ListIndexRow[];
28
72
  try {
29
- rows = await store.listResources(ApiResourceKind.workflow_execution);
73
+ rows = await store.queryResources(workflowExecutionListIndex, query);
30
74
  } catch (error) {
31
75
  throw internalError(error, internalMessage);
32
76
  }
33
-
34
77
  const executions: WorkflowExecution[] = [];
35
- for (const data of rows) {
36
- let execution: WorkflowExecution;
37
- try {
38
- execution = fromBinary(WorkflowExecutionSchema, data);
39
- } catch (error) {
40
- logger.warn("Failed to unmarshal workflow execution, skipping", {
41
- error: error instanceof Error ? error.message : String(error),
42
- });
43
- continue;
78
+ for (const row of rows) {
79
+ const execution = decodeWorkflowExecution(row.data, logger);
80
+ if (execution !== undefined) {
81
+ executions.push(execution);
44
82
  }
45
- executions.push(execution);
46
83
  }
47
84
  return executions;
48
85
  }
86
+
87
+ export interface WorkflowExecutionListRead {
88
+ readonly store: Store;
89
+ readonly logger: Logger;
90
+ readonly listReadScope: ListReadScope | undefined;
91
+ readonly identity: CallerIdentity;
92
+ readonly query: ListIndexQuery<WorkflowExecutionListKey>;
93
+ readonly request: ListPageRequest;
94
+ readonly sortField: ExecutionSortField;
95
+ readonly sortAscending: boolean;
96
+ /** The lane's filters beyond the indexed predicates. */
97
+ readonly keep: (execution: WorkflowExecution) => boolean;
98
+ /** The request's other fields, which its token is bound to. */
99
+ readonly fingerprint: Readonly<Record<string, unknown>>;
100
+ }
101
+
102
+ /** One list response: a page in the default order, or the whole set sorted by another field. */
103
+ export async function readWorkflowExecutionList(
104
+ read: WorkflowExecutionListRead,
105
+ ): Promise<WorkflowExecutionList> {
106
+ const scope = (
107
+ executions: WorkflowExecution[],
108
+ ): Promise<WorkflowExecution[]> =>
109
+ restrictListByReadScope(
110
+ read.listReadScope,
111
+ read.identity,
112
+ ApiResourceKind.workflow_execution,
113
+ executions,
114
+ "",
115
+ );
116
+
117
+ if (read.sortField === ExecutionSortField.UNSPECIFIED) {
118
+ const page = await readListPage({
119
+ store: read.store,
120
+ declaration: workflowExecutionListIndex,
121
+ query: read.query,
122
+ request: read.request,
123
+ fingerprint: listPageFingerprint(read.fingerprint),
124
+ decode: (data) => decodeWorkflowExecution(data, read.logger),
125
+ keep: read.keep,
126
+ scope,
127
+ failure: "failed to list workflow executions",
128
+ });
129
+ return create(WorkflowExecutionListSchema, {
130
+ entries: page.entries,
131
+ nextPageToken: page.nextPageToken,
132
+ totalPages: page.nextPageToken === "" ? 1 : 0,
133
+ });
134
+ }
135
+
136
+ // Another sort field never issues a token, so none can be continued.
137
+ if (read.request.pageToken !== "") {
138
+ throw invalidArgumentError("invalid page_token");
139
+ }
140
+ if (read.request.pageSize < 0) {
141
+ throw invalidArgumentError("page_size must not be negative");
142
+ }
143
+ const executions = await scope(
144
+ (
145
+ await loadWorkflowExecutions(
146
+ read.store,
147
+ read.logger,
148
+ read.query,
149
+ "failed to list workflow executions",
150
+ )
151
+ ).filter(read.keep),
152
+ );
153
+ applySortField(executions, read.sortField, read.sortAscending);
154
+ const size = Math.min(read.request.pageSize, LIST_PAGE_MAX_SIZE);
155
+ return create(WorkflowExecutionListSchema, {
156
+ entries: size === 0 ? executions : executions.slice(0, size),
157
+ totalPages: 1,
158
+ });
159
+ }
@@ -81,18 +81,15 @@
81
81
  * driver's cost is proportional to the candidates offered (the cloud
82
82
  * checks each one against its authorization engine), and the whole
83
83
  * kind across every tenant is what rolled back on 2026-09-14; the
84
- * request's org is tens of rows. Two idioms carry the rule and both
85
- * keep their reason: a lane whose Go/Java contract org-narrows in
86
- * BOTH editions (environment, memory, schedule, agent-instance,
87
- * agent-share and agent-channel lists; the getBy* parent filters)
88
- * applies its own filter ABOVE the helper call and passes `""`; a lane
89
- * whose contract is cross-org on OSS (`agentExecution.list`,
90
- * `workflowExecution.list`, `listPendingApprovals`) hands the request
91
- * org to the helper, which applies it before the scope ONLY when a
92
- * scope is composed — a scope-less server still returns the input
93
- * untouched with the org unconsulted (the first line above). Two lanes
94
- * have no org on the wire (`session.list`, `apiKey.findAll`) and offer
95
- * the kind until P1 entry 12 pages them.
84
+ * request's org is tens of rows. The lanes over the session, execution
85
+ * and artifact kinds narrow by the request's org or parent in the
86
+ * store's indexed read (store/list-index.ts; every posture honours the
87
+ * request) and page there (pipeline/steps/list-page.ts, one batch per
88
+ * scope call), passing `""`; the small org-scoped lists (environment,
89
+ * memory, schedule, agent-instance, agent-share and agent-channel; the
90
+ * getBy* parent filters) apply their own filter above the helper call
91
+ * and pass `""` too. `apiKey.findAll` has no org on the wire and offers
92
+ * the kind: a few rows per person.
96
93
  * - A KIND WHOSE AUTHORIZATION IS ITS PARENT'S CARRIES THAT PARENT ON
97
94
  * EVERY CANDIDATE (stigmer-cloud 20260913.04 T04, Q-LB-33..36). When
98
95
  * `kind_meta` declares PARENT scope with INHERITED owner
package/src/index.ts CHANGED
@@ -380,11 +380,20 @@ export { newResolveSlugStep } from "./pipeline/steps/slug.js";
380
380
  // The driver interfaces and the store-fault classes the ratified mapping
381
381
  // keys on (typed not-found → NotFound; anything else rethrows as an
382
382
  // infrastructure fault — the guidelines' instanceof idiom).
383
- export type { Store } from "./store/interface.js";
383
+ export type { Store, StoreOpenOptions } from "./store/interface.js";
384
384
  export {
385
385
  AuditNotFoundError,
386
386
  ResourceNotFoundError,
387
387
  } from "./store/interface.js";
388
+ // The list index's read shapes, which `Store.queryResources` speaks
389
+ // (store/list-index.ts). Declaring an index stays internal: the list is
390
+ // the composition root's (boot/list-indexes.ts), one per server.
391
+ export type {
392
+ ListIndexCursor,
393
+ ListIndexDeclaration,
394
+ ListIndexQuery,
395
+ ListIndexRow,
396
+ } from "./store/list-index.js";
388
397
  // The maintenance-surface row shape (20260830.04 Stage 1, ruling Q3):
389
398
  // what findResourcesRawOrderedAfter pages and what
390
399
  // replaceResourceDataIfUnchanged guards on — the secret-convergence
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pins the page loop every paged list lane shares (../list-page.ts),
3
+ * against a real store opened with the server's list indexes:
4
+ * `page_size` 0 reads the whole list with no token; a positive size walks
5
+ * every row in the store's order with no gap and no duplicate, the last
6
+ * page carrying no token (also when the rows end exactly on a page); a
7
+ * scope that refuses rows still fills pages and the cut resumes after the
8
+ * last row returned; a caller who may see nothing gets short pages bounded
9
+ * by the examine budget, each with a token, until the rows run out; a size
10
+ * above the contract's maximum is capped; and the token's refusals — a
11
+ * negative size, malformed text, a token issued for another request.
12
+ */
13
+ import { create } from "@bufbuild/protobuf";
14
+ import { ConnectError, Code } from "@connectrpc/connect";
15
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
+
17
+ import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
18
+ import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
19
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
20
+ import { fromBinary } from "@bufbuild/protobuf";
21
+
22
+ import { sessionListIndex } from "../../../domain/session/list-index.js";
23
+ import { tempStore } from "../../../store/sqlite/__tests__/support.js";
24
+ import type { TempStore } from "../../../store/sqlite/__tests__/support.js";
25
+ import {
26
+ LIST_PAGE_MAX_SIZE,
27
+ examineBudgetOf,
28
+ listPageFingerprint,
29
+ readListPage,
30
+ } from "../list-page.js";
31
+ import type { ListPageParams } from "../list-page.js";
32
+
33
+ let temp: TempStore;
34
+
35
+ beforeEach(() => {
36
+ temp = tempStore();
37
+ });
38
+
39
+ afterEach(async () => {
40
+ await temp.cleanup();
41
+ });
42
+
43
+ async function seed(count: number): Promise<string[]> {
44
+ const ids: string[] = [];
45
+ for (let i = 0; i < count; i++) {
46
+ const id = `ses_${String(i).padStart(4, "0")}`;
47
+ ids.push(id);
48
+ await temp.store.saveResource(
49
+ ApiResourceKind.session,
50
+ id,
51
+ SessionSchema,
52
+ create(SessionSchema, {
53
+ metadata: { id, org: "acme" },
54
+ status: {
55
+ audit: {
56
+ specAudit: { createdAt: { seconds: BigInt(1_000 + i), nanos: 0 } },
57
+ },
58
+ },
59
+ }),
60
+ );
61
+ }
62
+ // Newest first: the highest creation instant first.
63
+ return ids.reverse();
64
+ }
65
+
66
+ function params(
67
+ pageSize: number,
68
+ pageToken: string,
69
+ scope: (rows: Session[]) => Session[] = (rows) => rows,
70
+ ): ListPageParams<Session, "agent_instance" | "channel"> {
71
+ return {
72
+ store: temp.store,
73
+ declaration: sessionListIndex,
74
+ query: { org: "acme" },
75
+ request: { pageSize, pageToken },
76
+ fingerprint: listPageFingerprint({ lane: "test", org: "acme" }),
77
+ decode: (data) => fromBinary(SessionSchema, data),
78
+ scope: (rows) => Promise.resolve(scope(rows)),
79
+ failure: "failed to list sessions",
80
+ };
81
+ }
82
+
83
+ async function walk(
84
+ pageSize: number,
85
+ scope?: (rows: Session[]) => Session[],
86
+ ): Promise<{ ids: string[]; pages: number }> {
87
+ const ids: string[] = [];
88
+ let token = "";
89
+ let pages = 0;
90
+ do {
91
+ const page = await readListPage(params(pageSize, token, scope));
92
+ ids.push(...page.entries.map((s) => s.metadata!.id));
93
+ token = page.nextPageToken;
94
+ pages += 1;
95
+ } while (token !== "");
96
+ return { ids, pages };
97
+ }
98
+
99
+ async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
100
+ try {
101
+ await promise;
102
+ } catch (error) {
103
+ if (error instanceof ConnectError) {
104
+ return error;
105
+ }
106
+ throw error;
107
+ }
108
+ throw new Error("expected a refusal");
109
+ }
110
+
111
+ describe("readListPage", () => {
112
+ it("reads the whole list with no token at page_size 0", async () => {
113
+ const all = await seed(7);
114
+ const page = await readListPage(params(0, ""));
115
+ expect(page.entries.map((s) => s.metadata!.id)).toEqual(all);
116
+ expect(page.nextPageToken).toBe("");
117
+ });
118
+
119
+ it("walks every row with no gap and no duplicate, the last page without a token", async () => {
120
+ const all = await seed(7);
121
+ const { ids, pages } = await walk(3);
122
+ expect(ids).toEqual(all);
123
+ expect(pages).toBe(3);
124
+ });
125
+
126
+ it("issues no token when the rows end exactly on a page", async () => {
127
+ const all = await seed(6);
128
+ const { ids, pages } = await walk(3);
129
+ expect(ids).toEqual(all);
130
+ expect(pages).toBe(2);
131
+ });
132
+
133
+ it("fills pages around rows the scope refuses and resumes after the last row returned", async () => {
134
+ const all = await seed(12);
135
+ const refused = new Set(["ses_0010", "ses_0007", "ses_0006", "ses_0001"]);
136
+ const scope = (rows: Session[]) =>
137
+ rows.filter((s) => !refused.has(s.metadata!.id));
138
+ const first = await readListPage(params(3, "", scope));
139
+ expect(first.entries).toHaveLength(3);
140
+ const { ids } = await walk(3, scope);
141
+ expect(ids).toEqual(all.filter((id) => !refused.has(id)));
142
+ });
143
+
144
+ it("bounds what a caller who may see nothing examines, with a token until the rows run out", async () => {
145
+ await seed(250);
146
+ const noneVisible = () => [];
147
+ const first = await readListPage(params(20, "", noneVisible));
148
+ expect(first.entries).toEqual([]);
149
+ expect(first.nextPageToken).not.toBe("");
150
+ expect(examineBudgetOf(20)).toBe(100);
151
+ const { ids, pages } = await walk(20, noneVisible);
152
+ expect(ids).toEqual([]);
153
+ expect(pages).toBe(3);
154
+ });
155
+
156
+ it("caps a page at the contract's maximum", async () => {
157
+ await seed(LIST_PAGE_MAX_SIZE + 5);
158
+ const page = await readListPage(params(1_000, ""));
159
+ expect(page.entries).toHaveLength(LIST_PAGE_MAX_SIZE);
160
+ expect(page.nextPageToken).not.toBe("");
161
+ });
162
+
163
+ it("refuses a negative size, a malformed token and a token issued for another request", async () => {
164
+ await seed(4);
165
+ expect((await refusal(readListPage(params(-1, "")))).code).toBe(
166
+ Code.InvalidArgument,
167
+ );
168
+ const bad = await refusal(readListPage(params(2, "not a token")));
169
+ expect([bad.code, bad.rawMessage]).toEqual([
170
+ Code.InvalidArgument,
171
+ "invalid page_token",
172
+ ]);
173
+
174
+ const token = (await readListPage(params(2, ""))).nextPageToken;
175
+ const other = await refusal(
176
+ readListPage({
177
+ ...params(2, token),
178
+ fingerprint: listPageFingerprint({ lane: "test", org: "other" }),
179
+ }),
180
+ );
181
+ expect([other.code, other.rawMessage]).toEqual([
182
+ Code.InvalidArgument,
183
+ "page_token was issued for a different request",
184
+ ]);
185
+ });
186
+ });