@auto-engineer/server-generator-apollo-emmett 0.11.13 → 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.
@@ -378,4 +378,408 @@ 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 = (document as InternalTodoSummary) || { ...initialState, _entities: {} };
553
+ *
554
+ * 2. Track entity changes:
555
+ * const entityId = event.data.todoId; // or relevant ID field
556
+ * const prevStatus = current._entities?.[entityId]?.status;
557
+ * current._entities[entityId] = { status: 'new_status', ...otherData };
558
+ *
559
+ * 3. Calculate aggregates from entity states:
560
+ * const counts = Object.values(current._entities).reduce((acc, entity) => {
561
+ * acc[entity.status] = (acc[entity.status] || 0) + 1;
562
+ * return acc;
563
+ * }, {});
564
+ *
565
+ * 4. Return with internal state:
566
+ * return { ...publicFields, _entities: current._entities } as InternalTodoSummary;
567
+ */
568
+ return {
569
+ totalCount: /* TODO: map from event.data */ 0,
570
+ };
571
+ }
572
+ default:
573
+ return document;
574
+ }
575
+ },
576
+ });
577
+
578
+ export default projection;
579
+ "
580
+ `);
581
+ });
582
+
583
+ it('should generate a valid composite key projection file', async () => {
584
+ const flows: Model = {
585
+ variant: 'specs',
586
+ narratives: [
587
+ {
588
+ name: 'user-project-flow',
589
+ slices: [
590
+ {
591
+ type: 'command',
592
+ name: 'manage-user-project',
593
+ stream: 'user-project-${userId}-${projectId}',
594
+ client: {
595
+ description: 'manage user project UI',
596
+ },
597
+ server: {
598
+ description: 'handles user project operations',
599
+ specs: {
600
+ name: 'Manage user project command',
601
+ rules: [
602
+ {
603
+ description: 'Should handle user project operations',
604
+ examples: [
605
+ {
606
+ description: 'User joins project',
607
+ when: {
608
+ commandRef: 'JoinProject',
609
+ exampleData: {
610
+ userId: 'user_123',
611
+ projectId: 'proj_456',
612
+ role: 'developer',
613
+ },
614
+ },
615
+ then: [
616
+ {
617
+ eventRef: 'UserJoinedProject',
618
+ exampleData: {
619
+ userId: 'user_123',
620
+ projectId: 'proj_456',
621
+ role: 'developer',
622
+ },
623
+ },
624
+ ],
625
+ },
626
+ ],
627
+ },
628
+ ],
629
+ },
630
+ },
631
+ },
632
+ {
633
+ type: 'query',
634
+ name: 'view-user-projects',
635
+ stream: 'user-projects',
636
+ client: {
637
+ description: 'view user projects UI',
638
+ },
639
+ server: {
640
+ description: 'composite key projection for user projects',
641
+ data: [
642
+ {
643
+ target: {
644
+ type: 'State',
645
+ name: 'UserProject',
646
+ },
647
+ origin: {
648
+ type: 'projection',
649
+ name: 'UserProjectsProjection',
650
+ idField: ['userId', 'projectId'],
651
+ },
652
+ },
653
+ ],
654
+ specs: {
655
+ name: 'View user projects query',
656
+ rules: [
657
+ {
658
+ description: 'Should track user project memberships',
659
+ examples: [
660
+ {
661
+ description: 'User joins project',
662
+ when: [
663
+ {
664
+ eventRef: 'UserJoinedProject',
665
+ exampleData: {
666
+ userId: 'user_123',
667
+ projectId: 'proj_456',
668
+ role: 'developer',
669
+ },
670
+ },
671
+ ],
672
+ then: [
673
+ {
674
+ stateRef: 'UserProject',
675
+ exampleData: {
676
+ userId: 'user_123',
677
+ projectId: 'proj_456',
678
+ role: 'developer',
679
+ },
680
+ },
681
+ ],
682
+ },
683
+ ],
684
+ },
685
+ ],
686
+ },
687
+ },
688
+ },
689
+ ],
690
+ },
691
+ ],
692
+ messages: [
693
+ {
694
+ type: 'command',
695
+ name: 'JoinProject',
696
+ fields: [
697
+ { name: 'userId', type: 'string', required: true },
698
+ { name: 'projectId', type: 'string', required: true },
699
+ { name: 'role', type: 'string', required: true },
700
+ ],
701
+ },
702
+ {
703
+ type: 'event',
704
+ name: 'UserJoinedProject',
705
+ source: 'internal',
706
+ fields: [
707
+ { name: 'userId', type: 'string', required: true },
708
+ { name: 'projectId', type: 'string', required: true },
709
+ { name: 'role', type: 'string', required: true },
710
+ ],
711
+ },
712
+ {
713
+ type: 'state',
714
+ name: 'UserProject',
715
+ fields: [
716
+ { name: 'userId', type: 'string', required: true },
717
+ { name: 'projectId', type: 'string', required: true },
718
+ { name: 'role', type: 'string', required: true },
719
+ ],
720
+ },
721
+ ],
722
+ };
723
+
724
+ const plans = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
725
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-user-projects/projection.ts'));
726
+
727
+ expect(projectionFile?.contents).toMatchInlineSnapshot(`
728
+ "import {
729
+ inMemorySingleStreamProjection,
730
+ type ReadEvent,
731
+ type InMemoryReadEventMetadata,
732
+ } from '@event-driven-io/emmett';
733
+ import type { UserProject } from './state';
734
+ import type { UserJoinedProject } from '../manage-user-project/events';
735
+
736
+ type AllEvents = UserJoinedProject;
737
+
738
+ export const projection = inMemorySingleStreamProjection<UserProject, AllEvents>({
739
+ collectionName: 'UserProjectsProjection',
740
+ canHandle: ['UserJoinedProject'],
741
+ getDocumentId: (event) => \`\${event.data.userId}-\${event.data.projectId}\`,
742
+ evolve: (
743
+ document: UserProject | null,
744
+ event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
745
+ ): UserProject | null => {
746
+ switch (event.type) {
747
+ case 'UserJoinedProject': {
748
+ /**
749
+ * ## IMPLEMENTATION INSTRUCTIONS ##
750
+ * **COMPOSITE KEY PROJECTION**
751
+ *
752
+ * This projection uses a composite key: userId + projectId
753
+ * Document ID format: \`\${event.data.userId}-\${event.data.projectId}\`
754
+ *
755
+ * CRITICAL: You MUST include ALL key fields in every return statement:
756
+ * - userId: event.data.userId
757
+ * - projectId: event.data.projectId
758
+ *
759
+ * Missing even one key field will cause the projection to fail.
760
+ * Key fields typically map directly from event data (no transformation needed).
761
+ *
762
+ * Example implementation:
763
+ * return {
764
+ * userId: event.data.userId,
765
+ * projectId: event.data.projectId,
766
+ * // ... other fields
767
+ * };
768
+ */
769
+ return {
770
+ userId: /* TODO: map from event.data */ '',
771
+ projectId: /* TODO: map from event.data */ '',
772
+ role: /* TODO: map from event.data */ '',
773
+ };
774
+ }
775
+ default:
776
+ return document;
777
+ }
778
+ },
779
+ });
780
+
781
+ export default projection;
782
+ "
783
+ `);
784
+ });
381
785
  });
