@auto-engineer/server-generator-apollo-emmett 0.11.9 → 0.11.11

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 (134) hide show
  1. package/.turbo/turbo-build.log +5 -6
  2. package/.turbo/turbo-format.log +1 -1
  3. package/.turbo/turbo-lint.log +1 -1
  4. package/.turbo/turbo-test.log +4 -4
  5. package/.turbo/turbo-type-check.log +1 -1
  6. package/CHANGELOG.md +18 -0
  7. package/dist/src/codegen/extract/commands.d.ts +1 -1
  8. package/dist/src/codegen/extract/commands.d.ts.map +1 -1
  9. package/dist/src/codegen/extract/data-sink.d.ts +1 -1
  10. package/dist/src/codegen/extract/data-sink.d.ts.map +1 -1
  11. package/dist/src/codegen/extract/events.d.ts +1 -1
  12. package/dist/src/codegen/extract/events.d.ts.map +1 -1
  13. package/dist/src/codegen/extract/gwt.d.ts +1 -1
  14. package/dist/src/codegen/extract/gwt.d.ts.map +1 -1
  15. package/dist/src/codegen/extract/index.d.ts +1 -0
  16. package/dist/src/codegen/extract/index.d.ts.map +1 -1
  17. package/dist/src/codegen/extract/index.js +1 -0
  18. package/dist/src/codegen/extract/index.js.map +1 -1
  19. package/dist/src/codegen/extract/messages.d.ts +1 -1
  20. package/dist/src/codegen/extract/messages.d.ts.map +1 -1
  21. package/dist/src/codegen/extract/projection.d.ts +1 -1
  22. package/dist/src/codegen/extract/projection.d.ts.map +1 -1
  23. package/dist/src/codegen/extract/query.d.ts +1 -1
  24. package/dist/src/codegen/extract/query.d.ts.map +1 -1
  25. package/dist/src/codegen/extract/states.d.ts +1 -1
  26. package/dist/src/codegen/extract/states.d.ts.map +1 -1
  27. package/dist/src/codegen/extract/type-helpers.d.ts +13 -0
  28. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -0
  29. package/dist/src/codegen/extract/type-helpers.js +98 -0
  30. package/dist/src/codegen/extract/type-helpers.js.map +1 -0
  31. package/dist/src/codegen/scaffoldFromSchema.d.ts +3 -3
  32. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  33. package/dist/src/codegen/scaffoldFromSchema.js +202 -19
  34. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  35. package/dist/src/codegen/templates/command/commands.specs.ts +3 -3
  36. package/dist/src/codegen/templates/command/commands.ts.ejs +14 -9
  37. package/dist/src/codegen/templates/command/decide.specs.specs.ts +54 -54
  38. package/dist/src/codegen/templates/command/decide.specs.ts +13 -9
  39. package/dist/src/codegen/templates/command/decide.ts.ejs +1 -0
  40. package/dist/src/codegen/templates/command/events.specs.ts +3 -3
  41. package/dist/src/codegen/templates/command/events.ts.ejs +16 -13
  42. package/dist/src/codegen/templates/command/evolve.specs.ts +3 -3
  43. package/dist/src/codegen/templates/command/handle.specs.ts +10 -5
  44. package/dist/src/codegen/templates/command/mutation.resolver.specs.ts +8 -7
  45. package/dist/src/codegen/templates/command/mutation.resolver.ts.ejs +2 -23
  46. package/dist/src/codegen/templates/command/register.specs.ts +4 -4
  47. package/dist/src/codegen/templates/command/register.ts.ejs +1 -4
  48. package/dist/src/codegen/templates/command/state.specs.ts +54 -50
  49. package/dist/src/codegen/templates/command/state.ts.ejs +8 -4
  50. package/dist/src/codegen/templates/query/projection.specs.specs.ts +7 -7
  51. package/dist/src/codegen/templates/query/projection.specs.ts +24 -7
  52. package/dist/src/codegen/templates/query/projection.ts.ejs +64 -12
  53. package/dist/src/codegen/templates/query/query.resolver.specs.ts +19 -16
  54. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +11 -49
  55. package/dist/src/codegen/templates/query/state.specs.ts +3 -3
  56. package/dist/src/codegen/templates/react/react.specs.specs.ts +3 -3
  57. package/dist/src/codegen/templates/react/react.specs.ts +3 -3
  58. package/dist/src/codegen/templates/react/react.ts.ejs +0 -1
  59. package/dist/src/codegen/templates/react/register.specs.ts +3 -3
  60. package/dist/src/codegen/templates/react/register.ts.ejs +0 -1
  61. package/dist/src/codegen/test-data/specVariant1.d.ts +1 -1
  62. package/dist/src/codegen/test-data/specVariant1.d.ts.map +1 -1
  63. package/dist/src/codegen/test-data/specVariant1.js +1 -1
  64. package/dist/src/codegen/test-data/specVariant1.js.map +1 -1
  65. package/dist/src/codegen/types.d.ts +1 -1
  66. package/dist/src/codegen/types.d.ts.map +1 -1
  67. package/dist/src/commands/generate-server.d.ts +0 -1
  68. package/dist/src/commands/generate-server.d.ts.map +1 -1
  69. package/dist/src/commands/generate-server.js +53 -31
  70. package/dist/src/commands/generate-server.js.map +1 -1
  71. package/dist/src/domain/shared/graphql-types.d.ts +10 -0
  72. package/dist/src/domain/shared/graphql-types.d.ts.map +1 -0
  73. package/dist/src/domain/shared/graphql-types.js +40 -0
  74. package/dist/src/domain/shared/graphql-types.js.map +1 -0
  75. package/dist/src/domain/shared/graphql-types.ts +20 -0
  76. package/dist/src/domain/shared/index.d.ts +1 -0
  77. package/dist/src/domain/shared/index.d.ts.map +1 -1
  78. package/dist/src/domain/shared/index.js +1 -0
  79. package/dist/src/domain/shared/index.js.map +1 -1
  80. package/dist/src/domain/shared/index.ts +1 -0
  81. package/dist/src/domain/shared/sendCommand.d.ts +1 -1
  82. package/dist/src/domain/shared/sendCommand.d.ts.map +1 -1
  83. package/dist/src/domain/shared/sendCommand.ts +1 -1
  84. package/dist/src/domain/shared/types.d.ts +5 -7
  85. package/dist/src/domain/shared/types.d.ts.map +1 -1
  86. package/dist/src/domain/shared/types.js +11 -38
  87. package/dist/src/domain/shared/types.js.map +1 -1
  88. package/dist/src/domain/shared/types.ts +10 -16
  89. package/dist/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +4 -4
  91. package/src/codegen/extract/commands.ts +1 -1
  92. package/src/codegen/extract/data-sink.ts +1 -1
  93. package/src/codegen/extract/events.ts +1 -1
  94. package/src/codegen/extract/gwt.ts +1 -1
  95. package/src/codegen/extract/index.ts +1 -0
  96. package/src/codegen/extract/messages.ts +1 -1
  97. package/src/codegen/extract/projection.ts +1 -1
  98. package/src/codegen/extract/query.ts +1 -1
  99. package/src/codegen/extract/states.ts +1 -1
  100. package/src/codegen/extract/type-helpers.ts +102 -0
  101. package/src/codegen/scaffoldFromSchema.ts +283 -25
  102. package/src/codegen/templates/command/commands.specs.ts +3 -3
  103. package/src/codegen/templates/command/commands.ts.ejs +14 -9
  104. package/src/codegen/templates/command/decide.specs.specs.ts +54 -54
  105. package/src/codegen/templates/command/decide.specs.ts +13 -9
  106. package/src/codegen/templates/command/decide.ts.ejs +1 -0
  107. package/src/codegen/templates/command/events.specs.ts +3 -3
  108. package/src/codegen/templates/command/events.ts.ejs +16 -13
  109. package/src/codegen/templates/command/evolve.specs.ts +3 -3
  110. package/src/codegen/templates/command/handle.specs.ts +10 -5
  111. package/src/codegen/templates/command/mutation.resolver.specs.ts +8 -7
  112. package/src/codegen/templates/command/mutation.resolver.ts.ejs +2 -23
  113. package/src/codegen/templates/command/register.specs.ts +4 -4
  114. package/src/codegen/templates/command/register.ts.ejs +1 -4
  115. package/src/codegen/templates/command/state.specs.ts +54 -50
  116. package/src/codegen/templates/command/state.ts.ejs +8 -4
  117. package/src/codegen/templates/query/projection.specs.specs.ts +7 -7
  118. package/src/codegen/templates/query/projection.specs.ts +24 -7
  119. package/src/codegen/templates/query/projection.ts.ejs +64 -12
  120. package/src/codegen/templates/query/query.resolver.specs.ts +19 -16
  121. package/src/codegen/templates/query/query.resolver.ts.ejs +11 -49
  122. package/src/codegen/templates/query/state.specs.ts +3 -3
  123. package/src/codegen/templates/react/react.specs.specs.ts +3 -3
  124. package/src/codegen/templates/react/react.specs.ts +3 -3
  125. package/src/codegen/templates/react/react.ts.ejs +0 -1
  126. package/src/codegen/templates/react/register.specs.ts +3 -3
  127. package/src/codegen/templates/react/register.ts.ejs +0 -1
  128. package/src/codegen/test-data/specVariant1.ts +2 -2
  129. package/src/codegen/types.ts +1 -1
  130. package/src/commands/generate-server.ts +63 -34
  131. package/src/domain/shared/graphql-types.ts +20 -0
  132. package/src/domain/shared/index.ts +1 -0
  133. package/src/domain/shared/sendCommand.ts +1 -1
  134. package/src/domain/shared/types.ts +10 -16
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateScaffoldFilePlans } from '../../scaffoldFromSchema';
3
- import { Model as SpecsSchema } from '@auto-engineer/flow';
3
+ import { Model as SpecsSchema } from '@auto-engineer/narrative';
4
4
 
