@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.
Files changed (67) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/dist/src/commands/export-schema-helper.js +1 -2
  4. package/dist/src/commands/export-schema-helper.js.map +1 -1
  5. package/dist/src/data-narrative-builders.d.ts +7 -5
  6. package/dist/src/data-narrative-builders.d.ts.map +1 -1
  7. package/dist/src/data-narrative-builders.js +19 -1
  8. package/dist/src/data-narrative-builders.js.map +1 -1
  9. package/dist/src/getNarratives.cache.specs.d.ts +2 -0
  10. package/dist/src/getNarratives.cache.specs.d.ts.map +1 -0
  11. package/dist/src/{getFlows.cache.specs.js → getNarratives.cache.specs.js} +1 -1
  12. package/dist/src/getNarratives.cache.specs.js.map +1 -0
  13. package/dist/src/getNarratives.specs.js +233 -19
  14. package/dist/src/getNarratives.specs.js.map +1 -1
  15. package/dist/src/model-to-narrative.specs.d.ts +2 -0
  16. package/dist/src/model-to-narrative.specs.d.ts.map +1 -0
  17. package/dist/src/{model-to-flow.specs.js → model-to-narrative.specs.js} +594 -2
  18. package/dist/src/model-to-narrative.specs.js.map +1 -0
  19. package/dist/src/narrative-context.d.ts.map +1 -1
  20. package/dist/src/narrative-context.js +0 -1
  21. package/dist/src/narrative-context.js.map +1 -1
  22. package/dist/src/narrative.d.ts +1 -0
  23. package/dist/src/narrative.d.ts.map +1 -1
  24. package/dist/src/narrative.js +11 -0
  25. package/dist/src/narrative.js.map +1 -1
  26. package/dist/src/samples/mixed-given-types.narrative.js +0 -1
  27. package/dist/src/samples/mixed-given-types.narrative.js.map +1 -1
  28. package/dist/src/samples/questionnaires.narrative.js +0 -2
  29. package/dist/src/samples/questionnaires.narrative.js.map +1 -1
  30. package/dist/src/schema.d.ts +2253 -2054
  31. package/dist/src/schema.d.ts.map +1 -1
  32. package/dist/src/schema.js +9 -1
  33. package/dist/src/schema.js.map +1 -1
  34. package/dist/src/transformers/model-to-narrative/generators/flow.d.ts.map +1 -1
  35. package/dist/src/transformers/model-to-narrative/generators/flow.js +49 -12
  36. package/dist/src/transformers/model-to-narrative/generators/flow.js.map +1 -1
  37. package/dist/src/transformers/model-to-narrative/generators/gwt.d.ts.map +1 -1
  38. package/dist/src/transformers/model-to-narrative/generators/gwt.js +32 -8
  39. package/dist/src/transformers/model-to-narrative/generators/gwt.js.map +1 -1
  40. package/dist/src/transformers/narrative-to-model/debug.d.ts.map +1 -1
  41. package/dist/src/transformers/narrative-to-model/debug.js +1 -1
  42. package/dist/src/transformers/narrative-to-model/debug.js.map +1 -1
  43. package/dist/src/types.d.ts +6 -8
  44. package/dist/src/types.d.ts.map +1 -1
  45. package/dist/src/types.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +5 -5
  48. package/src/commands/export-schema-helper.ts +1 -2
  49. package/src/data-narrative-builders.ts +41 -9
  50. package/src/getNarratives.specs.ts +266 -20
  51. package/src/{model-to-flow.specs.ts → model-to-narrative.specs.ts} +609 -1
  52. package/src/narrative-context.ts +0 -1
  53. package/src/narrative.ts +16 -1
  54. package/src/samples/mixed-given-types.narrative.ts +0 -1
  55. package/src/samples/questionnaires.narrative.ts +0 -2
  56. package/src/schema.ts +13 -1
  57. package/src/transformers/model-to-narrative/generators/flow.ts +85 -26
  58. package/src/transformers/model-to-narrative/generators/gwt.ts +44 -9
  59. package/src/transformers/narrative-to-model/debug.ts +1 -1
  60. package/src/types.ts +7 -9
  61. package/dist/src/getFlows.cache.specs.d.ts +0 -2
  62. package/dist/src/getFlows.cache.specs.d.ts.map +0 -1
  63. package/dist/src/getFlows.cache.specs.js.map +0 -1
  64. package/dist/src/model-to-flow.specs.d.ts +0 -2
  65. package/dist/src/model-to-flow.specs.d.ts.map +0 -1
  66. package/dist/src/model-to-flow.specs.js.map +0 -1
  67. /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.12",
23
- "@auto-engineer/id": "0.11.12",
24
- "@auto-engineer/message-bus": "0.11.12"
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.12"
32
+ "@auto-engineer/cli": "0.11.14"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
- "version": "0.11.12",
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 getFlows from: %s', projectNarrativePath);
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
- fromProjection(name: string, idField: string): ChainableSource {
249
+ fromSingletonProjection(name: string): ChainableSource {
250
250
  const sourceItem: DataSourceItem = {
251
251
  target: this.target as MessageTarget,
252
- origin: { type: 'projection', name, idField },
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(nameOrBuilder: string | BuilderResult): StateSourceBuilder {
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(builderResult: BuilderResult): StateSourceBuilder {
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 flows and generates correct models', async () => {
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 flows with integrations', async () => {
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 flows and slices that have ids', async () => {
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 for empty when()', async () => {
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).toBeDefined();
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
+ });