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

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 (83) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/src/codegen/extract/index.d.ts +1 -0
  3. package/dist/src/codegen/extract/index.d.ts.map +1 -1
  4. package/dist/src/codegen/extract/index.js +1 -0
  5. package/dist/src/codegen/extract/index.js.map +1 -1
  6. package/dist/src/codegen/extract/type-helpers.d.ts +13 -0
  7. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -0
  8. package/dist/src/codegen/extract/type-helpers.js +98 -0
  9. package/dist/src/codegen/extract/type-helpers.js.map +1 -0
  10. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  11. package/dist/src/codegen/scaffoldFromSchema.js +202 -19
  12. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  13. package/dist/src/codegen/templates/command/commands.ts.ejs +14 -9
  14. package/dist/src/codegen/templates/command/decide.specs.specs.ts +47 -47
  15. package/dist/src/codegen/templates/command/decide.specs.ts +4 -0
  16. package/dist/src/codegen/templates/command/decide.ts.ejs +1 -0
  17. package/dist/src/codegen/templates/command/events.ts.ejs +16 -13
  18. package/dist/src/codegen/templates/command/mutation.resolver.specs.ts +1 -0
  19. package/dist/src/codegen/templates/command/mutation.resolver.ts.ejs +2 -23
  20. package/dist/src/codegen/templates/command/register.specs.ts +1 -1
  21. package/dist/src/codegen/templates/command/register.ts.ejs +1 -4
  22. package/dist/src/codegen/templates/command/state.specs.ts +51 -47
  23. package/dist/src/codegen/templates/command/state.ts.ejs +8 -4
  24. package/dist/src/codegen/templates/query/projection.specs.ts +19 -2
  25. package/dist/src/codegen/templates/query/projection.ts.ejs +64 -12
  26. package/dist/src/codegen/templates/query/query.resolver.specs.ts +6 -3
  27. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +11 -49
  28. package/dist/src/codegen/templates/react/react.ts.ejs +0 -1
  29. package/dist/src/codegen/templates/react/register.ts.ejs +0 -1
  30. package/dist/src/commands/generate-server.d.ts +0 -1
  31. package/dist/src/commands/generate-server.d.ts.map +1 -1
  32. package/dist/src/commands/generate-server.js +42 -23
  33. package/dist/src/commands/generate-server.js.map +1 -1
  34. package/dist/src/domain/shared/graphql-types.d.ts +10 -0
  35. package/dist/src/domain/shared/graphql-types.d.ts.map +1 -0
  36. package/dist/src/domain/shared/graphql-types.js +40 -0
  37. package/dist/src/domain/shared/graphql-types.js.map +1 -0
  38. package/dist/src/domain/shared/graphql-types.ts +20 -0
  39. package/dist/src/domain/shared/index.d.ts +1 -0
  40. package/dist/src/domain/shared/index.d.ts.map +1 -1
  41. package/dist/src/domain/shared/index.js +1 -0
  42. package/dist/src/domain/shared/index.js.map +1 -1
  43. package/dist/src/domain/shared/index.ts +1 -0
  44. package/dist/src/domain/shared/sendCommand.d.ts +1 -1
  45. package/dist/src/domain/shared/sendCommand.d.ts.map +1 -1
  46. package/dist/src/domain/shared/sendCommand.ts +1 -1
  47. package/dist/src/domain/shared/types.d.ts +5 -7
  48. package/dist/src/domain/shared/types.d.ts.map +1 -1
  49. package/dist/src/domain/shared/types.js +11 -38
  50. package/dist/src/domain/shared/types.js.map +1 -1
  51. package/dist/src/domain/shared/types.ts +10 -16
  52. package/dist/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +4 -4
  54. package/src/codegen/extract/index.ts +1 -0
  55. package/src/codegen/extract/type-helpers.ts +102 -0
  56. package/src/codegen/scaffoldFromSchema.ts +273 -15
  57. package/src/codegen/templates/command/commands.ts.ejs +14 -9
  58. package/src/codegen/templates/command/decide.specs.specs.ts +47 -47
  59. package/src/codegen/templates/command/decide.specs.ts +4 -0
  60. package/src/codegen/templates/command/decide.ts.ejs +1 -0
  61. package/src/codegen/templates/command/events.ts.ejs +16 -13
  62. package/src/codegen/templates/command/mutation.resolver.specs.ts +1 -0
  63. package/src/codegen/templates/command/mutation.resolver.ts.ejs +2 -23
  64. package/src/codegen/templates/command/register.specs.ts +1 -1
  65. package/src/codegen/templates/command/register.ts.ejs +1 -4
  66. package/src/codegen/templates/command/state.specs.ts +51 -47
  67. package/src/codegen/templates/command/state.ts.ejs +8 -4
  68. package/src/codegen/templates/query/projection.specs.ts +19 -2
  69. package/src/codegen/templates/query/projection.ts.ejs +64 -12
  70. package/src/codegen/templates/query/query.resolver.specs.ts +6 -3
  71. package/src/codegen/templates/query/query.resolver.ts.ejs +11 -49
  72. package/src/codegen/templates/react/react.ts.ejs +0 -1
  73. package/src/codegen/templates/react/register.ts.ejs +0 -1
  74. package/src/commands/generate-server.ts +50 -25
  75. package/src/domain/shared/graphql-types.ts +20 -0
  76. package/src/domain/shared/index.ts +1 -0
  77. package/src/domain/shared/sendCommand.ts +1 -1
  78. package/src/domain/shared/types.ts +10 -16
  79. package/.turbo/turbo-build.log +0 -6
  80. package/.turbo/turbo-format.log +0 -4
  81. package/.turbo/turbo-lint.log +0 -4
  82. package/.turbo/turbo-test.log +0 -14
  83. package/.turbo/turbo-type-check.log +0 -4
