@auto-engineer/server-generator-apollo-emmett 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.
@@ -239,7 +239,7 @@ describe('projection.ts.ejs', () => {
239
239
  * }
240
240
  *
241
241
  * 2. Cast document parameter to extended type:
242
- * const current: InternalAvailableListings = (document as InternalAvailableListings) || { ...defaults };
242
+ * const current: InternalAvailableListings = document ?? { ...defaults };
243
243
  *
244
244
  * 3. Cast return values to extended type:
245
245
  * return { ...allFields, internalField } as InternalAvailableListings;
@@ -378,4 +378,412 @@ describe('projection.ts.ejs', () => {
378
378
  "
379
379
  `);
380
380
  });
381
+
382
+ it('should generate a valid singleton projection file', async () => {
383
+ const flows: Model = {
384
+ variant: 'specs',
385
+ narratives: [
386
+ {
387
+ name: 'todo-flow',
388
+ slices: [
389
+ {
390
+ type: 'command',
391
+ name: 'manage-todo',
392
+ stream: 'todo-${todoId}',
393
+ client: {
394
+ description: 'manage todo UI',
395
+ },
396
+ server: {
397
+ description: 'handles todo operations',
398
+ specs: {
399
+ name: 'Manage todo command',
400
+ rules: [
401
+ {
402
+ description: 'Should handle todo operations',
403
+ examples: [
404
+ {
405
+ description: 'User adds todo',
406
+ when: {
407
+ commandRef: 'AddTodo',
408
+ exampleData: {
409
+ todoId: 'todo_123',
410
+ title: 'Buy milk',
411
+ },
412
+ },
413
+ then: [
414
+ {
415
+ eventRef: 'TodoAdded',
416
+ exampleData: {
417
+ todoId: 'todo_123',
418
+ title: 'Buy milk',
419
+ },
420
+ },
421
+ ],
422
+ },
423
+ ],
424
+ },
425
+ ],
426
+ },
427
+ },
428
+ },
429
+ {
430
+ type: 'query',
431
+ name: 'view-summary',
432
+ stream: 'todos',
433
+ client: {
434
+ description: 'view todo summary UI',
435
+ },
436
+ server: {
437
+ description: 'singleton projection for todo summary',
438
+ data: [
439
+ {
440
+ target: {
441
+ type: 'State',
442
+ name: 'TodoSummary',
443
+ },
444
+ origin: {
445
+ type: 'projection',
446
+ name: 'TodoSummaryProjection',
447
+ singleton: true,
448
+ },
449
+ },
450
+ ],
451
+ specs: {
452
+ name: 'View summary query',
453
+ rules: [
454
+ {
455
+ description: 'Should aggregate todo counts',
456
+ examples: [
457
+ {
458
+ description: 'Todo added updates count',
459
+ when: [
460
+ {
461
+ eventRef: 'TodoAdded',
462
+ exampleData: {
463
+ todoId: 'todo_123',
464
+ title: 'Buy milk',
465
+ },
466
+ },
467
+ ],
468
+ then: [
469
+ {
470
+ stateRef: 'TodoSummary',
471
+ exampleData: {
472
+ totalCount: 1,
473
+ },
474
+ },
475
+ ],
476
+ },
477
+ ],
478
+ },
479
+ ],
480
+ },
481
+ },
482
+ },
483
+ ],
484
+ },
485
+ ],
486
+ messages: [
487
+ {
488
+ type: 'command',
489
+ name: 'AddTodo',
490
+ fields: [
491
+ { name: 'todoId', type: 'string', required: true },
492
+ { name: 'title', type: 'string', required: true },
493
+ ],
494
+ },
495
+ {
496
+ type: 'event',
497
+ name: 'TodoAdded',
498
+ source: 'internal',
499
+ fields: [
500
+ { name: 'todoId', type: 'string', required: true },
501
+ { name: 'title', type: 'string', required: true },
502
+ ],
503
+ },
504
+ {
505
+ type: 'state',
506
+ name: 'TodoSummary',
507
+ fields: [{ name: 'totalCount', type: 'number', required: true }],
508
+ },
509
+ ],
510
+ };
511
+
512
+ const plans = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
513
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-summary/projection.ts'));
514
+
515
+ expect(projectionFile?.contents).toMatchInlineSnapshot(`
516
+ "import {
517
+ inMemorySingleStreamProjection,
518
+ type ReadEvent,
519
+ type InMemoryReadEventMetadata,
520
+ } from '@event-driven-io/emmett';
521
+ import type { TodoSummary } from './state';
522
+ import type { TodoAdded } from '../manage-todo/events';
523
+
524
+ // SINGLETON AGGREGATION PATTERN
525
+ // This projection maintains a single document that aggregates data from multiple entities.
526
+ // Use internal state to track individual entity information for accurate calculations.
527
+ interface InternalTodoSummary extends TodoSummary {
528
+ _entities?: Record<string, { status?: string; [key: string]: unknown }>;
529
+ }
530
+
531
+ type AllEvents = TodoAdded;
532
+
533
+ export const projection = inMemorySingleStreamProjection<TodoSummary, AllEvents>({
534
+ collectionName: 'TodoSummaryProjection',
535
+ canHandle: ['TodoAdded'],
536
+ getDocumentId: (_event) => 'todo-summary',
537
+ evolve: (
538
+ document: TodoSummary | null,
539
+ event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
540
+ ): TodoSummary | null => {
541
+ switch (event.type) {
542
+ case 'TodoAdded': {
543
+ /**
544
+ * ## IMPLEMENTATION INSTRUCTIONS ##
545
+ * **SINGLETON AGGREGATION PATTERN**
546
+ *
547
+ * This projection maintains ONE document aggregating data from MANY entities.
548
+ *
549
+ * CRITICAL: Use internal state to track individual entity information:
550
+ *
551
+ * 1. Access current state:
552
+ * const current: InternalTodoSummary = document ?? { ...initialState, _entities: {} };
553
+ *
554
+ * 2. Track entity changes:
555
+ * // a) Extract the unique identifier that distinguishes this entity
556
+ * // Examine event.data to find the ID field (often 'id' or '<entity>Id')
557
+ * const entityId = event.data.[ENTITY_ID_FIELD];
558
+ *
559
+ * // b) Store/update entity state with relevant properties from event.data
560
+ * // Include only fields needed for aggregation calculations
561
+ * current._entities[entityId] = { [field]: value, ... };
562
+ *
563
+ * 3. Calculate aggregates from entity states:
564
+ * const counts = Object.values(current._entities).reduce((acc, entity) => {
565
+ * acc[entity.status] = (acc[entity.status] || 0) + 1;
566
+ * return acc;
567
+ * }, {});
568
+ *
569
+ * 4. Return with internal state:
570
+ * return { ...publicFields, _entities: current._entities } as InternalTodoSummary;
571
+ */
572
+ return {
573
+ totalCount: /* TODO: map from event.data */ 0,
574
+ };
575
+ }
576
+ default:
577
+ return document;
578
+ }
579
+ },
580
+ });
581
+
582
+ export default projection;
583
+ "
584
+ `);
585
+ });
586
+
587
+ it('should generate a valid composite key projection file', async () => {
588
+ const flows: Model = {
589
+ variant: 'specs',
590
+ narratives: [
591
+ {
592
+ name: 'user-project-flow',
593
+ slices: [
594
+ {
595
+ type: 'command',
596
+ name: 'manage-user-project',
597
+ stream: 'user-project-${userId}-${projectId}',
598
+ client: {
599
+ description: 'manage user project UI',
600
+ },
601
+ server: {
602
+ description: 'handles user project operations',
603
+ specs: {
604
+ name: 'Manage user project command',
605
+ rules: [
606
+ {
607
+ description: 'Should handle user project operations',
608
+ examples: [
609
+ {
610
+ description: 'User joins project',
611
+ when: {
612
+ commandRef: 'JoinProject',
613
+ exampleData: {
614
+ userId: 'user_123',
615
+ projectId: 'proj_456',
616
+ role: 'developer',
617
+ },
618
+ },
619
+ then: [
620
+ {
621
+ eventRef: 'UserJoinedProject',
622
+ exampleData: {
623
+ userId: 'user_123',
624
+ projectId: 'proj_456',
625
+ role: 'developer',
626
+ },
627
+ },
628
+ ],
629
+ },
630
+ ],
631
+ },
632
+ ],
633
+ },
634
+ },
635
+ },
636
+ {
637
+ type: 'query',
638
+ name: 'view-user-projects',
639
+ stream: 'user-projects',
640
+ client: {
641
+ description: 'view user projects UI',
642
+ },
643
+ server: {
644
+ description: 'composite key projection for user projects',
645
+ data: [
646
+ {
647
+ target: {
648
+ type: 'State',
649
+ name: 'UserProject',
650
+ },
651
+ origin: {
652
+ type: 'projection',
653
+ name: 'UserProjectsProjection',
654
+ idField: ['userId', 'projectId'],
655
+ },
656
+ },
657
+ ],
658
+ specs: {
659
+ name: 'View user projects query',
660
+ rules: [
661
+ {
662
+ description: 'Should track user project memberships',
663
+ examples: [
664
+ {
665
+ description: 'User joins project',
666
+ when: [
667
+ {
668
+ eventRef: 'UserJoinedProject',
669
+ exampleData: {
670
+ userId: 'user_123',
671
+ projectId: 'proj_456',
672
+ role: 'developer',
673
+ },
674
+ },
675
+ ],
676
+ then: [
677
+ {
678
+ stateRef: 'UserProject',
679
+ exampleData: {
680
+ userId: 'user_123',
681
+ projectId: 'proj_456',
682
+ role: 'developer',
683
+ },
684
+ },
685
+ ],
686
+ },
687
+ ],
688
+ },
689
+ ],
690
+ },
691
+ },
692
+ },
693
+ ],
694
+ },
695
+ ],
696
+ messages: [
697
+ {
698
+ type: 'command',
699
+ name: 'JoinProject',
700
+ fields: [
701
+ { name: 'userId', type: 'string', required: true },
702
+ { name: 'projectId', type: 'string', required: true },
703
+ { name: 'role', type: 'string', required: true },
704
+ ],
705
+ },
706
+ {
707
+ type: 'event',
708
+ name: 'UserJoinedProject',
709
+ source: 'internal',
710
+ fields: [
711
+ { name: 'userId', type: 'string', required: true },
712
+ { name: 'projectId', type: 'string', required: true },
713
+ { name: 'role', type: 'string', required: true },
714
+ ],
715
+ },
716
+ {
717
+ type: 'state',
718
+ name: 'UserProject',
719
+ fields: [
720
+ { name: 'userId', type: 'string', required: true },
721
+ { name: 'projectId', type: 'string', required: true },
722
+ { name: 'role', type: 'string', required: true },
723
+ ],
724
+ },
725
+ ],
726
+ };
727
+
728
+ const plans = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
729
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-user-projects/projection.ts'));
730
+
731
+ expect(projectionFile?.contents).toMatchInlineSnapshot(`
732
+ "import {
733
+ inMemorySingleStreamProjection,
734
+ type ReadEvent,
735
+ type InMemoryReadEventMetadata,
736
+ } from '@event-driven-io/emmett';
737
+ import type { UserProject } from './state';
738
+ import type { UserJoinedProject } from '../manage-user-project/events';
739
+
740
+ type AllEvents = UserJoinedProject;
741
+
742
+ export const projection = inMemorySingleStreamProjection<UserProject, AllEvents>({
743
+ collectionName: 'UserProjectsProjection',
744
+ canHandle: ['UserJoinedProject'],
745
+ getDocumentId: (event) => \`\${event.data.userId}-\${event.data.projectId}\`,
746
+ evolve: (
747
+ document: UserProject | null,
748
+ event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
749
+ ): UserProject | null => {
750
+ switch (event.type) {
751
+ case 'UserJoinedProject': {
752
+ /**
753
+ * ## IMPLEMENTATION INSTRUCTIONS ##
754
+ * **COMPOSITE KEY PROJECTION**
755
+ *
756
+ * This projection uses a composite key: userId + projectId
757
+ * Document ID format: \`\${event.data.userId}-\${event.data.projectId}\`
758
+ *
759
+ * CRITICAL: You MUST include ALL key fields in every return statement:
760
+ * - userId: event.data.userId
761
+ * - projectId: event.data.projectId
762
+ *
763
+ * Missing even one key field will cause the projection to fail.
764
+ * Key fields typically map directly from event data (no transformation needed).
765
+ *
766
+ * Example implementation:
767
+ * return {
768
+ * userId: event.data.userId,
769
+ * projectId: event.data.projectId,
770
+ * // ... other fields
771
+ * };
772
+ */
773
+ return {
774
+ userId: /* TODO: map from event.data */ '',
775
+ projectId: /* TODO: map from event.data */ '',
776
+ role: /* TODO: map from event.data */ '',
777
+ };
778
+ }
779
+ default:
780
+ return document;
781
+ }
782
+ },
783
+ });
784
+
785
+ export default projection;
786
+ "
787
+ `);
788
+ });
381
789
  });
