@auto-engineer/narrative 0.11.13 → 0.11.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/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.13",
23
- "@auto-engineer/id": "0.11.13",
24
- "@auto-engineer/message-bus": "0.11.13"
22
+ "@auto-engineer/file-store": "0.11.15",
23
+ "@auto-engineer/id": "0.11.15",
24
+ "@auto-engineer/message-bus": "0.11.15"
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.13"
32
+ "@auto-engineer/cli": "0.11.15"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
- "version": "0.11.13",
37
+ "version": "0.11.15",
38
38
  "scripts": {
39
39
  "build": "tsx scripts/build.ts",
40
40
  "test": "vitest run --reporter=dot",
@@ -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
  }
@@ -1232,3 +1232,263 @@ function validateThenEvents(example: unknown): void {
1232
1232
  });
1233
1233
  }
1234
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
+ });