@@ -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,47 @@ 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 = (document as Internal<%= pascalCase(targetName || 'State') %>) || { ...initialState, _entities: {} };
118
+ *
119
+ * 2. Track entity changes:
120
+ * const entityId = event.data.todoId; // or relevant ID field
121
+ * const prevStatus = current._entities?.[entityId]?.status;
122
+ * current._entities[entityId] = { status: 'new_status', ...otherData };
123
+ *
124
+ * 3. Calculate aggregates from entity states:
125
+ * const counts = Object.values(current._entities).reduce((acc, entity) => {
126
+ * acc[entity.status] = (acc[entity.status] || 0) + 1;
127
+ * return acc;
128
+ * }, {});
129
+ *
130
+ * 4. Return with internal state:
131
+ * return { ...publicFields, _entities: current._entities } as Internal<%= pascalCase(targetName || 'State') %>;
132
+ <% } else if (isCompositeKey) { -%>
133
+ * **COMPOSITE KEY PROJECTION**
134
+ *
135
+ * This projection uses a composite key: <%= compositeKeyFields.join(' + ') %>
136
+ * Document ID format: `${event.data.<%= compositeKeyFields[0] %>}-${event.data.<%= compositeKeyFields.slice(1).join('}}-${event.data.') %>}`
137
+ *
138
+ * CRITICAL: You MUST include ALL key fields in every return statement:
139
+ * <%- compositeKeyFields.map(f => `- ${f}: event.data.${f}`).join('\n * ') %>
140
+ *
141
+ * Missing even one key field will cause the projection to fail.
142
+ * Key fields typically map directly from event data (no transformation needed).
143
+ *
144
+ * Example implementation:
145
+ * return {
146
+ * <%- compositeKeyFields.map(f => `${f}: event.data.${f},`).join('\n * ') %>
147
+ * // ... other fields
148
+ * };
149
+ <% } else if (isRemovalEvent || eventNameSuggestsRemoval) { -%>
97
150
  * This event might indicate removal of a <%= targetName || 'document' %>.
98
151
  *
99
152
  * - If the intent is to **remove the document**, return `null`.