@@ -141,7 +141,9 @@ _%>
141
141
  .then(async (state) => {
142
142
  const document = await state.database
143
143
  .collection<<%= TargetType %>>('<%= projName %>')
144
- .findOne((doc) => <%
144
+ .findOne(<% if (projectionSingleton) { %>);<%
145
+ } else {
146
+ %>(doc) => <%
145
147
  const idField = projectionIdField ?? 'id';
146
148
  if (idField.includes('-')) {
147
149
  // Handle composite keys
@@ -157,7 +159,9 @@ if (idField.includes('-')) {
157
159
  const valueStr = typeof value === 'string' ? `'${value}'` : value || "'test-id'";
158
160
  %>doc.<%= idField %> === <%= valueStr %><%
159
161
  }
160
- %>);
162
+ %>);<%
163
+ }
164
+ %>
161
165
 
162
166
  const expected: <%= TargetType %> = {
163
167
  <% const stateKeys = Object.keys(expectedState.exampleData || {});
@@ -1,4 +1,10 @@
1
- import {
1
+ <%
2
+ const origin = slice.server?.data?.[0]?.origin;
3
+ const isSingleton = origin?.singleton === true;
4
+ const idField = origin?.idField;
5
+ const isCompositeKey = Array.isArray(idField);
6
+ const compositeKeyFields = isCompositeKey ? idField : [];
7
+ %>import {
2
8
  inMemorySingleStreamProjection,
3
9
  type ReadEvent,
4
10
  type InMemoryReadEventMetadata,
@@ -35,7 +41,14 @@ if (stateEnums.length > 0) {
35
41
  import { <%= stateEnums.join(', ') %> } from '../../../shared';
36
42
  <%
37
43
  } -%>
38
-
44
+ <% if (isSingleton) { %>
45
+ // SINGLETON AGGREGATION PATTERN
46
+ // This projection maintains a single document that aggregates data from multiple entities.
47
+ // Use internal state to track individual entity information for accurate calculations.
48
+ interface Internal<%= pascalCase(targetName || 'State') %> extends <%= pascalCase(targetName || 'State') %> {
49
+ _entities?: Record<string, { status?: string; [key: string]: unknown }>;
50
+ }
51
+ <% } %>
39
52
  type AllEvents = <%= allEventTypes %>;
40
53
 
41
54
  export const projection = inMemorySingleStreamProjection<
@@ -44,17 +57,17 @@ AllEvents
44
57
  >({
45
58
  collectionName: '<%= pascalCase(slice.server?.data?.[0]?.origin?.name || "unknown-collection") %>',
46
59
  canHandle: [<%- events.map(e => `'${e.type}'`).join(', ') %>],
47
- getDocumentId: (event) => <%
48
- const idField = slice.server?.data?.[0]?.origin?.idField ?? 'id';
49
- // Check if idField contains hyphen-separated composite keys
50
- if (idField.includes('-')) {
51
- const parts = idField.split('-');
52
- const template = parts.map((part, index) =>
53
- index === 0 ? `\${event.data.${part}}` : `-\${event.data.${part}}`
60
+ getDocumentId: (<%- isSingleton ? '_event' : 'event' %>) => <%
61
+ if (isSingleton) {
62
+ %>'<%= toKebabCase(slice.server?.data?.[0]?.target?.name || 'singleton') %>'<%
63
+ } else if (isCompositeKey) {
64
+ const template = compositeKeyFields.map((field, index) =>
65
+ index === 0 ? `\${event.data.${field}}` : `-\${event.data.${field}}`
54
66
  ).join('');
55
67
  %>`<%= template %>`<%
56
68
  } else {
57
- %>event.data.<%= idField %><%
69
+ const singleIdField = typeof idField === 'string' ? idField : 'id';
70
+ %>event.data.<%= singleIdField %><%
58
71
  }
59
72
  %>,
60
73
  evolve: (
@@ -93,7 +106,51 @@ switch (event.type) {
93
106
  case '<%= event.type %>': {
94
107
  /**
95
108
  * ## IMPLEMENTATION INSTRUCTIONS ##
96
- <% if (isRemovalEvent || eventNameSuggestsRemoval) { -%>
109
+ <% if (isSingleton) { -%>
110
+ * **SINGLETON AGGREGATION PATTERN**
111
+ *
112
+ * This projection maintains ONE document aggregating data from MANY entities.
113
+ *
114
+ * CRITICAL: Use internal state to track individual entity information:
115
+ *
116
+ * 1. Access current state:
117
+ * const current: Internal<%= pascalCase(targetName || 'State') %> = document ?? { ...initialState, _entities: {} };
118
+ *
119
+ * 2. Track entity changes:
120
+ * // a) Extract the unique identifier that distinguishes this entity
121
+ * // Examine event.data to find the ID field (often 'id' or '<entity>Id')
122
+ * const entityId = event.data.[ENTITY_ID_FIELD];
123
+ *
124
+ * // b) Store/update entity state with relevant properties from event.data
125
+ * // Include only fields needed for aggregation calculations
126
+ * current._entities[entityId] = { [field]: value, ... };
127
+ *
128
+ * 3. Calculate aggregates from entity states:
129
+ * const counts = Object.values(current._entities).reduce((acc, entity) => {
130
+ * acc[entity.status] = (acc[entity.status] || 0) + 1;
131
+ * return acc;
132
+ * }, {});
133
+ *
134
+ * 4. Return with internal state:
135
+ * return { ...publicFields, _entities: current._entities } as Internal<%= pascalCase(targetName || 'State') %>;
136
+ <% } else if (isCompositeKey) { -%>
137
+ * **COMPOSITE KEY PROJECTION**
138
+ *
139
+ * This projection uses a composite key: <%= compositeKeyFields.join(' + ') %>
140
+ * Document ID format: `${event.data.<%= compositeKeyFields[0] %>}-${event.data.<%= compositeKeyFields.slice(1).join('}}-${event.data.') %>}`
141
+ *
142
+ * CRITICAL: You MUST include ALL key fields in every return statement:
143
+ * <%- compositeKeyFields.map(f => `- ${f}: event.data.${f}`).join('\n * ') %>
144
+ *
145
+ * Missing even one key field will cause the projection to fail.
146
+ * Key fields typically map directly from event data (no transformation needed).
147
+ *
148
+ * Example implementation:
149
+ * return {
150
+ * <%- compositeKeyFields.map(f => `${f}: event.data.${f},`).join('\n * ') %>
151
+ * // ... other fields
152
+ * };
153
+ <% } else if (isRemovalEvent || eventNameSuggestsRemoval) { -%>
97
154
  * This event might indicate removal of a <%= targetName || 'document' %>.
98
155
  *
99
156
  * - If the intent is to **remove the document**, return `null`.
@@ -112,7 +169,7 @@ case '<%= event.type %>': {
112
169
  * }
113
170
  *
114
171
  * 2. Cast document parameter to extended type:
115
- * const current: Internal<%= pascalCase(targetName || 'State') %> = (document as Internal<%= pascalCase(targetName || 'State') %>) || { ...defaults };
172
+ * const current: Internal<%= pascalCase(targetName || 'State') %> = document ?? { ...defaults };
116
173
  *
117
174
  * 3. Cast return values to extended type:
118
175
  * return { ...allFields, internalField } as Internal<%= pascalCase(targetName || 'State') %>;