@voltro/testing 0.1.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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +4596 -0
- package/dist/dialect.d.ts +51 -0
- package/dist/dialect.js +363 -0
- package/dist/index.d.ts +202 -0
- package/dist/index.js +237 -0
- package/package.json +52 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { applySchema } from '@voltro/database/sql';
|
|
2
|
+
import { DataStore } from '@voltro/database';
|
|
3
|
+
import { SqlDialect } from '@voltro/database';
|
|
4
|
+
|
|
5
|
+
export { applySchema }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-dialect fixture passed into the harness. `setup` runs before
|
|
9
|
+
* each scenario, `teardown` after. Both are responsible for whatever
|
|
10
|
+
* the dialect needs to give the scenario a clean slate — temp-file
|
|
11
|
+
* sqlite, scratch docker postgres, etc.
|
|
12
|
+
*
|
|
13
|
+
* `make` returns the live `DataStore` for the scenario. Most fixtures
|
|
14
|
+
* will close the store + drop the scratch state in `teardown`.
|
|
15
|
+
*/
|
|
16
|
+
export declare interface DialectFixture {
|
|
17
|
+
readonly dialect: SqlDialect;
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly setup: () => Promise<void>;
|
|
20
|
+
readonly teardown: () => Promise<void>;
|
|
21
|
+
readonly make: () => Promise<DialectFixtureSession>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export declare interface DialectFixtureSession {
|
|
25
|
+
readonly store: DataStore;
|
|
26
|
+
/** Apply `applySchema(tables, dialect.id)` against the same runtime
|
|
27
|
+
* the store is using, so DDL and DML hit the same database. The
|
|
28
|
+
* fixture knows how (most dialects expose a `run` method on their
|
|
29
|
+
* store; the fixture forwards). */
|
|
30
|
+
readonly migrate: (tables: ReadonlyArray<unknown>) => Promise<void>;
|
|
31
|
+
/** Tear down the session (close store, drop scratch DB, etc.). */
|
|
32
|
+
readonly dispose: () => Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register the dialect's parity suite with vitest. Calls
|
|
37
|
+
* `describe(...)` so the dialect's name appears in test output.
|
|
38
|
+
*
|
|
39
|
+
* Caller imports this from `@voltro/testing` inside its
|
|
40
|
+
* `__tests__/parity.test.ts` and supplies a fixture. The fixture's
|
|
41
|
+
* lifecycle hooks (`setup` / `teardown` / `make.dispose`) coordinate
|
|
42
|
+
* docker / temp-file state.
|
|
43
|
+
*
|
|
44
|
+
* Returning early when the fixture's `setup` throws keeps CI green on
|
|
45
|
+
* machines without docker — the fixture reports a clean skip via its
|
|
46
|
+
* own logic before calling `runDialectParity`. We don't soft-skip here
|
|
47
|
+
* because that would mask real fixture bugs.
|
|
48
|
+
*/
|
|
49
|
+
export declare const runDialectParity: (fixture: DialectFixture) => void;
|
|
50
|
+
|
|
51
|
+
export { }
|
package/dist/dialect.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { boolean as e, eq as t, id as n, integer as r, table as i, text as a, timestamp as o } from "@voltro/database";
|
|
2
|
+
import { _voltroUndoLogTable as s, applyInverses as c, synthesizeInverse as l } from "@voltro/runtime";
|
|
3
|
+
import { afterEach as u, beforeEach as d, describe as f, expect as p, it as m } from "vitest";
|
|
4
|
+
import { applySchema as h } from "@voltro/database/sql";
|
|
5
|
+
//#region src/dialectParity.ts
|
|
6
|
+
var g = (e) => ({
|
|
7
|
+
table: e,
|
|
8
|
+
predicate: void 0,
|
|
9
|
+
order: [],
|
|
10
|
+
take: void 0,
|
|
11
|
+
skip: void 0,
|
|
12
|
+
projection: void 0
|
|
13
|
+
}), _ = i("voltro_parity_a", {
|
|
14
|
+
id: n(),
|
|
15
|
+
title: a(),
|
|
16
|
+
count: r()
|
|
17
|
+
}), v = i("voltro_parity_b", {
|
|
18
|
+
id: n(),
|
|
19
|
+
title: a()
|
|
20
|
+
}), y = i("voltro_parity_c", {
|
|
21
|
+
id: n(),
|
|
22
|
+
title: a(),
|
|
23
|
+
count: r(),
|
|
24
|
+
flag: e().default(!1)
|
|
25
|
+
}), b = i("voltro_parity_d", {
|
|
26
|
+
id: n(),
|
|
27
|
+
title: a(),
|
|
28
|
+
at: o().nullable(),
|
|
29
|
+
n: r().nullable()
|
|
30
|
+
}), x = (e) => {
|
|
31
|
+
f(`dialect parity: ${e.name ?? e.dialect.id}`, () => {
|
|
32
|
+
let n = null;
|
|
33
|
+
d(async () => {
|
|
34
|
+
await e.setup(), n = await e.make(), await n.migrate([
|
|
35
|
+
_,
|
|
36
|
+
v,
|
|
37
|
+
y,
|
|
38
|
+
b,
|
|
39
|
+
s
|
|
40
|
+
]);
|
|
41
|
+
for (let e of [
|
|
42
|
+
"voltro_parity_a",
|
|
43
|
+
"voltro_parity_b",
|
|
44
|
+
"voltro_parity_c",
|
|
45
|
+
"voltro_parity_d",
|
|
46
|
+
"_voltro_undo_log"
|
|
47
|
+
]) {
|
|
48
|
+
let t = await n.store.query(g(e));
|
|
49
|
+
for (let r of t) await n.store.delete(e, r.id);
|
|
50
|
+
}
|
|
51
|
+
}), u(async () => {
|
|
52
|
+
await n?.dispose(), await e.teardown(), n = null;
|
|
53
|
+
}), m("DDL is idempotent — re-applying the schema is a no-op", async () => {
|
|
54
|
+
await n.migrate([_, v]);
|
|
55
|
+
}), m("insert + query round-trips a row", async () => {
|
|
56
|
+
p(await n.store.insert("voltro_parity_a", {
|
|
57
|
+
id: "a1",
|
|
58
|
+
title: "one",
|
|
59
|
+
count: 1
|
|
60
|
+
})).toMatchObject({
|
|
61
|
+
id: "a1",
|
|
62
|
+
title: "one",
|
|
63
|
+
count: 1
|
|
64
|
+
});
|
|
65
|
+
let e = await n.store.query(g("voltro_parity_a"));
|
|
66
|
+
p(e.length).toBe(1), p(e[0]?.id).toBe("a1");
|
|
67
|
+
}), m("update returns the post-image", async () => {
|
|
68
|
+
await n.store.insert("voltro_parity_a", {
|
|
69
|
+
id: "a2",
|
|
70
|
+
title: "pre",
|
|
71
|
+
count: 1
|
|
72
|
+
}), p(await n.store.update("voltro_parity_a", "a2", { title: "post" })).toMatchObject({
|
|
73
|
+
id: "a2",
|
|
74
|
+
title: "post"
|
|
75
|
+
});
|
|
76
|
+
}), m("delete removes the row + reports true", async () => {
|
|
77
|
+
await n.store.insert("voltro_parity_a", {
|
|
78
|
+
id: "a3",
|
|
79
|
+
title: "x",
|
|
80
|
+
count: 1
|
|
81
|
+
}), p(await n.store.delete("voltro_parity_a", "a3")).toBe(!0), p((await n.store.query(g("voltro_parity_a"))).length).toBe(0);
|
|
82
|
+
}), m("undo: the _voltro_undo_log TEXT changes column round-trips a JSON ChangeSet", async () => {
|
|
83
|
+
let e = [{
|
|
84
|
+
table: "voltro_parity_c",
|
|
85
|
+
op: "update",
|
|
86
|
+
id: "c1",
|
|
87
|
+
prev: {
|
|
88
|
+
id: "c1",
|
|
89
|
+
title: "t",
|
|
90
|
+
count: 1,
|
|
91
|
+
flag: !0
|
|
92
|
+
},
|
|
93
|
+
next: {
|
|
94
|
+
id: "c1",
|
|
95
|
+
title: "t",
|
|
96
|
+
count: 2,
|
|
97
|
+
flag: !1
|
|
98
|
+
}
|
|
99
|
+
}];
|
|
100
|
+
await n.store.insert("_voltro_undo_log", {
|
|
101
|
+
id: "undo_p1",
|
|
102
|
+
tag: "parityC.update",
|
|
103
|
+
label: null,
|
|
104
|
+
subjectId: null,
|
|
105
|
+
tenantId: null,
|
|
106
|
+
traceId: null,
|
|
107
|
+
changes: JSON.stringify(e),
|
|
108
|
+
crossesAction: !1,
|
|
109
|
+
undone: !1,
|
|
110
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
111
|
+
});
|
|
112
|
+
let t = await n.store.query(g("_voltro_undo_log"));
|
|
113
|
+
p(t.length).toBe(1), p(JSON.parse(t[0].changes)).toEqual(e);
|
|
114
|
+
}), m("undo: reverts an INSERT (inverse delete) on this dialect", async () => {
|
|
115
|
+
let e = {
|
|
116
|
+
invocationId: "inv1",
|
|
117
|
+
subject: null,
|
|
118
|
+
changes: [{
|
|
119
|
+
table: "voltro_parity_c",
|
|
120
|
+
op: "insert",
|
|
121
|
+
next: await n.store.insert("voltro_parity_c", {
|
|
122
|
+
id: "c2",
|
|
123
|
+
title: "new",
|
|
124
|
+
count: 1,
|
|
125
|
+
flag: !0
|
|
126
|
+
})
|
|
127
|
+
}]
|
|
128
|
+
};
|
|
129
|
+
await c(n.store, l(e)), p((await n.store.query(g("voltro_parity_c"))).find((e) => e.id === "c2")).toBeUndefined();
|
|
130
|
+
}), m("undo: reverts an UPDATE including the BOOLEAN, restoring the captured value FAITHFULLY", async () => {
|
|
131
|
+
await n.store.insert("voltro_parity_c", {
|
|
132
|
+
id: "c3",
|
|
133
|
+
title: "t",
|
|
134
|
+
count: 1,
|
|
135
|
+
flag: !0
|
|
136
|
+
});
|
|
137
|
+
let e = (await n.store.query({
|
|
138
|
+
...g("voltro_parity_c"),
|
|
139
|
+
predicate: t("id", "c3")
|
|
140
|
+
}))[0], r = await n.store.update("voltro_parity_c", "c3", {
|
|
141
|
+
count: 2,
|
|
142
|
+
flag: !1
|
|
143
|
+
});
|
|
144
|
+
p(r.flag).not.toEqual(e.flag);
|
|
145
|
+
let i = {
|
|
146
|
+
invocationId: "inv2",
|
|
147
|
+
subject: null,
|
|
148
|
+
changes: [{
|
|
149
|
+
table: "voltro_parity_c",
|
|
150
|
+
op: "update",
|
|
151
|
+
id: "c3",
|
|
152
|
+
prev: e,
|
|
153
|
+
next: r
|
|
154
|
+
}]
|
|
155
|
+
};
|
|
156
|
+
await c(n.store, l(i));
|
|
157
|
+
let a = (await n.store.query({
|
|
158
|
+
...g("voltro_parity_c"),
|
|
159
|
+
predicate: t("id", "c3")
|
|
160
|
+
}))[0];
|
|
161
|
+
p(a.flag).toEqual(e.flag), p(a.count).toEqual(e.count);
|
|
162
|
+
}), m("undo: reverts a DELETE (inverse re-insert) on this dialect", async () => {
|
|
163
|
+
let e = await n.store.insert("voltro_parity_c", {
|
|
164
|
+
id: "c4",
|
|
165
|
+
title: "gone",
|
|
166
|
+
count: 9,
|
|
167
|
+
flag: !0
|
|
168
|
+
});
|
|
169
|
+
await n.store.delete("voltro_parity_c", "c4");
|
|
170
|
+
let r = {
|
|
171
|
+
invocationId: "inv3",
|
|
172
|
+
subject: null,
|
|
173
|
+
changes: [{
|
|
174
|
+
table: "voltro_parity_c",
|
|
175
|
+
op: "delete",
|
|
176
|
+
id: "c4",
|
|
177
|
+
prev: e
|
|
178
|
+
}]
|
|
179
|
+
};
|
|
180
|
+
await c(n.store, l(r));
|
|
181
|
+
let i = (await n.store.query({
|
|
182
|
+
...g("voltro_parity_c"),
|
|
183
|
+
predicate: t("id", "c4")
|
|
184
|
+
}))[0];
|
|
185
|
+
p(i.id).toBe("c4"), p(i.title).toBe("gone"), p(i.flag).toEqual(e.flag);
|
|
186
|
+
}), m("onChange fires insert + update + delete events", async () => {
|
|
187
|
+
let e = [], t = n.store.onChange((t) => e.push(t.op));
|
|
188
|
+
await n.store.insert("voltro_parity_a", {
|
|
189
|
+
id: "a4",
|
|
190
|
+
title: "a",
|
|
191
|
+
count: 1
|
|
192
|
+
}), await n.store.update("voltro_parity_a", "a4", { title: "b" }), await n.store.delete("voltro_parity_a", "a4"), t(), p(e).toEqual([
|
|
193
|
+
"insert",
|
|
194
|
+
"update",
|
|
195
|
+
"delete"
|
|
196
|
+
]);
|
|
197
|
+
}), m("transactional rolls back on throw + drops queued events", async () => {
|
|
198
|
+
let e = [], t = n.store.onChange((t) => e.push(t.op));
|
|
199
|
+
await p(n.store.transactional(async (e) => {
|
|
200
|
+
throw await e.insert("voltro_parity_a", {
|
|
201
|
+
id: "rollback",
|
|
202
|
+
title: "r",
|
|
203
|
+
count: 0
|
|
204
|
+
}), Error("intentional rollback");
|
|
205
|
+
})).rejects.toThrow("intentional rollback"), t(), p(e).toEqual([]), p((await n.store.query(g("voltro_parity_a"))).length).toBe(0);
|
|
206
|
+
}), m("transactional commit drains queued events in order", async () => {
|
|
207
|
+
let e = [], t = n.store.onChange((t) => e.push(t.op));
|
|
208
|
+
await n.store.transactional(async (e) => {
|
|
209
|
+
await e.insert("voltro_parity_a", {
|
|
210
|
+
id: "commit1",
|
|
211
|
+
title: "a",
|
|
212
|
+
count: 1
|
|
213
|
+
}), await e.insert("voltro_parity_a", {
|
|
214
|
+
id: "commit2",
|
|
215
|
+
title: "b",
|
|
216
|
+
count: 2
|
|
217
|
+
});
|
|
218
|
+
}), t(), p(e).toEqual(["insert", "insert"]);
|
|
219
|
+
}), m("upsert inserts a new row, then updates on conflict — no duplicate", async () => {
|
|
220
|
+
await n.store.upsert("voltro_parity_a", {
|
|
221
|
+
id: "u1",
|
|
222
|
+
title: "first",
|
|
223
|
+
count: 1
|
|
224
|
+
}, { conflictColumns: ["id"] }), await n.store.upsert("voltro_parity_a", {
|
|
225
|
+
id: "u1",
|
|
226
|
+
title: "second",
|
|
227
|
+
count: 2
|
|
228
|
+
}, {
|
|
229
|
+
conflictColumns: ["id"],
|
|
230
|
+
update: ["title", "count"]
|
|
231
|
+
});
|
|
232
|
+
let e = await n.store.query(g("voltro_parity_a"));
|
|
233
|
+
p(e.length).toBe(1), p(e[0]).toMatchObject({
|
|
234
|
+
id: "u1",
|
|
235
|
+
title: "second",
|
|
236
|
+
count: 2
|
|
237
|
+
});
|
|
238
|
+
}), m("insert + update bind an explicit NULL into a datetime2/int column", async () => {
|
|
239
|
+
p(await n.store.insert("voltro_parity_d", {
|
|
240
|
+
id: "nd1",
|
|
241
|
+
title: "nulls",
|
|
242
|
+
at: null,
|
|
243
|
+
n: null
|
|
244
|
+
})).toMatchObject({
|
|
245
|
+
id: "nd1",
|
|
246
|
+
title: "nulls",
|
|
247
|
+
at: null,
|
|
248
|
+
n: null
|
|
249
|
+
}), await n.store.insert("voltro_parity_d", {
|
|
250
|
+
id: "nd2",
|
|
251
|
+
title: "set",
|
|
252
|
+
at: /* @__PURE__ */ new Date(),
|
|
253
|
+
n: 7
|
|
254
|
+
}), p(await n.store.update("voltro_parity_d", "nd2", {
|
|
255
|
+
at: null,
|
|
256
|
+
n: null
|
|
257
|
+
})).toMatchObject({
|
|
258
|
+
id: "nd2",
|
|
259
|
+
at: null,
|
|
260
|
+
n: null
|
|
261
|
+
});
|
|
262
|
+
}), m("updateMany updates only the rows matching the predicate + returns the count", async () => {
|
|
263
|
+
await n.store.insert("voltro_parity_a", {
|
|
264
|
+
id: "m1",
|
|
265
|
+
title: "x",
|
|
266
|
+
count: 5
|
|
267
|
+
}), await n.store.insert("voltro_parity_a", {
|
|
268
|
+
id: "m2",
|
|
269
|
+
title: "y",
|
|
270
|
+
count: 5
|
|
271
|
+
}), await n.store.insert("voltro_parity_a", {
|
|
272
|
+
id: "m3",
|
|
273
|
+
title: "z",
|
|
274
|
+
count: 9
|
|
275
|
+
}), p(await n.store.updateMany("voltro_parity_a", { title: "hit" }, { where: {
|
|
276
|
+
column: "count",
|
|
277
|
+
op: "eq",
|
|
278
|
+
value: 5
|
|
279
|
+
} })).toBe(2), p((await n.store.query({
|
|
280
|
+
...g("voltro_parity_a"),
|
|
281
|
+
predicate: {
|
|
282
|
+
column: "title",
|
|
283
|
+
op: "eq",
|
|
284
|
+
value: "hit"
|
|
285
|
+
}
|
|
286
|
+
})).length).toBe(2);
|
|
287
|
+
}), m("updateMany with an AND predicate is an atomic compare-and-set (the wakeup-claim pattern)", async () => {
|
|
288
|
+
await n.store.insert("voltro_parity_a", {
|
|
289
|
+
id: "cas",
|
|
290
|
+
title: "pending",
|
|
291
|
+
count: 1
|
|
292
|
+
});
|
|
293
|
+
let e = (e) => ({ and: [{
|
|
294
|
+
column: "id",
|
|
295
|
+
op: "eq",
|
|
296
|
+
value: "cas"
|
|
297
|
+
}, {
|
|
298
|
+
column: "title",
|
|
299
|
+
op: "eq",
|
|
300
|
+
value: e
|
|
301
|
+
}] });
|
|
302
|
+
p(await n.store.updateMany("voltro_parity_a", { title: "claimed" }, { where: e("pending") })).toBe(1), p(await n.store.updateMany("voltro_parity_a", { title: "claimed-again" }, { where: e("pending") })).toBe(0);
|
|
303
|
+
}), m("deleteMany removes only the rows matching the predicate + returns the count", async () => {
|
|
304
|
+
await n.store.insert("voltro_parity_a", {
|
|
305
|
+
id: "d1",
|
|
306
|
+
title: "x",
|
|
307
|
+
count: 5
|
|
308
|
+
}), await n.store.insert("voltro_parity_a", {
|
|
309
|
+
id: "d2",
|
|
310
|
+
title: "y",
|
|
311
|
+
count: 5
|
|
312
|
+
}), await n.store.insert("voltro_parity_a", {
|
|
313
|
+
id: "d3",
|
|
314
|
+
title: "z",
|
|
315
|
+
count: 9
|
|
316
|
+
}), p(await n.store.deleteMany("voltro_parity_a", { where: {
|
|
317
|
+
column: "count",
|
|
318
|
+
op: "eq",
|
|
319
|
+
value: 5
|
|
320
|
+
} })).toBe(2), p((await n.store.query(g("voltro_parity_a"))).map((e) => e.id)).toEqual(["d3"]);
|
|
321
|
+
}), m("deleteMany emits one delete ChangeEvent per removed row (old-image)", async () => {
|
|
322
|
+
await n.store.insert("voltro_parity_a", {
|
|
323
|
+
id: "e1",
|
|
324
|
+
title: "gone",
|
|
325
|
+
count: 1
|
|
326
|
+
}), await n.store.insert("voltro_parity_a", {
|
|
327
|
+
id: "e2",
|
|
328
|
+
title: "gone",
|
|
329
|
+
count: 1
|
|
330
|
+
}), await n.store.insert("voltro_parity_a", {
|
|
331
|
+
id: "e3",
|
|
332
|
+
title: "stay",
|
|
333
|
+
count: 2
|
|
334
|
+
});
|
|
335
|
+
let e = [], t = n.store.onChange((t) => e.push({
|
|
336
|
+
op: t.op,
|
|
337
|
+
id: t.old?.id
|
|
338
|
+
})), r = await n.store.deleteMany("voltro_parity_a", { where: {
|
|
339
|
+
column: "title",
|
|
340
|
+
op: "eq",
|
|
341
|
+
value: "gone"
|
|
342
|
+
} });
|
|
343
|
+
t(), p(r).toBe(2);
|
|
344
|
+
let i = e.filter((e) => e.op === "delete");
|
|
345
|
+
p(i).toHaveLength(2), p(i.map((e) => e.id).sort()).toEqual(["e1", "e2"]);
|
|
346
|
+
}), m("deleteMany with no match returns 0 and removes nothing", async () => {
|
|
347
|
+
await n.store.insert("voltro_parity_a", {
|
|
348
|
+
id: "k1",
|
|
349
|
+
title: "keep",
|
|
350
|
+
count: 1
|
|
351
|
+
}), p(await n.store.deleteMany("voltro_parity_a", { where: {
|
|
352
|
+
column: "title",
|
|
353
|
+
op: "eq",
|
|
354
|
+
value: "absent"
|
|
355
|
+
} })).toBe(0), p((await n.store.query(g("voltro_parity_a"))).length).toBe(1);
|
|
356
|
+
}), m("retryFilter recognises the dialect's own transient codes", () => {
|
|
357
|
+
let t = e.dialect.retryFilter(/* @__PURE__ */ Error("garbage"));
|
|
358
|
+
p(["retry", "noRetry"]).toContain(t);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
//#endregion
|
|
363
|
+
export { h as applySchema, x as runDialectParity };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { AppContext } from '@voltro/runtime';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { InspectedStep } from '@voltro/workflow';
|
|
4
|
+
import { InspectedWorkflow } from '@voltro/workflow';
|
|
5
|
+
import { Layer } from 'effect';
|
|
6
|
+
import { ParseResult } from 'effect';
|
|
7
|
+
import { ProcedureDescriptor } from '@voltro/protocol';
|
|
8
|
+
import { Row } from '@voltro/database';
|
|
9
|
+
import { Schema } from 'effect';
|
|
10
|
+
import { Subject } from '@voltro/protocol';
|
|
11
|
+
import { TableLike } from '@voltro/database';
|
|
12
|
+
|
|
13
|
+
/** Effect shape the runner erases a workflow's success/error/requirement
|
|
14
|
+
* channels down to. `unknown` for value/error (covariant — anything
|
|
15
|
+
* assigns), `never` for the requirement (the runner provides the engine
|
|
16
|
+
* layer itself). Callers pass concrete workflows cast via `as never` —
|
|
17
|
+
* `never` assigns to any field type, so the object-literal form stays
|
|
18
|
+
* ergonomic without an `any` in the public type. */
|
|
19
|
+
declare type ErasedWorkflowEffect = Effect.Effect<unknown, unknown, never>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Decode `rawInput` through the procedure descriptor's `input` Schema, then
|
|
23
|
+
* call `executor` with the decoded value and `ctx`. Rejects with a
|
|
24
|
+
* `ParseError` if `rawInput` doesn't satisfy the input Schema — the same
|
|
25
|
+
* validation the live dispatcher runs before your handler sees the input.
|
|
26
|
+
*
|
|
27
|
+
* Only the input-decode hop is reproduced (see the file header): guards /
|
|
28
|
+
* middleware are dispatch-time rpc concerns, not descriptor-carried.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { createNote } from './notes.mutation' // descriptor
|
|
32
|
+
* import { createNoteHandler } from './notes.mutation.server' // executor
|
|
33
|
+
*
|
|
34
|
+
* const ctx = makeTestContext({ subject: user('A') })
|
|
35
|
+
* const note = await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
|
|
36
|
+
* // a bad shape rejects at the decode, before the handler runs:
|
|
37
|
+
* await expect(invoke(createNote, createNoteHandler, { title: 42 }, ctx)).rejects.toThrow()
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const invoke: <D extends Pick<ProcedureDescriptor, "input">, Output>(descriptor: D, executor: ProcedureExecutor<Schema.Schema.Type<D["input"]>, Output>, rawInput: unknown, ctx: TestContext) => Promise<Output>;
|
|
41
|
+
|
|
42
|
+
export declare const makeTestContext: (options?: MakeTestContextOptions) => TestContext;
|
|
43
|
+
|
|
44
|
+
export declare interface MakeTestContextOptions {
|
|
45
|
+
/** The acting subject. Default: anonymous, no tenant. Read it back at
|
|
46
|
+
* `ctx.request.subject` (the same place a live handler reads it). */
|
|
47
|
+
readonly subject?: Subject;
|
|
48
|
+
/** Tables to wire into the store's schema. Default: all globally
|
|
49
|
+
* registered tables (whatever the test imported). */
|
|
50
|
+
readonly tables?: ReadonlyArray<TableLike>;
|
|
51
|
+
/** Seed rows keyed by table name (use `mockStore({...})`). */
|
|
52
|
+
readonly store?: Record<string, ReadonlyArray<Row>>;
|
|
53
|
+
/** Frozen clock start. Default 2026-01-01T00:00:00Z. */
|
|
54
|
+
readonly clockStart?: Date;
|
|
55
|
+
/** Injected AI mock (plan 05's `mockAi({...})`). */
|
|
56
|
+
readonly ai?: MockAi;
|
|
57
|
+
/** Queued LLM responses for the bundled `MockLLM`. */
|
|
58
|
+
readonly llmResponses?: ReadonlyArray<MockResponse>;
|
|
59
|
+
/** Env values sealed into the boot snapshot so handler code that reads
|
|
60
|
+
* `getSecret('X')` / `serverEnv.X` resolves under test. Without a snapshot
|
|
61
|
+
* those accessors throw ("read before the boot env gate ran") because the
|
|
62
|
+
* test harness never runs the real boot gate. Merged OVER `process.env`
|
|
63
|
+
* (these win). Default: just the ambient `process.env`. */
|
|
64
|
+
readonly env?: Record<string, string | undefined>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export declare const makeWorkflowRunner: (opts: MakeWorkflowRunnerOptions) => WorkflowRunner;
|
|
68
|
+
|
|
69
|
+
export declare interface MakeWorkflowRunnerOptions {
|
|
70
|
+
readonly ctx: TestContext;
|
|
71
|
+
readonly workflows?: ReadonlyArray<WorkflowEntry>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Minimal AI mock surface. The full `mockAi({...})` helper is owned by
|
|
75
|
+
* the AI package (plan 05); typed here as an optional injected interface
|
|
76
|
+
* so `@voltro/testing` carries no hard `@voltro/ai` dependency. */
|
|
77
|
+
export declare interface MockAi {
|
|
78
|
+
readonly generate?: (...args: ReadonlyArray<unknown>) => unknown;
|
|
79
|
+
readonly generateObject?: (...args: ReadonlyArray<unknown>) => unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export declare class MockClock {
|
|
83
|
+
private currentMs;
|
|
84
|
+
constructor(initial?: Date | number);
|
|
85
|
+
/** Current mock time as an instant in ms. */
|
|
86
|
+
now(): number;
|
|
87
|
+
/** Current mock time as a Date. */
|
|
88
|
+
date(): Date;
|
|
89
|
+
/** Advance time by a duration (ms / seconds / minutes / hours / days). */
|
|
90
|
+
advance(amount: number | string): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export declare class MockEmail {
|
|
94
|
+
readonly sent: SentEmail[];
|
|
95
|
+
send(email: Omit<SentEmail, 'sentAt'>): void;
|
|
96
|
+
/** Find the most recent email to a recipient, or undefined. */
|
|
97
|
+
lastTo(recipient: string): SentEmail | undefined;
|
|
98
|
+
clear(): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export declare class MockLLM {
|
|
102
|
+
private readonly queue;
|
|
103
|
+
readonly calls: MockLLMCall[];
|
|
104
|
+
constructor(responses: ReadonlyArray<MockResponse>);
|
|
105
|
+
next(call: MockLLMCall): MockResponse;
|
|
106
|
+
remaining(): number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export declare interface MockLLMCall {
|
|
110
|
+
readonly model: string;
|
|
111
|
+
readonly messages: ReadonlyArray<unknown>;
|
|
112
|
+
readonly tools?: ReadonlyArray<string>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export declare type MockResponse = {
|
|
116
|
+
readonly text: string;
|
|
117
|
+
} | {
|
|
118
|
+
readonly toolCall: {
|
|
119
|
+
readonly name: string;
|
|
120
|
+
readonly input: unknown;
|
|
121
|
+
};
|
|
122
|
+
} | {
|
|
123
|
+
readonly error: {
|
|
124
|
+
readonly code: string;
|
|
125
|
+
readonly message?: string;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Seed helper: `makeTestContext({ store: mockStore({ docs: [...] }) })`.
|
|
130
|
+
* Identity over the table→rows map; exists so the call site reads well. */
|
|
131
|
+
export declare const mockStore: (seed: Record<string, ReadonlyArray<Row>>) => Record<string, ReadonlyArray<Row>>;
|
|
132
|
+
|
|
133
|
+
export { ParseResult }
|
|
134
|
+
|
|
135
|
+
/** Any procedure executor: takes the DECODED input + a context, returns a
|
|
136
|
+
* result (sync or async). Mirrors the `(input, ctx) => …` shape a real
|
|
137
|
+
* query / mutation / action handler has. */
|
|
138
|
+
export declare type ProcedureExecutor<Input, Output> = (input: Input, ctx: TestContext) => Output | Promise<Output>;
|
|
139
|
+
|
|
140
|
+
export declare interface SentEmail {
|
|
141
|
+
readonly to: string;
|
|
142
|
+
readonly template: string;
|
|
143
|
+
readonly props?: Readonly<Record<string, unknown>>;
|
|
144
|
+
readonly locale?: string;
|
|
145
|
+
readonly sentAt: Date;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The test context IS an `AppContext` (store + request + cache), plus the
|
|
149
|
+
* deterministic doubles and the subject/tenant re-scopers. Because it
|
|
150
|
+
* extends `AppContext`, you can pass it straight into an executor typed
|
|
151
|
+
* `(input, ctx: AppContext) => …`. */
|
|
152
|
+
export declare interface TestContext extends AppContext {
|
|
153
|
+
readonly clock: MockClock;
|
|
154
|
+
readonly email: MockEmail;
|
|
155
|
+
readonly llm: MockLLM;
|
|
156
|
+
readonly ai?: MockAi;
|
|
157
|
+
/** Re-scope to a different subject for one block (real subject swap, not
|
|
158
|
+
* a closure stub). Shares the underlying data so cross-subject reads
|
|
159
|
+
* exercise real tenant scoping. */
|
|
160
|
+
withSubject<T>(subject: Subject, fn: (ctx: TestContext) => T | Promise<T>): Promise<T>;
|
|
161
|
+
/** Re-scope to a different tenant (keeps the current subject identity). */
|
|
162
|
+
withTenant<T>(tenantId: string, fn: (ctx: TestContext) => T | Promise<T>): Promise<T>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export declare const TESTING_PRESET_VERSION: 1;
|
|
166
|
+
|
|
167
|
+
/** A workflow + its execute function, as the framework pairs them via
|
|
168
|
+
* `workflow.toLayer(execute)`. Structural (no `@effect/workflow` generic
|
|
169
|
+
* signature) so callers can pass the value from `workflow({ name, ... })`
|
|
170
|
+
* plus its `(payload, executionId) => Effect` execute directly — type-erased
|
|
171
|
+
* over the workflow's payload/success/error/requirement shapes. */
|
|
172
|
+
export declare interface WorkflowEntry {
|
|
173
|
+
readonly workflow: {
|
|
174
|
+
readonly name: string;
|
|
175
|
+
readonly execute: (payload: unknown, options?: unknown) => ErasedWorkflowEffect;
|
|
176
|
+
readonly toLayer: (execute: (payload: unknown, executionId: string) => ErasedWorkflowEffect) => Layer.Layer<unknown, unknown, never>;
|
|
177
|
+
};
|
|
178
|
+
readonly execute: (payload: unknown, executionId: string) => ErasedWorkflowEffect;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export declare interface WorkflowRunner {
|
|
182
|
+
/** Start a workflow by its `name` (tag) with a payload and block until
|
|
183
|
+
* terminal. Records every step attempt so `result.steps[i].attempts`
|
|
184
|
+
* reflects real retries. */
|
|
185
|
+
start(workflowName: string, payload: unknown): Promise<WorkflowRunResult>;
|
|
186
|
+
/** Inspect a previously-started run by id without re-running it. Returns
|
|
187
|
+
* `null` for an unknown run id. */
|
|
188
|
+
inspect(runId: string): Promise<InspectedWorkflow | null>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export declare interface WorkflowRunResult {
|
|
192
|
+
readonly status: 'succeeded' | 'failed';
|
|
193
|
+
readonly output: unknown;
|
|
194
|
+
readonly error: {
|
|
195
|
+
readonly tag: string | null;
|
|
196
|
+
readonly message: string;
|
|
197
|
+
} | null;
|
|
198
|
+
readonly steps: ReadonlyArray<InspectedStep>;
|
|
199
|
+
readonly runId: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { }
|