@retinue/agentkit 0.2.0 → 0.3.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/README.md +1 -1
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/content-parts.d.ts +18 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +3 -1
- package/dist/graphql/resolvers.js +14 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +89 -0
- package/dist/models/streaming.js +179 -2
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +46 -0
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +29 -2
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FileMetadataStore` and `FileContentStore` conformance (#129).
|
|
3
|
+
*
|
|
4
|
+
* Both carry safety properties rather than only a data contract, and both are asserted here rather than
|
|
5
|
+
* assumed:
|
|
6
|
+
*
|
|
7
|
+
* - **A `contentKey` from one tenant must not resolve another's bytes.** The key is opaque and a caller
|
|
8
|
+
* cannot construct one — but an adapter that took the key as sufficient would leak across tenants the
|
|
9
|
+
* moment a key was guessed, logged or copied. #91 found an `AgentStore` that accepted `TenantScope` and
|
|
10
|
+
* ignored it, which is why every adapter is asked the question directly.
|
|
11
|
+
* - **`transition` is a compare-and-set.** A blind write lets a conversation delete racing an upload leave a
|
|
12
|
+
* file `stored` after its bytes were scheduled for removal — bytes that then never get swept, because
|
|
13
|
+
* nothing is looking for a `stored` file's object.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { asId } from "../../core/ids.js";
|
|
17
|
+
import { withConversation } from "./parents.js";
|
|
18
|
+
const T1 = asId("conf-file-tenant-1");
|
|
19
|
+
const T2 = asId("conf-file-tenant-2");
|
|
20
|
+
const C1 = asId("conf-file-convo-1");
|
|
21
|
+
const C2 = asId("conf-file-convo-2");
|
|
22
|
+
const P1 = asId("conf-file-principal-1");
|
|
23
|
+
/**
|
|
24
|
+
* `Omit<…, "id">` before the intersection, not `Partial<FileMetadata> & { id: string }`.
|
|
25
|
+
*
|
|
26
|
+
* The latter intersects `FileId | undefined` with `string`, so the parameter demanded a *branded* id and every
|
|
27
|
+
* call site passing `{ id: "f1" }` was a type error. Invisible until #253 put this directory under the compiler:
|
|
28
|
+
* it was excluded from `tsconfig`, and vitest transpiles without typechecking.
|
|
29
|
+
*/
|
|
30
|
+
const file = (over) => ({
|
|
31
|
+
conversationId: C1,
|
|
32
|
+
filename: "notes.pdf",
|
|
33
|
+
mediaType: "application/pdf",
|
|
34
|
+
byteSize: 1_024,
|
|
35
|
+
contentKey: `key-${over.id}`,
|
|
36
|
+
state: "stored",
|
|
37
|
+
uploadedBy: P1,
|
|
38
|
+
createdAt: "2026-08-23T10:00:00.000Z",
|
|
39
|
+
...over,
|
|
40
|
+
id: asId(over.id),
|
|
41
|
+
});
|
|
42
|
+
const bytes = (text) => (async function* () {
|
|
43
|
+
yield new TextEncoder().encode(text);
|
|
44
|
+
})();
|
|
45
|
+
/**
|
|
46
|
+
* A real timestamp, because `at` is one.
|
|
47
|
+
*
|
|
48
|
+
* This was a placeholder string until #129 ran the harness against Postgres, which rejected it: the
|
|
49
|
+
* in-memory adapter had been storing whatever it was handed. The lesson is the matrix's whole purpose --
|
|
50
|
+
* a reference adapter laxer than the real one turns a production failure into a passing test -- so the
|
|
51
|
+
* placeholder became a timestamp and `rejects a non-timestamp \`at\`` below holds both adapters to it.
|
|
52
|
+
*/
|
|
53
|
+
const AT = "2026-08-23T12:00:00.000Z";
|
|
54
|
+
export function fileMetadataStoreConformance(makeFixture) {
|
|
55
|
+
// A file references a conversation, and Postgres enforces that with a foreign key — so the parent has to
|
|
56
|
+
// exist before the child, and only the fixture can create it. Same shape as `messageStoreConformance`.
|
|
57
|
+
const open = (conversations = [C1]) => withConversation(makeFixture(), conversations.map((conversationId) => ({ tenantId: T1, conversationId })));
|
|
58
|
+
describe("FileMetadataStore conformance", () => {
|
|
59
|
+
it("stores and reads back a file", async () => {
|
|
60
|
+
const store = await open();
|
|
61
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
62
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({
|
|
63
|
+
id: "f1",
|
|
64
|
+
filename: "notes.pdf",
|
|
65
|
+
state: "stored",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
it("returns null for another tenant's file", async () => {
|
|
69
|
+
// Indistinguishable from absent, so the store cannot be used to probe which ids exist.
|
|
70
|
+
const store = await open();
|
|
71
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
72
|
+
expect(await store.get({ tenantId: T2, id: asId("f1") })).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
it("refuses to create the same id twice", async () => {
|
|
75
|
+
const store = await open();
|
|
76
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
77
|
+
await expect(store.create({ tenantId: T1, file: file({ id: "f1" }) })).rejects.toThrow();
|
|
78
|
+
});
|
|
79
|
+
it("lists a conversation's live files, newest cursor last", async () => {
|
|
80
|
+
const store = await open([C1, C2]);
|
|
81
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", createdAt: "2026-08-23T10:00:00.000Z" }) });
|
|
82
|
+
await store.create({ tenantId: T1, file: file({ id: "f2", createdAt: "2026-08-23T11:00:00.000Z" }) });
|
|
83
|
+
await store.create({ tenantId: T1, file: file({ id: "f3", conversationId: C2 }) });
|
|
84
|
+
const page = await store.listByConversation({ tenantId: T1, conversationId: C1, limit: 10 });
|
|
85
|
+
expect(page.items.map((f) => f.id)).toEqual(["f1", "f2"]);
|
|
86
|
+
// Another conversation's file is absent, not merely last.
|
|
87
|
+
expect(page.items.map((f) => f.id)).not.toContain("f3");
|
|
88
|
+
});
|
|
89
|
+
it("pages on a keyset cursor rather than an offset", async () => {
|
|
90
|
+
// An offset shifts when a row is inserted, so a caller paging a conversation while a file uploads
|
|
91
|
+
// either sees one twice or misses one.
|
|
92
|
+
const store = await open();
|
|
93
|
+
for (const n of [1, 2, 3, 4]) {
|
|
94
|
+
await store.create({
|
|
95
|
+
tenantId: T1,
|
|
96
|
+
file: file({ id: `f${n}`, createdAt: `2026-08-23T1${n}:00:00.000Z` }),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const first = await store.listByConversation({ tenantId: T1, conversationId: C1, limit: 2 });
|
|
100
|
+
expect(first.items.map((f) => f.id)).toEqual(["f1", "f2"]);
|
|
101
|
+
expect(first.nextCursor).toBeDefined();
|
|
102
|
+
const second = await store.listByConversation({
|
|
103
|
+
tenantId: T1,
|
|
104
|
+
conversationId: C1,
|
|
105
|
+
limit: 2,
|
|
106
|
+
cursor: first.nextCursor,
|
|
107
|
+
});
|
|
108
|
+
expect(second.items.map((f) => f.id)).toEqual(["f3", "f4"]);
|
|
109
|
+
// No cursor when the page is the last one, so a caller loops on its presence.
|
|
110
|
+
expect(second.nextCursor).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
it("hides a soft-deleted file from the conversation listing", async () => {
|
|
113
|
+
const store = await open();
|
|
114
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
115
|
+
await store.transition({
|
|
116
|
+
tenantId: T1,
|
|
117
|
+
id: asId("f1"),
|
|
118
|
+
from: "stored",
|
|
119
|
+
to: "deleting",
|
|
120
|
+
at: "2026-08-23T12:00:00.000Z",
|
|
121
|
+
});
|
|
122
|
+
// A deleted attachment reappearing in the list is the failure this guards.
|
|
123
|
+
const page = await store.listByConversation({ tenantId: T1, conversationId: C1, limit: 10 });
|
|
124
|
+
expect(page.items).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
it("moves a file only from the state the caller expected", async () => {
|
|
127
|
+
const store = await open();
|
|
128
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", state: "pending" }) });
|
|
129
|
+
const wrong = await store.transition({
|
|
130
|
+
tenantId: T1,
|
|
131
|
+
id: asId("f1"),
|
|
132
|
+
from: "stored",
|
|
133
|
+
to: "deleted",
|
|
134
|
+
at: AT,
|
|
135
|
+
});
|
|
136
|
+
expect(wrong.moved).toBe(false);
|
|
137
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({ state: "pending" });
|
|
138
|
+
const right = await store.transition({
|
|
139
|
+
tenantId: T1,
|
|
140
|
+
id: asId("f1"),
|
|
141
|
+
from: "pending",
|
|
142
|
+
to: "stored",
|
|
143
|
+
at: AT,
|
|
144
|
+
checksum: "abc",
|
|
145
|
+
});
|
|
146
|
+
expect(right.moved).toBe(true);
|
|
147
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({
|
|
148
|
+
state: "stored",
|
|
149
|
+
checksum: "abc",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
it("lets exactly one of two racing transitions win", async () => {
|
|
153
|
+
// The race that matters: a conversation delete and an upload completion both trying to move the same
|
|
154
|
+
// file. A blind write would let the upload land after the delete, leaving a `stored` file whose bytes
|
|
155
|
+
// are scheduled for removal — and nothing sweeps a `stored` file's object.
|
|
156
|
+
const store = await open();
|
|
157
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", state: "pending" }) });
|
|
158
|
+
const results = await Promise.all([
|
|
159
|
+
store.transition({ tenantId: T1, id: asId("f1"), from: "pending", to: "stored", at: AT }),
|
|
160
|
+
store.transition({ tenantId: T1, id: asId("f1"), from: "pending", to: "deleting", at: AT }),
|
|
161
|
+
]);
|
|
162
|
+
expect(results.filter((r) => r.moved)).toHaveLength(1);
|
|
163
|
+
});
|
|
164
|
+
it("does not transition another tenant's file", async () => {
|
|
165
|
+
const store = await open();
|
|
166
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", state: "pending" }) });
|
|
167
|
+
const result = await store.transition({
|
|
168
|
+
tenantId: T2,
|
|
169
|
+
id: asId("f1"),
|
|
170
|
+
from: "pending",
|
|
171
|
+
to: "stored",
|
|
172
|
+
at: AT,
|
|
173
|
+
});
|
|
174
|
+
expect(result.moved).toBe(false);
|
|
175
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({ state: "pending" });
|
|
176
|
+
});
|
|
177
|
+
it("records an extraction outcome and reads it back", async () => {
|
|
178
|
+
// #131. Every field, because they are stored as separate columns in Postgres and a mapper that dropped
|
|
179
|
+
// one would still return a plausible record.
|
|
180
|
+
const store = await open();
|
|
181
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
182
|
+
const { recorded } = await store.recordExtraction({
|
|
183
|
+
tenantId: T1,
|
|
184
|
+
id: asId("f1"),
|
|
185
|
+
extraction: {
|
|
186
|
+
state: "extracted",
|
|
187
|
+
ref: asId("blob-1"),
|
|
188
|
+
pageCount: 3,
|
|
189
|
+
blockCount: 42,
|
|
190
|
+
truncated: false,
|
|
191
|
+
at: AT,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
expect(recorded).toBe(true);
|
|
195
|
+
expect((await store.get({ tenantId: T1, id: asId("f1") }))?.extraction).toEqual({
|
|
196
|
+
state: "extracted",
|
|
197
|
+
ref: "blob-1",
|
|
198
|
+
pageCount: 3,
|
|
199
|
+
blockCount: 42,
|
|
200
|
+
truncated: false,
|
|
201
|
+
at: AT,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
it("records a typed failure with its reason and message", async () => {
|
|
205
|
+
// AC-4 of #131 depends on both surviving storage: the reason drives behaviour and the message is what
|
|
206
|
+
// the user reads, so a store that kept one and dropped the other would half-work.
|
|
207
|
+
const store = await open();
|
|
208
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
209
|
+
await store.recordExtraction({
|
|
210
|
+
tenantId: T1,
|
|
211
|
+
id: asId("f1"),
|
|
212
|
+
extraction: {
|
|
213
|
+
state: "failed",
|
|
214
|
+
failureReason: "no-text-layer",
|
|
215
|
+
failureMessage: "That PDF is a scan and needs OCR.",
|
|
216
|
+
at: AT,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
expect((await store.get({ tenantId: T1, id: asId("f1") }))?.extraction).toMatchObject({
|
|
220
|
+
state: "failed",
|
|
221
|
+
failureReason: "no-text-layer",
|
|
222
|
+
failureMessage: "That PDF is a scan and needs OCR.",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
it("does not record an extraction against another tenant's file", async () => {
|
|
226
|
+
const store = await open();
|
|
227
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
228
|
+
const { recorded } = await store.recordExtraction({
|
|
229
|
+
tenantId: T2,
|
|
230
|
+
id: asId("f1"),
|
|
231
|
+
extraction: { state: "extracted", at: AT },
|
|
232
|
+
});
|
|
233
|
+
// Reported rather than thrown: it is the same answer a file deleted mid-extraction gives, and a worker
|
|
234
|
+
// must be able to tell "gone" from "broken".
|
|
235
|
+
expect(recorded).toBe(false);
|
|
236
|
+
expect((await store.get({ tenantId: T1, id: asId("f1") }))?.extraction).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
it("reports a file nobody has extracted as pending, not as absent", async () => {
|
|
239
|
+
// The property that makes the sweep able to find a lost enqueue: a file with no extraction record at
|
|
240
|
+
// all is waiting, which is the same fact as `pending`.
|
|
241
|
+
const store = await open();
|
|
242
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
243
|
+
const page = await store.listByExtractionState({
|
|
244
|
+
tenantId: T1,
|
|
245
|
+
state: "pending",
|
|
246
|
+
olderThan: "2030-01-01T00:00:00.000Z",
|
|
247
|
+
limit: 10,
|
|
248
|
+
});
|
|
249
|
+
expect(page.items.map((f) => f.id)).toEqual(["f1"]);
|
|
250
|
+
});
|
|
251
|
+
it("finds an extraction stuck mid-parse", async () => {
|
|
252
|
+
// A worker that died leaves `running`, and nothing else would ever notice.
|
|
253
|
+
const store = await open();
|
|
254
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
255
|
+
await store.create({ tenantId: T1, file: file({ id: "f2" }) });
|
|
256
|
+
await store.recordExtraction({ tenantId: T1, id: asId("f1"), extraction: { state: "running", at: AT } });
|
|
257
|
+
await store.recordExtraction({
|
|
258
|
+
tenantId: T1,
|
|
259
|
+
id: asId("f2"),
|
|
260
|
+
extraction: { state: "extracted", ref: asId("b"), at: AT },
|
|
261
|
+
});
|
|
262
|
+
const page = await store.listByExtractionState({
|
|
263
|
+
tenantId: T1,
|
|
264
|
+
state: "running",
|
|
265
|
+
olderThan: "2030-01-01T00:00:00.000Z",
|
|
266
|
+
limit: 10,
|
|
267
|
+
});
|
|
268
|
+
expect(page.items.map((f) => f.id)).toEqual(["f1"]);
|
|
269
|
+
});
|
|
270
|
+
it("does not list another tenant's stuck extraction", async () => {
|
|
271
|
+
const store = await open();
|
|
272
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
273
|
+
const page = await store.listByExtractionState({
|
|
274
|
+
tenantId: T2,
|
|
275
|
+
state: "pending",
|
|
276
|
+
olderThan: "2030-01-01T00:00:00.000Z",
|
|
277
|
+
limit: 10,
|
|
278
|
+
});
|
|
279
|
+
expect(page.items).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
it("rejects a non-timestamp `at` rather than storing it", async () => {
|
|
282
|
+
// Found by running this harness against Postgres for the first time (#129): it rejected the
|
|
283
|
+
// placeholder string the in-memory adapter had been storing happily. Either behaviour is defensible
|
|
284
|
+
// in isolation; differing is not, because it makes a production write failure something the
|
|
285
|
+
// in-memory tests cannot see. Pinned here so neither adapter can drift back.
|
|
286
|
+
const store = await open();
|
|
287
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", state: "pending" }) });
|
|
288
|
+
await expect(store.transition({ tenantId: T1, id: asId("f1"), from: "pending", to: "stored", at: "t" })).rejects.toThrow();
|
|
289
|
+
// And it did not move: a rejected write must not be a partial one.
|
|
290
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({ state: "pending" });
|
|
291
|
+
});
|
|
292
|
+
it("schedules every live file of a conversation in one call", async () => {
|
|
293
|
+
// One call rather than list-then-loop: a file uploaded between the two would be missed, leaving bytes
|
|
294
|
+
// for a conversation that no longer exists.
|
|
295
|
+
const store = await open([C1, C2]);
|
|
296
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", state: "stored" }) });
|
|
297
|
+
await store.create({ tenantId: T1, file: file({ id: "f2", state: "pending" }) });
|
|
298
|
+
await store.create({ tenantId: T1, file: file({ id: "f3", conversationId: C2 }) });
|
|
299
|
+
const result = await store.scheduleConversationDeletion({
|
|
300
|
+
tenantId: T1,
|
|
301
|
+
conversationId: C1,
|
|
302
|
+
at: "2026-08-23T12:00:00.000Z",
|
|
303
|
+
});
|
|
304
|
+
// `pending` as well as `stored`: an in-flight upload must not complete into a deleted conversation.
|
|
305
|
+
expect(result.scheduled).toBe(2);
|
|
306
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({ state: "deleting" });
|
|
307
|
+
expect(await store.get({ tenantId: T1, id: asId("f2") })).toMatchObject({ state: "deleting" });
|
|
308
|
+
// Another conversation's file is untouched.
|
|
309
|
+
expect(await store.get({ tenantId: T1, id: asId("f3") })).toMatchObject({ state: "stored" });
|
|
310
|
+
});
|
|
311
|
+
it("does not schedule another tenant's conversation", async () => {
|
|
312
|
+
const store = await open();
|
|
313
|
+
await store.create({ tenantId: T1, file: file({ id: "f1" }) });
|
|
314
|
+
const result = await store.scheduleConversationDeletion({
|
|
315
|
+
tenantId: T2,
|
|
316
|
+
conversationId: C1,
|
|
317
|
+
at: AT,
|
|
318
|
+
});
|
|
319
|
+
expect(result.scheduled).toBe(0);
|
|
320
|
+
expect(await store.get({ tenantId: T1, id: asId("f1") })).toMatchObject({ state: "stored" });
|
|
321
|
+
});
|
|
322
|
+
it("lists by state only past the age threshold", async () => {
|
|
323
|
+
// Without the threshold the job reports every upload happening while it runs.
|
|
324
|
+
const store = await open();
|
|
325
|
+
await store.create({ tenantId: T1, file: file({ id: "old", state: "pending", createdAt: "2026-08-23T09:00:00.000Z" }) });
|
|
326
|
+
await store.create({ tenantId: T1, file: file({ id: "new", state: "pending", createdAt: "2026-08-23T12:00:00.000Z" }) });
|
|
327
|
+
const page = await store.listByState({
|
|
328
|
+
tenantId: T1,
|
|
329
|
+
state: "pending",
|
|
330
|
+
olderThan: "2026-08-23T10:00:00.000Z",
|
|
331
|
+
limit: 10,
|
|
332
|
+
});
|
|
333
|
+
expect(page.items.map((f) => f.id)).toEqual(["old"]);
|
|
334
|
+
});
|
|
335
|
+
it("does not list another tenant's files by state", async () => {
|
|
336
|
+
const store = await open();
|
|
337
|
+
await store.create({ tenantId: T1, file: file({ id: "f1", state: "pending" }) });
|
|
338
|
+
const page = await store.listByState({
|
|
339
|
+
tenantId: T2,
|
|
340
|
+
state: "pending",
|
|
341
|
+
olderThan: "2027-01-01T00:00:00.000Z",
|
|
342
|
+
limit: 10,
|
|
343
|
+
});
|
|
344
|
+
expect(page.items).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
export function fileContentStoreConformance(make) {
|
|
349
|
+
describe("FileContentStore conformance", () => {
|
|
350
|
+
it("writes bytes and reads them back unchanged", async () => {
|
|
351
|
+
const store = make();
|
|
352
|
+
const stored = await store.putFile({
|
|
353
|
+
tenantId: T1,
|
|
354
|
+
contentKey: "k1",
|
|
355
|
+
mediaType: "text/plain",
|
|
356
|
+
bytes: bytes("hello"),
|
|
357
|
+
maxBytes: 1_000,
|
|
358
|
+
});
|
|
359
|
+
expect(stored.byteSize).toBe(5);
|
|
360
|
+
expect(stored.checksum).toMatch(/^[0-9a-f]{64}$/);
|
|
361
|
+
const read = await store.readFile({ tenantId: T1, contentKey: "k1" });
|
|
362
|
+
expect(read).not.toBeNull();
|
|
363
|
+
const chunks = [];
|
|
364
|
+
for await (const chunk of read)
|
|
365
|
+
chunks.push(chunk);
|
|
366
|
+
expect(new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))))).toBe("hello");
|
|
367
|
+
});
|
|
368
|
+
it("does not resolve another tenant's key", async () => {
|
|
369
|
+
// The key is opaque and a caller cannot construct one — but an adapter treating the key as sufficient
|
|
370
|
+
// would leak the moment one was guessed, logged or copied.
|
|
371
|
+
const store = make();
|
|
372
|
+
await store.putFile({
|
|
373
|
+
tenantId: T1,
|
|
374
|
+
contentKey: "k1",
|
|
375
|
+
mediaType: "text/plain",
|
|
376
|
+
bytes: bytes("secret"),
|
|
377
|
+
maxBytes: 1_000,
|
|
378
|
+
});
|
|
379
|
+
expect(await store.readFile({ tenantId: T2, contentKey: "k1" })).toBeNull();
|
|
380
|
+
});
|
|
381
|
+
it("stops at the cap and writes nothing", async () => {
|
|
382
|
+
// Stops, rather than reading to the end and then refusing — and stores nothing, because a kept partial
|
|
383
|
+
// object is an orphan created by every oversized upload.
|
|
384
|
+
const store = make();
|
|
385
|
+
await expect(store.putFile({
|
|
386
|
+
tenantId: T1,
|
|
387
|
+
contentKey: "k1",
|
|
388
|
+
mediaType: "text/plain",
|
|
389
|
+
bytes: bytes("far too long"),
|
|
390
|
+
maxBytes: 4,
|
|
391
|
+
})).rejects.toThrow();
|
|
392
|
+
expect(await store.readFile({ tenantId: T1, contentKey: "k1" })).toBeNull();
|
|
393
|
+
});
|
|
394
|
+
it("deletes idempotently", async () => {
|
|
395
|
+
// A retried sweep depends on it: the sweep deletes bytes before moving the state, so a crash between
|
|
396
|
+
// them means the next run deletes an object that is already gone.
|
|
397
|
+
const store = make();
|
|
398
|
+
await store.putFile({
|
|
399
|
+
tenantId: T1,
|
|
400
|
+
contentKey: "k1",
|
|
401
|
+
mediaType: "text/plain",
|
|
402
|
+
bytes: bytes("x"),
|
|
403
|
+
maxBytes: 10,
|
|
404
|
+
});
|
|
405
|
+
await store.deleteFile({ tenantId: T1, contentKey: "k1" });
|
|
406
|
+
await expect(store.deleteFile({ tenantId: T1, contentKey: "k1" })).resolves.toBeUndefined();
|
|
407
|
+
expect(await store.readFile({ tenantId: T1, contentKey: "k1" })).toBeNull();
|
|
408
|
+
});
|
|
409
|
+
it("does not delete another tenant's object", async () => {
|
|
410
|
+
const store = make();
|
|
411
|
+
await store.putFile({
|
|
412
|
+
tenantId: T1,
|
|
413
|
+
contentKey: "k1",
|
|
414
|
+
mediaType: "text/plain",
|
|
415
|
+
bytes: bytes("keep"),
|
|
416
|
+
maxBytes: 10,
|
|
417
|
+
});
|
|
418
|
+
await store.deleteFile({ tenantId: T2, contentKey: "k1" });
|
|
419
|
+
expect(await store.readFile({ tenantId: T1, contentKey: "k1" })).not.toBeNull();
|
|
420
|
+
});
|
|
421
|
+
it("lists only this tenant's objects", async () => {
|
|
422
|
+
const store = make();
|
|
423
|
+
for (const [tenantId, key] of [
|
|
424
|
+
[T1, "a"],
|
|
425
|
+
[T1, "b"],
|
|
426
|
+
[T2, "c"],
|
|
427
|
+
]) {
|
|
428
|
+
await store.putFile({ tenantId, contentKey: key, mediaType: "text/plain", bytes: bytes("x"), maxBytes: 10 });
|
|
429
|
+
}
|
|
430
|
+
const page = await store.listObjects({ tenantId: T1, limit: 10 });
|
|
431
|
+
expect(page.items.map((o) => o.contentKey).sort()).toEqual(["a", "b"]);
|
|
432
|
+
});
|
|
433
|
+
it("either signs with an expiry or returns null, never a durable URL", async () => {
|
|
434
|
+
// AC-6 as a property of the port. An adapter with no signing mechanism says so; one that signs must
|
|
435
|
+
// take an expiry, and there is no method that returns a URL without one.
|
|
436
|
+
const store = make();
|
|
437
|
+
await store.putFile({
|
|
438
|
+
tenantId: T1,
|
|
439
|
+
contentKey: "k1",
|
|
440
|
+
mediaType: "text/plain",
|
|
441
|
+
bytes: bytes("x"),
|
|
442
|
+
maxBytes: 10,
|
|
443
|
+
});
|
|
444
|
+
const url = await store.signedUrl({ tenantId: T1, contentKey: "k1", expiresInSeconds: 60 });
|
|
445
|
+
if (url !== null) {
|
|
446
|
+
// A signed URL carries its expiry somewhere in it. Asserted loosely because the parameter name is
|
|
447
|
+
// the provider's, and asserting a specific one would only test the adapter we happened to write.
|
|
448
|
+
expect(url).toMatch(/https?:\/\//);
|
|
449
|
+
expect(url.length).toBeGreaterThan("https://".length);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flow stores' shared contract — #187, #186.
|
|
3
|
+
*
|
|
4
|
+
* Runs against every adapter, so "the Postgres one behaves like the reference" is a fact rather than a claim about
|
|
5
|
+
* two hundred lines nobody compared. Two cases matter more than the rest and are the reason this file exists
|
|
6
|
+
* rather than per-adapter tests:
|
|
7
|
+
*
|
|
8
|
+
* - **A version cannot be overwritten.** An execution pins the version it started with and reads it for its whole
|
|
9
|
+
* life, so a definition that could change under it would change an automation's shape halfway through.
|
|
10
|
+
* - **`save` is monotonic.** Two workers holding the same execution must not let the slower one move it
|
|
11
|
+
* backwards, because a flow that goes backwards re-performs external writes.
|
|
12
|
+
*/
|
|
13
|
+
import type { FlowDefinitionStore, FlowExecutionStore } from "../../persistence/index.js";
|
|
14
|
+
export declare function flowDefinitionStoreConformance(make: () => FlowDefinitionStore): void;
|
|
15
|
+
export declare function flowExecutionStoreConformance(make: () => FlowExecutionStore): void;
|
|
16
|
+
//# sourceMappingURL=flows.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flow stores' shared contract — #187, #186.
|
|
3
|
+
*
|
|
4
|
+
* Runs against every adapter, so "the Postgres one behaves like the reference" is a fact rather than a claim about
|
|
5
|
+
* two hundred lines nobody compared. Two cases matter more than the rest and are the reason this file exists
|
|
6
|
+
* rather than per-adapter tests:
|
|
7
|
+
*
|
|
8
|
+
* - **A version cannot be overwritten.** An execution pins the version it started with and reads it for its whole
|
|
9
|
+
* life, so a definition that could change under it would change an automation's shape halfway through.
|
|
10
|
+
* - **`save` is monotonic.** Two workers holding the same execution must not let the slower one move it
|
|
11
|
+
* backwards, because a flow that goes backwards re-performs external writes.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { asId } from "../../core/ids.js";
|
|
15
|
+
const T1 = asId("tenant-1");
|
|
16
|
+
const T2 = asId("tenant-2");
|
|
17
|
+
const definition = (over = {}) => ({
|
|
18
|
+
flowId: "onboard",
|
|
19
|
+
version: 1,
|
|
20
|
+
name: "Onboarding",
|
|
21
|
+
kind: "flow",
|
|
22
|
+
definition: { steps: [{ name: "a", kind: "done" }], start: "a" },
|
|
23
|
+
createdAt: "2026-08-26T10:00:00.000Z",
|
|
24
|
+
createdBy: asId("p1"),
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
const execution = (over = {}) => ({
|
|
28
|
+
id: "exec-1",
|
|
29
|
+
flowId: "onboard",
|
|
30
|
+
flowVersion: 1,
|
|
31
|
+
runId: asId("run-1"),
|
|
32
|
+
status: "running",
|
|
33
|
+
currentStep: "a",
|
|
34
|
+
steps: 0,
|
|
35
|
+
execution: { state: {} },
|
|
36
|
+
startedAt: "2026-08-26T10:00:00.000Z",
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
export function flowDefinitionStoreConformance(make) {
|
|
40
|
+
describe("FlowDefinitionStore conformance", () => {
|
|
41
|
+
it("stores and reads a definition by version", async () => {
|
|
42
|
+
const store = make();
|
|
43
|
+
await store.put({ tenantId: T1, definition: definition() });
|
|
44
|
+
const read = await store.get({ tenantId: T1, flowId: "onboard", version: 1 });
|
|
45
|
+
expect(read?.name).toBe("Onboarding");
|
|
46
|
+
expect(read?.definition).toEqual({ steps: [{ name: "a", kind: "done" }], start: "a" });
|
|
47
|
+
});
|
|
48
|
+
it("refuses to overwrite a version", async () => {
|
|
49
|
+
// The property an execution's version pin depends on. Without it, "the definition at version 1" is not a
|
|
50
|
+
// stable thing to have pinned.
|
|
51
|
+
const store = make();
|
|
52
|
+
await store.put({ tenantId: T1, definition: definition() });
|
|
53
|
+
await expect(store.put({ tenantId: T1, definition: definition({ name: "changed" }) })).rejects.toThrow();
|
|
54
|
+
expect((await store.get({ tenantId: T1, flowId: "onboard", version: 1 }))?.name).toBe("Onboarding");
|
|
55
|
+
});
|
|
56
|
+
it("keeps versions side by side and reports the latest", async () => {
|
|
57
|
+
const store = make();
|
|
58
|
+
await store.put({ tenantId: T1, definition: definition({ version: 1 }) });
|
|
59
|
+
await store.put({ tenantId: T1, definition: definition({ version: 3, name: "v3" }) });
|
|
60
|
+
await store.put({ tenantId: T1, definition: definition({ version: 2, name: "v2" }) });
|
|
61
|
+
expect((await store.latest({ tenantId: T1, flowId: "onboard" }))?.version).toBe(3);
|
|
62
|
+
// Every earlier version is still readable: an execution pinned to 1 must still find it.
|
|
63
|
+
expect((await store.get({ tenantId: T1, flowId: "onboard", version: 1 }))?.name).toBe("Onboarding");
|
|
64
|
+
});
|
|
65
|
+
it("returns null for an unknown flow or version rather than throwing", async () => {
|
|
66
|
+
const store = make();
|
|
67
|
+
expect(await store.get({ tenantId: T1, flowId: "nope", version: 1 })).toBeNull();
|
|
68
|
+
expect(await store.latest({ tenantId: T1, flowId: "nope" })).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
it("does not resolve another tenant's definition", async () => {
|
|
71
|
+
const store = make();
|
|
72
|
+
await store.put({ tenantId: T1, definition: definition() });
|
|
73
|
+
expect(await store.get({ tenantId: T2, flowId: "onboard", version: 1 })).toBeNull();
|
|
74
|
+
expect(await store.latest({ tenantId: T2, flowId: "onboard" })).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it("lists the latest version of each flow, not every version", async () => {
|
|
77
|
+
const store = make();
|
|
78
|
+
await store.put({ tenantId: T1, definition: definition({ version: 1 }) });
|
|
79
|
+
await store.put({ tenantId: T1, definition: definition({ version: 2 }) });
|
|
80
|
+
await store.put({ tenantId: T1, definition: definition({ flowId: "other", version: 1 }) });
|
|
81
|
+
const listed = await store.list({ tenantId: T1, limit: 50 });
|
|
82
|
+
expect(listed.items.map((d) => [d.flowId, d.version]).sort()).toEqual([
|
|
83
|
+
["onboard", 2],
|
|
84
|
+
["other", 1],
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
it("pages the list", async () => {
|
|
88
|
+
const store = make();
|
|
89
|
+
for (const flowId of ["a", "b", "c"])
|
|
90
|
+
await store.put({ tenantId: T1, definition: definition({ flowId }) });
|
|
91
|
+
const first = await store.list({ tenantId: T1, limit: 2 });
|
|
92
|
+
expect(first.items).toHaveLength(2);
|
|
93
|
+
expect(first.nextCursor).toBeDefined();
|
|
94
|
+
const second = await store.list({ tenantId: T1, limit: 2, cursor: first.nextCursor });
|
|
95
|
+
expect(second.items.map((d) => d.flowId)).toEqual(["c"]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export function flowExecutionStoreConformance(make) {
|
|
100
|
+
describe("FlowExecutionStore conformance", () => {
|
|
101
|
+
it("creates and reads an execution", async () => {
|
|
102
|
+
const store = make();
|
|
103
|
+
await store.create({ tenantId: T1, execution: execution() });
|
|
104
|
+
expect((await store.get({ tenantId: T1, executionId: "exec-1" }))?.currentStep).toBe("a");
|
|
105
|
+
});
|
|
106
|
+
it("refuses to create the same execution twice", async () => {
|
|
107
|
+
const store = make();
|
|
108
|
+
await store.create({ tenantId: T1, execution: execution() });
|
|
109
|
+
await expect(store.create({ tenantId: T1, execution: execution() })).rejects.toThrow();
|
|
110
|
+
});
|
|
111
|
+
it("saves forward", async () => {
|
|
112
|
+
const store = make();
|
|
113
|
+
await store.create({ tenantId: T1, execution: execution() });
|
|
114
|
+
await store.save({ tenantId: T1, execution: execution({ steps: 3, currentStep: "b" }) });
|
|
115
|
+
expect((await store.get({ tenantId: T1, executionId: "exec-1" }))?.steps).toBe(3);
|
|
116
|
+
});
|
|
117
|
+
it("ignores a save that would move the execution backwards", async () => {
|
|
118
|
+
/**
|
|
119
|
+
* The monotonic guard. Two workers holding the same execution: the slower one's document is stale, and
|
|
120
|
+
* writing it would rewind the flow — which re-performs whatever external writes the rewound steps did.
|
|
121
|
+
*/
|
|
122
|
+
const store = make();
|
|
123
|
+
await store.create({ tenantId: T1, execution: execution() });
|
|
124
|
+
await store.save({ tenantId: T1, execution: execution({ steps: 5, currentStep: "e" }) });
|
|
125
|
+
await store.save({ tenantId: T1, execution: execution({ steps: 2, currentStep: "b" }) });
|
|
126
|
+
const read = await store.get({ tenantId: T1, executionId: "exec-1" });
|
|
127
|
+
expect(read?.steps).toBe(5);
|
|
128
|
+
expect(read?.currentStep).toBe("e");
|
|
129
|
+
});
|
|
130
|
+
it("accepts a save at the same step count, because a status can change without progress", async () => {
|
|
131
|
+
// Parking on a question completes no step. Rejecting an equal count would make "waiting" unrecordable.
|
|
132
|
+
const store = make();
|
|
133
|
+
await store.create({ tenantId: T1, execution: execution({ steps: 2 }) });
|
|
134
|
+
await store.save({ tenantId: T1, execution: execution({ steps: 2, status: "waiting", waitingSignal: "paid" }) });
|
|
135
|
+
expect((await store.get({ tenantId: T1, executionId: "exec-1" }))?.status).toBe("waiting");
|
|
136
|
+
});
|
|
137
|
+
it("does not resolve another tenant's execution", async () => {
|
|
138
|
+
const store = make();
|
|
139
|
+
await store.create({ tenantId: T1, execution: execution() });
|
|
140
|
+
expect(await store.get({ tenantId: T2, executionId: "exec-1" })).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
it("finds executions parked on a signal, and only those", async () => {
|
|
143
|
+
const store = make();
|
|
144
|
+
await store.create({ tenantId: T1, execution: execution({ id: "waiting", status: "waiting", waitingSignal: "invoice.paid" }) });
|
|
145
|
+
await store.create({ tenantId: T1, execution: execution({ id: "other-signal", status: "waiting", waitingSignal: "shipped" }) });
|
|
146
|
+
// Running, not waiting: a signal must not resume something that never asked for it.
|
|
147
|
+
await store.create({ tenantId: T1, execution: execution({ id: "running", status: "running", waitingSignal: "invoice.paid" }) });
|
|
148
|
+
const found = await store.waitingOnSignal({ tenantId: T1, signal: "invoice.paid" });
|
|
149
|
+
expect(found.map((e) => e.id)).toEqual(["waiting"]);
|
|
150
|
+
});
|
|
151
|
+
it("finds the execution parked on a child run, and only while it is waiting", async () => {
|
|
152
|
+
// #202. A settled run has to find its parent, and a stale id on a *running* execution is not a parent
|
|
153
|
+
// waiting for anything — resuming it would advance a flow that is already advancing.
|
|
154
|
+
const store = make();
|
|
155
|
+
await store.create({
|
|
156
|
+
tenantId: T1,
|
|
157
|
+
execution: execution({ id: "parked", status: "waiting", waitingRunId: asId("child-1") }),
|
|
158
|
+
});
|
|
159
|
+
await store.create({
|
|
160
|
+
tenantId: T1,
|
|
161
|
+
execution: execution({ id: "running", status: "running", waitingRunId: asId("child-2") }),
|
|
162
|
+
});
|
|
163
|
+
expect((await store.waitingOnRun({ tenantId: T1, runId: asId("child-1") }))?.id).toBe("parked");
|
|
164
|
+
expect(await store.waitingOnRun({ tenantId: T1, runId: asId("child-2") })).toBeNull();
|
|
165
|
+
expect(await store.waitingOnRun({ tenantId: T1, runId: asId("nobody") })).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
it("does not resolve another tenant's parked execution by child run", async () => {
|
|
168
|
+
const store = make();
|
|
169
|
+
await store.create({
|
|
170
|
+
tenantId: T1,
|
|
171
|
+
execution: execution({ status: "waiting", waitingRunId: asId("child-1") }),
|
|
172
|
+
});
|
|
173
|
+
expect(await store.waitingOnRun({ tenantId: T2, runId: asId("child-1") })).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
it("lists a flow's executions, newest first", async () => {
|
|
176
|
+
const store = make();
|
|
177
|
+
await store.create({ tenantId: T1, execution: execution({ id: "old", startedAt: "2026-08-01T00:00:00.000Z" }) });
|
|
178
|
+
await store.create({ tenantId: T1, execution: execution({ id: "new", startedAt: "2026-08-20T00:00:00.000Z" }) });
|
|
179
|
+
await store.create({ tenantId: T1, execution: execution({ id: "elsewhere", flowId: "other" }) });
|
|
180
|
+
const listed = await store.listByFlow({ tenantId: T1, flowId: "onboard", limit: 50 });
|
|
181
|
+
expect(listed.items.map((e) => e.id)).toEqual(["new", "old"]);
|
|
182
|
+
});
|
|
183
|
+
it("keeps the whole execution document, so an inspector can read the state", async () => {
|
|
184
|
+
// #187 AC-7. A stored execution nobody can read the state of is not inspectable.
|
|
185
|
+
const store = make();
|
|
186
|
+
const state = { order: { id: "o1", total: 42 }, approved: true };
|
|
187
|
+
await store.create({ tenantId: T1, execution: execution({ execution: { state } }) });
|
|
188
|
+
const read = await store.get({ tenantId: T1, executionId: "exec-1" });
|
|
189
|
+
expect((read?.execution).state).toEqual(state);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=flows.js.map
|