@fragno-dev/test 1.0.2 → 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 +43 -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 +12 -11
- 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
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import type { FragnoRuntime } from "@fragno-dev/core";
|
|
2
|
+
import {
|
|
3
|
+
runWithTraceRecorder,
|
|
4
|
+
type FragnoCoreTraceEvent,
|
|
5
|
+
} from "@fragno-dev/core/internal/trace-context";
|
|
6
|
+
import { FragnoId, FragnoReference, type AnySchema } from "@fragno-dev/db/schema";
|
|
7
|
+
import type { SimpleQueryInterface } from "@fragno-dev/db/query";
|
|
8
|
+
import type { TypedUnitOfWork } from "@fragno-dev/db";
|
|
9
|
+
import type { MutationOperation } from "@fragno-dev/db/unit-of-work";
|
|
10
|
+
|
|
11
|
+
export type ModelCheckerMode = "exhaustive" | "bounded-exhaustive" | "random" | "infinite";
|
|
12
|
+
export type ModelCheckerPhase = "retrieve" | "mutate";
|
|
13
|
+
|
|
14
|
+
export type ModelCheckerStep = {
|
|
15
|
+
txId: number;
|
|
16
|
+
phase: ModelCheckerPhase;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ModelCheckerTraceHashMode = "state" | "trace" | "state+trace";
|
|
20
|
+
|
|
21
|
+
export type NormalizedMutationOperation = {
|
|
22
|
+
type: "create" | "update" | "delete" | "check";
|
|
23
|
+
table: string;
|
|
24
|
+
namespace?: string | null;
|
|
25
|
+
id?: unknown;
|
|
26
|
+
checkVersion?: boolean;
|
|
27
|
+
set?: Record<string, unknown>;
|
|
28
|
+
values?: Record<string, unknown>;
|
|
29
|
+
generatedExternalId?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ModelCheckerTraceEvent =
|
|
33
|
+
| {
|
|
34
|
+
type: "schedule-step";
|
|
35
|
+
step: ModelCheckerStep;
|
|
36
|
+
stepIndex: number;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
type: "retrieve-output";
|
|
40
|
+
txId?: number;
|
|
41
|
+
uowName?: string;
|
|
42
|
+
output: unknown;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
type: "mutation-input";
|
|
46
|
+
txId?: number;
|
|
47
|
+
uowName?: string;
|
|
48
|
+
operations: NormalizedMutationOperation[];
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
type: "mutation-result";
|
|
52
|
+
txId?: number;
|
|
53
|
+
uowName?: string;
|
|
54
|
+
success: boolean;
|
|
55
|
+
createdIds: unknown[];
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
type: "runtime";
|
|
60
|
+
operation: "time.now" | "random.float" | "random.uuid" | "random.cuid";
|
|
61
|
+
value: unknown;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
type: "external";
|
|
65
|
+
name: string;
|
|
66
|
+
payload: unknown;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type ModelCheckerTrace = {
|
|
70
|
+
events: ModelCheckerTraceEvent[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ModelCheckerTraceRecorder = (event: ModelCheckerTraceEvent) => void;
|
|
74
|
+
|
|
75
|
+
export type ModelCheckerTraceHasher = (trace: ModelCheckerTrace) => Promise<string> | string;
|
|
76
|
+
|
|
77
|
+
export type RawUowTransactionContext<TSchema extends AnySchema, TUowConfig> = {
|
|
78
|
+
queryEngine: SimpleQueryInterface<TSchema, TUowConfig>;
|
|
79
|
+
createUnitOfWork: (name?: string, config?: TUowConfig) => TypedUnitOfWork<TSchema, [], unknown>;
|
|
80
|
+
runtime?: FragnoRuntime;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type RawUowMutateContext<
|
|
84
|
+
TSchema extends AnySchema,
|
|
85
|
+
TUowConfig,
|
|
86
|
+
TRetrieve,
|
|
87
|
+
> = RawUowTransactionContext<TSchema, TUowConfig> & {
|
|
88
|
+
retrieveResult: TRetrieve;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export interface RawUowTransaction<
|
|
92
|
+
TRetrieve = unknown,
|
|
93
|
+
TMutate = unknown,
|
|
94
|
+
TSchema extends AnySchema = AnySchema,
|
|
95
|
+
TUowConfig = void,
|
|
96
|
+
> {
|
|
97
|
+
name?: string;
|
|
98
|
+
retrieve(ctx: RawUowTransactionContext<TSchema, TUowConfig>): TRetrieve | Promise<TRetrieve>;
|
|
99
|
+
mutate?(ctx: RawUowMutateContext<TSchema, TUowConfig, TRetrieve>): TMutate | Promise<TMutate>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type RawUowTransactionBuilder<
|
|
103
|
+
TRetrieve = unknown,
|
|
104
|
+
TMutate = unknown,
|
|
105
|
+
TSchema extends AnySchema = AnySchema,
|
|
106
|
+
TUowConfig = void,
|
|
107
|
+
> = {
|
|
108
|
+
name?: string;
|
|
109
|
+
config?: TUowConfig;
|
|
110
|
+
retrieve: (
|
|
111
|
+
uow: TypedUnitOfWork<TSchema, [], unknown>,
|
|
112
|
+
ctx: RawUowTransactionContext<TSchema, TUowConfig>,
|
|
113
|
+
) => TRetrieve | Promise<TRetrieve>;
|
|
114
|
+
mutate?: (
|
|
115
|
+
uow: TypedUnitOfWork<TSchema, [], unknown>,
|
|
116
|
+
ctx: RawUowMutateContext<TSchema, TUowConfig, TRetrieve>,
|
|
117
|
+
) => TMutate | Promise<TMutate>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const createRawUowTransaction = <
|
|
121
|
+
TRetrieve = unknown,
|
|
122
|
+
TMutate = unknown,
|
|
123
|
+
TSchema extends AnySchema = AnySchema,
|
|
124
|
+
TUowConfig = void,
|
|
125
|
+
>(
|
|
126
|
+
builder: RawUowTransactionBuilder<TRetrieve, TMutate, TSchema, TUowConfig>,
|
|
127
|
+
): RawUowTransaction<TRetrieve, TMutate, TSchema, TUowConfig> => {
|
|
128
|
+
let uow: TypedUnitOfWork<TSchema, [], unknown> | null = null;
|
|
129
|
+
const getUow = (ctx: RawUowTransactionContext<TSchema, TUowConfig>, phase: ModelCheckerPhase) => {
|
|
130
|
+
if (!uow) {
|
|
131
|
+
if (phase === "mutate") {
|
|
132
|
+
throw new Error("Raw UOW mutate invoked before retrieve.");
|
|
133
|
+
}
|
|
134
|
+
uow = ctx.createUnitOfWork(builder.name, builder.config);
|
|
135
|
+
}
|
|
136
|
+
return uow;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const mutate = builder.mutate;
|
|
140
|
+
return {
|
|
141
|
+
name: builder.name,
|
|
142
|
+
retrieve: (ctx) => builder.retrieve(getUow(ctx, "retrieve"), ctx),
|
|
143
|
+
mutate: mutate ? (ctx) => mutate(getUow(ctx, "mutate"), ctx) : undefined,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type ModelCheckerExecutionContext<TSchema extends AnySchema, TUowConfig> = {
|
|
148
|
+
ctx: RawUowTransactionContext<TSchema, TUowConfig>;
|
|
149
|
+
cleanup?: () => Promise<void>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type ModelCheckerHistory =
|
|
153
|
+
| false
|
|
154
|
+
| { type: "lru"; maxEntries: number }
|
|
155
|
+
| { type: "unbounded" };
|
|
156
|
+
|
|
157
|
+
export type ModelCheckerBounds = {
|
|
158
|
+
maxSteps?: number;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export type ModelCheckerStateHasherContext<TSchema extends AnySchema, TUowConfig> = {
|
|
162
|
+
schema: TSchema;
|
|
163
|
+
queryEngine: SimpleQueryInterface<TSchema, TUowConfig>;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export type ModelCheckerConfig<TSchema extends AnySchema, TUowConfig = void> = {
|
|
167
|
+
schema: TSchema;
|
|
168
|
+
mode?: ModelCheckerMode;
|
|
169
|
+
seed?: number;
|
|
170
|
+
maxSchedules?: number;
|
|
171
|
+
bounds?: ModelCheckerBounds;
|
|
172
|
+
history?: ModelCheckerHistory;
|
|
173
|
+
stopWhenFrontierExhausted?: boolean;
|
|
174
|
+
stateHasher?: (ctx: ModelCheckerStateHasherContext<TSchema, TUowConfig>) => Promise<string>;
|
|
175
|
+
traceRecorder?: ModelCheckerTraceRecorder;
|
|
176
|
+
traceHasher?: ModelCheckerTraceHasher;
|
|
177
|
+
traceHashMode?: ModelCheckerTraceHashMode;
|
|
178
|
+
runtime?: FragnoRuntime;
|
|
179
|
+
createContext: () => Promise<ModelCheckerExecutionContext<TSchema, TUowConfig>>;
|
|
180
|
+
setup?: (ctx: RawUowTransactionContext<TSchema, TUowConfig>) => Promise<void>;
|
|
181
|
+
buildTransactions: (
|
|
182
|
+
ctx: RawUowTransactionContext<TSchema, TUowConfig>,
|
|
183
|
+
) => RawUowTransaction<unknown, unknown, TSchema, TUowConfig>[];
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export type ModelCheckerScheduleResult = {
|
|
187
|
+
schedule: ModelCheckerStep[];
|
|
188
|
+
stateHash: string;
|
|
189
|
+
traceHash?: string;
|
|
190
|
+
trace?: ModelCheckerTrace;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export type ModelCheckerRunResult = {
|
|
194
|
+
schedules: ModelCheckerScheduleResult[];
|
|
195
|
+
visitedPaths: number;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type HistoryTracker = {
|
|
199
|
+
add: (key: string) => boolean;
|
|
200
|
+
size: () => number;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
type TransactionPlan = {
|
|
204
|
+
stepsPerTransaction: number[];
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
type ScheduleExecutionResult = {
|
|
208
|
+
stateHash: string;
|
|
209
|
+
newPathsAdded: boolean;
|
|
210
|
+
traceHash?: string;
|
|
211
|
+
trace?: ModelCheckerTrace;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const defaultHistoryConfig: ModelCheckerHistory = { type: "lru", maxEntries: 5000 };
|
|
215
|
+
|
|
216
|
+
const mulberry32 = (seed: number) => {
|
|
217
|
+
let t = seed >>> 0;
|
|
218
|
+
return () => {
|
|
219
|
+
t += 1831565813;
|
|
220
|
+
let r = Math.imul(t ^ (t >>> 15), t | 1);
|
|
221
|
+
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
|
|
222
|
+
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const serializeSchedulePrefix = (schedule: ModelCheckerStep[]): string =>
|
|
227
|
+
schedule.map((step) => `${step.txId}:${step.phase}`).join("|");
|
|
228
|
+
|
|
229
|
+
const buildPathKey = (pathHash: string, prefix: ModelCheckerStep[]): string =>
|
|
230
|
+
`${pathHash}::${serializeSchedulePrefix(prefix)}`;
|
|
231
|
+
|
|
232
|
+
const createHistoryTracker = (history: ModelCheckerHistory): HistoryTracker | null => {
|
|
233
|
+
if (!history) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (history.type === "unbounded") {
|
|
238
|
+
const set = new Set<string>();
|
|
239
|
+
return {
|
|
240
|
+
add: (key: string) => {
|
|
241
|
+
const has = set.has(key);
|
|
242
|
+
if (!has) {
|
|
243
|
+
set.add(key);
|
|
244
|
+
}
|
|
245
|
+
return !has;
|
|
246
|
+
},
|
|
247
|
+
size: () => set.size,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const maxEntries = Math.max(history.maxEntries, 1);
|
|
252
|
+
const map = new Map<string, true>();
|
|
253
|
+
return {
|
|
254
|
+
add: (key: string) => {
|
|
255
|
+
const existed = map.delete(key);
|
|
256
|
+
map.set(key, true);
|
|
257
|
+
if (map.size > maxEntries) {
|
|
258
|
+
const first = map.keys().next().value;
|
|
259
|
+
if (first !== undefined) {
|
|
260
|
+
map.delete(first);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return !existed;
|
|
264
|
+
},
|
|
265
|
+
size: () => map.size,
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const normalizeForHash = (value: unknown): unknown => {
|
|
270
|
+
if (value === null || value === undefined) {
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (typeof value === "bigint") {
|
|
275
|
+
return value.toString();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (value instanceof Date) {
|
|
279
|
+
return value.toISOString();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (value instanceof FragnoId) {
|
|
283
|
+
return {
|
|
284
|
+
externalId: value.externalId,
|
|
285
|
+
internalId: value.internalId?.toString(),
|
|
286
|
+
version: value.version,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (value instanceof FragnoReference) {
|
|
291
|
+
return value.internalId.toString();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (Array.isArray(value)) {
|
|
295
|
+
return value.map((entry) => normalizeForHash(entry));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (typeof value === "object") {
|
|
299
|
+
const record = value as Record<string, unknown>;
|
|
300
|
+
const sortedKeys = Object.keys(record).sort();
|
|
301
|
+
const normalized: Record<string, unknown> = {};
|
|
302
|
+
for (const key of sortedKeys) {
|
|
303
|
+
normalized[key] = normalizeForHash(record[key]);
|
|
304
|
+
}
|
|
305
|
+
return normalized;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return value;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const stableStringify = (value: unknown): string => JSON.stringify(normalizeForHash(value));
|
|
312
|
+
|
|
313
|
+
export const defaultStateHasher = async <TSchema extends AnySchema, TUowConfig>(
|
|
314
|
+
ctx: ModelCheckerStateHasherContext<TSchema, TUowConfig>,
|
|
315
|
+
): Promise<string> => {
|
|
316
|
+
const tableNames = Object.keys(ctx.schema.tables).sort();
|
|
317
|
+
const snapshot: { table: string; rows: unknown[] }[] = [];
|
|
318
|
+
|
|
319
|
+
for (const tableName of tableNames) {
|
|
320
|
+
const rows = await ctx.queryEngine.find(tableName, (b) =>
|
|
321
|
+
b.whereIndex("primary").orderByIndex("primary", "asc"),
|
|
322
|
+
);
|
|
323
|
+
snapshot.push({ table: tableName, rows });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return stableStringify(snapshot);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export const defaultTraceHasher = async (trace: ModelCheckerTrace): Promise<string> =>
|
|
330
|
+
stableStringify(trace.events);
|
|
331
|
+
|
|
332
|
+
const resolvePathHash = (
|
|
333
|
+
stateHash: string,
|
|
334
|
+
traceHash: string | undefined,
|
|
335
|
+
mode: ModelCheckerTraceHashMode,
|
|
336
|
+
): string => {
|
|
337
|
+
if (mode === "state") {
|
|
338
|
+
return stateHash;
|
|
339
|
+
}
|
|
340
|
+
if (!traceHash) {
|
|
341
|
+
throw new Error("Trace hash mode requires traceHasher to be configured.");
|
|
342
|
+
}
|
|
343
|
+
if (mode === "trace") {
|
|
344
|
+
return traceHash;
|
|
345
|
+
}
|
|
346
|
+
return `${stateHash}::${traceHash}`;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const wrapRuntimeForTrace = (
|
|
350
|
+
runtime: FragnoRuntime,
|
|
351
|
+
record: (event: ModelCheckerTraceEvent) => void,
|
|
352
|
+
): FragnoRuntime => ({
|
|
353
|
+
time: {
|
|
354
|
+
now: () => {
|
|
355
|
+
const value = runtime.time.now();
|
|
356
|
+
record({ type: "runtime", operation: "time.now", value });
|
|
357
|
+
return value;
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
random: {
|
|
361
|
+
float: () => {
|
|
362
|
+
const value = runtime.random.float();
|
|
363
|
+
record({ type: "runtime", operation: "random.float", value });
|
|
364
|
+
return value;
|
|
365
|
+
},
|
|
366
|
+
uuid: () => {
|
|
367
|
+
const value = runtime.random.uuid();
|
|
368
|
+
record({ type: "runtime", operation: "random.uuid", value });
|
|
369
|
+
return value;
|
|
370
|
+
},
|
|
371
|
+
cuid: () => {
|
|
372
|
+
const value = runtime.random.cuid();
|
|
373
|
+
record({ type: "runtime", operation: "random.cuid", value });
|
|
374
|
+
return value;
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const captureCoreTrace = (
|
|
380
|
+
record: (event: ModelCheckerTraceEvent) => void,
|
|
381
|
+
event: FragnoCoreTraceEvent,
|
|
382
|
+
) => {
|
|
383
|
+
record({ type: "external", name: event.type, payload: event });
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const createTraceContext = (
|
|
387
|
+
recorder: ModelCheckerTraceRecorder | undefined,
|
|
388
|
+
traceHasher: ModelCheckerTraceHasher | undefined,
|
|
389
|
+
) => {
|
|
390
|
+
const events = recorder || traceHasher ? ([] as ModelCheckerTraceEvent[]) : null;
|
|
391
|
+
const record = (event: ModelCheckerTraceEvent) => {
|
|
392
|
+
if (events) {
|
|
393
|
+
events.push(event);
|
|
394
|
+
}
|
|
395
|
+
recorder?.(event);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
return { events, record };
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const normalizeMutationOperation = (
|
|
402
|
+
op: MutationOperation<AnySchema>,
|
|
403
|
+
): NormalizedMutationOperation => {
|
|
404
|
+
if (op.type === "create") {
|
|
405
|
+
return {
|
|
406
|
+
type: "create",
|
|
407
|
+
table: op.table,
|
|
408
|
+
namespace: op.namespace,
|
|
409
|
+
values: normalizeForHash(op.values) as Record<string, unknown>,
|
|
410
|
+
generatedExternalId: op.generatedExternalId,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (op.type === "update") {
|
|
415
|
+
return {
|
|
416
|
+
type: "update",
|
|
417
|
+
table: op.table,
|
|
418
|
+
namespace: op.namespace,
|
|
419
|
+
id: normalizeForHash(op.id),
|
|
420
|
+
checkVersion: op.checkVersion,
|
|
421
|
+
set: normalizeForHash(op.set) as Record<string, unknown>,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (op.type === "delete") {
|
|
426
|
+
return {
|
|
427
|
+
type: "delete",
|
|
428
|
+
table: op.table,
|
|
429
|
+
namespace: op.namespace,
|
|
430
|
+
id: normalizeForHash(op.id),
|
|
431
|
+
checkVersion: op.checkVersion,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
type: "check",
|
|
437
|
+
table: op.table,
|
|
438
|
+
namespace: op.namespace,
|
|
439
|
+
id: normalizeForHash(op.id),
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const wrapCreateUnitOfWorkForTrace = <TSchema extends AnySchema, TUowConfig>(
|
|
444
|
+
createUnitOfWork: (name?: string, config?: TUowConfig) => TypedUnitOfWork<TSchema, [], unknown>,
|
|
445
|
+
getTxId: () => number | undefined,
|
|
446
|
+
record: (event: ModelCheckerTraceEvent) => void,
|
|
447
|
+
) => {
|
|
448
|
+
return (name?: string, config?: TUowConfig) => {
|
|
449
|
+
const uow = createUnitOfWork(name, config);
|
|
450
|
+
return new Proxy(uow, {
|
|
451
|
+
get(target, prop, receiver) {
|
|
452
|
+
if (prop === "executeRetrieve") {
|
|
453
|
+
return async () => {
|
|
454
|
+
const result = await target.executeRetrieve();
|
|
455
|
+
record({
|
|
456
|
+
type: "retrieve-output",
|
|
457
|
+
txId: getTxId(),
|
|
458
|
+
uowName: target.name,
|
|
459
|
+
output: normalizeForHash(result),
|
|
460
|
+
});
|
|
461
|
+
return result;
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (prop === "executeMutations") {
|
|
466
|
+
return async () => {
|
|
467
|
+
const txId = getTxId();
|
|
468
|
+
record({
|
|
469
|
+
type: "mutation-input",
|
|
470
|
+
txId,
|
|
471
|
+
uowName: target.name,
|
|
472
|
+
operations: target.getMutationOperations().map(normalizeMutationOperation),
|
|
473
|
+
});
|
|
474
|
+
try {
|
|
475
|
+
const result = await target.executeMutations();
|
|
476
|
+
record({
|
|
477
|
+
type: "mutation-result",
|
|
478
|
+
txId,
|
|
479
|
+
uowName: target.name,
|
|
480
|
+
success: result.success,
|
|
481
|
+
createdIds: result.success ? target.getCreatedIds().map(normalizeForHash) : [],
|
|
482
|
+
});
|
|
483
|
+
return result;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
record({
|
|
486
|
+
type: "mutation-result",
|
|
487
|
+
txId,
|
|
488
|
+
uowName: target.name,
|
|
489
|
+
success: false,
|
|
490
|
+
createdIds: [],
|
|
491
|
+
error: error instanceof Error ? error.message : String(error),
|
|
492
|
+
});
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const value = Reflect.get(target, prop, receiver);
|
|
499
|
+
if (typeof value === "function") {
|
|
500
|
+
return value.bind(target);
|
|
501
|
+
}
|
|
502
|
+
return value;
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const planTransactions = <TSchema extends AnySchema, TUowConfig>(
|
|
509
|
+
transactions: RawUowTransaction<unknown, unknown, TSchema, TUowConfig>[],
|
|
510
|
+
): TransactionPlan => {
|
|
511
|
+
if (transactions.length === 0) {
|
|
512
|
+
throw new Error("Model checker requires at least one transaction.");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const stepsPerTransaction = transactions.map((tx) => {
|
|
516
|
+
if (!tx.retrieve) {
|
|
517
|
+
throw new Error("Model checker transactions must define a retrieve step.");
|
|
518
|
+
}
|
|
519
|
+
return tx.mutate ? 2 : 1;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
return { stepsPerTransaction };
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const generateAllSchedules = (stepsPerTransaction: number[]): ModelCheckerStep[][] => {
|
|
526
|
+
const schedules: ModelCheckerStep[][] = [];
|
|
527
|
+
const totalSteps = stepsPerTransaction.reduce((sum, steps) => sum + steps, 0);
|
|
528
|
+
const progress = stepsPerTransaction.map(() => 0);
|
|
529
|
+
const current: ModelCheckerStep[] = [];
|
|
530
|
+
|
|
531
|
+
const walk = () => {
|
|
532
|
+
if (current.length === totalSteps) {
|
|
533
|
+
schedules.push([...current]);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
for (let txId = 0; txId < stepsPerTransaction.length; txId += 1) {
|
|
538
|
+
if (progress[txId] >= stepsPerTransaction[txId]) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const phase: ModelCheckerPhase = progress[txId] === 0 ? "retrieve" : "mutate";
|
|
542
|
+
current.push({ txId, phase });
|
|
543
|
+
progress[txId] += 1;
|
|
544
|
+
walk();
|
|
545
|
+
progress[txId] -= 1;
|
|
546
|
+
current.pop();
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
walk();
|
|
551
|
+
return schedules;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const generateRandomSchedule = (
|
|
555
|
+
stepsPerTransaction: number[],
|
|
556
|
+
rng: () => number,
|
|
557
|
+
): ModelCheckerStep[] => {
|
|
558
|
+
const totalSteps = stepsPerTransaction.reduce((sum, steps) => sum + steps, 0);
|
|
559
|
+
const progress = stepsPerTransaction.map(() => 0);
|
|
560
|
+
const schedule: ModelCheckerStep[] = [];
|
|
561
|
+
|
|
562
|
+
for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
|
|
563
|
+
const candidates: number[] = [];
|
|
564
|
+
for (let txId = 0; txId < stepsPerTransaction.length; txId += 1) {
|
|
565
|
+
if (progress[txId] < stepsPerTransaction[txId]) {
|
|
566
|
+
candidates.push(txId);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const pick = candidates[Math.floor(rng() * candidates.length)];
|
|
570
|
+
const phase: ModelCheckerPhase = progress[pick] === 0 ? "retrieve" : "mutate";
|
|
571
|
+
schedule.push({ txId: pick, phase });
|
|
572
|
+
progress[pick] += 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return schedule;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const executeSchedule = async <TSchema extends AnySchema, TUowConfig>(
|
|
579
|
+
schedule: ModelCheckerStep[],
|
|
580
|
+
config: ModelCheckerConfig<TSchema, TUowConfig>,
|
|
581
|
+
history: HistoryTracker | null,
|
|
582
|
+
stateHasher: (ctx: ModelCheckerStateHasherContext<TSchema, TUowConfig>) => Promise<string>,
|
|
583
|
+
): Promise<ScheduleExecutionResult> => {
|
|
584
|
+
const traceHashMode = config.traceHashMode ?? "state";
|
|
585
|
+
const traceHasher =
|
|
586
|
+
config.traceHasher ?? (traceHashMode === "state" ? undefined : defaultTraceHasher);
|
|
587
|
+
const traceContext = createTraceContext(config.traceRecorder, traceHasher);
|
|
588
|
+
const traceEnabled = Boolean(traceContext.events);
|
|
589
|
+
const runtime =
|
|
590
|
+
config.runtime && traceEnabled
|
|
591
|
+
? wrapRuntimeForTrace(config.runtime, traceContext.record)
|
|
592
|
+
: config.runtime;
|
|
593
|
+
|
|
594
|
+
const { ctx, cleanup } = await config.createContext();
|
|
595
|
+
try {
|
|
596
|
+
let currentTxId: number | undefined;
|
|
597
|
+
const tracedContext = traceEnabled
|
|
598
|
+
? {
|
|
599
|
+
...ctx,
|
|
600
|
+
createUnitOfWork: wrapCreateUnitOfWorkForTrace(
|
|
601
|
+
ctx.createUnitOfWork,
|
|
602
|
+
() => currentTxId,
|
|
603
|
+
traceContext.record,
|
|
604
|
+
),
|
|
605
|
+
runtime,
|
|
606
|
+
}
|
|
607
|
+
: { ...ctx, runtime };
|
|
608
|
+
|
|
609
|
+
if (config.setup) {
|
|
610
|
+
await config.setup(tracedContext);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const transactions = config.buildTransactions(tracedContext);
|
|
614
|
+
const transactionState = transactions.map(() => ({ retrieveResult: undefined as unknown }));
|
|
615
|
+
let lastStateHash: string | null = null;
|
|
616
|
+
let newPathsAdded = false;
|
|
617
|
+
|
|
618
|
+
const runSchedule = async () => {
|
|
619
|
+
for (let stepIndex = 0; stepIndex < schedule.length; stepIndex += 1) {
|
|
620
|
+
const step = schedule[stepIndex];
|
|
621
|
+
if (!step) {
|
|
622
|
+
throw new Error("Schedule step is missing.");
|
|
623
|
+
}
|
|
624
|
+
const tx = transactions[step.txId];
|
|
625
|
+
if (!tx) {
|
|
626
|
+
throw new Error(`Transaction ${step.txId} is missing.`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
currentTxId = step.txId;
|
|
630
|
+
if (traceEnabled) {
|
|
631
|
+
traceContext.record({ type: "schedule-step", step, stepIndex });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (step.phase === "retrieve") {
|
|
635
|
+
transactionState[step.txId].retrieveResult = await tx.retrieve(tracedContext);
|
|
636
|
+
} else {
|
|
637
|
+
if (!tx.mutate) {
|
|
638
|
+
throw new Error(`Transaction ${step.txId} does not define a mutate step.`);
|
|
639
|
+
}
|
|
640
|
+
await tx.mutate({
|
|
641
|
+
...tracedContext,
|
|
642
|
+
retrieveResult: transactionState[step.txId].retrieveResult,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (history) {
|
|
647
|
+
lastStateHash = await stateHasher({
|
|
648
|
+
schema: config.schema,
|
|
649
|
+
queryEngine: tracedContext.queryEngine,
|
|
650
|
+
});
|
|
651
|
+
const traceHash = traceHasher
|
|
652
|
+
? await traceHasher({ events: traceContext.events ?? [] })
|
|
653
|
+
: undefined;
|
|
654
|
+
const prefix = schedule.slice(0, stepIndex + 1);
|
|
655
|
+
const added = history.add(
|
|
656
|
+
buildPathKey(resolvePathHash(lastStateHash, traceHash, traceHashMode), prefix),
|
|
657
|
+
);
|
|
658
|
+
if (added) {
|
|
659
|
+
newPathsAdded = true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
currentTxId = undefined;
|
|
665
|
+
|
|
666
|
+
if (!lastStateHash) {
|
|
667
|
+
lastStateHash = await stateHasher({
|
|
668
|
+
schema: config.schema,
|
|
669
|
+
queryEngine: tracedContext.queryEngine,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const finalTraceHash = traceHasher
|
|
674
|
+
? await traceHasher({ events: traceContext.events ?? [] })
|
|
675
|
+
: undefined;
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
stateHash: lastStateHash,
|
|
679
|
+
newPathsAdded,
|
|
680
|
+
traceHash: finalTraceHash,
|
|
681
|
+
trace: traceContext.events ? { events: traceContext.events } : undefined,
|
|
682
|
+
};
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
if (traceEnabled) {
|
|
686
|
+
return await runWithTraceRecorder(
|
|
687
|
+
(event) => captureCoreTrace(traceContext.record, event),
|
|
688
|
+
runSchedule,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return await runSchedule();
|
|
693
|
+
} finally {
|
|
694
|
+
if (cleanup) {
|
|
695
|
+
await cleanup();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const getTransactionPlan = async <TSchema extends AnySchema, TUowConfig>(
|
|
701
|
+
config: ModelCheckerConfig<TSchema, TUowConfig>,
|
|
702
|
+
): Promise<TransactionPlan> => {
|
|
703
|
+
const { ctx, cleanup } = await config.createContext();
|
|
704
|
+
try {
|
|
705
|
+
const context = config.runtime ? { ...ctx, runtime: config.runtime } : ctx;
|
|
706
|
+
if (config.setup) {
|
|
707
|
+
await config.setup(context);
|
|
708
|
+
}
|
|
709
|
+
const transactions = config.buildTransactions(context);
|
|
710
|
+
return planTransactions(transactions);
|
|
711
|
+
} finally {
|
|
712
|
+
if (cleanup) {
|
|
713
|
+
await cleanup();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
export const runModelChecker = async <TSchema extends AnySchema, TUowConfig>(
|
|
719
|
+
config: ModelCheckerConfig<TSchema, TUowConfig>,
|
|
720
|
+
): Promise<ModelCheckerRunResult> => {
|
|
721
|
+
const mode = config.mode ?? "exhaustive";
|
|
722
|
+
const historyConfig = config.history ?? defaultHistoryConfig;
|
|
723
|
+
const history = createHistoryTracker(historyConfig);
|
|
724
|
+
const stateHasher = config.stateHasher ?? defaultStateHasher;
|
|
725
|
+
const { stepsPerTransaction } = await getTransactionPlan(config);
|
|
726
|
+
const schedules: ModelCheckerScheduleResult[] = [];
|
|
727
|
+
const maxSchedules = config.maxSchedules;
|
|
728
|
+
const seed = config.seed ?? 1;
|
|
729
|
+
|
|
730
|
+
if (mode === "exhaustive" || mode === "bounded-exhaustive") {
|
|
731
|
+
if (mode === "bounded-exhaustive") {
|
|
732
|
+
const bounds = config.bounds;
|
|
733
|
+
if (bounds?.maxSteps !== undefined) {
|
|
734
|
+
const totalSteps = stepsPerTransaction.reduce((sum, steps) => sum + steps, 0);
|
|
735
|
+
if (totalSteps > bounds.maxSteps) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`bounded-exhaustive requires maxSteps >= ${totalSteps}, ` +
|
|
738
|
+
`but received ${bounds.maxSteps}`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const allSchedules = generateAllSchedules(stepsPerTransaction);
|
|
744
|
+
for (const schedule of allSchedules) {
|
|
745
|
+
if (maxSchedules !== undefined && schedules.length >= maxSchedules) {
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
const result = await executeSchedule(schedule, config, history, stateHasher);
|
|
749
|
+
schedules.push({
|
|
750
|
+
schedule,
|
|
751
|
+
stateHash: result.stateHash,
|
|
752
|
+
traceHash: result.traceHash,
|
|
753
|
+
trace: result.trace,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
} else if (mode === "random") {
|
|
757
|
+
const rng = mulberry32(seed);
|
|
758
|
+
const total = maxSchedules ?? 1;
|
|
759
|
+
for (let i = 0; i < total; i += 1) {
|
|
760
|
+
const schedule = generateRandomSchedule(stepsPerTransaction, rng);
|
|
761
|
+
const result = await executeSchedule(schedule, config, history, stateHasher);
|
|
762
|
+
schedules.push({
|
|
763
|
+
schedule,
|
|
764
|
+
stateHash: result.stateHash,
|
|
765
|
+
traceHash: result.traceHash,
|
|
766
|
+
trace: result.trace,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
} else {
|
|
770
|
+
const rng = mulberry32(seed);
|
|
771
|
+
const stopWhenFrontierExhausted = config.stopWhenFrontierExhausted ?? false;
|
|
772
|
+
let iterations = 0;
|
|
773
|
+
let frontierExhausted = false;
|
|
774
|
+
|
|
775
|
+
while (!frontierExhausted) {
|
|
776
|
+
if (maxSchedules !== undefined && iterations >= maxSchedules) {
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
const schedule = generateRandomSchedule(stepsPerTransaction, rng);
|
|
780
|
+
const result = await executeSchedule(schedule, config, history, stateHasher);
|
|
781
|
+
schedules.push({
|
|
782
|
+
schedule,
|
|
783
|
+
stateHash: result.stateHash,
|
|
784
|
+
traceHash: result.traceHash,
|
|
785
|
+
trace: result.trace,
|
|
786
|
+
});
|
|
787
|
+
iterations += 1;
|
|
788
|
+
|
|
789
|
+
if (stopWhenFrontierExhausted && history) {
|
|
790
|
+
frontierExhausted = !result.newPathsAdded;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
schedules,
|
|
797
|
+
visitedPaths: history?.size() ?? 0,
|
|
798
|
+
};
|
|
799
|
+
};
|