5
5
  describe('state.ts.ejs', () => {
6
6
  it('should generate an initial state', async () => {
7
7
  const spec: SpecsSchema = {
8
8
  variant: 'specs',
9
- flows: [
9
+ narratives: [
10
10
  {
11
11
  name: 'Host creates a listing',
12
12
  slices: [
@@ -78,58 +78,62 @@ describe('state.ts.ejs', () => {
78
78
  ],
79
79
  };
80
80
 
81
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
81
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
82
82
  const stateFile = plans.find((p) => p.outputPath.endsWith('state.ts'));
83
83
 
84
84
  expect(stateFile?.contents).toMatchInlineSnapshot(`
85
- "/**
86
- * ## IMPLEMENTATION INSTRUCTIONS ##
87
- *
88
- * Define the shape of the domain state for the current slice below. This state is used by \`decide.ts\`
89
- * to determine whether a command is valid.
90
- *
91
- * The state is evolved over time by applying domain events (in \`evolve.ts\`).
92
- * Each event updates the state incrementally based on business rules.
93
- *
94
- * Guidelines:
95
- * - Include only fields that are **read** during command validation.
96
- * - Use discriminated unions (e.g., \`status: 'Pending' | 'Done'\`) to model state transitions.
97
- * - Prefer primitive types: \`string\`, \`boolean\`, \`number\`.
98
- * - Use objects or maps only when structure is essential for decision logic.
99
- *
100
- * Do NOT include:
101
- * - Redundant data already emitted in events unless required to enforce business rules.
102
- * - Fields used only for projections, UI, or query purposes.
103
- *
104
- * ### Example (for a Task domain):
105
- *
106
- * \`\`\`ts
107
- * export type PendingTask = {
108
- * status: 'Pending';
109
- * };
110
- *
111
- * export type InProgressTask = {
112
- * status: 'InProgress';
113
- * startedAt: string;
114
- * };
115
- *
116
- * export type CompletedTask = {
117
- * status: 'Completed';
118
- * completedAt: string;
119
- * };
120
- *
121
- * export type State = PendingTask | InProgressTask | CompletedTask;
122
- * \`\`\`
123
- */
85
+ "/**
86
+ * ## IMPLEMENTATION INSTRUCTIONS ##
87
+ *
88
+ * Define the shape of the domain state for the current slice below. This state is used by \`decide.ts\`
89
+ * to determine whether a command is valid.
90
+ *
91
+ * The state is evolved over time by applying domain events (in \`evolve.ts\`).
92
+ * Each event updates the state incrementally based on business rules.
93
+ *
94
+ * Guidelines:
95
+ * - Include only fields that are **read** during command validation.
96
+ * - Use discriminated unions with string literal types (e.g., \`status: 'pending' | 'done'\`) to model state transitions.
97
+ * - IMPORTANT: If an enum exists in domain/shared/types.ts for your field (e.g., Status enum), use the enum constant type instead (e.g., \`status: Status.PENDING\`).
98
+ * - Prefer primitive types: \`string\`, \`boolean\`, \`number\`.
99
+ * - Use objects or maps only when structure is essential for decision logic.
100
+ *
101
+ * Do NOT include:
102
+ * - Redundant data already emitted in events unless required to enforce business rules.
103
+ * - Fields used only for projections, UI, or query purposes.
104
+ *
105
+ * ### Example (for a Task domain):
106
+ *
107
+ * \`\`\`ts
108
+ * export type PendingTask = {
109
+ * status: 'pending';
110
+ * };
111
+ *
112
+ * export type InProgressTask = {
113
+ * status: 'in_progress';
114
+ * startedAt: string;
115
+ * };
116
+ *
117
+ * export type CompletedTask = {
118
+ * status: 'completed';
119
+ * completedAt: string;
120
+ * };
121
+ *
122
+ * export type State = PendingTask | InProgressTask | CompletedTask;
123
+ * \`\`\`
124
+ *
125
+ * Note: Status string literals should match your schema's enum values (usually snake_case).
126
+ * If an enum is defined in domain/shared/types.ts, reference the enum type instead of literals.
127
+ */
124
128
 
125
- // TODO: Replace with a discriminated union of domain states for the current slice
126
- export type State = {};
129
+ // TODO: Replace with a discriminated union of domain states for the current slice
130
+ export type State = {};
127
131
 
128
- // TODO: Replace the Return with the initial domain state of the current slice
129
- export const initialState = (): State => {
130
- return {};
131
- };
132
- "
133
- `);
132
+ // TODO: Replace the Return with the initial domain state of the current slice
133
+ export const initialState = (): State => {
134
+ return {};
135
+ };
136
+ "
137
+ `);
134
138
  });
135
139
  });
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * Guidelines:
11
11
  * - Include only fields that are **read** during command validation.
12
- * - Use discriminated unions (e.g., `status: 'Pending' | 'Done'`) to model state transitions.
12
+ * - Use discriminated unions with string literal types (e.g., `status: 'pending' | 'done'`) to model state transitions.
13
+ * - IMPORTANT: If an enum exists in domain/shared/types.ts for your field (e.g., Status enum), use the enum constant type instead (e.g., `status: Status.PENDING`).
13
14
  * - Prefer primitive types: `string`, `boolean`, `number`.
14
15
  * - Use objects or maps only when structure is essential for decision logic.
15
16
  *
@@ -21,21 +22,24 @@
21
22
  *
22
23
  * ```ts
23
24
  * export type PendingTask = {
24
- * status: 'Pending';
25
+ * status: 'pending';
25
26
  * };
26
27
  *
27
28
  * export type InProgressTask = {
28
- * status: 'InProgress';
29
+ * status: 'in_progress';
29
30
  * startedAt: string;
30
31
  * };
31
32
  *
32
33
  * export type CompletedTask = {
33
- * status: 'Completed';
34
+ * status: 'completed';
34
35
  * completedAt: string;
35
36
  * };
36
37
  *
37
38
  * export type State = PendingTask | InProgressTask | CompletedTask;
38
39
  * ```
40
+ *
41
+ * Note: Status string literals should match your schema's enum values (usually snake_case).
42
+ * If an enum is defined in domain/shared/types.ts, reference the enum type instead of literals.
39
43
  */
40
44
 
41
45
  // TODO: Replace with a discriminated union of domain states for the current slice
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateScaffoldFilePlans } from '../../scaffoldFromSchema';
3
- import { Model as SpecsSchema } from '@auto-engineer/flow';
3
+ import { Model as SpecsSchema } from '@auto-engineer/narrative';
4
4
 
5
5
  describe('projection.specs.ts.ejs', () => {
6
6
  it('should generate a valid test spec for a query slice projection', async () => {
7
7
  const spec: SpecsSchema = {
8
8
  variant: 'specs',
9
- flows: [
9
+ narratives: [
10
10
  {
11
11
  name: 'listing-flow',
12
12
  slices: [
@@ -180,7 +180,7 @@ describe('projection.specs.ts.ejs', () => {
180
180
  ],
181
181
  } as SpecsSchema;
182
182
 
183
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
183
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
184
184
  const specFile = plans.find((p) => p.outputPath.endsWith('projection.specs.ts'));
185
185
 
186
186
  expect(specFile?.contents).toMatchInlineSnapshot(`
@@ -241,7 +241,7 @@ describe('projection.specs.ts.ejs', () => {
241
241
  it('should generate a valid test spec for a model with given/when/then pattern', async () => {
242
242
  const questionnaireSpec: SpecsSchema = {
243
243
  variant: 'specs',
244
- flows: [
244
+ narratives: [
245
245
  {
246
246
  name: 'Questionnaires',
247
247
  slices: [
@@ -363,7 +363,7 @@ describe('projection.specs.ts.ejs', () => {
363
363
  } as SpecsSchema;
364
364
 
365
365
  const plans = await generateScaffoldFilePlans(
366
- questionnaireSpec.flows,
366
+ questionnaireSpec.narratives,
367
367
  questionnaireSpec.messages,
368
368
  undefined,
369
369
  'src/domain/flows',
@@ -380,7 +380,7 @@ describe('projection.specs.ts.ejs', () => {
380
380
  it('should include all events from both given and when clauses in projection imports and types', async () => {
381
381
  const spec: SpecsSchema = {
382
382
  variant: 'specs',
383
- flows: [
383
+ narratives: [
384
384
  {
385
385
  name: 'questionnaires',
386
386
  slices: [
@@ -569,7 +569,7 @@ describe('projection.specs.ts.ejs', () => {
569
569
  } as SpecsSchema;
570
570
 
571
571
  const plans = await generateScaffoldFilePlans(
572
- spec.flows,
572
+ spec.narratives,
573
573
  [
574
574
  {
575
575
  type: 'command',
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateScaffoldFilePlans } from '../../scaffoldFromSchema';
3
- import { Model } from '@auto-engineer/flow';
3
+ import { Model } from '@auto-engineer/narrative';
4
4
 
5
5
  describe('projection.ts.ejs', () => {
6
6
  it('should generate a valid projection file with correct relative event import path from producing slice', async () => {
7
7
  const flows: Model = {
8
8
  variant: 'specs',
9
- flows: [
9
+ narratives: [
10
10
  {
11
11
  name: 'listing-flow',
12
12
  slices: [
@@ -201,7 +201,7 @@ describe('projection.ts.ejs', () => {
201
201
  ],
202
202
  };
203
203
 
204
- const plans = await generateScaffoldFilePlans(flows.flows, flows.messages, undefined, 'src/domain/flows');
204
+ const plans = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
205
205
  const projectionFile = plans.find((p) => p.outputPath.endsWith('projection.ts'));
206
206
 
207
207
  expect(projectionFile?.contents).toMatchInlineSnapshot(`
@@ -227,8 +227,24 @@ describe('projection.ts.ejs', () => {
227
227
  case 'ListingCreated': {
228
228
  /**
229
229
  * ## IMPLEMENTATION INSTRUCTIONS ##
230
- * This event adds or updates the document.
231
- * Implement the correct fields as needed for your read model.
230
+ * Implement how this event updates the projection.
231
+ *
232
+ * **IMPORTANT - Internal State Pattern:**
233
+ * If you need to track state beyond the public AvailableListings type (e.g., to calculate
234
+ * aggregations, track previous values, etc.), follow this pattern:
235
+ *
236
+ * 1. Define an extended interface BEFORE the projection:
237
+ * interface InternalAvailableListings extends AvailableListings {
238
+ * internalField: SomeType;
239
+ * }
240
+ *
241
+ * 2. Cast document parameter to extended type:
242
+ * const current: InternalAvailableListings = (document as InternalAvailableListings) || { ...defaults };
243
+ *
244
+ * 3. Cast return values to extended type:
245
+ * return { ...allFields, internalField } as InternalAvailableListings;
246
+ *
247
+ * This keeps internal state separate from the public GraphQL schema.
232
248
  */
233
249
  return {
234
250
  propertyId: /* TODO: map from event.data */ '',
@@ -263,7 +279,7 @@ describe('projection.ts.ejs', () => {
263
279
  it('should generate a valid query resolver using ID type', async () => {
264
280
  const spec: Model = {
265
281
  variant: 'specs',
266
- flows: [
282
+ narratives: [
267
283
  {
268
284
  name: 'wishlist-flow',
269
285
  slices: [
@@ -314,7 +330,7 @@ describe('projection.ts.ejs', () => {
314
330
  ],
315
331
  };
316
332
 
317
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
333
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
318
334
  const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
319
335
 
320
336
  expect(resolverFile?.contents).toMatchInlineSnapshot(`
@@ -329,6 +345,7 @@ describe('projection.ts.ejs', () => {
329
345
  @Field(() => String)
330
346
  items!: string;
331
347
 
348
+ // IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
332
349
  [key: string]: unknown;
333
350
  }
334
351
 
@@ -10,6 +10,30 @@ if (eventImportGroups.length > 0) {
10
10
  import type { <%= group.eventTypes.join(', ') %> } from '<%= group.importPath %>';
11
11
  <%
12
12
  }
13
+ }
14
+
15
+ // Collect enums used in state fields
16
+ const stateEnums = [];
17
+ const targetName = slice.server?.data?.[0]?.target?.name;
18
+ if (targetName && messages) {
19
+ const targetDef = messages.find(m => m.name === targetName);
20
+ if (targetDef?.fields) {
21
+ for (const field of targetDef.fields) {
22
+ const fieldType = (field.type || '').replace(/\s*\|\s*null/g, '').trim();
23
+ if (fieldType.includes('|') && (fieldType.includes('"') || fieldType.includes("'"))) {
24
+ const enumName = toTsFieldType(field.type);
25
+ if (enumName && enumName !== 'String' && !stateEnums.includes(enumName)) {
26
+ stateEnums.push(enumName);
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ if (stateEnums.length > 0) {
34
+ %>
35
+ import { <%= stateEnums.join(', ') %> } from '../../../shared';
36
+ <%
13
37
  } -%>
14
38
 
15
39
  type AllEvents = <%= allEventTypes %>;
@@ -76,8 +100,24 @@ case '<%= event.type %>': {
76
100
  * - If the intent is to **soft delete**, consider adding a `status` field (e.g., `status: 'removed'`).
77
101
  * - Ensure consumers of this projection (e.g., UI) handle the chosen approach appropriately.
78
102
  <% } else { -%>
79
- * This event adds or updates the document.
80
- * Implement the correct fields as needed for your read model.
103
+ * Implement how this event updates the projection.
104
+ *
105
+ * **IMPORTANT - Internal State Pattern:**
106
+ * If you need to track state beyond the public <%= pascalCase(targetName || 'State') %> type (e.g., to calculate
107
+ * aggregations, track previous values, etc.), follow this pattern:
108
+ *
109
+ * 1. Define an extended interface BEFORE the projection:
110
+ * interface Internal<%= pascalCase(targetName || 'State') %> extends <%= pascalCase(targetName || 'State') %> {
111
+ * internalField: SomeType;
112
+ * }
113
+ *
114
+ * 2. Cast document parameter to extended type:
115
+ * const current: Internal<%= pascalCase(targetName || 'State') %> = (document as Internal<%= pascalCase(targetName || 'State') %>) || { ...defaults };
116
+ *
117
+ * 3. Cast return values to extended type:
118
+ * return { ...allFields, internalField } as Internal<%= pascalCase(targetName || 'State') %>;
119
+ *
120
+ * This keeps internal state separate from the public GraphQL schema.
81
121
  <% } -%>
82
122
  */
83
123
  <% if (isRemovalEvent) { -%>
@@ -94,16 +134,28 @@ case '<%= event.type %>': {
94
134
  const type = def?.type ?? 'string';
95
135
 
96
136
  let placeholder = 'undefined';
97
- if (type === 'string' || type === 'ID') {
98
- placeholder = "/* TODO: map from event.data */ ''";
99
- } else if (type === 'number') {
100
- placeholder = '/* TODO: map from event.data */ 0';
101
- } else if (type === 'boolean') {
102
- placeholder = '/* TODO: map from event.data */ false';
103
- } else if (type === 'Date') {
104
- placeholder = '/* TODO: map from event.data */ new Date()';
105
- } else if (type.startsWith('Array<')) {
106
- placeholder = '/* TODO: map from event.data */ []';
137
+ const isNullable = type.includes('| null') || type.includes('|null');
138
+ const baseType = isNullable ? type.replace(/\s*\|\s*null/g, '').trim() : type;
139
+
140
+ if (baseType === 'string' || baseType === 'ID') {
141
+ placeholder = isNullable ? "/* TODO: map from event.data */ null" : "/* TODO: map from event.data */ ''";
142
+ } else if (baseType === 'number') {
143
+ placeholder = isNullable ? '/* TODO: map from event.data */ null' : '/* TODO: map from event.data */ 0';
144
+ } else if (baseType === 'boolean') {
145
+ placeholder = isNullable ? '/* TODO: map from event.data */ null' : '/* TODO: map from event.data */ false';
146
+ } else if (baseType === 'Date') {
147
+ placeholder = isNullable ? '/* TODO: map from event.data */ null' : '/* TODO: map from event.data */ new Date()';
148
+ } else if (baseType.startsWith('Array<')) {
149
+ placeholder = isNullable ? '/* TODO: map from event.data */ null' : '/* TODO: map from event.data */ []';
150
+ } else if (baseType.includes('|') && (baseType.includes('"') || baseType.includes("'"))) {
151
+ const enumName = toTsFieldType(type);
152
+ const firstValue = baseType.match(/['"]([^'"]+)['"]/)?.[1] ?? '';
153
+ const enumConstant = firstValue ? firstValue.toUpperCase().replace(/[^A-Z0-9]/g, '_') : '';
154
+ if (enumName && enumName !== 'String') {
155
+ placeholder = `${enumName}.${enumConstant} /* TODO: verify this enum constant is correct */`;
156
+ } else {
157
+ placeholder = `/* TODO: use enum constant from types.ts */ '${firstValue}'`;
158
+ }
107
159
  } else {
108
160
  placeholder = '/* TODO: map from event.data */ undefined as any';
109
161
  }
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateScaffoldFilePlans } from '../../scaffoldFromSchema';
3
- import { Model as SpecsSchema } from '@auto-engineer/flow';
3
+ import { Model as SpecsSchema } from '@auto-engineer/narrative';
4
4
 
5
5
  describe('query.resolver.ts.ejs', () => {
6
6
  it('should generate a valid query resolver from request field', async () => {
7
7
  const spec: SpecsSchema = {
8
8
  variant: 'specs',
9
- flows: [
9
+ narratives: [
10
10
  {
11
11
  name: 'listing-flow',
12
12
  slices: [
@@ -63,7 +63,7 @@ describe('query.resolver.ts.ejs', () => {
63
63
  ],
64
64
  };
65
65
 
66
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
66
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
67
67
  const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
68
68
 
69
69
  expect(resolverFile?.contents).toMatchInlineSnapshot(`
@@ -87,6 +87,7 @@ describe('query.resolver.ts.ejs', () => {
87
87
  @Field(() => Float)
88
88
  maxGuests!: number;
89
89
 
90
+ // IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
90
91
  [key: string]: unknown;
91
92
  }
92
93
 
@@ -128,7 +129,7 @@ describe('query.resolver.ts.ejs', () => {
128
129
  it('should generate a valid query resolver with array of inline object field', async () => {
129
130
  const spec: SpecsSchema = {
130
131
  variant: 'specs',
131
- flows: [
132
+ narratives: [
132
133
  {
133
134
  name: 'assistant-flow',
134
135
  slices: [
@@ -186,7 +187,7 @@ describe('query.resolver.ts.ejs', () => {
186
187
  ],
187
188
  };
188
189
 
189
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
190
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
190
191
  const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
191
192
 
192
193
  expect(resolverFile?.contents).toMatchInlineSnapshot(`
@@ -217,6 +218,7 @@ describe('query.resolver.ts.ejs', () => {
217
218
  @Field(() => [SuggestedItemsItems])
218
219
  items!: SuggestedItemsItems[];
219
220
 
221
+ // IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
220
222
  [key: string]: unknown;
221
223
  }
222
224
 
@@ -252,7 +254,7 @@ describe('query.resolver.ts.ejs', () => {
252
254
  it('should generate the query resolver for "views the questionnaire"', async () => {
253
255
  const spec: SpecsSchema = {
254
256
  variant: 'specs',
255
- flows: [
257
+ narratives: [
256
258
  {
257
259
  name: 'Questionnaires',
258
260
  slices: [
@@ -352,7 +354,7 @@ describe('query.resolver.ts.ejs', () => {
352
354
  integrations: [],
353
355
  };
354
356
 
355
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
357
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
356
358
  const queryFile = plans.find(
357
359
  (p) => p.outputPath.endsWith('query.resolver.ts') && p.contents.includes('ViewsTheQuestionnaireQueryResolver'),
358
360
  );
@@ -360,7 +362,7 @@ describe('query.resolver.ts.ejs', () => {
360
362
  expect(queryFile?.contents).toMatchInlineSnapshot(`
361
363
  "import { Query, Resolver, Arg, Ctx, ObjectType, Field, ID } from 'type-graphql';
362
364
  import { GraphQLJSON } from 'graphql-type-json';
363
- import { type GraphQLContext, ReadModel } from '../../../shared';
365
+ import { type GraphQLContext, ReadModel, QuestionnaireProgressStatus } from '../../../shared';
364
366
 
365
367
  @ObjectType()
366
368
  export class QuestionnaireProgressAnswers {
@@ -379,8 +381,8 @@ describe('query.resolver.ts.ejs', () => {
379
381
  @Field(() => String)
380
382
  participantId!: string;
381
383
 
382
- @Field(() => String)
383
- status!: 'in_progress' | 'ready_to_submit' | 'submitted';
384
+ @Field(() => QuestionnaireProgressStatus)
385
+ status!: QuestionnaireProgressStatus;
384
386
 
385
387
  @Field(() => String, { nullable: true })
386
388
  currentQuestionId?: string | null;
@@ -391,6 +393,7 @@ describe('query.resolver.ts.ejs', () => {
391
393
  @Field(() => [QuestionnaireProgressAnswers])
392
394
  answers!: QuestionnaireProgressAnswers[];
393
395
 
396
+ // IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
394
397
  [key: string]: unknown;
395
398
  }
396
399
 
@@ -426,7 +429,7 @@ describe('query.resolver.ts.ejs', () => {
426
429
  it('should import Float when Float fields are used', async () => {
427
430
  const spec: SpecsSchema = {
428
431
  variant: 'specs',
429
- flows: [
432
+ narratives: [
430
433
  {
431
434
  name: 'product-flow',
432
435
  slices: [
@@ -479,7 +482,7 @@ describe('query.resolver.ts.ejs', () => {
479
482
  ],
480
483
  };
481
484
 
482
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
485
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
483
486
  const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
484
487
 
485
488
  expect(resolverFile?.contents).toContain(
@@ -490,7 +493,7 @@ describe('query.resolver.ts.ejs', () => {
490
493
  it('should import Float when array of numbers is used', async () => {
491
494
  const spec: SpecsSchema = {
492
495
  variant: 'specs',
493
- flows: [
496
+ narratives: [
494
497
  {
495
498
  name: 'stats-flow',
496
499
  slices: [
@@ -541,7 +544,7 @@ describe('query.resolver.ts.ejs', () => {
541
544
  ],
542
545
  };
543
546
 
544
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
547
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
545
548
  const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
546
549
 
547
550
  expect(resolverFile?.contents).toContain('Float');
@@ -550,7 +553,7 @@ describe('query.resolver.ts.ejs', () => {
550
553
  it('should import Float when Float query arg is used', async () => {
551
554
  const spec: SpecsSchema = {
552
555
  variant: 'specs',
553
- flows: [
556
+ narratives: [
554
557
  {
555
558
  name: 'search-flow',
556
559
  slices: [
@@ -601,7 +604,7 @@ describe('query.resolver.ts.ejs', () => {
601
604
  ],
602
605
  };
603
606
 
604
- const plans = await generateScaffoldFilePlans(spec.flows, spec.messages, undefined, 'src/domain/flows');
607
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
605
608
  const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
606
609
 
607
610
  expect(resolverFile?.contents).toContain('Float');
@@ -8,50 +8,6 @@ const message = messages?.find(m => m.name === viewType);
8
8
  const resolverClassName = `${pascalCase(slice.name)}QueryResolver`;
9
9
  const usesID = parsedRequest?.args?.some(arg => graphqlType(arg.tsType) === 'ID');
10
10
 
11
- function isInlineObject(ts) {
12
- return /^\{[\s\S]*\}$/.test((ts ?? '').trim());
13
- }
14
- function isInlineObjectArray(ts) {
15
- const t = (ts ?? '').trim();
16
- return /^Array<\{[\s\S]*\}>$/.test(t) || /^\{[\s\S]*\}\[\]$/.test(t);
17
- }
18
- function baseTs(ts) {
19
- return (ts ?? 'string').replace(/\s*\|\s*null\b/g, '').trim();
20
- }
21
- function fieldUsesDate(ts) {
22
- const b = baseTs(ts);
23
- const gqlType = graphqlType(b);
24
- if (gqlType.includes('GraphQLISODateTime')) return true;
25
- if (isInlineObject(b) || isInlineObjectArray(b)) return /:\s*Date\b/.test(b);
26
- return false;
27
- }
28
- function fieldUsesJSON(ts) {
29
- const b = baseTs(ts);
30
- const gqlType = graphqlType(b);
31
- if (gqlType.includes('GraphQLJSON') || gqlType.includes('JSON')) return true;
32
- if (isInlineObject(b) || isInlineObjectArray(b)) return /:\s*(unknown|any|object)\b/.test(b);
33
- return false;
34
- }
35
- function fieldUsesFloat(ts) {
36
- const b = baseTs(ts);
37
- const gqlType = graphqlType(b);
38
- if (gqlType.includes('Float')) return true;
39
- if (isInlineObject(b) || isInlineObjectArray(b)) {
40
- const inner = b.trim().startsWith('Array<')
41
- ? b.trim().replace(/^Array<\{/, '{').replace(/}>$/, '}')
42
- : b.trim().replace(/\[\]$/, '');
43
- const match = inner.match(/^\{([\s\S]*)\}$/);
44
- const body = match ? match[1] : '';
45
- const rawFields = body.split(/[,;]\s*/).filter(Boolean);
46
- return rawFields.some(f => {
47
- const parts = f.split(':');
48
- const type = parts.slice(1).join(':').trim();
49
- return type && graphqlType(type).includes('Float');
50
- });
51
- }
52
- return false;
53
- }
54
-
55
11
  const messageFields = message?.fields ?? [];
56
12
  const usesDate = messageFields.some(f => fieldUsesDate(f.type)) ||
57
13
  (parsedRequest?.args ?? []).some(a => fieldUsesDate(a.tsType));
@@ -60,6 +16,8 @@ const usesJSON = messageFields.some(f => fieldUsesJSON(f.type)) ||
60
16
  const usesFloat = messageFields.some(f => fieldUsesFloat(f.type)) ||
61
17
  (parsedRequest?.args ?? []).some(a => fieldUsesFloat(a.tsType));
62
18
 
19
+ const enumList = collectEnumNames([...messageFields, ...(parsedRequest?.args ?? [])]);
20
+
63
21
  const embeddedTypes = [];
64
22
  for (const field of messageFields) {
65
23
  const tsType = field.type ?? 'string';
@@ -70,10 +28,11 @@ for (const field of messageFields) {
70
28
  });
71
29
  }
72
30
  }
31
+ const hasArgs = parsedRequest?.args?.length > 0;
73
32
  %>
74
- import { Query, Resolver, Arg, Ctx, ObjectType, Field<% if (usesID) { %>, ID<% } %><% if (usesFloat) { %>, Float<% } %><% if (usesDate) { %>, GraphQLISODateTime<% } %> } from 'type-graphql';
33
+ import { Query, Resolver<% if (hasArgs) { %>, Arg<% } %>, Ctx, ObjectType, Field<% if (usesID) { %>, ID<% } %><% if (usesFloat) { %>, Float<% } %><% if (usesDate) { %>, GraphQLISODateTime<% } %> } from 'type-graphql';
75
34
  <% if (usesJSON) { %>import { GraphQLJSON } from 'graphql-type-json';
76
- <% } %>import { type GraphQLContext, ReadModel } from '../../../shared';
35
+ <% } %>import { type GraphQLContext, ReadModel<% if (enumList.length > 0) { %>, <%= enumList.join(', ') %><% } %> } from '../../../shared';
77
36
 
78
37
  <%
79
38
  for (const { typeName, tsType } of embeddedTypes) {
@@ -118,9 +77,12 @@ export class <%= viewType %> {
118
77
  @Field(() => <%= gqlType %><%= isNullable(tsType) ? ', { nullable: true }' : '' %>)
119
78
  <%= field.name %><%= isNullable(tsType) ? '?' : '!' %>: <%= toTsFieldType(tsType) %>;
120
79
  <% } } %>
121
- [key: string]: unknown;
80
+
81
+ // IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
82
+ [key: string]: unknown;
122
83
  <% } else { %>
123
- [key: string]: unknown;
84
+ // IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
85
+ [key: string]: unknown;
124
86
  <% } %>
125
87
  }
126
88
 
@@ -148,7 +110,7 @@ const model = new ReadModel<<%= viewType %>>(ctx.database, '<%= projectionType %
148
110
  // Example below uses \`.find()\` to filter
149
111
  // change the logic for the query as needed to meet the requirements for the current slice.
150
112
 
151
- return model.find((item) => {
113
+ return model.find((<%= hasArgs ? 'item' : '_item' %>) => {
152
114
  <% if (parsedRequest?.args?.length) {
153
115
  for (const arg of parsedRequest.args) { %>
154
116
  if (<%= arg.name %> !== undefined && item.<%= arg.name %> !== <%= arg.name %>) return false;