@fragno-dev/test 1.0.1 → 2.0.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/.turbo/turbo-build.log +31 -15
- package/CHANGELOG.md +54 -0
- package/dist/adapters.d.ts +21 -3
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +125 -31
- package/dist/adapters.js.map +1 -1
- package/dist/db-test.d.ts.map +1 -1
- package/dist/db-test.js +33 -2
- package/dist/db-test.js.map +1 -1
- package/dist/durable-hooks.d.ts +7 -0
- package/dist/durable-hooks.d.ts.map +1 -0
- package/dist/durable-hooks.js +12 -0
- package/dist/durable-hooks.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/model-checker-actors.d.ts +41 -0
- package/dist/model-checker-actors.d.ts.map +1 -0
- package/dist/model-checker-actors.js +406 -0
- package/dist/model-checker-actors.js.map +1 -0
- package/dist/model-checker-adapter.d.ts +32 -0
- package/dist/model-checker-adapter.d.ts.map +1 -0
- package/dist/model-checker-adapter.js +109 -0
- package/dist/model-checker-adapter.js.map +1 -0
- package/dist/model-checker.d.ts +128 -0
- package/dist/model-checker.d.ts.map +1 -0
- package/dist/model-checker.js +443 -0
- package/dist/model-checker.js.map +1 -0
- package/package.json +13 -12
- package/src/adapter-conformance.test.ts +322 -0
- package/src/adapters.ts +199 -36
- package/src/db-test.test.ts +2 -2
- package/src/db-test.ts +53 -3
- package/src/durable-hooks.ts +13 -0
- package/src/index.test.ts +84 -7
- package/src/index.ts +39 -4
- package/src/model-checker-actors.test.ts +78 -0
- package/src/model-checker-actors.ts +642 -0
- package/src/model-checker-adapter.ts +200 -0
- package/src/model-checker.test.ts +399 -0
- package/src/model-checker.ts +799 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fragno-dev/test",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"module": "./dist/index.js",
|
|
14
14
|
"types": "./dist/index.d.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@standard-schema/spec": "^1.
|
|
17
|
-
"kysely": "^0.28.
|
|
16
|
+
"@standard-schema/spec": "^1.1.0",
|
|
17
|
+
"kysely": "^0.28.10",
|
|
18
18
|
"sqlocal": "^0.15.2"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"drizzle-kit": "^0.31.7",
|
|
23
23
|
"drizzle-orm": "^0.44.7",
|
|
24
24
|
"kysely-pglite": "^0.6.1",
|
|
25
|
-
"@fragno-dev/core": "0.
|
|
26
|
-
"@fragno-dev/db": "0.
|
|
25
|
+
"@fragno-dev/core": "0.2.0",
|
|
26
|
+
"@fragno-dev/db": "0.3.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependenciesMeta": {
|
|
29
29
|
"@electric-sql/pglite": {
|
|
@@ -40,19 +40,20 @@
|
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@electric-sql/pglite": "^0.3.
|
|
43
|
+
"@electric-sql/pglite": "^0.3.15",
|
|
44
44
|
"@libsql/client": "^0.14.0",
|
|
45
|
-
"@types/node": "^22",
|
|
45
|
+
"@types/node": "^22.19.7",
|
|
46
46
|
"@vitest/coverage-istanbul": "^3.2.4",
|
|
47
|
-
"drizzle-kit": "^0.31.
|
|
47
|
+
"drizzle-kit": "^0.31.8",
|
|
48
48
|
"drizzle-orm": "^0.44.7",
|
|
49
49
|
"kysely-pglite": "^0.6.1",
|
|
50
|
+
"superjson": "^2.2.1",
|
|
50
51
|
"vitest": "^3.2.4",
|
|
51
|
-
"zod": "^4.
|
|
52
|
-
"@fragno-dev/core": "0.
|
|
52
|
+
"zod": "^4.3.5",
|
|
53
|
+
"@fragno-dev/core": "0.2.0",
|
|
54
|
+
"@fragno-dev/db": "0.3.0",
|
|
53
55
|
"@fragno-private/typescript-config": "0.0.1",
|
|
54
|
-
"@fragno-private/vitest-config": "0.0.0"
|
|
55
|
-
"@fragno-dev/db": "0.2.1"
|
|
56
|
+
"@fragno-private/vitest-config": "0.0.0"
|
|
56
57
|
},
|
|
57
58
|
"repository": {
|
|
58
59
|
"type": "git",
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { column, idColumn, referenceColumn, schema } from "@fragno-dev/db/schema";
|
|
3
|
+
import type { SimpleQueryInterface } from "@fragno-dev/db/query";
|
|
4
|
+
import { createAdapter, type SupportedAdapter } from "./adapters";
|
|
5
|
+
|
|
6
|
+
const conformanceSchema = schema("conformance", (s) =>
|
|
7
|
+
s
|
|
8
|
+
.addTable("users", (t) =>
|
|
9
|
+
t
|
|
10
|
+
.addColumn("id", idColumn())
|
|
11
|
+
.addColumn("name", column("string"))
|
|
12
|
+
.addColumn(
|
|
13
|
+
"slug",
|
|
14
|
+
column("string").defaultTo$((b) => b.cuid()),
|
|
15
|
+
)
|
|
16
|
+
.addColumn(
|
|
17
|
+
"createdAt",
|
|
18
|
+
column("timestamp").defaultTo$((b) => b.now()),
|
|
19
|
+
)
|
|
20
|
+
.createIndex("users_by_name", ["name"]),
|
|
21
|
+
)
|
|
22
|
+
.addTable("posts", (t) =>
|
|
23
|
+
t
|
|
24
|
+
.addColumn("id", idColumn())
|
|
25
|
+
.addColumn("title", column("string"))
|
|
26
|
+
.addColumn("authorId", referenceColumn())
|
|
27
|
+
.createIndex("posts_by_author", ["authorId"]),
|
|
28
|
+
)
|
|
29
|
+
.addTable("comments", (t) =>
|
|
30
|
+
t
|
|
31
|
+
.addColumn("id", idColumn())
|
|
32
|
+
.addColumn("postId", referenceColumn())
|
|
33
|
+
.addColumn("authorId", referenceColumn())
|
|
34
|
+
.addColumn("body", column("string"))
|
|
35
|
+
.createIndex("comments_by_post", ["postId"]),
|
|
36
|
+
)
|
|
37
|
+
.addReference("author", {
|
|
38
|
+
type: "one",
|
|
39
|
+
from: { table: "posts", column: "authorId" },
|
|
40
|
+
to: { table: "users", column: "id" },
|
|
41
|
+
})
|
|
42
|
+
.addReference("post", {
|
|
43
|
+
type: "one",
|
|
44
|
+
from: { table: "comments", column: "postId" },
|
|
45
|
+
to: { table: "posts", column: "id" },
|
|
46
|
+
})
|
|
47
|
+
.addReference("commenter", {
|
|
48
|
+
type: "one",
|
|
49
|
+
from: { table: "comments", column: "authorId" },
|
|
50
|
+
to: { table: "users", column: "id" },
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const namespace = "conformance";
|
|
55
|
+
const fixedClock = new Date("2024-01-01T00:00:00.000Z");
|
|
56
|
+
|
|
57
|
+
type AdapterCase = {
|
|
58
|
+
name: string;
|
|
59
|
+
config: SupportedAdapter;
|
|
60
|
+
supportsDeterministicDefaults: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ConformanceDb = SimpleQueryInterface<typeof conformanceSchema>;
|
|
64
|
+
|
|
65
|
+
const adapterCases: AdapterCase[] = [
|
|
66
|
+
{
|
|
67
|
+
name: "in-memory",
|
|
68
|
+
config: {
|
|
69
|
+
type: "in-memory",
|
|
70
|
+
options: { idSeed: "conformance-seed", clock: { now: () => fixedClock } },
|
|
71
|
+
},
|
|
72
|
+
supportsDeterministicDefaults: true,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "kysely-sqlite",
|
|
76
|
+
config: { type: "kysely-sqlite" },
|
|
77
|
+
supportsDeterministicDefaults: false,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
async function withAdapter<T>(config: SupportedAdapter, run: (db: ConformanceDb) => Promise<T>) {
|
|
82
|
+
const { testContext } = await createAdapter(config, [{ schema: conformanceSchema, namespace }]);
|
|
83
|
+
const db = testContext.getOrm<typeof conformanceSchema>(namespace) as ConformanceDb;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return await run(db);
|
|
87
|
+
} finally {
|
|
88
|
+
await testContext.cleanup();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const adapterCase of adapterCases) {
|
|
93
|
+
describe(`adapter conformance (${adapterCase.name})`, () => {
|
|
94
|
+
it("enforces optimistic checks and version bumps", async () => {
|
|
95
|
+
await withAdapter(adapterCase.config, async (db) => {
|
|
96
|
+
const createUow = db.createUnitOfWork("create-user");
|
|
97
|
+
createUow.create("users", { name: "Ada" });
|
|
98
|
+
const createResult = await createUow.executeMutations();
|
|
99
|
+
expect(createResult.success).toBe(true);
|
|
100
|
+
|
|
101
|
+
const createdId = createUow.getCreatedIds()[0];
|
|
102
|
+
expect(createdId).toBeDefined();
|
|
103
|
+
|
|
104
|
+
const updateUow = db.createUnitOfWork("update-user");
|
|
105
|
+
updateUow.update("users", createdId!, (b) => b.set({ name: "Ada Lovelace" }).check());
|
|
106
|
+
const updateResult = await updateUow.executeMutations();
|
|
107
|
+
expect(updateResult.success).toBe(true);
|
|
108
|
+
|
|
109
|
+
const [[updatedUser]] = await db
|
|
110
|
+
.createUnitOfWork("verify-update")
|
|
111
|
+
.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", createdId!)))
|
|
112
|
+
.executeRetrieve();
|
|
113
|
+
|
|
114
|
+
expect(updatedUser).toMatchObject({
|
|
115
|
+
name: "Ada Lovelace",
|
|
116
|
+
id: expect.objectContaining({ version: 1 }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const staleUow = db.createUnitOfWork("stale-update");
|
|
120
|
+
staleUow.update("users", createdId!, (b) => b.set({ name: "Ada Byron" }).check());
|
|
121
|
+
const staleResult = await staleUow.executeMutations();
|
|
122
|
+
expect(staleResult.success).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("paginates with cursors on ordered indexes", async () => {
|
|
127
|
+
await withAdapter(adapterCase.config, async (db) => {
|
|
128
|
+
const users = ["Ada", "Brett", "Cora", "Dylan", "Emma"];
|
|
129
|
+
for (const name of users) {
|
|
130
|
+
await db.create("users", { name });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const firstPage = await db.findWithCursor("users", (b) =>
|
|
134
|
+
b.whereIndex("users_by_name").orderByIndex("users_by_name", "asc").pageSize(2),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(firstPage.items.map((user) => user.name)).toEqual(["Ada", "Brett"]);
|
|
138
|
+
expect(firstPage.hasNextPage).toBe(true);
|
|
139
|
+
expect(firstPage.cursor).toBeDefined();
|
|
140
|
+
|
|
141
|
+
const secondPage = await db.findWithCursor("users", (b) =>
|
|
142
|
+
b
|
|
143
|
+
.whereIndex("users_by_name")
|
|
144
|
+
.orderByIndex("users_by_name", "asc")
|
|
145
|
+
.pageSize(2)
|
|
146
|
+
.after(firstPage.cursor!),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(secondPage.items.map((user) => user.name)).toEqual(["Cora", "Dylan"]);
|
|
150
|
+
expect(secondPage.hasNextPage).toBe(true);
|
|
151
|
+
expect(secondPage.cursor).toBeDefined();
|
|
152
|
+
|
|
153
|
+
const thirdPage = await db.findWithCursor("users", (b) =>
|
|
154
|
+
b
|
|
155
|
+
.whereIndex("users_by_name")
|
|
156
|
+
.orderByIndex("users_by_name", "asc")
|
|
157
|
+
.pageSize(2)
|
|
158
|
+
.after(secondPage.cursor!),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(thirdPage.items.map((user) => user.name)).toEqual(["Emma"]);
|
|
162
|
+
expect(thirdPage.hasNextPage).toBe(false);
|
|
163
|
+
expect(thirdPage.cursor).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("executes nested joins consistently", async () => {
|
|
168
|
+
await withAdapter(adapterCase.config, async (db) => {
|
|
169
|
+
const authorId = await db.create("users", { name: "Author" });
|
|
170
|
+
const commenterId = await db.create("users", { name: "Commenter" });
|
|
171
|
+
const postId = await db.create("posts", { title: "Hello", authorId });
|
|
172
|
+
|
|
173
|
+
await db.create("comments", {
|
|
174
|
+
postId,
|
|
175
|
+
authorId: commenterId,
|
|
176
|
+
body: "Nice post",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const comments = await db.find("comments", (b) =>
|
|
180
|
+
b
|
|
181
|
+
.whereIndex("primary")
|
|
182
|
+
.join((jb) =>
|
|
183
|
+
jb
|
|
184
|
+
.post((pb) =>
|
|
185
|
+
pb.select(["title"]).join((jb2) => jb2.author((ab) => ab.select(["name"]))),
|
|
186
|
+
)
|
|
187
|
+
.commenter((cb) => cb.select(["name"])),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(comments).toHaveLength(1);
|
|
192
|
+
expect(comments[0]).toMatchObject({
|
|
193
|
+
body: "Nice post",
|
|
194
|
+
post: {
|
|
195
|
+
title: "Hello",
|
|
196
|
+
author: { name: "Author" },
|
|
197
|
+
},
|
|
198
|
+
commenter: { name: "Commenter" },
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (adapterCase.supportsDeterministicDefaults) {
|
|
204
|
+
it("generates deterministic ids and clocks runtime defaults", async () => {
|
|
205
|
+
const createDeterministicConfig = (): SupportedAdapter => {
|
|
206
|
+
let counter = 0;
|
|
207
|
+
return {
|
|
208
|
+
type: "in-memory",
|
|
209
|
+
options: {
|
|
210
|
+
clock: { now: () => fixedClock },
|
|
211
|
+
idGenerator: () => `seeded-${counter++}`,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const { testContext: firstContext } = await createAdapter(createDeterministicConfig(), [
|
|
217
|
+
{ schema: conformanceSchema, namespace },
|
|
218
|
+
]);
|
|
219
|
+
const { testContext: secondContext } = await createAdapter(createDeterministicConfig(), [
|
|
220
|
+
{ schema: conformanceSchema, namespace },
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const firstDb = firstContext.getOrm<typeof conformanceSchema>(namespace);
|
|
225
|
+
const secondDb = secondContext.getOrm<typeof conformanceSchema>(namespace);
|
|
226
|
+
|
|
227
|
+
const firstId = await firstDb.create("users", { id: "seeded-user", name: "Seeded" });
|
|
228
|
+
const secondId = await secondDb.create("users", { id: "seeded-user", name: "Seeded" });
|
|
229
|
+
|
|
230
|
+
expect(firstId.externalId).toBe(secondId.externalId);
|
|
231
|
+
|
|
232
|
+
const createdUser = await firstDb.findFirst("users", (b) =>
|
|
233
|
+
b.whereIndex("primary", (eb) => eb("id", "=", firstId)),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(createdUser?.slug).toBe("seeded-0");
|
|
237
|
+
expect(createdUser?.createdAt?.toISOString()).toBe(fixedClock.toISOString());
|
|
238
|
+
} finally {
|
|
239
|
+
await firstContext.cleanup();
|
|
240
|
+
await secondContext.cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
describe("adapter conformance (kysely-sqlite instrumentation)", () => {
|
|
248
|
+
it("invokes instrumentation hooks in order", async () => {
|
|
249
|
+
const calls: string[] = [];
|
|
250
|
+
const { testContext } = await createAdapter(
|
|
251
|
+
{
|
|
252
|
+
type: "kysely-sqlite",
|
|
253
|
+
uowConfig: {
|
|
254
|
+
instrumentation: {
|
|
255
|
+
beforeRetrieve: () => {
|
|
256
|
+
calls.push("beforeRetrieve");
|
|
257
|
+
},
|
|
258
|
+
afterRetrieve: () => {
|
|
259
|
+
calls.push("afterRetrieve");
|
|
260
|
+
},
|
|
261
|
+
beforeMutate: () => {
|
|
262
|
+
calls.push("beforeMutate");
|
|
263
|
+
},
|
|
264
|
+
afterMutate: () => {
|
|
265
|
+
calls.push("afterMutate");
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
[{ schema: conformanceSchema, namespace }],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const db = testContext.getOrm<typeof conformanceSchema>(namespace);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const uow = db.createUnitOfWork("instrumented");
|
|
277
|
+
uow.find("users", (b) => b.whereIndex("primary"));
|
|
278
|
+
uow.create("users", { name: "Instrumented" });
|
|
279
|
+
|
|
280
|
+
await uow.executeRetrieve();
|
|
281
|
+
await uow.executeMutations();
|
|
282
|
+
|
|
283
|
+
expect(calls).toEqual(["beforeRetrieve", "afterRetrieve", "beforeMutate", "afterMutate"]);
|
|
284
|
+
} finally {
|
|
285
|
+
await testContext.cleanup();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("short-circuits mutation execution on injected conflicts", async () => {
|
|
290
|
+
const calls: string[] = [];
|
|
291
|
+
const { testContext } = await createAdapter(
|
|
292
|
+
{
|
|
293
|
+
type: "kysely-sqlite",
|
|
294
|
+
uowConfig: {
|
|
295
|
+
instrumentation: {
|
|
296
|
+
beforeMutate: () => ({ type: "conflict", reason: "Injected conflict" }),
|
|
297
|
+
afterMutate: () => {
|
|
298
|
+
calls.push("afterMutate");
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
[{ schema: conformanceSchema, namespace }],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const db = testContext.getOrm<typeof conformanceSchema>(namespace);
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const uow = db.createUnitOfWork("conflict-injected");
|
|
310
|
+
uow.create("users", { name: "Should not persist" });
|
|
311
|
+
|
|
312
|
+
const result = await uow.executeMutations();
|
|
313
|
+
expect(result.success).toBe(false);
|
|
314
|
+
|
|
315
|
+
const users = await db.find("users", (b) => b.whereIndex("primary"));
|
|
316
|
+
expect(users).toHaveLength(0);
|
|
317
|
+
expect(calls).toEqual([]);
|
|
318
|
+
} finally {
|
|
319
|
+
await testContext.cleanup();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|