@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.
package/package.json CHANGED
@@ -31,8 +31,8 @@
31
31
  "graphql-type-json": "^0.3.2",
32
32
  "uuid": "^11.0.0",
33
33
  "web-streams-polyfill": "^4.1.0",
34
- "@auto-engineer/narrative": "0.11.13",
35
- "@auto-engineer/message-bus": "0.11.13"
34
+ "@auto-engineer/narrative": "0.11.15",
35
+ "@auto-engineer/message-bus": "0.11.15"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -43,9 +43,9 @@
43
43
  "typescript": "^5.8.3",
44
44
  "vitest": "^3.2.4",
45
45
  "tsx": "^4.19.2",
46
- "@auto-engineer/cli": "0.11.13"
46
+ "@auto-engineer/cli": "0.11.15"
47
47
  },
48
- "version": "0.11.13",
48
+ "version": "0.11.15",
49
49
  "scripts": {
50
50
  "generate:server": "tsx src/cli/index.ts",
51
51
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts && rm -rf dist/src/codegen/templates && mkdir -p dist/src/codegen && cp -r src/codegen/templates dist/src/codegen/templates && cp src/server.ts dist/src && cp -r src/utils dist/src && cp -r src/domain dist/src",
@@ -3,7 +3,7 @@ import { CommandExample, EventExample, Slice } from '@auto-engineer/narrative';
3
3
  import { Message, MessageDefinition } from '../types';
4
4
  import { extractEventsFromGiven, extractEventsFromThen, extractEventsFromWhen } from './events';
5
5
  import { extractFieldsFromMessage } from './fields';
6
- import { extractProjectionIdField } from './projection';
6
+ import { extractProjectionIdField, extractProjectionSingleton } from './projection';
7
7
  import { extractStatesFromData, extractStatesFromTarget } from './states';
8
8
  import createDebug from 'debug';
9
9
 
@@ -19,6 +19,7 @@ export interface ExtractedMessages {
19
19
  states: Message[];
20
20
  commandSchemasByName: Record<string, Message>;
21
21
  projectionIdField?: string;
22
+ projectionSingleton?: boolean;
22
23
  }
23
24
 
24
25
  export interface ReactGwtSpec {
@@ -122,6 +123,9 @@ function extractMessagesForQuery(slice: Slice, allMessages: MessageDefinition[])
122
123
  const projectionIdField = extractProjectionIdField(slice);
123
124
  debugQuery(' Projection ID field: %s', projectionIdField ?? 'none');
124
125
 
126
+ const projectionSingleton = extractProjectionSingleton(slice);
127
+ debugQuery(' Projection singleton: %s', projectionSingleton);
128
+
125
129
  const events: Message[] = gwtSpecs.flatMap((gwt) => {
126
130
  const eventsFromGiven = Array.isArray(gwt.given)
127
131
  ? gwt.given.filter((item): item is EventExample => 'eventRef' in item)
@@ -166,6 +170,7 @@ function extractMessagesForQuery(slice: Slice, allMessages: MessageDefinition[])
166
170
  states,
167
171
  commandSchemasByName: {},
168
172
  projectionIdField,
173
+ projectionSingleton,
169
174
  };
170
175
 
171
176
  debugQuery(' Final result: %d events, %d states', result.events.length, result.states.length);
@@ -4,6 +4,7 @@ interface ProjectionOrigin {
4
4
  type: 'projection';
5
5
  idField?: string;
6
6
  name?: string;
7
+ singleton?: boolean;
7
8
  }
8
9
 
9
10
  interface HasOrigin {
@@ -46,3 +47,16 @@ export function extractProjectionIdField(slice: Slice): string | undefined {
46
47
  export function extractProjectionName(slice: Slice): string | undefined {
47
48
  return extractProjectionField(slice, 'name');
48
49
  }
50
+
51
+ export function extractProjectionSingleton(slice: Slice): boolean {
52
+ if (!('server' in slice)) return false;
53
+ const dataSource = slice.server?.data?.[0];
54
+ if (!hasOrigin(dataSource)) return false;
55
+
56
+ const origin = dataSource.origin;
57
+ if (isProjectionOrigin(origin)) {
58
+ return origin.singleton === true;
59
+ }
60
+
61
+ return false;
62
+ }
@@ -490,6 +490,7 @@ async function prepareTemplateData(
490
490
  states: Message[],
491
491
  commandSchemasByName: Record<string, Message>,
492
492
  projectionIdField: string | undefined,
493
+ projectionSingleton: boolean | undefined,
493
494
  allMessages?: MessageDefinition[],
494
495
  integrations?: Model['integrations'],
495
496
  ): Promise<Record<string, unknown>> {
@@ -538,6 +539,7 @@ async function prepareTemplateData(
538
539
  usedErrors,
539
540
  commandSchemasByName,
540
541
  projectionIdField,
542
+ projectionSingleton,
541
543
  projectionName,
542
544
  projectionType: projectionName != null ? pascalCase(projectionName) : undefined,
543
545
  parsedRequest: slice.type === 'query' && slice.request != null ? parseGraphQlRequest(slice.request) : undefined,
@@ -693,6 +695,7 @@ async function generateFilesForSlice(
693
695
  extracted.states,
694
696
  extracted.commandSchemasByName,
695
697
  extracted.projectionIdField,
698
+ extracted.projectionSingleton,
696
699
  messages,
697
700
  integrations,
698
701
  );
@@ -620,4 +620,370 @@ describe('projection.specs.ts.ejs', () => {
620
620
  // canHandle must include BOTH events
621
621
  expect(projectionFile?.contents).toContain("canHandle: ['QuestionnaireLinkSent', 'QuestionAnswered']");
622
622
  });
623
+
624
+ it('should generate a valid test spec for singleton projection', async () => {
625
+ const spec: SpecsSchema = {
626
+ variant: 'specs',
627
+ narratives: [
628
+ {
629
+ name: 'todo-flow',
630
+ slices: [
631
+ {
632
+ type: 'command',
633
+ name: 'manage-todo',
634
+ stream: 'todo-${todoId}',
635
+ client: { description: '' },
636
+ server: {
637
+ description: '',
638
+ specs: {
639
+ name: 'Manage todo command',
640
+ rules: [
641
+ {
642
+ description: 'Should handle todo operations',
643
+ examples: [
644
+ {
645
+ description: 'User adds todo',
646
+ when: {
647
+ commandRef: 'AddTodo',
648
+ exampleData: {
649
+ todoId: 'todo_123',
650
+ title: 'Buy milk',
651
+ },
652
+ },
653
+ then: [
654
+ {
655
+ eventRef: 'TodoAdded',
656
+ exampleData: {
657
+ todoId: 'todo_123',
658
+ title: 'Buy milk',
659
+ },
660
+ },
661
+ ],
662
+ },
663
+ ],
664
+ },
665
+ ],
666
+ },
667
+ },
668
+ },
669
+ {
670
+ type: 'query',
671
+ name: 'view-summary',
672
+ stream: 'todos',
673
+ client: { description: '' },
674
+ server: {
675
+ description: '',
676
+ data: [
677
+ {
678
+ target: {
679
+ type: 'State',
680
+ name: 'TodoSummary',
681
+ },
682
+ origin: {
683
+ type: 'projection',
684
+ name: 'TodoSummaryProjection',
685
+ singleton: true,
686
+ },
687
+ },
688
+ ],
689
+ specs: {
690
+ name: 'View summary query',
691
+ rules: [
692
+ {
693
+ description: 'Should aggregate todo counts',
694
+ examples: [
695
+ {
696
+ description: 'Todo added updates count',
697
+ when: [
698
+ {
699
+ eventRef: 'TodoAdded',
700
+ exampleData: {
701
+ todoId: 'todo_123',
702
+ title: 'Buy milk',
703
+ },
704
+ },
705
+ ],
706
+ then: [
707
+ {
708
+ stateRef: 'TodoSummary',
709
+ exampleData: {
710
+ totalCount: 1,
711
+ },
712
+ },
713
+ ],
714
+ },
715
+ ],
716
+ },
717
+ ],
718
+ },
719
+ },
720
+ },
721
+ ],
722
+ },
723
+ ],
724
+ messages: [
725
+ {
726
+ type: 'command',
727
+ name: 'AddTodo',
728
+ fields: [
729
+ { name: 'todoId', type: 'string', required: true },
730
+ { name: 'title', type: 'string', required: true },
731
+ ],
732
+ },
733
+ {
734
+ type: 'event',
735
+ name: 'TodoAdded',
736
+ source: 'internal',
737
+ fields: [
738
+ { name: 'todoId', type: 'string', required: true },
739
+ { name: 'title', type: 'string', required: true },
740
+ ],
741
+ },
742
+ {
743
+ type: 'state',
744
+ name: 'TodoSummary',
745
+ fields: [{ name: 'totalCount', type: 'number', required: true }],
746
+ },
747
+ ],
748
+ } as SpecsSchema;
749
+
750
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
751
+ const specFile = plans.find((p) => p.outputPath.endsWith('view-summary/projection.specs.ts'));
752
+
753
+ expect(specFile?.contents).toMatchInlineSnapshot(`
754
+ "import { describe, it, beforeEach, expect } from 'vitest';
755
+ import { InMemoryProjectionSpec } from '@event-driven-io/emmett';
756
+ import { projection } from './projection';
757
+ import type { TodoAdded } from '../manage-todo/events';
758
+ import { TodoSummary } from './state';
759
+
760
+ type ProjectionEvent = TodoAdded;
761
+
762
+ describe('Should aggregate todo counts', () => {
763
+ let given: InMemoryProjectionSpec<ProjectionEvent>;
764
+
765
+ beforeEach(() => {
766
+ given = InMemoryProjectionSpec.for({ projection });
767
+ });
768
+
769
+ it('Todo added updates count', () =>
770
+ given([])
771
+ .when([
772
+ {
773
+ type: 'TodoAdded',
774
+ data: {
775
+ todoId: 'todo_123',
776
+ title: 'Buy milk',
777
+ },
778
+ metadata: {
779
+ streamName: 'todos',
780
+ streamPosition: 1n,
781
+ globalPosition: 1n,
782
+ },
783
+ },
784
+ ])
785
+ .then(async (state) => {
786
+ const document = await state.database.collection<TodoSummary>('TodoSummaryProjection').findOne();
787
+
788
+ const expected: TodoSummary = {
789
+ totalCount: 1,
790
+ };
791
+
792
+ expect(document).toMatchObject(expected);
793
+ }));
794
+ });
795
+ "
796
+ `);
797
+ });
798
+
799
+ it('should generate a valid test spec for composite key projection', async () => {
800
+ const spec: SpecsSchema = {
801
+ variant: 'specs',
802
+ narratives: [
803
+ {
804
+ name: 'user-project-flow',
805
+ slices: [
806
+ {
807
+ type: 'command',
808
+ name: 'manage-user-project',
809
+ stream: 'user-project-${userId}-${projectId}',
810
+ client: { description: '' },
811
+ server: {
812
+ description: '',
813
+ specs: {
814
+ name: 'Manage user project command',
815
+ rules: [
816
+ {
817
+ description: 'Should handle user project operations',
818
+ examples: [
819
+ {
820
+ description: 'User joins project',
821
+ when: {
822
+ commandRef: 'JoinProject',
823
+ exampleData: {
824
+ userId: 'user_123',
825
+ projectId: 'proj_456',
826
+ role: 'developer',
827
+ },
828
+ },
829
+ then: [
830
+ {
831
+ eventRef: 'UserJoinedProject',
832
+ exampleData: {
833
+ userId: 'user_123',
834
+ projectId: 'proj_456',
835
+ role: 'developer',
836
+ },
837
+ },
838
+ ],
839
+ },
840
+ ],
841
+ },
842
+ ],
843
+ },
844
+ },
845
+ },
846
+ {
847
+ type: 'query',
848
+ name: 'view-user-projects',
849
+ stream: 'user-projects',
850
+ client: { description: '' },
851
+ server: {
852
+ description: '',
853
+ data: [
854
+ {
855
+ target: {
856
+ type: 'State',
857
+ name: 'UserProject',
858
+ },
859
+ origin: {
860
+ type: 'projection',
861
+ name: 'UserProjectsProjection',
862
+ idField: ['userId', 'projectId'],
863
+ },
864
+ },
865
+ ],
866
+ specs: {
867
+ name: 'View user projects query',
868
+ rules: [
869
+ {
870
+ description: 'Should track user project memberships',
871
+ examples: [
872
+ {
873
+ description: 'User joins project',
874
+ when: [
875
+ {
876
+ eventRef: 'UserJoinedProject',
877
+ exampleData: {
878
+ userId: 'user_123',
879
+ projectId: 'proj_456',
880
+ role: 'developer',
881
+ },
882
+ },
883
+ ],
884
+ then: [
885
+ {
886
+ stateRef: 'UserProject',
887
+ exampleData: {
888
+ userId: 'user_123',
889
+ projectId: 'proj_456',
890
+ role: 'developer',
891
+ },
892
+ },
893
+ ],
894
+ },
895
+ ],
896
+ },
897
+ ],
898
+ },
899
+ },
900
+ },
901
+ ],
902
+ },
903
+ ],
904
+ messages: [
905
+ {
906
+ type: 'command',
907
+ name: 'JoinProject',
908
+ fields: [
909
+ { name: 'userId', type: 'string', required: true },
910
+ { name: 'projectId', type: 'string', required: true },
911
+ { name: 'role', type: 'string', required: true },
912
+ ],
913
+ },
914
+ {
915
+ type: 'event',
916
+ name: 'UserJoinedProject',
917
+ source: 'internal',
918
+ fields: [
919
+ { name: 'userId', type: 'string', required: true },
920
+ { name: 'projectId', type: 'string', required: true },
921
+ { name: 'role', type: 'string', required: true },
922
+ ],
923
+ },
924
+ {
925
+ type: 'state',
926
+ name: 'UserProject',
927
+ fields: [
928
+ { name: 'userId', type: 'string', required: true },
929
+ { name: 'projectId', type: 'string', required: true },
930
+ { name: 'role', type: 'string', required: true },
931
+ ],
932
+ },
933
+ ],
934
+ } as SpecsSchema;
935
+
936
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
937
+ const specFile = plans.find((p) => p.outputPath.endsWith('view-user-projects/projection.specs.ts'));
938
+
939
+ expect(specFile?.contents).toMatchInlineSnapshot(`
940
+ "import { describe, it, beforeEach, expect } from 'vitest';
941
+ import { InMemoryProjectionSpec } from '@event-driven-io/emmett';
942
+ import { projection } from './projection';
943
+ import type { UserJoinedProject } from '../manage-user-project/events';
944
+ import { UserProject } from './state';
945
+
946
+ type ProjectionEvent = UserJoinedProject;
947
+
948
+ describe('Should track user project memberships', () => {
949
+ let given: InMemoryProjectionSpec<ProjectionEvent>;
950
+
951
+ beforeEach(() => {
952
+ given = InMemoryProjectionSpec.for({ projection });
953
+ });
954
+
955
+ it('User joins project', () =>
956
+ given([])
957
+ .when([
958
+ {
959
+ type: 'UserJoinedProject',
960
+ data: {
961
+ userId: 'user_123',
962
+ projectId: 'proj_456',
963
+ role: 'developer',
964
+ },
965
+ metadata: {
966
+ streamName: 'user-projects',
967
+ streamPosition: 1n,
968
+ globalPosition: 1n,
969
+ },
970
+ },
971
+ ])
972
+ .then(async (state) => {
973
+ const document = await state.database
974
+ .collection<UserProject>('UserProjectsProjection')
975
+ .findOne((doc) => doc.id === 'test-id');
976
+
977
+ const expected: UserProject = {
978
+ userId: 'user_123',
979
+ projectId: 'proj_456',
980
+ role: 'developer',
981
+ };
982
+
983
+ expect(document).toMatchObject(expected);
984
+ }));
985
+ });
986
+ "
987
+ `);
988
+ });
623
989
  });