@auto-engineer/narrative 0.18.0 → 0.19.1

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/dist/src/data-narrative-builders.d.ts +13 -8
  4. package/dist/src/data-narrative-builders.d.ts.map +1 -1
  5. package/dist/src/data-narrative-builders.js +47 -20
  6. package/dist/src/data-narrative-builders.js.map +1 -1
  7. package/dist/src/id/addAutoIds.d.ts.map +1 -1
  8. package/dist/src/id/addAutoIds.js +18 -0
  9. package/dist/src/id/addAutoIds.js.map +1 -1
  10. package/dist/src/id/hasAllIds.d.ts.map +1 -1
  11. package/dist/src/id/hasAllIds.js +13 -1
  12. package/dist/src/id/hasAllIds.js.map +1 -1
  13. package/dist/src/schema.d.ts +231 -0
  14. package/dist/src/schema.d.ts.map +1 -1
  15. package/dist/src/schema.js +2 -0
  16. package/dist/src/schema.js.map +1 -1
  17. package/dist/src/transformers/model-to-narrative/generators/flow.d.ts.map +1 -1
  18. package/dist/src/transformers/model-to-narrative/generators/flow.js +10 -5
  19. package/dist/src/transformers/model-to-narrative/generators/flow.js.map +1 -1
  20. package/dist/src/types.d.ts +2 -0
  21. package/dist/src/types.d.ts.map +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +4 -4
  24. package/src/data-narrative-builders.ts +57 -20
  25. package/src/getNarratives.specs.ts +69 -0
  26. package/src/id/addAutoIds.specs.ts +268 -0
  27. package/src/id/addAutoIds.ts +19 -0
  28. package/src/id/hasAllIds.specs.ts +223 -0
  29. package/src/id/hasAllIds.ts +13 -1
  30. package/src/model-to-narrative.specs.ts +176 -0
  31. package/src/schema.ts +2 -0
  32. package/src/transformers/model-to-narrative/generators/flow.ts +16 -4
  33. package/src/types.ts +2 -0
package/package.json CHANGED
@@ -23,9 +23,9 @@
23
23
  "typescript": "^5.9.2",
24
24
  "zod": "^3.22.4",
25
25
  "zod-to-json-schema": "^3.22.3",
