@ontrails/core 1.0.0-beta.13 → 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-lint.log +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +19 -14
- package/dist/branded.d.ts +1 -1
- package/dist/branded.d.ts.map +1 -1
- package/dist/branded.js +1 -1
- package/dist/branded.js.map +1 -1
- package/dist/context.d.ts +13 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +19 -3
- package/dist/context.js.map +1 -1
- package/dist/contour.d.ts +81 -0
- package/dist/contour.d.ts.map +1 -0
- package/dist/contour.js +176 -0
- package/dist/contour.js.map +1 -0
- package/dist/cross-schema.d.ts +18 -0
- package/dist/cross-schema.d.ts.map +1 -0
- package/dist/cross-schema.js +29 -0
- package/dist/cross-schema.js.map +1 -0
- package/dist/derive.d.ts +6 -0
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +29 -5
- package/dist/derive.js.map +1 -1
- package/dist/draft.d.ts +28 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +157 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +62 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +26 -9
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +441 -20
- package/dist/execute.js.map +1 -1
- package/dist/fire.d.ts +43 -0
- package/dist/fire.d.ts.map +1 -0
- package/dist/fire.js +185 -0
- package/dist/fire.js.map +1 -0
- package/dist/index.d.ts +33 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cross-batch.d.ts +44 -0
- package/dist/internal/cross-batch.d.ts.map +1 -0
- package/dist/internal/cross-batch.js +58 -0
- package/dist/internal/cross-batch.js.map +1 -0
- package/dist/internal/fork-ctx.d.ts +41 -0
- package/dist/internal/fork-ctx.d.ts.map +1 -0
- package/dist/internal/fork-ctx.js +47 -0
- package/dist/internal/fork-ctx.js.map +1 -0
- package/dist/internal/signal-ref.d.ts +15 -0
- package/dist/internal/signal-ref.d.ts.map +1 -0
- package/dist/internal/signal-ref.js +41 -0
- package/dist/internal/signal-ref.js.map +1 -0
- package/dist/internal/topo-saves.d.ts +53 -0
- package/dist/internal/topo-saves.d.ts.map +1 -0
- package/dist/internal/topo-saves.js +449 -0
- package/dist/internal/topo-saves.js.map +1 -0
- package/dist/internal/topo-snapshots.d.ts +57 -0
- package/dist/internal/topo-snapshots.d.ts.map +1 -0
- package/dist/internal/topo-snapshots.js +372 -0
- package/dist/internal/topo-snapshots.js.map +1 -0
- package/dist/internal/topo-store-read.d.ts +70 -0
- package/dist/internal/topo-store-read.d.ts.map +1 -0
- package/dist/internal/topo-store-read.js +221 -0
- package/dist/internal/topo-store-read.js.map +1 -0
- package/dist/internal/topo-store.d.ts +26 -0
- package/dist/internal/topo-store.d.ts.map +1 -0
- package/dist/internal/topo-store.js +621 -0
- package/dist/internal/topo-store.js.map +1 -0
- package/dist/internal/tracing.d.ts +98 -0
- package/dist/internal/tracing.d.ts.map +1 -0
- package/dist/internal/tracing.js +102 -0
- package/dist/internal/tracing.js.map +1 -0
- package/dist/internal/trails-db.d.ts +16 -0
- package/dist/internal/trails-db.d.ts.map +1 -0
- package/dist/internal/trails-db.js +118 -0
- package/dist/internal/trails-db.js.map +1 -0
- package/dist/internal/zod-wrappers.d.ts +40 -0
- package/dist/internal/zod-wrappers.d.ts.map +1 -0
- package/dist/internal/zod-wrappers.js +59 -0
- package/dist/internal/zod-wrappers.js.map +1 -0
- package/dist/layer.d.ts +22 -5
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +20 -3
- package/dist/layer.js.map +1 -1
- package/dist/path-security.d.ts +1 -1
- package/dist/path-security.d.ts.map +1 -1
- package/dist/path-security.js +1 -1
- package/dist/path-security.js.map +1 -1
- package/dist/resilience.d.ts +1 -1
- package/dist/resilience.d.ts.map +1 -1
- package/dist/resilience.js +3 -3
- package/dist/resilience.js.map +1 -1
- package/dist/resource-config.d.ts +22 -0
- package/dist/resource-config.d.ts.map +1 -0
- package/dist/resource-config.js +210 -0
- package/dist/resource-config.js.map +1 -0
- package/dist/resource.d.ts +74 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +61 -0
- package/dist/resource.js.map +1 -0
- package/dist/run.d.ts +4 -3
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +5 -4
- package/dist/run.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +2 -1
- package/dist/serialization.js.map +1 -1
- package/dist/store/accessor-protocol.d.ts +51 -0
- package/dist/store/accessor-protocol.d.ts.map +1 -0
- package/dist/store/accessor-protocol.js +2 -0
- package/dist/store/accessor-protocol.js.map +1 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +2 -0
- package/dist/store/index.js.map +1 -0
- package/dist/surface-filter.d.ts +13 -0
- package/dist/surface-filter.d.ts.map +1 -0
- package/dist/surface-filter.js +76 -0
- package/dist/surface-filter.js.map +1 -0
- package/dist/topo-store.d.ts +62 -0
- package/dist/topo-store.d.ts.map +1 -0
- package/dist/topo-store.js +319 -0
- package/dist/topo-store.js.map +1 -0
- package/dist/topo.d.ts +23 -9
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +175 -46
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +86 -19
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +59 -4
- package/dist/trail.js.map +1 -1
- package/dist/trails/derive-trail.d.ts +66 -0
- package/dist/trails/derive-trail.d.ts.map +1 -0
- package/dist/trails/derive-trail.js +378 -0
- package/dist/trails/derive-trail.js.map +1 -0
- package/dist/trails/index.d.ts +5 -0
- package/dist/trails/index.d.ts.map +1 -0
- package/dist/trails/index.js +3 -0
- package/dist/trails/index.js.map +1 -0
- package/dist/trails/ingest.d.ts +24 -0
- package/dist/trails/ingest.d.ts.map +1 -0
- package/dist/trails/ingest.js +63 -0
- package/dist/trails/ingest.js.map +1 -0
- package/dist/transport-error-map.d.ts +106 -0
- package/dist/transport-error-map.d.ts.map +1 -0
- package/dist/transport-error-map.js +24 -0
- package/dist/transport-error-map.js.map +1 -0
- package/dist/type-checks.test-d.d.ts +71 -0
- package/dist/type-checks.test-d.d.ts.map +1 -0
- package/dist/type-checks.test-d.js +12 -0
- package/dist/type-checks.test-d.js.map +1 -0
- package/dist/type-utils.d.ts +11 -2
- package/dist/type-utils.d.ts.map +1 -1
- package/dist/type-utils.js.map +1 -1
- package/dist/types.d.ts +113 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate-established-topo.d.ts +76 -0
- package/dist/validate-established-topo.d.ts.map +1 -0
- package/dist/validate-established-topo.js +43 -0
- package/dist/validate-established-topo.js.map +1 -0
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +27 -8
- package/dist/validate-topo.js.map +1 -1
- package/dist/workspace.d.ts +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +1 -1
- package/dist/workspace.js.map +1 -1
- package/package.json +10 -1
- package/src/__tests__/branded.test.ts +5 -5
- package/src/__tests__/context.test.ts +3 -3
- package/src/__tests__/contour.test.ts +263 -0
- package/src/__tests__/derive-trail.test.ts +668 -0
- package/src/__tests__/derive.test.ts +58 -1
- package/src/__tests__/errors.test.ts +118 -0
- package/src/__tests__/execute.test.ts +1179 -141
- package/src/__tests__/fire.test.ts +1056 -0
- package/src/__tests__/fork-ctx.test.ts +83 -0
- package/src/__tests__/ingest.test.ts +191 -0
- package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
- package/src/__tests__/path-security.test.ts +7 -7
- package/src/__tests__/resilience.test.ts +8 -8
- package/src/__tests__/resource.test.ts +204 -0
- package/src/__tests__/run.test.ts +13 -13
- package/src/__tests__/service-config.test.ts +28 -32
- package/src/__tests__/surface-filter.test.ts +196 -0
- package/src/__tests__/topo-store-read.test.ts +256 -0
- package/src/__tests__/topo-store.test.ts +869 -0
- package/src/__tests__/topo.test.ts +350 -54
- package/src/__tests__/trail.test.ts +189 -24
- package/src/__tests__/trails-db.test.ts +198 -0
- package/src/__tests__/transport-error-map.test.ts +82 -0
- package/src/__tests__/type-utils.test.ts +29 -1
- package/src/__tests__/validate-topo.test.ts +305 -16
- package/src/__tests__/workspace.test.ts +6 -6
- package/src/__tests__/zod-wrappers.test.ts +62 -0
- package/src/branded.ts +1 -1
- package/src/context.ts +24 -4
- package/src/contour.ts +344 -0
- package/src/cross-schema.ts +36 -0
- package/src/derive.ts +39 -8
- package/src/draft.ts +338 -0
- package/src/errors.ts +62 -18
- package/src/execute.ts +840 -29
- package/src/fire.ts +295 -0
- package/src/index.ts +142 -26
- package/src/internal/cross-batch.ts +77 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/internal/signal-ref.ts +87 -0
- package/src/internal/topo-snapshots.ts +517 -0
- package/src/internal/topo-store-read.ts +481 -0
- package/src/internal/topo-store.ts +1192 -0
- package/src/internal/tracing.ts +187 -0
- package/src/internal/trails-db.ts +187 -0
- package/src/internal/zod-wrappers.ts +78 -0
- package/src/layer.ts +50 -0
- package/src/path-security.ts +1 -1
- package/src/resilience.ts +3 -3
- package/src/{provision-config.ts → resource-config.ts} +109 -123
- package/src/resource.ts +155 -0
- package/src/run.ts +5 -4
- package/src/serialization.ts +2 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/surface-filter.ts +182 -0
- package/src/topo-store.ts +510 -0
- package/src/topo.ts +328 -57
- package/src/trail.ts +199 -31
- package/src/trails/derive-trail.ts +842 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +146 -0
- package/src/transport-error-map.ts +56 -0
- package/src/type-checks.test-d.ts +106 -0
- package/src/type-utils.ts +17 -2
- package/src/types.ts +148 -11
- package/src/validate-established-topo.ts +63 -0
- package/src/validate-topo.ts +37 -9
- package/src/workspace.ts +1 -1
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/adapters.d.ts +0 -39
- package/dist/adapters.d.ts.map +0 -1
- package/dist/adapters.js +0 -2
- package/dist/adapters.js.map +0 -1
- package/dist/dispatch.d.ts +0 -27
- package/dist/dispatch.d.ts.map +0 -1
- package/dist/dispatch.js +0 -34
- package/dist/dispatch.js.map +0 -1
- package/dist/event.d.ts +0 -8
- package/dist/event.d.ts.map +0 -1
- package/dist/event.js +0 -7
- package/dist/event.js.map +0 -1
- package/dist/gate.d.ts +0 -17
- package/dist/gate.d.ts.map +0 -1
- package/dist/gate.js +0 -21
- package/dist/gate.js.map +0 -1
- package/dist/health.d.ts +0 -18
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js +0 -5
- package/dist/health.js.map +0 -1
- package/dist/job.d.ts +0 -24
- package/dist/job.d.ts.map +0 -1
- package/dist/job.js +0 -17
- package/dist/job.js.map +0 -1
- package/dist/provision-config.d.ts +0 -22
- package/dist/provision-config.d.ts.map +0 -1
- package/dist/provision-config.js +0 -210
- package/dist/provision-config.js.map +0 -1
- package/dist/provision.d.ts +0 -71
- package/dist/provision.d.ts.map +0 -1
- package/dist/provision.js +0 -56
- package/dist/provision.js.map +0 -1
- package/dist/service-config.d.ts +0 -22
- package/dist/service-config.d.ts.map +0 -1
- package/dist/service-config.js +0 -210
- package/dist/service-config.js.map +0 -1
- package/dist/service.d.ts +0 -71
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -56
- package/dist/service.js.map +0 -1
- package/src/__tests__/service.test.ts +0 -197
- package/src/event.ts +0 -15
- package/src/gate.ts +0 -44
- package/src/provision.ts +0 -148
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { contour } from '../contour.js';
|
|
6
|
+
import { createTrailContext } from '../context.js';
|
|
7
|
+
import { DerivationError, InternalError, NotFoundError } from '../errors.js';
|
|
8
|
+
import { Result } from '../result.js';
|
|
9
|
+
import { resource } from '../resource.js';
|
|
10
|
+
import type { DeriveTrailOperation } from '../trails/index.js';
|
|
11
|
+
import { deriveTrail } from '../trails/index.js';
|
|
12
|
+
|
|
13
|
+
const note = contour(
|
|
14
|
+
'note',
|
|
15
|
+
{
|
|
16
|
+
body: z.string().default('draft'),
|
|
17
|
+
createdAt: z.string(),
|
|
18
|
+
id: z.string(),
|
|
19
|
+
title: z.string(),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
examples: [
|
|
23
|
+
{
|
|
24
|
+
body: 'Hello, Trails',
|
|
25
|
+
createdAt: '2026-04-10T12:00:00.000Z',
|
|
26
|
+
id: 'note-1',
|
|
27
|
+
title: 'First note',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
identity: 'id',
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const noteResource = resource('db.notes', {
|
|
35
|
+
create: () =>
|
|
36
|
+
Result.ok({
|
|
37
|
+
name: 'notes',
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const noteExample = note.examples?.[0];
|
|
42
|
+
|
|
43
|
+
const createBlaze = () => Result.ok(noteExample);
|
|
44
|
+
const deleteBlaze = () => Result.ok();
|
|
45
|
+
const listBlaze = () => Result.ok([noteExample]);
|
|
46
|
+
|
|
47
|
+
const unsupportedOperation = (operation: never): never => {
|
|
48
|
+
throw new TypeError(`Unsupported test operation: ${String(operation)}`);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const deriveNoteTrail = <TOperation extends DeriveTrailOperation>(
|
|
52
|
+
operation: TOperation
|
|
53
|
+
) => {
|
|
54
|
+
switch (operation) {
|
|
55
|
+
case 'create': {
|
|
56
|
+
return deriveTrail(note, operation, {
|
|
57
|
+
blaze: createBlaze,
|
|
58
|
+
generated: ['id', 'createdAt'],
|
|
59
|
+
resource: noteResource,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
case 'read': {
|
|
63
|
+
return deriveTrail(note, operation, {
|
|
64
|
+
blaze: createBlaze,
|
|
65
|
+
resource: noteResource,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
case 'update': {
|
|
69
|
+
return deriveTrail(note, operation, {
|
|
70
|
+
blaze: createBlaze,
|
|
71
|
+
generated: ['id', 'createdAt'],
|
|
72
|
+
resource: noteResource,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
case 'delete': {
|
|
76
|
+
return deriveTrail(note, operation, {
|
|
77
|
+
blaze: deleteBlaze,
|
|
78
|
+
resource: noteResource,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
case 'list': {
|
|
82
|
+
return deriveTrail(note, operation, {
|
|
83
|
+
blaze: listBlaze,
|
|
84
|
+
resource: noteResource,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
default: {
|
|
88
|
+
return unsupportedOperation(operation);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const deriveCrudTrails = () => ({
|
|
94
|
+
created: deriveNoteTrail('create'),
|
|
95
|
+
listed: deriveNoteTrail('list'),
|
|
96
|
+
read: deriveNoteTrail('read'),
|
|
97
|
+
removed: deriveNoteTrail('delete'),
|
|
98
|
+
updated: deriveNoteTrail('update'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const expectTrailMetadata = (
|
|
102
|
+
trail: ReturnType<typeof deriveNoteTrail>,
|
|
103
|
+
expectedId: string,
|
|
104
|
+
expectedIntent: 'destroy' | 'read' | 'write'
|
|
105
|
+
) => {
|
|
106
|
+
expect(trail.id).toBe(expectedId);
|
|
107
|
+
expect(trail.intent).toBe(expectedIntent);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const expectCreateSchemas = (
|
|
111
|
+
created: ReturnType<typeof deriveNoteTrail<'create'>>
|
|
112
|
+
) => {
|
|
113
|
+
expect(
|
|
114
|
+
created.input.safeParse({ body: 'Body', title: 'Created note' }).success
|
|
115
|
+
).toBe(true);
|
|
116
|
+
expect(
|
|
117
|
+
created.input.parse({
|
|
118
|
+
createdAt: '2026-04-10T12:00:00.000Z',
|
|
119
|
+
id: 'note-1',
|
|
120
|
+
title: 'Created note',
|
|
121
|
+
})
|
|
122
|
+
).toEqual({ body: 'draft', title: 'Created note' });
|
|
123
|
+
expect(created.output?.safeParse(note.examples?.[0]).success).toBe(true);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const expectReadSchemas = (
|
|
127
|
+
read: ReturnType<typeof deriveNoteTrail<'read'>>
|
|
128
|
+
) => {
|
|
129
|
+
expect(read.input.safeParse({ id: 'note-1' }).success).toBe(true);
|
|
130
|
+
expect(read.input.safeParse({ title: 'Nope' }).success).toBe(false);
|
|
131
|
+
expect(read.output?.safeParse(note.examples?.[0]).success).toBe(true);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const expectUpdateSchemas = (
|
|
135
|
+
updated: ReturnType<typeof deriveNoteTrail<'update'>>
|
|
136
|
+
) => {
|
|
137
|
+
expect(updated.input.safeParse({ id: 'note-1' }).success).toBe(true);
|
|
138
|
+
expect(
|
|
139
|
+
updated.input.safeParse({ body: 'Updated', id: 'note-1' }).success
|
|
140
|
+
).toBe(true);
|
|
141
|
+
expect(
|
|
142
|
+
updated.input.parse({
|
|
143
|
+
createdAt: '2026-04-10T12:00:00.000Z',
|
|
144
|
+
id: 'note-1',
|
|
145
|
+
})
|
|
146
|
+
).toEqual({ id: 'note-1' });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const expectDeleteSchemas = (
|
|
150
|
+
removed: ReturnType<typeof deriveNoteTrail<'delete'>>
|
|
151
|
+
) => {
|
|
152
|
+
expect(removed.input.safeParse({ id: 'note-1' }).success).toBe(true);
|
|
153
|
+
expect(removed.output?.safeParse().success).toBe(true);
|
|
154
|
+
expect(removed.output?.safeParse(note.examples?.[0]).success).toBe(false);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const expectListSchemas = (
|
|
158
|
+
listed: ReturnType<typeof deriveNoteTrail<'list'>>
|
|
159
|
+
) => {
|
|
160
|
+
expect(listed.input.safeParse({}).success).toBe(true);
|
|
161
|
+
expect(listed.input.safeParse({ id: 'note-1' }).success).toBe(true);
|
|
162
|
+
expect(listed.output?.safeParse([note.examples?.[0]]).success).toBe(true);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
describe('deriveTrail()', () => {
|
|
166
|
+
test('derives trail ids, intents, contours, and resources for CRUD operations', () => {
|
|
167
|
+
const { created, listed, read, removed, updated } = deriveCrudTrails();
|
|
168
|
+
|
|
169
|
+
expectTrailMetadata(created, 'note.create', 'write');
|
|
170
|
+
expectTrailMetadata(read, 'note.read', 'read');
|
|
171
|
+
expectTrailMetadata(updated, 'note.update', 'write');
|
|
172
|
+
expectTrailMetadata(removed, 'note.delete', 'destroy');
|
|
173
|
+
expectTrailMetadata(listed, 'note.list', 'read');
|
|
174
|
+
|
|
175
|
+
expect(created.contours).toEqual([note]);
|
|
176
|
+
expect(created.resources).toEqual([noteResource]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('derives input and output schemas for each CRUD operation', () => {
|
|
180
|
+
const { created, listed, read, removed, updated } = deriveCrudTrails();
|
|
181
|
+
|
|
182
|
+
expectCreateSchemas(created);
|
|
183
|
+
expectReadSchemas(read);
|
|
184
|
+
expectUpdateSchemas(updated);
|
|
185
|
+
expectDeleteSchemas(removed);
|
|
186
|
+
expectListSchemas(listed);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('derives per-operation examples from contour examples', () => {
|
|
190
|
+
const { created, listed, read, removed, updated } = deriveCrudTrails();
|
|
191
|
+
|
|
192
|
+
expect(created.examples).toEqual([
|
|
193
|
+
{
|
|
194
|
+
expected: note.examples?.[0],
|
|
195
|
+
input: { body: 'Hello, Trails', title: 'First note' },
|
|
196
|
+
name: 'Create note note-1',
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
expect(read.examples).toEqual([
|
|
200
|
+
{
|
|
201
|
+
expected: note.examples?.[0],
|
|
202
|
+
input: { id: 'note-1' },
|
|
203
|
+
name: 'Read note note-1',
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
expect(updated.examples).toEqual([
|
|
207
|
+
{
|
|
208
|
+
expected: note.examples?.[0],
|
|
209
|
+
input: { body: 'Hello, Trails', id: 'note-1', title: 'First note' },
|
|
210
|
+
name: 'Update note note-1',
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
expect(removed.examples).toEqual([
|
|
214
|
+
{
|
|
215
|
+
input: { id: 'note-1' },
|
|
216
|
+
name: 'Delete note note-1',
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
expect(listed.examples).toEqual([
|
|
220
|
+
{
|
|
221
|
+
expected: [note.examples?.[0]],
|
|
222
|
+
input: {},
|
|
223
|
+
name: 'note list example',
|
|
224
|
+
},
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('drops schema defaults from update and list filters', () => {
|
|
229
|
+
const updated = deriveNoteTrail('update');
|
|
230
|
+
const listed = deriveNoteTrail('list');
|
|
231
|
+
|
|
232
|
+
expect(updated.input.parse({ id: 'note-1' })).toEqual({ id: 'note-1' });
|
|
233
|
+
expect(listed.input.parse({})).toEqual({});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('does not auto-stamp a pattern when none is declared', () => {
|
|
237
|
+
const created = deriveNoteTrail('create');
|
|
238
|
+
|
|
239
|
+
expect(created.pattern).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('preserves a caller-declared pattern', () => {
|
|
243
|
+
const created = deriveTrail(note, 'create', {
|
|
244
|
+
blaze: createBlaze,
|
|
245
|
+
generated: ['id', 'createdAt'],
|
|
246
|
+
pattern: 'crud',
|
|
247
|
+
resource: noteResource,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(created.pattern).toBe('crud');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Default-blaze synthesis
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
interface NoteEntity {
|
|
259
|
+
readonly id: string;
|
|
260
|
+
readonly title: string;
|
|
261
|
+
readonly body: string;
|
|
262
|
+
readonly createdAt: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface FakeNoteAccessor {
|
|
266
|
+
get: (id: string) => Promise<NoteEntity | null>;
|
|
267
|
+
list: (filters?: Partial<NoteEntity>) => Promise<readonly NoteEntity[]>;
|
|
268
|
+
upsert: (input: Partial<NoteEntity>) => Promise<NoteEntity>;
|
|
269
|
+
remove: (id: string) => Promise<{ readonly deleted: boolean }>;
|
|
270
|
+
insert?: (input: Partial<NoteEntity>) => Promise<NoteEntity>;
|
|
271
|
+
update?: (
|
|
272
|
+
id: string,
|
|
273
|
+
patch: Partial<NoteEntity>
|
|
274
|
+
) => Promise<NoteEntity | null>;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const cloneNote = (value: NoteEntity): NoteEntity => ({ ...value });
|
|
278
|
+
|
|
279
|
+
const seedNote: NoteEntity = {
|
|
280
|
+
body: 'Hello, Trails',
|
|
281
|
+
createdAt: '2026-04-10T12:00:00.000Z',
|
|
282
|
+
id: 'note-1',
|
|
283
|
+
title: 'First note',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const matchesFilters = (
|
|
287
|
+
row: NoteEntity,
|
|
288
|
+
filters: Partial<NoteEntity> | undefined
|
|
289
|
+
): boolean =>
|
|
290
|
+
filters === undefined ||
|
|
291
|
+
Object.entries(filters).every(
|
|
292
|
+
([field, value]) => row[field as keyof NoteEntity] === value
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const readOneFromMap = <V>(table: Map<string, V>, id: string): V | null => {
|
|
296
|
+
const entry = table.get(id);
|
|
297
|
+
return entry === undefined ? null : entry;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const seedRecords = (seed: NoteEntity | null): Map<string, NoteEntity> => {
|
|
301
|
+
const records = new Map<string, NoteEntity>();
|
|
302
|
+
if (seed !== null) {
|
|
303
|
+
records.set(seed.id, cloneNote(seed));
|
|
304
|
+
}
|
|
305
|
+
return records;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const makeBaseAccessor = (
|
|
309
|
+
records: Map<string, NoteEntity>
|
|
310
|
+
): FakeNoteAccessor => {
|
|
311
|
+
const writePayload = (input: Partial<NoteEntity>) => {
|
|
312
|
+
const payload = { ...seedNote, ...(input as NoteEntity) };
|
|
313
|
+
records.set(payload.id, payload);
|
|
314
|
+
return cloneNote(payload);
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
318
|
+
get: async (id) => readOneFromMap(records, id),
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
320
|
+
list: async (filters) =>
|
|
321
|
+
[...records.values()].filter((row) => matchesFilters(row, filters)),
|
|
322
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
323
|
+
remove: async (id) => ({ deleted: records.delete(id) }),
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
325
|
+
upsert: async (input) => writePayload(input),
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const attachInsert = (
|
|
330
|
+
accessor: FakeNoteAccessor,
|
|
331
|
+
records: Map<string, NoteEntity>
|
|
332
|
+
): void => {
|
|
333
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
334
|
+
accessor.insert = async (input) => {
|
|
335
|
+
const payload = { ...seedNote, ...(input as NoteEntity) };
|
|
336
|
+
records.set(payload.id, payload);
|
|
337
|
+
return cloneNote(payload);
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const attachUpdate = (
|
|
342
|
+
accessor: FakeNoteAccessor,
|
|
343
|
+
records: Map<string, NoteEntity>
|
|
344
|
+
): void => {
|
|
345
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
346
|
+
accessor.update = async (id, patch) => {
|
|
347
|
+
const existing = records.get(id);
|
|
348
|
+
if (!existing) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const merged = { ...existing, ...patch } as NoteEntity;
|
|
352
|
+
records.set(id, merged);
|
|
353
|
+
return cloneNote(merged);
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const createFakeAccessor = (
|
|
358
|
+
options: {
|
|
359
|
+
insert?: boolean;
|
|
360
|
+
update?: boolean;
|
|
361
|
+
seed?: NoteEntity | null;
|
|
362
|
+
} = {}
|
|
363
|
+
): FakeNoteAccessor => {
|
|
364
|
+
const seed = options.seed === undefined ? seedNote : options.seed;
|
|
365
|
+
const records = seedRecords(seed);
|
|
366
|
+
const accessor = makeBaseAccessor(records);
|
|
367
|
+
if (options.insert !== false) {
|
|
368
|
+
attachInsert(accessor, records);
|
|
369
|
+
}
|
|
370
|
+
if (options.update !== false) {
|
|
371
|
+
attachUpdate(accessor, records);
|
|
372
|
+
}
|
|
373
|
+
return accessor;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
interface VersionedFake {
|
|
377
|
+
readonly accessor: FakeNoteAccessor;
|
|
378
|
+
readonly upsertLog: readonly Record<string, unknown>[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const createVersionedFake = (): VersionedFake => {
|
|
382
|
+
const versionedSeed = {
|
|
383
|
+
...seedNote,
|
|
384
|
+
version: 7,
|
|
385
|
+
} as NoteEntity & { version: number };
|
|
386
|
+
const records = new Map<string, NoteEntity & { version: number }>([
|
|
387
|
+
[versionedSeed.id, versionedSeed],
|
|
388
|
+
]);
|
|
389
|
+
const upsertLog: Record<string, unknown>[] = [];
|
|
390
|
+
const accessor: FakeNoteAccessor = {
|
|
391
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
392
|
+
get: async (id) => readOneFromMap(records, id),
|
|
393
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
394
|
+
list: async () => [...records.values()],
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
396
|
+
remove: async (id) => ({ deleted: records.delete(id) }),
|
|
397
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
398
|
+
upsert: async (input) => {
|
|
399
|
+
const payload = input as Record<string, unknown>;
|
|
400
|
+
upsertLog.push(payload);
|
|
401
|
+
const merged = {
|
|
402
|
+
...versionedSeed,
|
|
403
|
+
...(payload as NoteEntity & { version?: number }),
|
|
404
|
+
};
|
|
405
|
+
records.set(merged.id, merged);
|
|
406
|
+
return cloneNote(merged);
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
return { accessor, upsertLog };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const expectOk = <T>(result: Result<T, Error>): T => {
|
|
413
|
+
if (result.isErr()) {
|
|
414
|
+
throw result.error;
|
|
415
|
+
}
|
|
416
|
+
return result.value;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const expectErr = <T>(result: Result<T, Error>): Error => {
|
|
420
|
+
if (result.isOk()) {
|
|
421
|
+
throw new Error(`Expected error, got ok: ${JSON.stringify(result.value)}`);
|
|
422
|
+
}
|
|
423
|
+
return result.error;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const makeNotesResource = (accessor: FakeNoteAccessor) =>
|
|
427
|
+
resource('db.notes', {
|
|
428
|
+
create: () => Result.ok({ note: accessor }),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const ctxFor = (accessor: FakeNoteAccessor) =>
|
|
432
|
+
createTrailContext({
|
|
433
|
+
extensions: {
|
|
434
|
+
'db.notes': { note: accessor },
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe('deriveTrail() default blaze synthesis — create', () => {
|
|
439
|
+
test('synthesizes create blaze that calls accessor.insert', async () => {
|
|
440
|
+
const accessor = createFakeAccessor();
|
|
441
|
+
const notesResource = makeNotesResource(accessor);
|
|
442
|
+
|
|
443
|
+
const created = deriveTrail(note, 'create', {
|
|
444
|
+
generated: ['id', 'createdAt'],
|
|
445
|
+
resource: notesResource,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const result = await created.blaze(
|
|
449
|
+
{ body: 'Bye, Trails', title: 'Second note' },
|
|
450
|
+
ctxFor(accessor)
|
|
451
|
+
);
|
|
452
|
+
expect(result.isOk()).toBe(true);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('create falls back to upsert when insert is absent', async () => {
|
|
456
|
+
const accessor = createFakeAccessor({ insert: false });
|
|
457
|
+
const notesResource = makeNotesResource(accessor);
|
|
458
|
+
|
|
459
|
+
const created = deriveTrail(note, 'create', {
|
|
460
|
+
generated: ['id', 'createdAt'],
|
|
461
|
+
resource: notesResource,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const result = await created.blaze(
|
|
465
|
+
{ body: 'Upsert body', title: 'Upsert note' },
|
|
466
|
+
ctxFor(accessor)
|
|
467
|
+
);
|
|
468
|
+
expect(result.isOk()).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('custom blaze wins over default synthesis', async () => {
|
|
472
|
+
const accessor = createFakeAccessor();
|
|
473
|
+
const notesResource = makeNotesResource(accessor);
|
|
474
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
475
|
+
const customBlaze = mock(async () => Result.ok(seedNote));
|
|
476
|
+
|
|
477
|
+
const created = deriveTrail(note, 'create', {
|
|
478
|
+
blaze: customBlaze,
|
|
479
|
+
generated: ['id', 'createdAt'],
|
|
480
|
+
resource: notesResource,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await created.blaze(
|
|
484
|
+
{ body: 'ignored', title: 'ignored' },
|
|
485
|
+
ctxFor(accessor)
|
|
486
|
+
);
|
|
487
|
+
expect(customBlaze).toHaveBeenCalledTimes(1);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe('deriveTrail() default blaze synthesis — read', () => {
|
|
492
|
+
test('synthesizes read blaze that returns entity', async () => {
|
|
493
|
+
const accessor = createFakeAccessor();
|
|
494
|
+
const notesResource = makeNotesResource(accessor);
|
|
495
|
+
|
|
496
|
+
const read = deriveTrail(note, 'read', { resource: notesResource });
|
|
497
|
+
const result = await read.blaze({ id: 'note-1' }, ctxFor(accessor));
|
|
498
|
+
const value = expectOk(result);
|
|
499
|
+
expect(value.id).toBe('note-1');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('read returns InternalError when accessor has no get', async () => {
|
|
503
|
+
const accessor = createFakeAccessor();
|
|
504
|
+
const broken = {
|
|
505
|
+
...accessor,
|
|
506
|
+
get: undefined,
|
|
507
|
+
} as unknown as FakeNoteAccessor;
|
|
508
|
+
const notesResource = makeNotesResource(broken);
|
|
509
|
+
|
|
510
|
+
const read = deriveTrail(note, 'read', { resource: notesResource });
|
|
511
|
+
const result = await read.blaze({ id: 'note-1' }, ctxFor(broken));
|
|
512
|
+
expect(expectErr(result)).toBeInstanceOf(InternalError);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('read returns Result.err when resource lookup throws', async () => {
|
|
516
|
+
const accessor = createFakeAccessor();
|
|
517
|
+
const notesResource = makeNotesResource(accessor);
|
|
518
|
+
|
|
519
|
+
const read = deriveTrail(note, 'read', { resource: notesResource });
|
|
520
|
+
const result = await read.blaze(
|
|
521
|
+
{ id: 'note-1' },
|
|
522
|
+
createTrailContext({ extensions: {} })
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(expectErr(result)).toBeInstanceOf(InternalError);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe('deriveTrail() default blaze synthesis — update', () => {
|
|
530
|
+
test('update uses accessor.update when present', async () => {
|
|
531
|
+
const accessor = createFakeAccessor();
|
|
532
|
+
const notesResource = makeNotesResource(accessor);
|
|
533
|
+
|
|
534
|
+
const updated = deriveTrail(note, 'update', {
|
|
535
|
+
generated: ['id', 'createdAt'],
|
|
536
|
+
resource: notesResource,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const result = await updated.blaze(
|
|
540
|
+
{ id: 'note-1', title: 'Renamed' },
|
|
541
|
+
ctxFor(accessor)
|
|
542
|
+
);
|
|
543
|
+
const value = expectOk(result);
|
|
544
|
+
expect(value.title).toBe('Renamed');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('update returns NotFoundError when accessor.update returns null', async () => {
|
|
548
|
+
const accessor = createFakeAccessor({ seed: null });
|
|
549
|
+
const notesResource = makeNotesResource(accessor);
|
|
550
|
+
|
|
551
|
+
const updated = deriveTrail(note, 'update', {
|
|
552
|
+
generated: ['id', 'createdAt'],
|
|
553
|
+
resource: notesResource,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const result = await updated.blaze(
|
|
557
|
+
{ id: 'missing', title: 'Nope' },
|
|
558
|
+
ctxFor(accessor)
|
|
559
|
+
);
|
|
560
|
+
expect(expectErr(result)).toBeInstanceOf(NotFoundError);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('update fallback read-null returns NotFoundError', async () => {
|
|
564
|
+
const accessor = createFakeAccessor({ seed: null, update: false });
|
|
565
|
+
const notesResource = makeNotesResource(accessor);
|
|
566
|
+
|
|
567
|
+
const updated = deriveTrail(note, 'update', {
|
|
568
|
+
generated: ['id', 'createdAt'],
|
|
569
|
+
resource: notesResource,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const result = await updated.blaze(
|
|
573
|
+
{ id: 'missing', title: 'Nope' },
|
|
574
|
+
ctxFor(accessor)
|
|
575
|
+
);
|
|
576
|
+
expect(expectErr(result)).toBeInstanceOf(NotFoundError);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('update fallback reads, merges, and upserts when update method is absent', async () => {
|
|
580
|
+
const accessor = createFakeAccessor({ update: false });
|
|
581
|
+
const upsertSpy = mock(accessor.upsert);
|
|
582
|
+
accessor.upsert = upsertSpy;
|
|
583
|
+
const notesResource = makeNotesResource(accessor);
|
|
584
|
+
|
|
585
|
+
const updated = deriveTrail(note, 'update', {
|
|
586
|
+
generated: ['id', 'createdAt'],
|
|
587
|
+
resource: notesResource,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const result = await updated.blaze(
|
|
591
|
+
{ id: 'note-1', title: 'Via fallback' },
|
|
592
|
+
ctxFor(accessor)
|
|
593
|
+
);
|
|
594
|
+
expect(result.isOk()).toBe(true);
|
|
595
|
+
expect(upsertSpy).toHaveBeenCalledTimes(1);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('update fallback strips generated fields from merged payload', async () => {
|
|
599
|
+
const { accessor, upsertLog } = createVersionedFake();
|
|
600
|
+
|
|
601
|
+
const updated = deriveTrail(note, 'update', {
|
|
602
|
+
generated: ['id', 'createdAt'],
|
|
603
|
+
resource: makeNotesResource(accessor),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const result = await updated.blaze(
|
|
607
|
+
{ id: 'note-1', title: 'No version' },
|
|
608
|
+
ctxFor(accessor)
|
|
609
|
+
);
|
|
610
|
+
expectOk(result);
|
|
611
|
+
expect(upsertLog.length).toBe(1);
|
|
612
|
+
const keys = Object.keys(upsertLog[0] as Record<string, unknown>);
|
|
613
|
+
// Identity preserved even though it's in generated; non-identity generated stripped
|
|
614
|
+
expect(keys).toContain('id');
|
|
615
|
+
expect(keys).not.toContain('createdAt');
|
|
616
|
+
expect(keys).toContain('version');
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe('deriveTrail() default blaze synthesis — delete and list', () => {
|
|
621
|
+
test('synthesizes delete blaze returning Result.ok(undefined)', async () => {
|
|
622
|
+
const accessor = createFakeAccessor();
|
|
623
|
+
const notesResource = makeNotesResource(accessor);
|
|
624
|
+
|
|
625
|
+
const removed = deriveTrail(note, 'delete', { resource: notesResource });
|
|
626
|
+
const result = await removed.blaze({ id: 'note-1' }, ctxFor(accessor));
|
|
627
|
+
|
|
628
|
+
expect(expectOk(result)).toBeUndefined();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('synthesizes list blaze returning array', async () => {
|
|
632
|
+
const accessor = createFakeAccessor();
|
|
633
|
+
const notesResource = makeNotesResource(accessor);
|
|
634
|
+
|
|
635
|
+
const listed = deriveTrail(note, 'list', { resource: notesResource });
|
|
636
|
+
const result = await listed.blaze({}, ctxFor(accessor));
|
|
637
|
+
|
|
638
|
+
const value = expectOk(result);
|
|
639
|
+
expect(Array.isArray(value)).toBe(true);
|
|
640
|
+
expect(value.length).toBe(1);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe('deriveTrail() multi-resource and invalid input', () => {
|
|
645
|
+
test('rejects multi-resource derivation without a blaze', () => {
|
|
646
|
+
const accessor = createFakeAccessor();
|
|
647
|
+
const a = makeNotesResource(accessor);
|
|
648
|
+
const b = makeNotesResource(accessor);
|
|
649
|
+
|
|
650
|
+
expect(() =>
|
|
651
|
+
deriveTrail(note, 'create', {
|
|
652
|
+
generated: ['id', 'createdAt'],
|
|
653
|
+
resource: [a, b],
|
|
654
|
+
})
|
|
655
|
+
).toThrow(DerivationError);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('rejects derivation with empty resource array and no blaze', () => {
|
|
659
|
+
expect(() =>
|
|
660
|
+
deriveTrail(note, 'create', {
|
|
661
|
+
generated: ['id', 'createdAt'],
|
|
662
|
+
resource: [],
|
|
663
|
+
})
|
|
664
|
+
).toThrow(
|
|
665
|
+
'deriveTrail("note.create") requires an explicit `blaze` when no resources are declared'
|
|
666
|
+
);
|
|
667
|
+
});
|
|
668
|
+
});
|