@@ -95,57 +95,57 @@ describe('spec.ts.ejs', () => {
95
95
  const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
96
96
 
97
97
  expect(specFile?.contents).toMatchInlineSnapshot(`
98
- "import { describe, it } from 'vitest';
99
- import { DeciderSpecification } from '@event-driven-io/emmett';
100
- import { decide } from './decide';
101
- import { evolve } from './evolve';
102
- import { initialState, State } from './state';
103
- import type { ListingCreated } from './events';
104
- import type { CreateListing } from './commands';
98
+ "import { describe, it } from 'vitest';
99
+ import { DeciderSpecification } from '@event-driven-io/emmett';
100
+ import { decide } from './decide';
101
+ import { evolve } from './evolve';
102
+ import { initialState, State } from './state';
103
+ import type { ListingCreated } from './events';
104
+ import type { CreateListing } from './commands';
105
105
 
106
- describe('Should create listing successfully', () => {
107
- type Events = ListingCreated;
106
+ describe('Should create listing successfully', () => {
107
+ type Events = ListingCreated;
108
108
 
109
- const given = DeciderSpecification.for<CreateListing, Events, State>({
110
- decide,
111
- evolve,
112
- initialState,
113
- });
109
+ const given = DeciderSpecification.for<CreateListing, Events, State>({
110
+ decide,
111
+ evolve,
112
+ initialState,
113
+ });
114
114
 
115
- it('User creates listing with valid data', () => {
116
- given([])
117
- .when({
118
- type: 'CreateListing',
119
- data: {
120
- propertyId: 'listing_123',
121
- title: 'blah',
122
- pricePerNight: 250,
123
- maxGuests: 4,
124
- amenities: ['wifi', 'kitchen'],
125
- available: true,
126
- tags: ['some tag'],
127
- rating: 4.8,
128
- metadata: { foo: 'bar' },
129
- listedAt: new Date('2024-01-15T10:00:00Z'),
130
- },
131
- metadata: { now: new Date() },
132
- })
115
+ it('User creates listing with valid data', () => {
116
+ given([])
117
+ .when({
118
+ type: 'CreateListing',
119
+ data: {
120
+ propertyId: 'listing_123',
121
+ title: 'blah',
122
+ pricePerNight: 250,
123
+ maxGuests: 4,
124
+ amenities: ['wifi', 'kitchen'],
125
+ available: true,
126
+ tags: ['some tag'],
127
+ rating: 4.8,
128
+ metadata: { foo: 'bar' },
129
+ listedAt: new Date('2024-01-15T10:00:00Z'),
130
+ },
131
+ metadata: { now: new Date() },
132
+ })
133
133
 
134
- .then([
135
- {
136
- type: 'ListingCreated',
137
- data: {
138
- propertyId: 'listing_123',
139
- listedAt: new Date('2024-01-15T10:00:00Z'),
140
- rating: 4.8,
141
- metadata: { foo: 'bar' },
142
- },
143
- },
144
- ]);
145
- });
146
- });
147
- "
148
- `);
134
+ .then([
135
+ {
136
+ type: 'ListingCreated',
137
+ data: {
138
+ propertyId: 'listing_123',
139
+ listedAt: new Date('2024-01-15T10:00:00Z'),
140
+ rating: 4.8,
141
+ metadata: { foo: 'bar' },
142
+ },
143
+ },
144
+ ]);
145
+ });
146
+ });
147
+ "
148
+ `);
149
149
  });
150
150
  it('should include given events in the spec file when provided', async () => {
151
151
  const spec: SpecsSchema = {
@@ -93,6 +93,7 @@ describe('decide.ts.ejs', () => {
93
93
  * - Validate the command input fields
94
94
  * - Inspect the current domain \`state\` to determine if the command is allowed
95
95
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
96
+ * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
96
97
  * - If valid, return one or more events with the correct structure
97
98
  *
98
99
  * ⚠️ Only read from inputs — never mutate them. \`evolve.ts\` handles state updates.
@@ -218,6 +219,7 @@ describe('decide.ts.ejs', () => {
218
219
  * - Validate the command input fields
219
220
  * - Inspect the current domain \`state\` to determine if the command is allowed
220
221
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
222
+ * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
221
223
  * - If valid, return one or more events with the correct structure
222
224
  *
223
225
  * ⚠️ Only read from inputs — never mutate them. \`evolve.ts\` handles state updates.
@@ -358,6 +360,7 @@ describe('decide.ts.ejs', () => {
358
360
  * - Validate the command input fields
359
361
  * - Inspect the current domain \`state\` to determine if the command is allowed
360
362
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
363
+ * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
361
364
  * - If valid, return one or more events with the correct structure
362
365
  *
363
366
  * ⚠️ Only read from inputs — never mutate them. \`evolve.ts\` handles state updates.
@@ -545,6 +548,7 @@ describe('decide.ts.ejs', () => {
545
548
  * - Inspect the current domain \`state\` to determine if the command is allowed
546
549
  * - Use \`products\` (integration result) to enrich or filter the output
547
550
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
551
+ * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
548
552
  * - If valid, return one or more events with the correct structure
549
553
  *
550
554
  * ⚠️ Only read from inputs — never mutate them. \`evolve.ts\` handles state updates.
@@ -71,6 +71,7 @@ case '<%= command %>': {
71
71
  * - Use `<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
