@ontrails/drizzle 1.0.0-beta.15
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 +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +9 -0
- package/README.md +36 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +755 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +332 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +24 -0
- package/src/__tests__/drizzle.test.ts +997 -0
- package/src/index.ts +9 -0
- package/src/runtime.ts +1346 -0
- package/src/schema.ts +590 -0
- package/src/types.ts +65 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
AlreadyExistsError,
|
|
4
|
+
ConflictError,
|
|
5
|
+
Result,
|
|
6
|
+
ValidationError,
|
|
7
|
+
createTrailContext,
|
|
8
|
+
} from '@ontrails/core';
|
|
9
|
+
import type { TrailContext } from '@ontrails/core';
|
|
10
|
+
import { store as defineStore } from '@ontrails/store';
|
|
11
|
+
import { createStoreAccessorContractCases } from '@ontrails/store/testing';
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
|
|
17
|
+
import { connectDrizzle, connectReadOnlyDrizzle } from '../index.js';
|
|
18
|
+
|
|
19
|
+
const userSchema = z.object({
|
|
20
|
+
email: z.string().email(),
|
|
21
|
+
id: z.string(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const gistSchema = z.object({
|
|
25
|
+
createdAt: z.string(),
|
|
26
|
+
description: z.string().nullable().default(null),
|
|
27
|
+
id: z.string(),
|
|
28
|
+
isPublic: z.boolean().default(true),
|
|
29
|
+
ownerId: z.string(),
|
|
30
|
+
tags: z.array(z.string()).default([]),
|
|
31
|
+
updatedAt: z.string(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const accountSchema = z.object({
|
|
35
|
+
id: z.string(),
|
|
36
|
+
name: z.string(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const userTable = {
|
|
40
|
+
generated: ['id'],
|
|
41
|
+
primaryKey: 'id',
|
|
42
|
+
schema: userSchema,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
const gistTable = {
|
|
46
|
+
generated: ['id', 'createdAt', 'updatedAt'],
|
|
47
|
+
indexes: ['ownerId'],
|
|
48
|
+
primaryKey: 'id',
|
|
49
|
+
references: { ownerId: 'users' },
|
|
50
|
+
schema: gistSchema,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
const createResourceInput = (rootDir: string) => ({
|
|
54
|
+
config: undefined,
|
|
55
|
+
cwd: rootDir,
|
|
56
|
+
env: {},
|
|
57
|
+
workspaceRoot: rootDir,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const writableDemoDefinition = defineStore({
|
|
61
|
+
gists: gistTable,
|
|
62
|
+
users: userTable,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const versionedUserDefinition = defineStore({
|
|
66
|
+
users: {
|
|
67
|
+
...userTable,
|
|
68
|
+
versioned: true,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const expectOk = async <T>(value: PromiseLike<T> | T): Promise<T> =>
|
|
73
|
+
await value;
|
|
74
|
+
|
|
75
|
+
const unwrapCreated = async <T>(
|
|
76
|
+
value:
|
|
77
|
+
| PromiseLike<{
|
|
78
|
+
unwrap(): T;
|
|
79
|
+
}>
|
|
80
|
+
| {
|
|
81
|
+
unwrap(): T;
|
|
82
|
+
}
|
|
83
|
+
): Promise<T> => {
|
|
84
|
+
const result = await value;
|
|
85
|
+
return result.unwrap();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const createWritableDemoStore = (rootDir: string) =>
|
|
89
|
+
connectDrizzle(
|
|
90
|
+
defineStore({
|
|
91
|
+
gists: gistTable,
|
|
92
|
+
users: userTable,
|
|
93
|
+
}),
|
|
94
|
+
{
|
|
95
|
+
description: 'Writable demo store',
|
|
96
|
+
id: 'demo.store',
|
|
97
|
+
meta: { domain: 'demo' },
|
|
98
|
+
url: join(rootDir, 'demo.sqlite'),
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const createVersionedUserStore = (rootDir: string) =>
|
|
103
|
+
connectDrizzle(
|
|
104
|
+
defineStore({
|
|
105
|
+
users: {
|
|
106
|
+
...userTable,
|
|
107
|
+
versioned: true,
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
{
|
|
111
|
+
description: 'Versioned demo store',
|
|
112
|
+
id: 'demo.store.versioned',
|
|
113
|
+
url: join(rootDir, 'versioned.sqlite'),
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const setupWritableDemoStore = async (rootDir: string) => {
|
|
118
|
+
const db = createWritableDemoStore(rootDir);
|
|
119
|
+
return {
|
|
120
|
+
created: await unwrapCreated(db.create(createResourceInput(rootDir))),
|
|
121
|
+
db,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const setupVersionedUserStore = async (rootDir: string) => {
|
|
126
|
+
const db = createVersionedUserStore(rootDir);
|
|
127
|
+
return {
|
|
128
|
+
created: await unwrapCreated(db.create(createResourceInput(rootDir))),
|
|
129
|
+
db,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const createTmpRootManager = (prefix: string) => {
|
|
134
|
+
let tmpRoot: string | undefined;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
cleanup() {
|
|
138
|
+
if (tmpRoot !== undefined) {
|
|
139
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
140
|
+
tmpRoot = undefined;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
makeRoot(): string {
|
|
144
|
+
tmpRoot = mkdtempSync(join(tmpdir(), prefix));
|
|
145
|
+
return tmpRoot;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const createFireRecorder = () => {
|
|
151
|
+
const events: { payload: unknown; signalId: string }[] = [];
|
|
152
|
+
const record = (
|
|
153
|
+
signal: string | { readonly id: string },
|
|
154
|
+
payload: unknown
|
|
155
|
+
) => {
|
|
156
|
+
events.push({
|
|
157
|
+
payload,
|
|
158
|
+
signalId: typeof signal === 'string' ? signal : signal.id,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return Result.ok();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
events,
|
|
166
|
+
fire: record as unknown as NonNullable<TrailContext['fire']>,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
type WritableDemoStoreRuntime = Awaited<
|
|
171
|
+
ReturnType<typeof setupWritableDemoStore>
|
|
172
|
+
>['created'];
|
|
173
|
+
|
|
174
|
+
const expectWritableResourceDefinition = (
|
|
175
|
+
db: ReturnType<typeof createWritableDemoStore>
|
|
176
|
+
): void => {
|
|
177
|
+
expect(db.kind).toBe('resource');
|
|
178
|
+
expect(db.id).toBe('demo.store');
|
|
179
|
+
expect(db.access).toBe('readwrite');
|
|
180
|
+
expect(db.mock).toBeDefined();
|
|
181
|
+
expect(db.meta).toEqual({ domain: 'demo' });
|
|
182
|
+
expect(db.signals.map((candidate) => candidate.id)).toEqual([
|
|
183
|
+
'demo.store:gists.created',
|
|
184
|
+
'demo.store:gists.updated',
|
|
185
|
+
'demo.store:gists.removed',
|
|
186
|
+
'demo.store:users.created',
|
|
187
|
+
'demo.store:users.updated',
|
|
188
|
+
'demo.store:users.removed',
|
|
189
|
+
]);
|
|
190
|
+
expect(db.tables.gists).toBeDefined();
|
|
191
|
+
expect(db.tables.users).toBeDefined();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const expectInsertedGist = (
|
|
195
|
+
gist: z.output<typeof gistSchema>,
|
|
196
|
+
ownerId: string
|
|
197
|
+
): void => {
|
|
198
|
+
expect(gist).toEqual(
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
createdAt: expect.any(String),
|
|
201
|
+
description: null,
|
|
202
|
+
id: expect.any(String),
|
|
203
|
+
isPublic: true,
|
|
204
|
+
ownerId,
|
|
205
|
+
tags: [],
|
|
206
|
+
updatedAt: expect.any(String),
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const seedWritableRecords = async (
|
|
212
|
+
created: WritableDemoStoreRuntime
|
|
213
|
+
): Promise<{
|
|
214
|
+
readonly gist: z.output<typeof gistSchema>;
|
|
215
|
+
readonly user: z.output<typeof userSchema>;
|
|
216
|
+
}> => {
|
|
217
|
+
const user = await expectOk(
|
|
218
|
+
created.users.upsert({ email: 'alice@example.com' })
|
|
219
|
+
);
|
|
220
|
+
expect(user.id).toEqual(expect.any(String));
|
|
221
|
+
|
|
222
|
+
const gist = await expectOk(created.gists.upsert({ ownerId: user.id }));
|
|
223
|
+
expectInsertedGist(gist, user.id);
|
|
224
|
+
return { gist, user };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const expectStoredGist = async (
|
|
228
|
+
created: WritableDemoStoreRuntime,
|
|
229
|
+
gist: z.output<typeof gistSchema>,
|
|
230
|
+
ownerId: string
|
|
231
|
+
): Promise<void> => {
|
|
232
|
+
expect(await created.gists.get(gist.id)).toEqual(gist);
|
|
233
|
+
expect(await created.gists.list({ ownerId })).toEqual([gist]);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const expectUpdatedGist = async (
|
|
237
|
+
created: WritableDemoStoreRuntime,
|
|
238
|
+
gist: z.output<typeof gistSchema>
|
|
239
|
+
): Promise<void> => {
|
|
240
|
+
const updated = await expectOk(
|
|
241
|
+
created.gists.upsert({
|
|
242
|
+
description: 'Updated',
|
|
243
|
+
id: gist.id,
|
|
244
|
+
ownerId: gist.ownerId,
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(updated).toEqual(
|
|
249
|
+
expect.objectContaining({
|
|
250
|
+
description: 'Updated',
|
|
251
|
+
id: gist.id,
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
expect(updated?.updatedAt).toEqual(expect.any(String));
|
|
255
|
+
expect(Date.parse(updated.updatedAt)).toBeGreaterThanOrEqual(
|
|
256
|
+
Date.parse(gist.updatedAt)
|
|
257
|
+
);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const expectQueryEscapeHatch = async (
|
|
261
|
+
created: WritableDemoStoreRuntime
|
|
262
|
+
): Promise<void> => {
|
|
263
|
+
const rows = await created.query(({ drizzle, tables }) =>
|
|
264
|
+
drizzle.select().from(tables.gists).all()
|
|
265
|
+
);
|
|
266
|
+
expect(rows).toHaveLength(1);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const expectDeletedGist = async (
|
|
270
|
+
created: WritableDemoStoreRuntime,
|
|
271
|
+
gistId: string
|
|
272
|
+
): Promise<void> => {
|
|
273
|
+
const deleted = await created.gists.remove(gistId);
|
|
274
|
+
expect(deleted).toEqual({ deleted: true });
|
|
275
|
+
expect(await created.gists.get(gistId)).toBeNull();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const expectMissingGistDelete = async (
|
|
279
|
+
created: WritableDemoStoreRuntime
|
|
280
|
+
): Promise<void> => {
|
|
281
|
+
const deleted = await created.gists.remove('non-existent-id');
|
|
282
|
+
expect(deleted).toEqual({ deleted: false });
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const expectWritableLifecycle = async (
|
|
286
|
+
created: WritableDemoStoreRuntime
|
|
287
|
+
): Promise<void> => {
|
|
288
|
+
const { gist, user } = await seedWritableRecords(created);
|
|
289
|
+
await expectStoredGist(created, gist, user.id);
|
|
290
|
+
await expectUpdatedGist(created, gist);
|
|
291
|
+
await expectQueryEscapeHatch(created);
|
|
292
|
+
await expectDeletedGist(created, gist.id);
|
|
293
|
+
await expectMissingGistDelete(created);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const expectResourceResolution = (
|
|
297
|
+
db: ReturnType<typeof createWritableDemoStore>,
|
|
298
|
+
created: WritableDemoStoreRuntime
|
|
299
|
+
): void => {
|
|
300
|
+
const ctx = createTrailContext({
|
|
301
|
+
abortSignal: new AbortController().signal,
|
|
302
|
+
extensions: {
|
|
303
|
+
[db.id]: created,
|
|
304
|
+
},
|
|
305
|
+
requestId: 'store-drizzle',
|
|
306
|
+
});
|
|
307
|
+
expect(db.from(ctx).users).toBeDefined();
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const createSignalBoundStore = async (rootDir: string) => {
|
|
311
|
+
const { created, db } = await setupWritableDemoStore(rootDir);
|
|
312
|
+
const recorder = createFireRecorder();
|
|
313
|
+
const ctx = createTrailContext({
|
|
314
|
+
abortSignal: new AbortController().signal,
|
|
315
|
+
extensions: {
|
|
316
|
+
[db.id]: created,
|
|
317
|
+
},
|
|
318
|
+
fire: recorder.fire,
|
|
319
|
+
requestId: 'store-drizzle-signals',
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
bound: db.from(ctx),
|
|
324
|
+
created,
|
|
325
|
+
db,
|
|
326
|
+
recorder,
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const exerciseSignalWrites = async (bound: WritableDemoStoreRuntime) => {
|
|
331
|
+
const user = await bound.users.upsert({ email: 'signals@example.com' });
|
|
332
|
+
const createdGist = await bound.gists.upsert({ ownerId: user.id });
|
|
333
|
+
const updatedGist = await bound.gists.upsert({
|
|
334
|
+
description: 'Updated',
|
|
335
|
+
id: createdGist.id,
|
|
336
|
+
ownerId: user.id,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(await bound.gists.remove(createdGist.id)).toEqual({ deleted: true });
|
|
340
|
+
return { createdGist, updatedGist };
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const expectRecordedSignals = (
|
|
344
|
+
recorder: ReturnType<typeof createFireRecorder>,
|
|
345
|
+
createdGist: z.output<typeof gistSchema>,
|
|
346
|
+
updatedGist: z.output<typeof gistSchema>
|
|
347
|
+
): void => {
|
|
348
|
+
expect(recorder.events.map((event) => event.signalId)).toEqual([
|
|
349
|
+
'demo.store:users.created',
|
|
350
|
+
'demo.store:gists.created',
|
|
351
|
+
'demo.store:gists.updated',
|
|
352
|
+
'demo.store:gists.removed',
|
|
353
|
+
]);
|
|
354
|
+
expect(recorder.events[1]?.payload).toEqual(createdGist);
|
|
355
|
+
expect(recorder.events[2]?.payload).toEqual(updatedGist);
|
|
356
|
+
expect(recorder.events[3]?.payload).toEqual(updatedGist);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const createFixtureBackedStore = () =>
|
|
360
|
+
connectDrizzle(
|
|
361
|
+
defineStore({
|
|
362
|
+
gists: {
|
|
363
|
+
...gistTable,
|
|
364
|
+
fixtures: [
|
|
365
|
+
{
|
|
366
|
+
id: 'gist-seed',
|
|
367
|
+
ownerId: 'user-seed',
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
users: {
|
|
372
|
+
...userTable,
|
|
373
|
+
fixtures: [
|
|
374
|
+
{
|
|
375
|
+
email: 'seed@example.com',
|
|
376
|
+
id: 'user-seed',
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
{
|
|
382
|
+
id: 'demo.store.mock',
|
|
383
|
+
url: ':memory:',
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const createWritableSeedStore = (url: string) =>
|
|
388
|
+
connectDrizzle(
|
|
389
|
+
defineStore({
|
|
390
|
+
users: userTable,
|
|
391
|
+
}),
|
|
392
|
+
{
|
|
393
|
+
id: 'demo.store.seed',
|
|
394
|
+
url,
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const seedReadonlyFixture = async (
|
|
399
|
+
url: string,
|
|
400
|
+
rootDir: string
|
|
401
|
+
): Promise<z.output<typeof userSchema>> => {
|
|
402
|
+
const writable = createWritableSeedStore(url);
|
|
403
|
+
const seeded = await unwrapCreated(
|
|
404
|
+
writable.create(createResourceInput(rootDir))
|
|
405
|
+
);
|
|
406
|
+
const inserted = await seeded.users.insert({ email: 'seed@example.com' });
|
|
407
|
+
await writable.dispose?.(seeded);
|
|
408
|
+
return inserted;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const createReadonlyUserStore = (url: string) =>
|
|
412
|
+
connectReadOnlyDrizzle(
|
|
413
|
+
defineStore({
|
|
414
|
+
users: userTable,
|
|
415
|
+
}),
|
|
416
|
+
{
|
|
417
|
+
id: 'demo.store.readonly',
|
|
418
|
+
meta: { domain: 'readonly-demo' },
|
|
419
|
+
url,
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const setupReadonlyUserStore = async (url: string, rootDir: string) => {
|
|
424
|
+
const db = createReadonlyUserStore(url);
|
|
425
|
+
return {
|
|
426
|
+
created: await unwrapCreated(db.create(createResourceInput(rootDir))),
|
|
427
|
+
db,
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
type ReadonlyUserStoreRuntime = Awaited<
|
|
432
|
+
ReturnType<typeof setupReadonlyUserStore>
|
|
433
|
+
>['created'];
|
|
434
|
+
type VersionedUserStoreRuntime = Awaited<
|
|
435
|
+
ReturnType<typeof setupVersionedUserStore>
|
|
436
|
+
>['created'];
|
|
437
|
+
|
|
438
|
+
const expectReadonlyReads = async (
|
|
439
|
+
created: ReadonlyUserStoreRuntime,
|
|
440
|
+
inserted: z.output<typeof userSchema>
|
|
441
|
+
): Promise<void> => {
|
|
442
|
+
expect(await created.users.get(inserted.id)).toEqual(inserted);
|
|
443
|
+
expect(await created.users.list()).toEqual([inserted]);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const expectReadonlyWriteFailure = async (
|
|
447
|
+
created: ReadonlyUserStoreRuntime
|
|
448
|
+
): Promise<void> => {
|
|
449
|
+
await expect(
|
|
450
|
+
created.query(({ drizzle, tables }) =>
|
|
451
|
+
drizzle
|
|
452
|
+
.insert(tables.users)
|
|
453
|
+
.values({ email: 'blocked@example.com', id: 'blocked' })
|
|
454
|
+
.run()
|
|
455
|
+
)
|
|
456
|
+
).rejects.toThrow();
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const expectVersionedCreate = async (
|
|
460
|
+
created: VersionedUserStoreRuntime
|
|
461
|
+
): Promise<{
|
|
462
|
+
readonly first: Awaited<ReturnType<typeof created.users.upsert>>;
|
|
463
|
+
readonly second: Awaited<ReturnType<typeof created.users.upsert>>;
|
|
464
|
+
}> => {
|
|
465
|
+
const first = await expectOk(
|
|
466
|
+
created.users.upsert({ email: 'versioned@example.com' })
|
|
467
|
+
);
|
|
468
|
+
expect(first).toEqual(
|
|
469
|
+
expect.objectContaining({
|
|
470
|
+
email: 'versioned@example.com',
|
|
471
|
+
id: expect.any(String),
|
|
472
|
+
version: 1,
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
expect(await created.users.get(first.id)).toEqual(first);
|
|
476
|
+
|
|
477
|
+
const second = await expectOk(
|
|
478
|
+
created.users.upsert({
|
|
479
|
+
email: 'versioned+updated@example.com',
|
|
480
|
+
id: first.id,
|
|
481
|
+
version: first.version,
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
expect(second).toEqual({
|
|
485
|
+
email: 'versioned+updated@example.com',
|
|
486
|
+
id: first.id,
|
|
487
|
+
version: 2,
|
|
488
|
+
});
|
|
489
|
+
expect(await created.users.get(first.id)).toEqual(second);
|
|
490
|
+
|
|
491
|
+
return { first, second };
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const createErrorStore = (rootDir: string) =>
|
|
495
|
+
connectDrizzle(
|
|
496
|
+
defineStore({
|
|
497
|
+
accounts: {
|
|
498
|
+
primaryKey: 'id',
|
|
499
|
+
schema: accountSchema,
|
|
500
|
+
},
|
|
501
|
+
gists: {
|
|
502
|
+
...gistTable,
|
|
503
|
+
references: { ownerId: 'accounts' },
|
|
504
|
+
},
|
|
505
|
+
}),
|
|
506
|
+
{
|
|
507
|
+
id: 'demo.store.errors',
|
|
508
|
+
url: join(rootDir, 'errors.sqlite'),
|
|
509
|
+
}
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
describe('writable user accessor contract', () => {
|
|
513
|
+
let tmpRoot: string | undefined;
|
|
514
|
+
|
|
515
|
+
afterEach(() => {
|
|
516
|
+
if (tmpRoot !== undefined) {
|
|
517
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
518
|
+
tmpRoot = undefined;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const makeRoot = (): string => {
|
|
523
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'store-drizzle-'));
|
|
524
|
+
return tmpRoot;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const contractCases = createStoreAccessorContractCases({
|
|
528
|
+
createInput: () => ({ email: 'contract@example.com' }),
|
|
529
|
+
async createSubject() {
|
|
530
|
+
const rootDir = makeRoot();
|
|
531
|
+
const { created, db } = await setupWritableDemoStore(rootDir);
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
accessor: created.users,
|
|
535
|
+
dispose: async () => {
|
|
536
|
+
await db.dispose?.(created);
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
expectCreated(entity, input) {
|
|
541
|
+
expect(entity).toEqual(
|
|
542
|
+
expect.objectContaining({
|
|
543
|
+
email: input.email,
|
|
544
|
+
id: expect.any(String),
|
|
545
|
+
})
|
|
546
|
+
);
|
|
547
|
+
},
|
|
548
|
+
expectUpdated(entity, previous, input) {
|
|
549
|
+
expect(entity).toEqual({
|
|
550
|
+
email: input.email,
|
|
551
|
+
id: previous.id,
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
missingId: 'missing-user-id',
|
|
555
|
+
table: writableDemoDefinition.tables.users,
|
|
556
|
+
updateInput(existing) {
|
|
557
|
+
return {
|
|
558
|
+
email: 'contract+updated@example.com',
|
|
559
|
+
id: existing.id,
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test.each(
|
|
565
|
+
contractCases.map((contractCase) => [contractCase.name, contractCase.run])
|
|
566
|
+
)('%s', async (_name, run) => {
|
|
567
|
+
await run();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe('versioned user accessor contract', () => {
|
|
572
|
+
let tmpRoot: string | undefined;
|
|
573
|
+
|
|
574
|
+
afterEach(() => {
|
|
575
|
+
if (tmpRoot !== undefined) {
|
|
576
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
577
|
+
tmpRoot = undefined;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const makeRoot = (): string => {
|
|
582
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'store-drizzle-versioned-'));
|
|
583
|
+
return tmpRoot;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const contractCases = createStoreAccessorContractCases({
|
|
587
|
+
createInput: () => ({ email: 'contract@example.com' }),
|
|
588
|
+
async createSubject() {
|
|
589
|
+
const rootDir = makeRoot();
|
|
590
|
+
const { created, db } = await setupVersionedUserStore(rootDir);
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
accessor: created.users,
|
|
594
|
+
dispose: async () => {
|
|
595
|
+
await db.dispose?.(created);
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
expectCreated(entity, input) {
|
|
600
|
+
expect(entity).toEqual(
|
|
601
|
+
expect.objectContaining({
|
|
602
|
+
email: input.email,
|
|
603
|
+
id: expect.any(String),
|
|
604
|
+
version: 1,
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
expectUpdated(entity, previous, input) {
|
|
609
|
+
expect(entity).toEqual({
|
|
610
|
+
email: input.email,
|
|
611
|
+
id: previous.id,
|
|
612
|
+
version: previous.version + 1,
|
|
613
|
+
});
|
|
614
|
+
},
|
|
615
|
+
missingId: 'missing-user-id',
|
|
616
|
+
table: versionedUserDefinition.tables.users,
|
|
617
|
+
updateInput(existing) {
|
|
618
|
+
return {
|
|
619
|
+
email: 'contract+updated@example.com',
|
|
620
|
+
id: existing.id,
|
|
621
|
+
version: existing.version,
|
|
622
|
+
};
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test.each(
|
|
627
|
+
contractCases.map((contractCase) => [contractCase.name, contractCase.run])
|
|
628
|
+
)('%s', async (_name, run) => {
|
|
629
|
+
await run();
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('@ontrails/drizzle resource access', () => {
|
|
634
|
+
const tmp = createTmpRootManager('store-drizzle-');
|
|
635
|
+
|
|
636
|
+
afterEach(() => {
|
|
637
|
+
tmp.cleanup();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('binds a writable resource with CRUD accessors and one escape hatch', async () => {
|
|
641
|
+
const rootDir = tmp.makeRoot();
|
|
642
|
+
const { created, db } = await setupWritableDemoStore(rootDir);
|
|
643
|
+
expectWritableResourceDefinition(db);
|
|
644
|
+
await expectWritableLifecycle(created);
|
|
645
|
+
expectResourceResolution(db, created);
|
|
646
|
+
await db.dispose?.(created);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test('creates a writable mock resource seeded from fixtures', async () => {
|
|
650
|
+
const db = createFixtureBackedStore();
|
|
651
|
+
|
|
652
|
+
const mock = await db.mock?.();
|
|
653
|
+
expect(mock).toBeDefined();
|
|
654
|
+
expect(await mock?.users.get('user-seed')).toEqual(
|
|
655
|
+
expect.objectContaining({
|
|
656
|
+
email: 'seed@example.com',
|
|
657
|
+
id: 'user-seed',
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
expect(await mock?.gists.get('gist-seed')).toEqual(
|
|
661
|
+
expect.objectContaining({
|
|
662
|
+
createdAt: expect.any(String),
|
|
663
|
+
description: null,
|
|
664
|
+
id: 'gist-seed',
|
|
665
|
+
isPublic: true,
|
|
666
|
+
ownerId: 'user-seed',
|
|
667
|
+
updatedAt: expect.any(String),
|
|
668
|
+
})
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('manages versioned writes and rejects stale optimistic-concurrency updates', async () => {
|
|
673
|
+
const rootDir = tmp.makeRoot();
|
|
674
|
+
const { created, db } = await setupVersionedUserStore(rootDir);
|
|
675
|
+
const { first, second } = await expectVersionedCreate(created);
|
|
676
|
+
|
|
677
|
+
await expect(
|
|
678
|
+
created.users.upsert({
|
|
679
|
+
email: 'stale@example.com',
|
|
680
|
+
id: first.id,
|
|
681
|
+
version: first.version,
|
|
682
|
+
})
|
|
683
|
+
).rejects.toBeInstanceOf(ConflictError);
|
|
684
|
+
|
|
685
|
+
expect(await created.users.list()).toEqual([second]);
|
|
686
|
+
await db.dispose?.(created);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('keeps non-versioned writes free of framework-managed version fields', async () => {
|
|
690
|
+
const rootDir = tmp.makeRoot();
|
|
691
|
+
const { created, db } = await setupWritableDemoStore(rootDir);
|
|
692
|
+
const user = await expectOk(
|
|
693
|
+
created.users.upsert({ email: 'plain@example.com' })
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
expect(user).toEqual({
|
|
697
|
+
email: 'plain@example.com',
|
|
698
|
+
id: expect.any(String),
|
|
699
|
+
});
|
|
700
|
+
expect(user).not.toHaveProperty('version');
|
|
701
|
+
await db.dispose?.(created);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test('round-trips a user-declared "version" field on non-versioned tables', async () => {
|
|
705
|
+
const rootDir = tmp.makeRoot();
|
|
706
|
+
const db = connectDrizzle(
|
|
707
|
+
defineStore({
|
|
708
|
+
documents: {
|
|
709
|
+
generated: ['id'],
|
|
710
|
+
primaryKey: 'id',
|
|
711
|
+
schema: z.object({
|
|
712
|
+
body: z.string(),
|
|
713
|
+
id: z.string(),
|
|
714
|
+
version: z.string(),
|
|
715
|
+
}),
|
|
716
|
+
},
|
|
717
|
+
}),
|
|
718
|
+
{
|
|
719
|
+
description: 'Non-versioned store with a user-owned "version" column',
|
|
720
|
+
id: 'demo.store.user-version',
|
|
721
|
+
url: join(rootDir, 'user-version.sqlite'),
|
|
722
|
+
}
|
|
723
|
+
);
|
|
724
|
+
const created = await unwrapCreated(
|
|
725
|
+
db.create(createResourceInput(rootDir))
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
const inserted = await created.documents.insert({
|
|
729
|
+
body: 'original',
|
|
730
|
+
version: 'draft-1',
|
|
731
|
+
});
|
|
732
|
+
expect(inserted).toEqual(
|
|
733
|
+
expect.objectContaining({
|
|
734
|
+
body: 'original',
|
|
735
|
+
id: expect.any(String),
|
|
736
|
+
version: 'draft-1',
|
|
737
|
+
})
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
const upserted = await created.documents.upsert({
|
|
741
|
+
body: 'final',
|
|
742
|
+
id: inserted.id,
|
|
743
|
+
version: 'draft-2',
|
|
744
|
+
});
|
|
745
|
+
expect(upserted).toEqual({
|
|
746
|
+
body: 'final',
|
|
747
|
+
id: inserted.id,
|
|
748
|
+
version: 'draft-2',
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const reread = await created.documents.get(inserted.id);
|
|
752
|
+
expect(reread).toEqual(upserted);
|
|
753
|
+
await db.dispose?.(created);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test('atomically rejects stale upserts on versioned tables', async () => {
|
|
757
|
+
const rootDir = tmp.makeRoot();
|
|
758
|
+
const { created, db } = await setupVersionedUserStore(rootDir);
|
|
759
|
+
|
|
760
|
+
const first = await expectOk(
|
|
761
|
+
created.users.upsert({ email: 'race@example.com' })
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// Simulate a concurrent writer that advances the version between two
|
|
765
|
+
// attempts sharing the same stale expected version.
|
|
766
|
+
const winner = await expectOk(
|
|
767
|
+
created.users.upsert({
|
|
768
|
+
email: 'winner@example.com',
|
|
769
|
+
id: first.id,
|
|
770
|
+
version: first.version,
|
|
771
|
+
})
|
|
772
|
+
);
|
|
773
|
+
expect(winner.version).toBe(first.version + 1);
|
|
774
|
+
|
|
775
|
+
// The second attempt still holds the stale version and must be rejected
|
|
776
|
+
// by the atomic WHERE clause rather than silently overwriting.
|
|
777
|
+
await expect(
|
|
778
|
+
created.users.upsert({
|
|
779
|
+
email: 'loser@example.com',
|
|
780
|
+
id: first.id,
|
|
781
|
+
version: first.version,
|
|
782
|
+
})
|
|
783
|
+
).rejects.toBeInstanceOf(ConflictError);
|
|
784
|
+
|
|
785
|
+
expect(await created.users.get(first.id)).toEqual(winner);
|
|
786
|
+
await db.dispose?.(created);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test('fires derived change signals from context-bound writable accessors', async () => {
|
|
790
|
+
const rootDir = tmp.makeRoot();
|
|
791
|
+
const { bound, created, db, recorder } =
|
|
792
|
+
await createSignalBoundStore(rootDir);
|
|
793
|
+
const { createdGist, updatedGist } = await exerciseSignalWrites(bound);
|
|
794
|
+
|
|
795
|
+
expectRecordedSignals(recorder, createdGist, updatedGist);
|
|
796
|
+
await db.dispose?.(created);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
describe('@ontrails/drizzle read-only resource access', () => {
|
|
801
|
+
const tmp = createTmpRootManager('store-drizzle-readonly-');
|
|
802
|
+
|
|
803
|
+
afterEach(() => {
|
|
804
|
+
tmp.cleanup();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('opens a read-only store and enforces writes at the database layer', async () => {
|
|
808
|
+
const rootDir = tmp.makeRoot();
|
|
809
|
+
const url = join(rootDir, 'readonly.sqlite');
|
|
810
|
+
const inserted = await seedReadonlyFixture(url, rootDir);
|
|
811
|
+
const { created, db: readOnly } = await setupReadonlyUserStore(
|
|
812
|
+
url,
|
|
813
|
+
rootDir
|
|
814
|
+
);
|
|
815
|
+
expect(readOnly.access).toBe('readonly');
|
|
816
|
+
expect(readOnly.mock).toBeDefined();
|
|
817
|
+
expect(readOnly.signals).toBeUndefined();
|
|
818
|
+
await expectReadonlyReads(created, inserted);
|
|
819
|
+
await expectReadonlyWriteFailure(created);
|
|
820
|
+
await readOnly.dispose?.(created);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test('creates a mock resource seeded from mockSeed', async () => {
|
|
824
|
+
const db = connectReadOnlyDrizzle(
|
|
825
|
+
defineStore({
|
|
826
|
+
users: userTable,
|
|
827
|
+
}),
|
|
828
|
+
{
|
|
829
|
+
id: 'demo.store.readonly.mock',
|
|
830
|
+
mockSeed: {
|
|
831
|
+
users: [
|
|
832
|
+
{
|
|
833
|
+
email: 'mock@example.com',
|
|
834
|
+
id: 'user-mock',
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
},
|
|
838
|
+
url: ':memory:',
|
|
839
|
+
}
|
|
840
|
+
);
|
|
841
|
+
expect(db.access).toBe('readonly');
|
|
842
|
+
expect(db.signals).toBeUndefined();
|
|
843
|
+
|
|
844
|
+
const mockFactory = db.mock;
|
|
845
|
+
expect(mockFactory).toBeDefined();
|
|
846
|
+
|
|
847
|
+
const mock = await mockFactory?.();
|
|
848
|
+
expect(mock).toBeDefined();
|
|
849
|
+
expect(await mock?.users.get('user-mock')).toEqual(
|
|
850
|
+
expect.objectContaining({
|
|
851
|
+
email: 'mock@example.com',
|
|
852
|
+
id: 'user-mock',
|
|
853
|
+
})
|
|
854
|
+
);
|
|
855
|
+
expect(await mock?.users.list()).toHaveLength(1);
|
|
856
|
+
await db.dispose?.(mock as ReadonlyUserStoreRuntime);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test('creates a read-only mock that rejects writes through query', async () => {
|
|
860
|
+
const db = createReadonlyUserStore(
|
|
861
|
+
join(tmp.makeRoot(), 'readonly-mock.sqlite')
|
|
862
|
+
);
|
|
863
|
+
const mockFactory = db.mock;
|
|
864
|
+
expect(mockFactory).toBeDefined();
|
|
865
|
+
|
|
866
|
+
const mock = await mockFactory?.();
|
|
867
|
+
expect(mock).toBeDefined();
|
|
868
|
+
|
|
869
|
+
await expectReadonlyWriteFailure(mock as ReadonlyUserStoreRuntime);
|
|
870
|
+
await db.dispose?.(mock as ReadonlyUserStoreRuntime);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('preserves connector metadata on the resource definition', () => {
|
|
874
|
+
const db = createReadonlyUserStore(':memory:');
|
|
875
|
+
expect(db.meta).toEqual({ domain: 'readonly-demo' });
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
describe('@ontrails/drizzle edge cases', () => {
|
|
880
|
+
const tmp = createTmpRootManager('store-drizzle-');
|
|
881
|
+
|
|
882
|
+
afterEach(() => {
|
|
883
|
+
tmp.cleanup();
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('maps primary-key and foreign-key failures into Trails errors', async () => {
|
|
887
|
+
const rootDir = tmp.makeRoot();
|
|
888
|
+
const db = createErrorStore(rootDir);
|
|
889
|
+
const created = await unwrapCreated(
|
|
890
|
+
db.create(createResourceInput(rootDir))
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
await created.accounts.insert({
|
|
894
|
+
id: 'acct-1',
|
|
895
|
+
name: 'Alpha',
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
await expect(
|
|
899
|
+
created.accounts.insert({
|
|
900
|
+
id: 'acct-1',
|
|
901
|
+
name: 'Duplicate',
|
|
902
|
+
})
|
|
903
|
+
).rejects.toBeInstanceOf(AlreadyExistsError);
|
|
904
|
+
|
|
905
|
+
await expect(
|
|
906
|
+
created.gists.insert({
|
|
907
|
+
ownerId: 'missing-account',
|
|
908
|
+
})
|
|
909
|
+
).rejects.toBeInstanceOf(ValidationError);
|
|
910
|
+
|
|
911
|
+
await db.dispose?.(created);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test('maps z.number().int() to INTEGER (Zod internals regression guard)', () => {
|
|
915
|
+
const intStore = connectDrizzle(
|
|
916
|
+
defineStore({
|
|
917
|
+
counters: {
|
|
918
|
+
generated: ['id'],
|
|
919
|
+
primaryKey: 'id',
|
|
920
|
+
schema: z.object({
|
|
921
|
+
id: z.number().int(),
|
|
922
|
+
value: z.number(),
|
|
923
|
+
}),
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
{ url: join(tmp.makeRoot(), 'int.sqlite') }
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
const col = intStore.tables.counters;
|
|
930
|
+
expect(col).toBeDefined();
|
|
931
|
+
|
|
932
|
+
const idColumn = col.id as unknown as { columnType: string };
|
|
933
|
+
expect(idColumn.columnType).toBe('SQLiteInteger');
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test('rejects non-tabular store definitions with a clear error', () => {
|
|
937
|
+
const documentStore = defineStore(
|
|
938
|
+
{
|
|
939
|
+
documents: {
|
|
940
|
+
generated: ['id'],
|
|
941
|
+
primaryKey: 'id',
|
|
942
|
+
schema: z.object({
|
|
943
|
+
body: z.string(),
|
|
944
|
+
id: z.string(),
|
|
945
|
+
}),
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
{ kind: 'document' }
|
|
949
|
+
);
|
|
950
|
+
const rootDir = tmp.makeRoot();
|
|
951
|
+
|
|
952
|
+
const expectKindMismatch = (run: () => unknown) => {
|
|
953
|
+
try {
|
|
954
|
+
run();
|
|
955
|
+
throw new Error(
|
|
956
|
+
'expected connector binding to reject a non-tabular store'
|
|
957
|
+
);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
expect(error).toBeInstanceOf(ValidationError);
|
|
960
|
+
expect((error as Error).message).toContain('kind "tabular"');
|
|
961
|
+
expect((error as Error).message).toContain('"document"');
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
expectKindMismatch(() =>
|
|
966
|
+
connectDrizzle(documentStore, {
|
|
967
|
+
url: join(rootDir, 'document.sqlite'),
|
|
968
|
+
})
|
|
969
|
+
);
|
|
970
|
+
expectKindMismatch(() =>
|
|
971
|
+
connectReadOnlyDrizzle(documentStore, {
|
|
972
|
+
url: join(rootDir, 'document-readonly.sqlite'),
|
|
973
|
+
})
|
|
974
|
+
);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
test('update returns null for a non-existent ID', async () => {
|
|
978
|
+
const db = createFixtureBackedStore();
|
|
979
|
+
const mock = await db.mock?.();
|
|
980
|
+
expect(mock).toBeDefined();
|
|
981
|
+
|
|
982
|
+
const result = await mock?.gists.update('ghost-id', {
|
|
983
|
+
description: 'nope',
|
|
984
|
+
});
|
|
985
|
+
expect(result).toBeNull();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test('update rejects empty fields even when updatedAt is generated', async () => {
|
|
989
|
+
const db = createFixtureBackedStore();
|
|
990
|
+
const mock = await db.mock?.();
|
|
991
|
+
expect(mock).toBeDefined();
|
|
992
|
+
|
|
993
|
+
await expect(mock?.gists.update('gist-seed', {})).rejects.toBeInstanceOf(
|
|
994
|
+
ValidationError
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
});
|