@auto-engineer/narrative 0.11.12 → 0.11.14
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 -1
- package/CHANGELOG.md +22 -0
- package/dist/src/commands/export-schema-helper.js +1 -2
- package/dist/src/commands/export-schema-helper.js.map +1 -1
- package/dist/src/data-narrative-builders.d.ts +7 -5
- package/dist/src/data-narrative-builders.d.ts.map +1 -1
- package/dist/src/data-narrative-builders.js +19 -1
- package/dist/src/data-narrative-builders.js.map +1 -1
- package/dist/src/getNarratives.cache.specs.d.ts +2 -0
- package/dist/src/getNarratives.cache.specs.d.ts.map +1 -0
- package/dist/src/{getFlows.cache.specs.js → getNarratives.cache.specs.js} +1 -1
- package/dist/src/getNarratives.cache.specs.js.map +1 -0
- package/dist/src/getNarratives.specs.js +233 -19
- package/dist/src/getNarratives.specs.js.map +1 -1
- package/dist/src/model-to-narrative.specs.d.ts +2 -0
- package/dist/src/model-to-narrative.specs.d.ts.map +1 -0
- package/dist/src/{model-to-flow.specs.js → model-to-narrative.specs.js} +594 -2
- package/dist/src/model-to-narrative.specs.js.map +1 -0
- package/dist/src/narrative-context.d.ts.map +1 -1
- package/dist/src/narrative-context.js +0 -1
- package/dist/src/narrative-context.js.map +1 -1
- package/dist/src/narrative.d.ts +1 -0
- package/dist/src/narrative.d.ts.map +1 -1
- package/dist/src/narrative.js +11 -0
- package/dist/src/narrative.js.map +1 -1
- package/dist/src/samples/mixed-given-types.narrative.js +0 -1
- package/dist/src/samples/mixed-given-types.narrative.js.map +1 -1
- package/dist/src/samples/questionnaires.narrative.js +0 -2
- package/dist/src/samples/questionnaires.narrative.js.map +1 -1
- package/dist/src/schema.d.ts +2253 -2054
- package/dist/src/schema.d.ts.map +1 -1
- package/dist/src/schema.js +9 -1
- package/dist/src/schema.js.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/flow.d.ts.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/flow.js +49 -12
- package/dist/src/transformers/model-to-narrative/generators/flow.js.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/gwt.d.ts.map +1 -1
- package/dist/src/transformers/model-to-narrative/generators/gwt.js +32 -8
- package/dist/src/transformers/model-to-narrative/generators/gwt.js.map +1 -1
- package/dist/src/transformers/narrative-to-model/debug.d.ts.map +1 -1
- package/dist/src/transformers/narrative-to-model/debug.js +1 -1
- package/dist/src/transformers/narrative-to-model/debug.js.map +1 -1
- package/dist/src/types.d.ts +6 -8
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/commands/export-schema-helper.ts +1 -2
- package/src/data-narrative-builders.ts +41 -9
- package/src/getNarratives.specs.ts +266 -20
- package/src/{model-to-flow.specs.ts → model-to-narrative.specs.ts} +609 -1
- package/src/narrative-context.ts +0 -1
- package/src/narrative.ts +16 -1
- package/src/samples/mixed-given-types.narrative.ts +0 -1
- package/src/samples/questionnaires.narrative.ts +0 -2
- package/src/schema.ts +13 -1
- package/src/transformers/model-to-narrative/generators/flow.ts +85 -26
- package/src/transformers/model-to-narrative/generators/gwt.ts +44 -9
- package/src/transformers/narrative-to-model/debug.ts +1 -1
- package/src/types.ts +7 -9
- package/dist/src/getFlows.cache.specs.d.ts +0 -2
- package/dist/src/getFlows.cache.specs.d.ts.map +0 -1
- package/dist/src/getFlows.cache.specs.js.map +0 -1
- package/dist/src/model-to-flow.specs.d.ts +0 -2
- package/dist/src/model-to-flow.specs.d.ts.map +0 -1
- package/dist/src/model-to-flow.specs.js.map +0 -1
- /package/src/{getFlows.cache.specs.ts → getNarratives.cache.specs.ts} +0 -0
package/package.json
CHANGED
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"typescript": "^5.9.2",
|
|
20
20
|
"zod": "^3.22.4",
|
|
21
21
|
"zod-to-json-schema": "^3.22.3",
|
|
22
|
-
"@auto-engineer/file-store": "0.11.
|
|
23
|
-
"@auto-engineer/id": "0.11.
|
|
24
|
-
"@auto-engineer/message-bus": "0.11.
|
|
22
|
+
"@auto-engineer/file-store": "0.11.14",
|
|
23
|
+
"@auto-engineer/id": "0.11.14",
|
|
24
|
+
"@auto-engineer/message-bus": "0.11.14"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^20.0.0",
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
"eslint-plugin-sort-keys-fix": "^1.1.2",
|
|
30
30
|
"fake-indexeddb": "^6.0.0",
|
|
31
31
|
"tsx": "^4.20.3",
|
|
32
|
-
"@auto-engineer/cli": "0.11.
|
|
32
|
+
"@auto-engineer/cli": "0.11.14"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
|
-
"version": "0.11.
|
|
37
|
+
"version": "0.11.14",
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsx scripts/build.ts",
|
|
40
40
|
"test": "vitest run --reporter=dot",
|
|
@@ -12,7 +12,6 @@ const main = async () => {
|
|
|
12
12
|
debug('Starting export-schema-helper with directory: %s', directory);
|
|
13
13
|
|
|
14
14
|
try {
|
|
15
|
-
// Import getFlows from the project's node_modules to ensure we use the same module context
|
|
16
15
|
const getFileStore = getFs as () => Promise<IExtendedFileStore>;
|
|
17
16
|
const fs: IExtendedFileStore = await getFileStore();
|
|
18
17
|
const projectNarrativePath = fs.join(
|
|
@@ -24,7 +23,7 @@ const main = async () => {
|
|
|
24
23
|
'src',
|
|
25
24
|
'getNarratives.js',
|
|
26
25
|
);
|
|
27
|
-
debug('Importing
|
|
26
|
+
debug('Importing getNarratives from: %s', projectNarrativePath);
|
|
28
27
|
|
|
29
28
|
const { pathToFileURL } = await import('url');
|
|
30
29
|
const narrativeModule = (await import(pathToFileURL(projectNarrativePath).href)) as {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataSinkItem, DataSourceItem, MessageTarget, Integration } from './types';
|
|
1
|
+
import type { DataSinkItem, DataSourceItem, MessageTarget, Integration, DefaultRecord } from './types';
|
|
2
2
|
import { createIntegrationOrigin } from './types';
|
|
3
3
|
import { integrationExportRegistry } from './integration-export-registry';
|
|
4
4
|
|
|
@@ -240,16 +240,44 @@ export class StateSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
// State source builder
|
|
243
|
-
export class StateSourceBuilder extends MessageTargetBuilder<DataSourceItem> {
|
|
243
|
+
export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSourceItem> {
|
|
244
244
|
constructor(name: string) {
|
|
245
245
|
super();
|
|
246
246
|
this.target = { type: 'State', name };
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
|
|
249
|
+
fromSingletonProjection(name: string): ChainableSource {
|
|
250
250
|
const sourceItem: DataSourceItem = {
|
|
251
251
|
target: this.target as MessageTarget,
|
|
252
|
-
origin: { type: 'projection', name,
|
|
252
|
+
origin: { type: 'projection', name, singleton: true },
|
|
253
|
+
__type: 'source' as const,
|
|
254
|
+
...(this.instructions != null && this.instructions !== '' && { _additionalInstructions: this.instructions }),
|
|
255
|
+
};
|
|
256
|
+
return createChainableSource(sourceItem);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fromProjection<
|
|
260
|
+
K extends S extends import('./types').State<string, infer D extends DefaultRecord, DefaultRecord | undefined>
|
|
261
|
+
? keyof D
|
|
262
|
+
: string,
|
|
263
|
+
>(name: string, idField: K): ChainableSource {
|
|
264
|
+
const sourceItem: DataSourceItem = {
|
|
265
|
+
target: this.target as MessageTarget,
|
|
266
|
+
origin: { type: 'projection', name, idField: idField as string },
|
|
267
|
+
__type: 'source' as const,
|
|
268
|
+
...(this.instructions != null && this.instructions !== '' && { _additionalInstructions: this.instructions }),
|
|
269
|
+
};
|
|
270
|
+
return createChainableSource(sourceItem);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fromCompositeProjection<
|
|
274
|
+
K extends S extends import('./types').State<string, infer D extends DefaultRecord, DefaultRecord | undefined>
|
|
275
|
+
? keyof D
|
|
276
|
+
: string,
|
|
277
|
+
>(name: string, idFields: K[]): ChainableSource {
|
|
278
|
+
const sourceItem: DataSourceItem = {
|
|
279
|
+
target: this.target as MessageTarget,
|
|
280
|
+
origin: { type: 'projection', name, idField: idFields as string[] },
|
|
253
281
|
__type: 'source' as const,
|
|
254
282
|
...(this.instructions != null && this.instructions !== '' && { _additionalInstructions: this.instructions }),
|
|
255
283
|
};
|
|
@@ -364,14 +392,16 @@ export class DataSinkBuilder {
|
|
|
364
392
|
}
|
|
365
393
|
|
|
366
394
|
export class DataSourceBuilder {
|
|
367
|
-
state(
|
|
395
|
+
state<S extends import('./types').State<string, DefaultRecord> = import('./types').State<string, DefaultRecord>>(
|
|
396
|
+
nameOrBuilder: string | BuilderResult,
|
|
397
|
+
): StateSourceBuilder<S> {
|
|
368
398
|
if (typeof nameOrBuilder === 'string') {
|
|
369
|
-
return new StateSourceBuilder(nameOrBuilder);
|
|
399
|
+
return new StateSourceBuilder<S>(nameOrBuilder);
|
|
370
400
|
}
|
|
371
401
|
|
|
372
402
|
// Handle state builder function
|
|
373
403
|
if (isValidBuilderResult(nameOrBuilder) && nameOrBuilder.__messageCategory === 'state') {
|
|
374
|
-
return new StateSourceBuilder(nameOrBuilder.type);
|
|
404
|
+
return new StateSourceBuilder<S>(nameOrBuilder.type);
|
|
375
405
|
}
|
|
376
406
|
|
|
377
407
|
throw new Error('Invalid state parameter - must be a string or state builder function');
|
|
@@ -405,7 +435,9 @@ export function typedSink(builderResult: BuilderResult): EventSinkBuilder | Comm
|
|
|
405
435
|
}
|
|
406
436
|
|
|
407
437
|
// Type-safe source function that accepts builder results
|
|
408
|
-
export function typedSource
|
|
438
|
+
export function typedSource<
|
|
439
|
+
S extends import('./types').State<string, DefaultRecord> = import('./types').State<string, DefaultRecord>,
|
|
440
|
+
>(builderResult: BuilderResult): StateSourceBuilder<S> {
|
|
409
441
|
if (!isValidBuilderResult(builderResult)) {
|
|
410
442
|
throw new Error('Invalid builder result - must be from State builders');
|
|
411
443
|
}
|
|
@@ -414,5 +446,5 @@ export function typedSource(builderResult: BuilderResult): StateSourceBuilder {
|
|
|
414
446
|
throw new Error('Source can only be created from State builders');
|
|
415
447
|
}
|
|
416
448
|
|
|
417
|
-
return new StateSourceBuilder(builderResult.type);
|
|
449
|
+
return new StateSourceBuilder<S>(builderResult.type);
|
|
418
450
|
}
|
|
@@ -19,7 +19,7 @@ describe('getNarratives', (_mode) => {
|
|
|
19
19
|
root = path.resolve(__dirname);
|
|
20
20
|
});
|
|
21
21
|
// eslint-disable-next-line complexity
|
|
22
|
-
it('loads multiple
|
|
22
|
+
it('loads multiple narratives and generates correct models', async () => {
|
|
23
23
|
const flows = await getNarratives({ vfs, root: path.resolve(__dirname), pattern, fastFsScan: true });
|
|
24
24
|
const schemas = flows.toModel();
|
|
25
25
|
|
|
@@ -191,7 +191,7 @@ describe('getNarratives', (_mode) => {
|
|
|
191
191
|
expect(Array.isArray(parsed.integrations)).toBe(true);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
it('should handle
|
|
194
|
+
it('should handle narratives with integrations', async () => {
|
|
195
195
|
const flows = await getNarratives({ vfs: vfs, root: root, pattern: /\.(narrative)\.(ts)$/, fastFsScan: true });
|
|
196
196
|
const specsSchema = flows.toModel();
|
|
197
197
|
|
|
@@ -267,7 +267,7 @@ describe('getNarratives', (_mode) => {
|
|
|
267
267
|
).toBe(true);
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
it('should have ids for
|
|
270
|
+
it('should have ids for narratives and slices that have ids', async () => {
|
|
271
271
|
const flows = await getNarratives({ vfs: vfs, root: root, pattern: /\.(narrative)\.(ts)$/, fastFsScan: true });
|
|
272
272
|
|
|
273
273
|
const schemas = flows.toModel();
|
|
@@ -640,7 +640,7 @@ flow('questionnaires-test', () => {
|
|
|
640
640
|
}
|
|
641
641
|
});
|
|
642
642
|
|
|
643
|
-
it('does not emit empty generics
|
|
643
|
+
it('does not emit empty generics or empty when clauses', async () => {
|
|
644
644
|
const flows = await getNarratives({
|
|
645
645
|
vfs,
|
|
646
646
|
root,
|
|
@@ -651,13 +651,9 @@ flow('questionnaires-test', () => {
|
|
|
651
651
|
const model = flows.toModel();
|
|
652
652
|
const code = await modelToNarrative(model);
|
|
653
653
|
|
|
654
|
-
// Should not produce `.when<>({})` for empty when-clauses
|
|
655
654
|
expect(code).not.toMatch(/\.when<>\(\{\}\)/);
|
|
656
|
-
|
|
657
|
-
// should render empty whens as `.when({})` for empty when-clauses
|
|
658
|
-
const emptyWhenCount = (code.match(/\.when\(\{}\)/g) ?? []).length;
|
|
659
|
-
expect(emptyWhenCount).toBeGreaterThanOrEqual(2);
|
|
660
655
|
expect(code).not.toMatch(/\.when<\s*\{\s*}\s*>\(\{}\)/);
|
|
656
|
+
expect(code).not.toMatch(/\.when\(\{}\)/);
|
|
661
657
|
});
|
|
662
658
|
|
|
663
659
|
it('should not generate phantom messages with empty names', async () => {
|
|
@@ -927,17 +923,7 @@ function validateMixedGivenTypes(example: Example): void {
|
|
|
927
923
|
}
|
|
928
924
|
|
|
929
925
|
function validateEmptyWhenClause(example: Example): void {
|
|
930
|
-
expect(example.when).
|
|
931
|
-
expect(typeof example.when === 'object' && !Array.isArray(example.when)).toBe(true);
|
|
932
|
-
if (typeof example.when === 'object' && !Array.isArray(example.when)) {
|
|
933
|
-
expect('commandRef' in example.when).toBe(false);
|
|
934
|
-
expect('eventRef' in example.when).toBe(true);
|
|
935
|
-
expect('stateRef' in example.when).toBe(false);
|
|
936
|
-
if ('eventRef' in example.when) {
|
|
937
|
-
expect(example.when.eventRef).toBe('');
|
|
938
|
-
}
|
|
939
|
-
expect(example.when.exampleData).toEqual({});
|
|
940
|
-
}
|
|
926
|
+
expect(example.when).toBeUndefined();
|
|
941
927
|
}
|
|
942
928
|
|
|
943
929
|
function validateThenClause(example: Example): void {
|
|
@@ -1246,3 +1232,263 @@ function validateThenEvents(example: unknown): void {
|
|
|
1246
1232
|
});
|
|
1247
1233
|
}
|
|
1248
1234
|
}
|
|
1235
|
+
|
|
1236
|
+
describe('projection DSL methods', () => {
|
|
1237
|
+
it('should generate correct origin for singleton projection', async () => {
|
|
1238
|
+
const memoryVfs = new InMemoryFileStore();
|
|
1239
|
+
const flowContent = `
|
|
1240
|
+
import { flow, query, specs, rule, example, data, source, type Event, type State } from '@auto-engineer/narrative';
|
|
1241
|
+
|
|
1242
|
+
type TodoAdded = Event<'TodoAdded', { todoId: string; description: string; addedAt: Date }>;
|
|
1243
|
+
type TodoListSummary = State<'TodoListSummary', { summaryId: string; totalTodos: number }>;
|
|
1244
|
+
|
|
1245
|
+
flow('Projection Test', () => {
|
|
1246
|
+
query('views summary')
|
|
1247
|
+
.server(() => {
|
|
1248
|
+
specs(() => {
|
|
1249
|
+
rule('shows summary', () => {
|
|
1250
|
+
example('summary')
|
|
1251
|
+
.given<TodoAdded>({ todoId: 'todo-001', description: 'Test', addedAt: new Date('2030-01-01T09:00:00Z') })
|
|
1252
|
+
.when({})
|
|
1253
|
+
.then<TodoListSummary>({ summaryId: 'main', totalTodos: 1 });
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
data([source().state<TodoListSummary>('TodoListSummary').fromSingletonProjection('TodoSummary')]);
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
`;
|
|
1260
|
+
|
|
1261
|
+
await memoryVfs.write('/test/projection.narrative.ts', new TextEncoder().encode(flowContent));
|
|
1262
|
+
|
|
1263
|
+
const flows = await getNarratives({ vfs: memoryVfs, root: '/test', pattern, fastFsScan: true });
|
|
1264
|
+
const model = flows.toModel();
|
|
1265
|
+
|
|
1266
|
+
const projectionFlow = model.narratives.find((f) => f.name === 'Projection Test');
|
|
1267
|
+
expect(projectionFlow).toBeDefined();
|
|
1268
|
+
|
|
1269
|
+
if (!projectionFlow) return;
|
|
1270
|
+
|
|
1271
|
+
const summarySlice = projectionFlow.slices.find((s) => s.name === 'views summary');
|
|
1272
|
+
expect(summarySlice?.type).toBe('query');
|
|
1273
|
+
|
|
1274
|
+
if (summarySlice?.type !== 'query') return;
|
|
1275
|
+
|
|
1276
|
+
const data = summarySlice.server.data as DataSource[] | undefined;
|
|
1277
|
+
expect(data).toBeDefined();
|
|
1278
|
+
expect(data).toHaveLength(1);
|
|
1279
|
+
|
|
1280
|
+
expect(data?.[0].origin).toMatchObject({
|
|
1281
|
+
type: 'projection',
|
|
1282
|
+
name: 'TodoSummary',
|
|
1283
|
+
singleton: true,
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
expect(data?.[0].origin).not.toHaveProperty('idField');
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it('should generate correct origin for regular projection with single idField', async () => {
|
|
1290
|
+
const memoryVfs = new InMemoryFileStore();
|
|
1291
|
+
const flowContent = `
|
|
1292
|
+
import { flow, query, specs, rule, example, data, source, type Event, type State } from '@auto-engineer/narrative';
|
|
1293
|
+
|
|
1294
|
+
type TodoAdded = Event<'TodoAdded', { todoId: string; description: string; addedAt: Date }>;
|
|
1295
|
+
type TodoState = State<'TodoState', { todoId: string; description: string; status: string }>;
|
|
1296
|
+
|
|
1297
|
+
flow('Projection Test', () => {
|
|
1298
|
+
query('views todo')
|
|
1299
|
+
.server(() => {
|
|
1300
|
+
specs(() => {
|
|
1301
|
+
rule('shows todo', () => {
|
|
1302
|
+
example('todo')
|
|
1303
|
+
.given<TodoAdded>({ todoId: 'todo-001', description: 'Test', addedAt: new Date('2030-01-01T09:00:00Z') })
|
|
1304
|
+
.when({})
|
|
1305
|
+
.then<TodoState>({ todoId: 'todo-001', description: 'Test', status: 'pending' });
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
data([source().state<TodoState>('TodoState').fromProjection('Todos', 'todoId')]);
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
`;
|
|
1312
|
+
|
|
1313
|
+
await memoryVfs.write('/test/projection.narrative.ts', new TextEncoder().encode(flowContent));
|
|
1314
|
+
|
|
1315
|
+
const flows = await getNarratives({ vfs: memoryVfs, root: '/test', pattern, fastFsScan: true });
|
|
1316
|
+
const model = flows.toModel();
|
|
1317
|
+
|
|
1318
|
+
const projectionFlow = model.narratives.find((f) => f.name === 'Projection Test');
|
|
1319
|
+
expect(projectionFlow).toBeDefined();
|
|
1320
|
+
|
|
1321
|
+
if (!projectionFlow) return;
|
|
1322
|
+
|
|
1323
|
+
const todoSlice = projectionFlow.slices.find((s) => s.name === 'views todo');
|
|
1324
|
+
expect(todoSlice?.type).toBe('query');
|
|
1325
|
+
|
|
1326
|
+
if (todoSlice?.type !== 'query') return;
|
|
1327
|
+
|
|
1328
|
+
const data = todoSlice.server.data as DataSource[] | undefined;
|
|
1329
|
+
expect(data).toBeDefined();
|
|
1330
|
+
expect(data).toHaveLength(1);
|
|
1331
|
+
|
|
1332
|
+
expect(data?.[0].origin).toMatchObject({
|
|
1333
|
+
type: 'projection',
|
|
1334
|
+
name: 'Todos',
|
|
1335
|
+
idField: 'todoId',
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
expect(data?.[0].origin).not.toHaveProperty('singleton');
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it('should generate correct origin for composite projection with multiple idFields', async () => {
|
|
1342
|
+
const memoryVfs = new InMemoryFileStore();
|
|
1343
|
+
const flowContent = `
|
|
1344
|
+
import { flow, query, specs, rule, example, data, source, type Event, type State } from '@auto-engineer/narrative';
|
|
1345
|
+
|
|
1346
|
+
type UserProjectAssigned = Event<'UserProjectAssigned', { userId: string; projectId: string; assignedAt: Date }>;
|
|
1347
|
+
type UserProjectState = State<'UserProjectState', { userId: string; projectId: string; role: string }>;
|
|
1348
|
+
|
|
1349
|
+
flow('Projection Test', () => {
|
|
1350
|
+
query('views user project')
|
|
1351
|
+
.server(() => {
|
|
1352
|
+
specs(() => {
|
|
1353
|
+
rule('shows user project', () => {
|
|
1354
|
+
example('user project')
|
|
1355
|
+
.given<UserProjectAssigned>({ userId: 'user-001', projectId: 'proj-001', assignedAt: new Date('2030-01-01T09:00:00Z') })
|
|
1356
|
+
.when({})
|
|
1357
|
+
.then<UserProjectState>({ userId: 'user-001', projectId: 'proj-001', role: 'admin' });
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
data([source().state<UserProjectState>('UserProjectState').fromCompositeProjection('UserProjects', ['userId', 'projectId'])]);
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
`;
|
|
1364
|
+
|
|
1365
|
+
await memoryVfs.write('/test/projection.narrative.ts', new TextEncoder().encode(flowContent));
|
|
1366
|
+
|
|
1367
|
+
const flows = await getNarratives({ vfs: memoryVfs, root: '/test', pattern, fastFsScan: true });
|
|
1368
|
+
const model = flows.toModel();
|
|
1369
|
+
|
|
1370
|
+
const projectionFlow = model.narratives.find((f) => f.name === 'Projection Test');
|
|
1371
|
+
expect(projectionFlow).toBeDefined();
|
|
1372
|
+
|
|
1373
|
+
if (!projectionFlow) return;
|
|
1374
|
+
|
|
1375
|
+
const userProjectSlice = projectionFlow.slices.find((s) => s.name === 'views user project');
|
|
1376
|
+
expect(userProjectSlice?.type).toBe('query');
|
|
1377
|
+
|
|
1378
|
+
if (userProjectSlice?.type !== 'query') return;
|
|
1379
|
+
|
|
1380
|
+
const data = userProjectSlice.server.data as DataSource[] | undefined;
|
|
1381
|
+
expect(data).toBeDefined();
|
|
1382
|
+
expect(data).toHaveLength(1);
|
|
1383
|
+
|
|
1384
|
+
expect(data?.[0].origin).toMatchObject({
|
|
1385
|
+
type: 'projection',
|
|
1386
|
+
name: 'UserProjects',
|
|
1387
|
+
idField: ['userId', 'projectId'],
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
expect(data?.[0].origin).not.toHaveProperty('singleton');
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('should validate all three projection patterns together', async () => {
|
|
1394
|
+
const memoryVfs = new InMemoryFileStore();
|
|
1395
|
+
const flowContent = `
|
|
1396
|
+
import { flow, query, specs, rule, example, data, source, type Event, type State } from '@auto-engineer/narrative';
|
|
1397
|
+
|
|
1398
|
+
type TodoAdded = Event<'TodoAdded', { todoId: string; userId: string; projectId: string; description: string; addedAt: Date }>;
|
|
1399
|
+
|
|
1400
|
+
type TodoListSummary = State<'TodoListSummary', { summaryId: string; totalTodos: number }>;
|
|
1401
|
+
type TodoState = State<'TodoState', { todoId: string; description: string; status: string }>;
|
|
1402
|
+
type UserProjectTodos = State<'UserProjectTodos', { userId: string; projectId: string; todos: string[] }>;
|
|
1403
|
+
|
|
1404
|
+
flow('All Projection Patterns', () => {
|
|
1405
|
+
query('views summary')
|
|
1406
|
+
.server(() => {
|
|
1407
|
+
specs(() => {
|
|
1408
|
+
rule('shows summary', () => {
|
|
1409
|
+
example('summary')
|
|
1410
|
+
.given<TodoAdded>({ todoId: 'todo-001', userId: 'u1', projectId: 'p1', description: 'Test', addedAt: new Date('2030-01-01T09:00:00Z') })
|
|
1411
|
+
.when({})
|
|
1412
|
+
.then<TodoListSummary>({ summaryId: 'main', totalTodos: 1 });
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
data([source().state<TodoListSummary>('TodoListSummary').fromSingletonProjection('TodoSummary')]);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
query('views todo')
|
|
1419
|
+
.server(() => {
|
|
1420
|
+
specs(() => {
|
|
1421
|
+
rule('shows todo', () => {
|
|
1422
|
+
example('todo')
|
|
1423
|
+
.given<TodoAdded>({ todoId: 'todo-001', userId: 'u1', projectId: 'p1', description: 'Test', addedAt: new Date('2030-01-01T09:00:00Z') })
|
|
1424
|
+
.when({})
|
|
1425
|
+
.then<TodoState>({ todoId: 'todo-001', description: 'Test', status: 'pending' });
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
data([source().state<TodoState>('TodoState').fromProjection('Todos', 'todoId')]);
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
query('views user project todos')
|
|
1432
|
+
.server(() => {
|
|
1433
|
+
specs(() => {
|
|
1434
|
+
rule('shows user project todos', () => {
|
|
1435
|
+
example('user project todos')
|
|
1436
|
+
.given<TodoAdded>({ todoId: 'todo-001', userId: 'u1', projectId: 'p1', description: 'Test', addedAt: new Date('2030-01-01T09:00:00Z') })
|
|
1437
|
+
.when({})
|
|
1438
|
+
.then<UserProjectTodos>({ userId: 'u1', projectId: 'p1', todos: ['todo-001'] });
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
data([source().state<UserProjectTodos>('UserProjectTodos').fromCompositeProjection('UserProjectTodos', ['userId', 'projectId'])]);
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
`;
|
|
1445
|
+
|
|
1446
|
+
await memoryVfs.write('/test/projection.narrative.ts', new TextEncoder().encode(flowContent));
|
|
1447
|
+
|
|
1448
|
+
const flows = await getNarratives({ vfs: memoryVfs, root: '/test', pattern, fastFsScan: true });
|
|
1449
|
+
const model = flows.toModel();
|
|
1450
|
+
|
|
1451
|
+
const parseResult = modelSchema.safeParse(model);
|
|
1452
|
+
if (!parseResult.success) {
|
|
1453
|
+
console.error('Schema validation errors:', parseResult.error.format());
|
|
1454
|
+
}
|
|
1455
|
+
expect(parseResult.success).toBe(true);
|
|
1456
|
+
|
|
1457
|
+
const projectionFlow = model.narratives.find((f) => f.name === 'All Projection Patterns');
|
|
1458
|
+
expect(projectionFlow).toBeDefined();
|
|
1459
|
+
|
|
1460
|
+
if (!projectionFlow) return;
|
|
1461
|
+
|
|
1462
|
+
expect(projectionFlow.slices).toHaveLength(3);
|
|
1463
|
+
|
|
1464
|
+
const summarySlice = projectionFlow.slices.find((s) => s.name === 'views summary');
|
|
1465
|
+
if (summarySlice?.type === 'query') {
|
|
1466
|
+
const data = summarySlice.server.data as DataSource[] | undefined;
|
|
1467
|
+
expect(data?.[0].origin).toMatchObject({
|
|
1468
|
+
type: 'projection',
|
|
1469
|
+
name: 'TodoSummary',
|
|
1470
|
+
singleton: true,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const todoSlice = projectionFlow.slices.find((s) => s.name === 'views todo');
|
|
1475
|
+
if (todoSlice?.type === 'query') {
|
|
1476
|
+
const data = todoSlice.server.data as DataSource[] | undefined;
|
|
1477
|
+
expect(data?.[0].origin).toMatchObject({
|
|
1478
|
+
type: 'projection',
|
|
1479
|
+
name: 'Todos',
|
|
1480
|
+
idField: 'todoId',
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const userProjectSlice = projectionFlow.slices.find((s) => s.name === 'views user project todos');
|
|
1485
|
+
if (userProjectSlice?.type === 'query') {
|
|
1486
|
+
const data = userProjectSlice.server.data as DataSource[] | undefined;
|
|
1487
|
+
expect(data?.[0].origin).toMatchObject({
|
|
1488
|
+
type: 'projection',
|
|
1489
|
+
name: 'UserProjectTodos',
|
|
1490
|
+
idField: ['userId', 'projectId'],
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
});
|