26
- "@auto-engineer/file-store": "0.18.0",
27
- "@auto-engineer/id": "0.18.0",
28
- "@auto-engineer/message-bus": "0.18.0"
26
+ "@auto-engineer/file-store": "0.19.1",
27
+ "@auto-engineer/id": "0.19.1",
28
+ "@auto-engineer/message-bus": "0.19.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^20.0.0",
@@ -35,7 +35,7 @@
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  },
38
- "version": "0.18.0",
38
+ "version": "0.19.1",
39
39
  "scripts": {
40
40
  "build": "tsx scripts/build.ts",
41
41
  "test": "vitest run --reporter=dot",
@@ -55,6 +55,7 @@ export interface FieldSelector {
55
55
  abstract class MessageTargetBuilder<TResult> {
56
56
  protected target: Partial<MessageTarget> = {};
57
57
  protected instructions?: string;
58
+ protected itemId?: string;
58
59
 
59
60
  fields(selector: FieldSelector): this {
60
61
  this.target.fields = selector as Record<string, unknown>;
@@ -71,13 +72,15 @@ abstract class MessageTargetBuilder<TResult> {
71
72
 
72
73
  // Event sink builder
73
74
  export class EventSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
74
- constructor(name: string) {
75
+ constructor(name: string, id?: string) {
75
76
  super();
76
77
  this.target = { type: 'Event', name };
78
+ this.itemId = id;
77
79
  }
78
80
 
79
81
  toStream(pattern: string): ChainableSink {
80
82
  const sinkItem: DataSinkItem = {
83
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
81
84
  target: this.target as MessageTarget,
82
85
  destination: { type: 'stream', pattern },
83
86
  __type: 'sink' as const,
@@ -88,6 +91,7 @@ export class EventSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
88
91
 
89
92
  toIntegration(...systems: Integration[]): ChainableSink {
90
93
  const sinkItem: DataSinkItem = {
94
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
91
95
  target: this.target as MessageTarget,
92
96
  destination: {
93
97
  type: 'integration',
@@ -101,6 +105,7 @@ export class EventSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
101
105
 
102
106
  toDatabase(collection: string): ChainableSink {
103
107
  const sinkItem: DataSinkItem = {
108
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
104
109
  target: this.target as MessageTarget,
105
110
  destination: { type: 'database', collection },
106
111
  __type: 'sink' as const,
@@ -111,6 +116,7 @@ export class EventSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
111
116
 
112
117
  toTopic(name: string): ChainableSink {
113
118
  const sinkItem: DataSinkItem = {
119
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
114
120
  target: this.target as MessageTarget,
115
121
  destination: { type: 'topic', name },
116
122
  __type: 'sink' as const,
@@ -128,9 +134,10 @@ export class EventSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
128
134
  export class CommandSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
129
135
  private stateSource?: DataSourceItem;
130
136
 
131
- constructor(name: string) {
137
+ constructor(name: string, id?: string) {
132
138
  super();
133
139
  this.target = { type: 'Command', name };
140
+ this.itemId = id;
134
141
  }
135
142
 
136
143
  withState(source: DataSourceItem | ChainableSource): this {
@@ -144,6 +151,7 @@ export class CommandSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
144
151
  messageType: 'command' | 'query' | 'reaction',
145
152
  ): ChainableSink {
146
153
  const sinkItem: DataSinkItem = {
154
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
147
155
  target: this.target as MessageTarget,
148
156
  destination: {
149
157
  type: 'integration',
@@ -167,6 +175,7 @@ export class CommandSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
167
175
 
168
176
  hints(hint: string): ChainableSink {
169
177
  const sinkItem: DataSinkItem = {
178
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
170
179
  target: this.target as MessageTarget,
171
180
  destination: { type: 'integration', systems: [] },
172
181
  transform: hint,
@@ -179,6 +188,7 @@ export class CommandSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
179
188
 
180
189
  toDatabase(collection: string): ChainableSink {
181
190
  const sinkItem: DataSinkItem = {
191
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
182
192
  target: this.target as MessageTarget,
183
193
  destination: { type: 'database', collection },
184
194
  __type: 'sink' as const,
@@ -190,6 +200,7 @@ export class CommandSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
190
200
 
191
201
  toTopic(name: string): ChainableSink {
192
202
  const sinkItem: DataSinkItem = {
203
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
193
204
  target: this.target as MessageTarget,
194
205
  destination: { type: 'topic', name },
195
206
  __type: 'sink' as const,
@@ -206,13 +217,15 @@ export class CommandSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
206
217
 
207
218
  // State sink builder
208
219
  export class StateSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
209
- constructor(name: string) {
220
+ constructor(name: string, id?: string) {
210
221
  super();
211
222
  this.target = { type: 'State', name };
223
+ this.itemId = id;
212
224
  }
213
225
 
214
226
  toDatabase(collection: string): ChainableSink {
215
227
  const sinkItem: DataSinkItem = {
228
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
216
229
  target: this.target as MessageTarget,
217
230
  destination: { type: 'database', collection },
218
231
  __type: 'sink' as const,
@@ -223,6 +236,7 @@ export class StateSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
223
236
 
224
237
  toStream(pattern: string): ChainableSink {
225
238
  const sinkItem: DataSinkItem = {
239
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
226
240
  target: this.target as MessageTarget,
227
241
  destination: { type: 'stream', pattern },
228
242
  __type: 'sink' as const,
@@ -238,13 +252,15 @@ export class StateSinkBuilder extends MessageTargetBuilder<DataSinkItem> {
238
252
 
239
253
  // State source builder
240
254
  export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSourceItem> {
241
- constructor(name: string) {
255
+ constructor(name: string, id?: string) {
242
256
  super();
243
257
  this.target = { type: 'State', name };
258
+ this.itemId = id;
244
259
  }
245
260
 
246
261
  fromSingletonProjection(name: string): ChainableSource {
247
262
  const sourceItem: DataSourceItem = {
263
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
248
264
  target: this.target as MessageTarget,
249
265
  origin: { type: 'projection', name, singleton: true },
250
266
  __type: 'source' as const,
@@ -259,6 +275,7 @@ export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSo
259
275
  : string,
260
276
  >(name: string, idField: K): ChainableSource {
261
277
  const sourceItem: DataSourceItem = {
278
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
262
279
  target: this.target as MessageTarget,
263
280
  origin: { type: 'projection', name, idField: idField as string },
264
281
  __type: 'source' as const,
@@ -273,6 +290,7 @@ export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSo
273
290
  : string,
274
291
  >(name: string, idFields: K[]): ChainableSource {
275
292
  const sourceItem: DataSourceItem = {
293
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
276
294
  target: this.target as MessageTarget,
277
295
  origin: { type: 'projection', name, idField: idFields as string[] },
278
296
  __type: 'source' as const,
@@ -283,6 +301,7 @@ export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSo
283
301
 
284
302
  fromReadModel(name: string): ChainableSource {
285
303
  const sourceItem: DataSourceItem = {
304
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
286
305
  target: this.target as MessageTarget,
287
306
  origin: { type: 'readModel', name },
288
307
  __type: 'source' as const,
@@ -293,6 +312,7 @@ export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSo
293
312
 
294
313
  fromDatabase(collection: string, query?: Record<string, unknown>): ChainableSource {
295
314
  const sourceItem: DataSourceItem = {
315
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
296
316
  target: this.target as MessageTarget,
297
317
  origin: { type: 'database', collection, query },
298
318
  __type: 'source' as const,
@@ -303,6 +323,7 @@ export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSo
303
323
 
304
324
  fromApi(endpoint: string, method?: string): ChainableSource {
305
325
  const sourceItem: DataSourceItem = {
326
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
306
327
  target: this.target as MessageTarget,
307
328
  origin: { type: 'api', endpoint, method },
308
329
  __type: 'source' as const,
@@ -313,6 +334,7 @@ export class StateSourceBuilder<S = unknown> extends MessageTargetBuilder<DataSo
313
334
 
314
335
  fromIntegration(...systems: (Integration | string)[]): ChainableSource {
315
336
  const sourceItem: DataSourceItem = {
337
+ ...(this.itemId != null && this.itemId !== '' && { id: this.itemId }),
316
338
  target: this.target as MessageTarget,
317
339
  origin: createIntegrationOrigin(
318
340
  systems.map((s) => (typeof s === 'string' ? s : integrationExportRegistry.getExportNameForIntegration(s))),
@@ -348,14 +370,20 @@ function isValidBuilderResult(obj: unknown): obj is BuilderResult {
348
370
  }
349
371
 
350
372
  export class DataSinkBuilder {
373
+ private readonly builderId?: string;
374
+
375
+ constructor(id?: string) {
376
+ this.builderId = id;
377
+ }
378
+
351
379
  event(nameOrBuilder: string | BuilderResult): EventSinkBuilder {
352
380
  if (typeof nameOrBuilder === 'string') {
353
- return new EventSinkBuilder(nameOrBuilder);
381
+ return new EventSinkBuilder(nameOrBuilder, this.builderId);
354
382
  }
355
383
 
356
384
  // Handle event builder function
357
385
  if (isValidBuilderResult(nameOrBuilder) && nameOrBuilder.__messageCategory === 'event') {
358
- return new EventSinkBuilder(nameOrBuilder.type);
386
+ return new EventSinkBuilder(nameOrBuilder.type, this.builderId);
359
387
  }
360
388
 
361
389
  throw new Error('Invalid event parameter - must be a string or event builder function');
@@ -363,12 +391,12 @@ export class DataSinkBuilder {
363
391
 
364
392
  command(nameOrBuilder: string | BuilderResult): CommandSinkBuilder {
365
393
  if (typeof nameOrBuilder === 'string') {
366
- return new CommandSinkBuilder(nameOrBuilder);
394
+ return new CommandSinkBuilder(nameOrBuilder, this.builderId);
367
395
  }
368
396
 
369
397
  // Handle command builder function
370
398
  if (isValidBuilderResult(nameOrBuilder) && nameOrBuilder.__messageCategory === 'command') {
371
- return new CommandSinkBuilder(nameOrBuilder.type);
399
+ return new CommandSinkBuilder(nameOrBuilder.type, this.builderId);
372
400
  }
373
401
 
374
402
  throw new Error('Invalid command parameter - must be a string or command builder function');
@@ -376,12 +404,12 @@ export class DataSinkBuilder {
376
404
 
377
405
  state(nameOrBuilder: string | BuilderResult): StateSinkBuilder {
378
406
  if (typeof nameOrBuilder === 'string') {
379
- return new StateSinkBuilder(nameOrBuilder);
407
+ return new StateSinkBuilder(nameOrBuilder, this.builderId);
380
408
  }
381
409
 
382
410
  // Handle state builder function
383
411
  if (isValidBuilderResult(nameOrBuilder) && nameOrBuilder.__messageCategory === 'state') {
384
- return new StateSinkBuilder(nameOrBuilder.type);
412
+ return new StateSinkBuilder(nameOrBuilder.type, this.builderId);
385
413
  }
386
414
 
387
415
  throw new Error('Invalid state parameter - must be a string or state builder function');
@@ -389,16 +417,22 @@ export class DataSinkBuilder {
389
417
  }
390
418
 
391
419
  export class DataSourceBuilder {
420
+ private readonly builderId?: string;
421
+
422
+ constructor(id?: string) {
423
+ this.builderId = id;
424
+ }
425
+
392
426
  state<S extends import('./types').State<string, DefaultRecord> = import('./types').State<string, DefaultRecord>>(
393
427
  nameOrBuilder: string | BuilderResult,
394
428
  ): StateSourceBuilder<S> {
395
429
  if (typeof nameOrBuilder === 'string') {
396
- return new StateSourceBuilder<S>(nameOrBuilder);
430
+ return new StateSourceBuilder<S>(nameOrBuilder, this.builderId);
397
431
  }
398
432
 
399
433
  // Handle state builder function
400
434
  if (isValidBuilderResult(nameOrBuilder) && nameOrBuilder.__messageCategory === 'state') {
401
- return new StateSourceBuilder<S>(nameOrBuilder.type);
435
+ return new StateSourceBuilder<S>(nameOrBuilder.type, this.builderId);
402
436
  }
403
437
 
404
438
  throw new Error('Invalid state parameter - must be a string or state builder function');
@@ -406,11 +440,14 @@ export class DataSourceBuilder {
406
440
  }
407
441
 
408
442
  // Factory functions for cleaner API
409
- export const sink = () => new DataSinkBuilder();
410
- export const source = () => new DataSourceBuilder();
443
+ export const sink = (id?: string) => new DataSinkBuilder(id);
444
+ export const source = (id?: string) => new DataSourceBuilder(id);
411
445
 
412
446
  // Type-safe sink function that accepts builder results
413
- export function typedSink(builderResult: BuilderResult): EventSinkBuilder | CommandSinkBuilder | StateSinkBuilder {
447
+ export function typedSink(
448
+ builderResult: BuilderResult,
449
+ id?: string,
450
+ ): EventSinkBuilder | CommandSinkBuilder | StateSinkBuilder {
414
451
  if (!isValidBuilderResult(builderResult)) {
415
452
  throw new Error('Invalid builder result - must be from Events, Commands, or State builders');
416
453
  }
@@ -419,11 +456,11 @@ export function typedSink(builderResult: BuilderResult): EventSinkBuilder | Comm
419
456
 
420
457
  switch (__messageCategory) {
421
458
  case 'event':
422
- return new EventSinkBuilder(messageName);
459
+ return new EventSinkBuilder(messageName, id);
423
460
  case 'command':
424
- return new CommandSinkBuilder(messageName);
461
+ return new CommandSinkBuilder(messageName, id);
425
462
  case 'state':
426
- return new StateSinkBuilder(messageName);
463
+ return new StateSinkBuilder(messageName, id);
427
464
  default: {
428
465
  const category: never = __messageCategory;
429
466
  throw new Error(`Unknown message category: ${String(category)}`);
@@ -434,7 +471,7 @@ export function typedSink(builderResult: BuilderResult): EventSinkBuilder | Comm
434
471
  // Type-safe source function that accepts builder results
435
472
  export function typedSource<
436
473
  S extends import('./types').State<string, DefaultRecord> = import('./types').State<string, DefaultRecord>,
437
- >(builderResult: BuilderResult): StateSourceBuilder<S> {
474
+ >(builderResult: BuilderResult, id?: string): StateSourceBuilder<S> {
438
475
  if (!isValidBuilderResult(builderResult)) {
439
476
  throw new Error('Invalid builder result - must be from State builders');
440
477
  }
@@ -443,5 +480,5 @@ export function typedSource<
443
480
  throw new Error('Source can only be created from State builders');
444
481
  }
445
482
 
446
- return new StateSourceBuilder<S>(builderResult.type);
483
+ return new StateSourceBuilder<S>(builderResult.type, id);
447
484
  }
@@ -1712,4 +1712,73 @@ flow('All Projection Patterns', () => {
1712
1712
  });
1713
1713
  }
1714
1714
  });
1715
+
1716
+ it('should capture optional id on data sink and source items', async () => {
1717
+ const memoryVfs = new InMemoryFileStore();
1718
+
1719
+ const flowContent = `
1720
+ import { flow, command, query, specs, rule, example, data, sink, source, type Event, type State } from '@auto-engineer/narrative';
1721
+
1722
+ type OrderPlaced = Event<'OrderPlaced', { orderId: string; amount: number }>;
1723
+ type OrderState = State<'OrderState', { orderId: string; status: string }>;
1724
+
1725
+ flow('Data Item IDs', () => {
1726
+ command('places order')
1727
+ .server(() => {
1728
+ specs(() => {
1729
+ rule('order is placed', () => {
1730
+ example('places order')
1731
+ .when({})
1732
+ .then<OrderPlaced>({ orderId: 'ord-001', amount: 100 });
1733
+ });
1734
+ });
1735
+ data([
1736
+ sink('SINK-001').event('OrderPlaced').toStream('orders-stream'),
1737
+ ]);
1738
+ });
1739
+
1740
+ query('views order status')
1741
+ .server(() => {
1742
+ specs(() => {
1743
+ rule('shows order status', () => {
1744
+ example('order status')
1745
+ .given<OrderPlaced>({ orderId: 'ord-001', amount: 100 })
1746
+ .when({})
1747
+ .then<OrderState>({ orderId: 'ord-001', status: 'pending' });
1748
+ });
1749
+ });
1750
+ data([
1751
+ source('SOURCE-001').state<OrderState>('OrderState').fromProjection('Orders', 'orderId'),
1752
+ ]);
1753
+ });
1754
+ });
1755
+ `;
1756
+
1757
+ await memoryVfs.write('/test/data-ids.narrative.ts', new TextEncoder().encode(flowContent));
1758
+
1759
+ const flows = await getNarratives({ vfs: memoryVfs, root: '/test', pattern, fastFsScan: true });
1760
+ const model = flows.toModel();
1761
+
1762
+ const parseResult = modelSchema.safeParse(model);
1763
+ expect(parseResult.success).toBe(true);
1764
+
1765
+ const dataIdsFlow = model.narratives.find((f) => f.name === 'Data Item IDs');
1766
+ expect(dataIdsFlow).toBeDefined();
1767
+
1768
+ if (!dataIdsFlow) return;
1769
+
1770
+ const commandSlice = dataIdsFlow.slices.find((s) => s.name === 'places order');
1771
+ if (commandSlice?.type === 'command') {
1772
+ const sinkData = commandSlice.server.data;
1773
+ expect(sinkData).toHaveLength(1);
1774
+ expect(sinkData?.[0].id).toBe('SINK-001');
1775
+ }
1776
+
1777
+ const querySlice = dataIdsFlow.slices.find((s) => s.name === 'views order status');
1778
+ if (querySlice?.type === 'query') {
1779
+ const sourceData = querySlice.server.data as DataSource[] | undefined;
1780
+ expect(sourceData).toHaveLength(1);
1781
+ expect(sourceData?.[0].id).toBe('SOURCE-001');
1782
+ }
1783
+ });
1715
1784
  });
@@ -669,6 +669,274 @@ describe('addAutoIds', () => {
669
669
  }
670
670
  });
671
671
 
672
+ describe('data item ID generation', () => {
673
+ const AUTO_ID_REGEX = /^[A-Za-z0-9_]{9}$/;
674
+
675
+ it('should assign ID to data sink without ID', () => {
676
+ const model: Model = {
677
+ variant: 'specs',
678
+ narratives: [
679
+ {
680
+ name: 'Test Flow',
681
+ id: 'FLOW-001',
682
+ slices: [
683
+ {
684
+ type: 'command',
685
+ name: 'Test Command',
686
+ id: 'SLICE-001',
687
+ client: { specs: [] },
688
+ server: {
689
+ description: 'Test server',
690
+ specs: [],
691
+ data: [
692
+ {
693
+ target: { type: 'Event', name: 'TestEvent' },
694
+ destination: { type: 'stream', pattern: 'test-stream' },
695
+ },
696
+ ],
697
+ },
698
+ },
699
+ ],
700
+ },
701
+ ],
702
+ messages: [],
703
+ integrations: [],
704
+ modules: [],
705
+ };
706
+
707
+ const result = addAutoIds(model);
708
+ const slice = result.narratives[0].slices[0];
709
+
710
+ if ('server' in slice && slice.server?.data) {
711
+ expect(slice.server.data[0].id).toMatch(AUTO_ID_REGEX);
712
+ }
713
+ });
714
+
715
+ it('should assign ID to data source without ID', () => {
716
+ const model: Model = {
717
+ variant: 'specs',
718
+ narratives: [
719
+ {
720
+ name: 'Test Flow',
721
+ id: 'FLOW-001',
722
+ slices: [
723
+ {
724
+ type: 'query',
725
+ name: 'Test Query',
726
+ id: 'SLICE-001',
727
+ client: { specs: [] },
728
+ server: {
729
+ description: 'Test server',
730
+ specs: [],
731
+ data: [
732
+ {
733
+ target: { type: 'State', name: 'TestState' },
734
+ origin: { type: 'projection', name: 'TestProjection' },
735
+ },
736
+ ],
737
+ },
738
+ },
739
+ ],
740
+ },
741
+ ],
742
+ messages: [],
743
+ integrations: [],
744
+ modules: [],
745
+ };
746
+
747
+ const result = addAutoIds(model);
748
+ const slice = result.narratives[0].slices[0];
749
+
750
+ if ('server' in slice && slice.server?.data) {
751
+ expect(slice.server.data[0].id).toMatch(AUTO_ID_REGEX);
752
+ }
753
+ });
754
+
755
+ it('should assign ID to nested _withState source without ID', () => {
756
+ const model: Model = {
757
+ variant: 'specs',
758
+ narratives: [
759
+ {
760
+ name: 'Test Flow',
761
+ id: 'FLOW-001',
762
+ slices: [
763
+ {
764
+ type: 'command',
765
+ name: 'Test Command',
766
+ id: 'SLICE-001',
767
+ client: { specs: [] },
768
+ server: {
769
+ description: 'Test server',
770
+ specs: [],
771
+ data: [
772
+ {
773
+ id: 'SINK-001',
774
+ target: { type: 'Command', name: 'TestCommand' },
775
+ destination: { type: 'stream', pattern: 'test-stream' },
776
+ _withState: {
777
+ target: { type: 'State', name: 'TestState' },
778
+ origin: { type: 'projection', name: 'TestProjection' },
779
+ },
780
+ },
781
+ ],
782
+ },
783
+ },
784
+ ],
785
+ },
786
+ ],
787
+ messages: [],
788
+ integrations: [],
789
+ modules: [],
790
+ };
791
+
792
+ const result = addAutoIds(model);
793
+ const slice = result.narratives[0].slices[0];
794
+
795
+ if ('server' in slice && slice.server?.data) {
796
+ const sink = slice.server.data[0];
797
+ expect(sink.id).toBe('SINK-001');
798
+ if ('destination' in sink && sink._withState) {
799
+ expect(sink._withState.id).toMatch(AUTO_ID_REGEX);
800
+ }
801
+ }
802
+ });
803
+
804
+ it('should preserve existing data item IDs', () => {
805
+ const model: Model = {
806
+ variant: 'specs',
807
+ narratives: [
808
+ {
809
+ name: 'Test Flow',
810
+ id: 'FLOW-001',
811
+ slices: [
812
+ {
813
+ type: 'react',
814
+ name: 'Test React',
815
+ id: 'SLICE-001',
816
+ server: {
817
+ specs: [],
818
+ data: [
819
+ {
820
+ id: 'EXISTING-SINK-001',
821
+ target: { type: 'Event', name: 'TestEvent' },
822
+ destination: { type: 'stream', pattern: 'test-stream' },
823
+ },
824
+ {
825
+ id: 'EXISTING-SOURCE-001',
826
+ target: { type: 'State', name: 'TestState' },
827
+ origin: { type: 'projection', name: 'TestProjection' },
828
+ },
829
+ ],
830
+ },
831
+ },
832
+ ],
833
+ },
834
+ ],
835
+ messages: [],
836
+ integrations: [],
837
+ modules: [],
838
+ };
839
+
840
+ const result = addAutoIds(model);
841
+ const slice = result.narratives[0].slices[0];
842
+
843
+ if ('server' in slice && slice.server?.data) {
844
+ expect(slice.server.data[0].id).toBe('EXISTING-SINK-001');
845
+ expect(slice.server.data[1].id).toBe('EXISTING-SOURCE-001');
846
+ }
847
+ });
848
+
849
+ it('should not mutate original data items', () => {
850
+ const model: Model = {
851
+ variant: 'specs',
852
+ narratives: [
853
+ {
854
+ name: 'Test Flow',
855
+ id: 'FLOW-001',
856
+ slices: [
857
+ {
858
+ type: 'command',
859
+ name: 'Test Command',
860
+ id: 'SLICE-001',
861
+ client: { specs: [] },
862
+ server: {
863
+ description: 'Test server',
864
+ specs: [],
865
+ data: [
866
+ {
867
+ target: { type: 'Event', name: 'TestEvent' },
868
+ destination: { type: 'stream', pattern: 'test-stream' },
869
+ },
870
+ ],
871
+ },
872
+ },
873
+ ],
874
+ },
875
+ ],
876
+ messages: [],
877
+ integrations: [],
878
+ modules: [],
879
+ };
880
+
881
+ const originalSlice = model.narratives[0].slices[0];
882
+ addAutoIds(model);
883
+
884
+ if ('server' in originalSlice && originalSlice.server?.data) {
885
+ expect(originalSlice.server.data[0].id).toBeUndefined();
886
+ }
887
+ });
888
+
889
+ it('should generate unique IDs for multiple data items', () => {
890
+ const model: Model = {
891
+ variant: 'specs',
892
+ narratives: [
893
+ {
894
+ name: 'Test Flow',
895
+ id: 'FLOW-001',
896
+ slices: [
897
+ {
898
+ type: 'react',
899
+ name: 'Test React',
900
+ id: 'SLICE-001',
901
+ server: {
902
+ specs: [],
903
+ data: [
904
+ {
905
+ target: { type: 'Event', name: 'Event1' },
906
+ destination: { type: 'stream', pattern: 'stream1' },
907
+ },
908
+ {
909
+ target: { type: 'Event', name: 'Event2' },
910
+ destination: { type: 'stream', pattern: 'stream2' },
911
+ },
912
+ {
913
+ target: { type: 'State', name: 'State1' },
914
+ origin: { type: 'projection', name: 'Proj1' },
915
+ },
916
+ ],
917
+ },
918
+ },
919
+ ],
920
+ },
921
+ ],
922
+ messages: [],
923
+ integrations: [],
924
+ modules: [],
925
+ };
926
+
927
+ const result = addAutoIds(model);
928
+ const slice = result.narratives[0].slices[0];
929
+
930
+ if ('server' in slice && slice.server?.data) {
931
+ const ids = slice.server.data.map((d) => d.id);
932
+ expect(ids[0]).toMatch(AUTO_ID_REGEX);
933
+ expect(ids[1]).toMatch(AUTO_ID_REGEX);
934
+ expect(ids[2]).toMatch(AUTO_ID_REGEX);
935
+ expect(new Set(ids).size).toBe(3);
936
+ }
937
+ });
938
+ });
939
+
672
940
  describe('module ID generation', () => {
673
941
  const AUTO_ID_REGEX = /^[A-Za-z0-9_]{9}$/;
674
942