@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.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +11 -8
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/list-indexes.d.ts +3 -0
- package/dist/boot/list-indexes.d.ts.map +1 -0
- package/dist/boot/list-indexes.js +24 -0
- package/dist/boot/list-indexes.js.map +1 -0
- package/dist/domain/agentexecution/controller.d.ts.map +1 -1
- package/dist/domain/agentexecution/controller.js +6 -8
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/list-index.d.ts +2 -0
- package/dist/domain/agentexecution/list-index.d.ts.map +1 -0
- package/dist/domain/agentexecution/list-index.js +23 -0
- package/dist/domain/agentexecution/list-index.js.map +1 -0
- package/dist/domain/agentexecution/steps.d.ts +17 -33
- package/dist/domain/agentexecution/steps.d.ts.map +1 -1
- package/dist/domain/agentexecution/steps.js +94 -95
- package/dist/domain/agentexecution/steps.js.map +1 -1
- package/dist/domain/agentexecution/usage.d.ts.map +1 -1
- package/dist/domain/agentexecution/usage.js +37 -14
- package/dist/domain/agentexecution/usage.js.map +1 -1
- package/dist/domain/artifact/controller.d.ts.map +1 -1
- package/dist/domain/artifact/controller.js +27 -15
- package/dist/domain/artifact/controller.js.map +1 -1
- package/dist/domain/artifact/list-index.d.ts +2 -0
- package/dist/domain/artifact/list-index.d.ts.map +1 -0
- package/dist/domain/artifact/list-index.js +21 -0
- package/dist/domain/artifact/list-index.js.map +1 -0
- package/dist/domain/session/list-index.d.ts +4 -0
- package/dist/domain/session/list-index.d.ts.map +1 -0
- package/dist/domain/session/list-index.js +26 -0
- package/dist/domain/session/list-index.js.map +1 -0
- package/dist/domain/session/steps.d.ts +7 -8
- package/dist/domain/session/steps.d.ts.map +1 -1
- package/dist/domain/session/steps.js +76 -78
- package/dist/domain/session/steps.js.map +1 -1
- package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
- package/dist/domain/workflowexecution/controller.js +53 -55
- package/dist/domain/workflowexecution/controller.js.map +1 -1
- package/dist/domain/workflowexecution/execution-filter.d.ts +4 -0
- package/dist/domain/workflowexecution/execution-filter.d.ts.map +1 -1
- package/dist/domain/workflowexecution/execution-filter.js +11 -5
- package/dist/domain/workflowexecution/execution-filter.js.map +1 -1
- package/dist/domain/workflowexecution/get-execution-summary.d.ts.map +1 -1
- package/dist/domain/workflowexecution/get-execution-summary.js +23 -17
- package/dist/domain/workflowexecution/get-execution-summary.js.map +1 -1
- package/dist/domain/workflowexecution/list-index.d.ts +2 -0
- package/dist/domain/workflowexecution/list-index.d.ts.map +1 -0
- package/dist/domain/workflowexecution/list-index.js +21 -0
- package/dist/domain/workflowexecution/list-index.js.map +1 -0
- package/dist/domain/workflowexecution/list-pending-approvals.d.ts.map +1 -1
- package/dist/domain/workflowexecution/list-pending-approvals.js +69 -32
- package/dist/domain/workflowexecution/list-pending-approvals.js.map +1 -1
- package/dist/domain/workflowexecution/queries.d.ts +30 -4
- package/dist/domain/workflowexecution/queries.d.ts.map +1 -1
- package/dist/domain/workflowexecution/queries.js +76 -22
- package/dist/domain/workflowexecution/queries.js.map +1 -1
- package/dist/extensions/list-read-scope.d.ts +9 -12
- package/dist/extensions/list-read-scope.d.ts.map +1 -1
- package/dist/extensions/list-read-scope.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/list-page.d.ts +63 -0
- package/dist/pipeline/steps/list-page.d.ts.map +1 -0
- package/dist/pipeline/steps/list-page.js +187 -0
- package/dist/pipeline/steps/list-page.js.map +1 -0
- package/dist/query/activity/handler.d.ts +6 -7
- package/dist/query/activity/handler.d.ts.map +1 -1
- package/dist/query/activity/handler.js +42 -59
- package/dist/query/activity/handler.js.map +1 -1
- package/dist/store/interface.d.ts +29 -0
- package/dist/store/interface.d.ts.map +1 -1
- package/dist/store/interface.js.map +1 -1
- package/dist/store/list-index.d.ts +123 -0
- package/dist/store/list-index.d.ts.map +1 -0
- package/dist/store/list-index.js +277 -0
- package/dist/store/list-index.js.map +1 -0
- package/dist/store/logger.d.ts +3 -0
- package/dist/store/logger.d.ts.map +1 -1
- package/dist/store/logger.js +5 -1
- package/dist/store/logger.js.map +1 -1
- package/dist/store/postgres/migrations.d.ts +10 -6
- package/dist/store/postgres/migrations.d.ts.map +1 -1
- package/dist/store/postgres/migrations.js +64 -6
- package/dist/store/postgres/migrations.js.map +1 -1
- package/dist/store/postgres/store.d.ts +26 -5
- package/dist/store/postgres/store.d.ts.map +1 -1
- package/dist/store/postgres/store.js +377 -22
- package/dist/store/postgres/store.js.map +1 -1
- package/dist/store/sqlite/migrations.d.ts +11 -5
- package/dist/store/sqlite/migrations.d.ts.map +1 -1
- package/dist/store/sqlite/migrations.js +50 -4
- package/dist/store/sqlite/migrations.js.map +1 -1
- package/dist/store/sqlite/store.d.ts +18 -3
- package/dist/store/sqlite/store.d.ts.map +1 -1
- package/dist/store/sqlite/store.js +321 -15
- package/dist/store/sqlite/store.js.map +1 -1
- package/package.json +6 -6
- package/src/authorization/__tests__/drivers.ts +6 -1
- package/src/boot/__tests__/list-indexes.test.ts +65 -0
- package/src/boot/compose.ts +23 -13
- package/src/boot/list-indexes.ts +25 -0
- package/src/domain/agentexecution/__tests__/agentexecution.test.ts +85 -3
- package/src/domain/agentexecution/controller.ts +8 -10
- package/src/domain/agentexecution/list-index.ts +24 -0
- package/src/domain/agentexecution/steps.ts +118 -115
- package/src/domain/agentexecution/usage.ts +38 -17
- package/src/domain/artifact/controller.ts +28 -18
- package/src/domain/artifact/list-index.ts +22 -0
- package/src/domain/session/list-index.ts +28 -0
- package/src/domain/session/steps.ts +108 -123
- package/src/domain/workflowexecution/__tests__/list-read-scope-summaries.test.ts +155 -99
- package/src/domain/workflowexecution/__tests__/workflowexecution.test.ts +193 -31
- package/src/domain/workflowexecution/controller.ts +54 -82
- package/src/domain/workflowexecution/execution-filter.ts +20 -7
- package/src/domain/workflowexecution/get-execution-summary.ts +31 -24
- package/src/domain/workflowexecution/list-index.ts +22 -0
- package/src/domain/workflowexecution/list-pending-approvals.ts +82 -25
- package/src/domain/workflowexecution/queries.ts +134 -23
- package/src/extensions/list-read-scope.ts +9 -12
- package/src/index.ts +10 -1
- package/src/pipeline/steps/__tests__/list-page.test.ts +186 -0
- package/src/pipeline/steps/list-page.ts +284 -0
- package/src/query/activity/handler.ts +57 -78
- package/src/store/README.md +51 -32
- package/src/store/__tests__/list-index.measure.test.ts +293 -0
- package/src/store/__tests__/list-index.test.ts +328 -0
- package/src/store/__tests__/store-contract.ts +346 -3
- package/src/store/interface.ts +39 -0
- package/src/store/list-index.ts +465 -0
- package/src/store/logger.ts +8 -1
- package/src/store/postgres/__tests__/list-index-repair.test.ts +133 -0
- package/src/store/postgres/__tests__/migrations.test.ts +27 -19
- package/src/store/postgres/__tests__/store-contract.test.ts +50 -3
- package/src/store/postgres/migrations.ts +69 -7
- package/src/store/postgres/store.ts +485 -29
- package/src/store/sqlite/__tests__/migrations.test.ts +51 -7
- package/src/store/sqlite/__tests__/store-contract.test.ts +70 -16
- package/src/store/sqlite/__tests__/support.ts +12 -3
- package/src/store/sqlite/migrations.ts +57 -6
- package/src/store/sqlite/store.ts +462 -21
|
@@ -1,48 +1,159 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* listByWorkflow, getExecutionSummary, listPendingApprovals)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
19
|
-
* Go (each handler wraps the same
|
|
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
|
|
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:
|
|
71
|
+
let rows: ListIndexRow[];
|
|
28
72
|
try {
|
|
29
|
-
rows = await store.
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
execution
|
|
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.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
+
});
|