72
72
  <% } -%>
73
73
  * - If invalid, throw one of the following domain errors: `NotFoundError`, `ValidationError`, or `IllegalStateError`
74
+ * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
74
75
  * - If valid, return one or more events with the correct structure
75
76
  *
76
77
  * ⚠️ Only read from inputs — never mutate them. `evolve.ts` handles state updates.
@@ -1,14 +1,17 @@
1
- <% if (localEvents.length) { -%>
2
- import type { Event } from '@event-driven-io/emmett';
3
-
4
- <% for (const event of localEvents) { -%>
5
- export type <%= pascalCase(event.type) %> = Event<
6
- '<%= event.type %>',
7
- {
8
- <% for (const field of event.fields) { -%>
9
- <%- field.name %>: <%- field.tsType %>;
10
- <% } -%>
11
- }
12
- >;
13
- <% } -%>
1
+ <%
2
+ const enumList = collectEnumNames(localEvents.flatMap(e => e.fields));
3
+ %><% if (localEvents.length) { -%>
4
+ import type { Event } from '@event-driven-io/emmett';
5
+ <% if (enumList.length > 0) { %>import { <%= enumList.join(', ') %> } from '../../../shared';
6
+ <% } %>
7
+ <% for (const event of localEvents) { -%>
8
+ export type <%= pascalCase(event.type) %> = Event<
9
+ '<%= event.type %>',
10
+ {
11
+ <% for (const field of event.fields) { -%>
12
+ <%- field.name %>: <%- toTsFieldType(field.tsType) %>;
13
+ <% } -%>
14
+ }
15
+ >;
16
+ <% } -%>
14
17
  <% } -%>
@@ -323,6 +323,7 @@ describe('mutation.resolver.ts.ejs', () => {
323
323
 
324
324
  expect(mutationFile?.contents).toMatchInlineSnapshot(`
325
325
  "import { Mutation, Resolver, Arg, Ctx, Field, InputType } from 'type-graphql';
326
+ import { GraphQLJSON } from 'graphql-type-json';
326
327
  import { type GraphQLContext, sendCommand, MutationResponse } from '../../../shared';
327
328
 
328
329
  @InputType()
@@ -1,29 +1,8 @@
1
1
  <%
2
- function isInlineObject(ts) {
3
- return /^\{[\s\S]*\}$/.test((ts ?? '').trim());
4
- }
5
- function isInlineObjectArray(ts) {
6
- const t = (ts ?? '').trim();
7
- return /^Array<\{[\s\S]*\}>$/.test(t) || /^\{[\s\S]*\}\[\]$/.test(t);
8
- }
9
- function baseTs(ts) {
10
- return (ts ?? 'string').replace(/\s*\|\s*null\b/g, '').trim();
11
- }
12
- function fieldUsesDate(ts) {
13
- const b = baseTs(ts);
14
- if (b === 'Date') return true;
15
- if (isInlineObject(b) || isInlineObjectArray(b)) return /:\s*Date\b/.test(b);
16
- return false;
17
- }
18
- function fieldUsesJSON(ts) {
19
- const b = baseTs(ts);
20
- if (b === 'unknown' || b === 'any' || b === 'object') return true;
21
- if (isInlineObject(b) || isInlineObjectArray(b)) return /:\s*(unknown|any|object)\b/.test(b);
22
- return false;
23
- }
24
2
  const cmd = commands[0];
25
3
  const usesDate = cmd.fields.some(f => fieldUsesDate(f.tsType));
26
4
  const usesJSON = cmd.fields.some(f => fieldUsesJSON(f.tsType));
5
+ const enumList = collectEnumNames(cmd.fields);
27
6
 
28
7
  const embeddedInputs = [];
29
8
  for (const f of cmd.fields) {
@@ -38,7 +17,7 @@ for (const f of cmd.fields) {
38
17
  %>
39
18
  import { Mutation, Resolver, Arg, Ctx, Field, InputType<% if (usesDate) { %>, GraphQLISODateTime<% } %> } from 'type-graphql';
40
19
  <% if (usesJSON) { %>import { GraphQLJSON } from 'graphql-type-json';
41
- <% } %>import { type GraphQLContext, sendCommand, MutationResponse } from '../../../shared';
20
+ <% } %>import { type GraphQLContext, sendCommand, MutationResponse<% if (enumList.length > 0) { %>, <%= enumList.join(', ') %><% } %> } from '../../../shared';
42
21
 
43
22
  <% for (const { typeName, tsType } of embeddedInputs) {
44
23
  const inner = tsType.trim().startsWith('Array<')
@@ -107,7 +107,7 @@ describe('generateScaffoldFilePlans', () => {
107
107
  import type { CreateListing } from './commands';
108
108
 
109
109
  export function register(messageBus: CommandProcessor, eventStore: EventStore) {
110
- messageBus.handle((command: CreateListing) => handle(eventStore, command), 'CreateListing');
110
+ messageBus.handle<CreateListing>((command: CreateListing) => handle(eventStore, command), 'CreateListing');
111
111
  }
112
112
  "
113
113
  `);
@@ -4,9 +4,6 @@ import type { <%= commands.map(c => pascalCase(c.type)).join(', ') %> } from './
4
4
 
5
5
  export function register(messageBus: CommandProcessor, eventStore: EventStore) {
6
6
  <% for (const command of commands) { -%>
7
- messageBus.handle(
8
- (command: <%= pascalCase(command.type) %>) => handle(eventStore, command),
9
- '<%= command.type %>'
10
- );
7
+ messageBus.handle<<%= pascalCase(command.type) %>>((command: <%= pascalCase(command.type) %>) => handle(eventStore, command), '<%= command.type %>');
11
8
  <% } -%>
12
9
  }
@@ -82,54 +82,58 @@ describe('state.ts.ejs', () => {
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
@@ -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 */ '',
@@ -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
  }
@@ -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
 
@@ -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
 
@@ -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