@auto-engineer/narrative 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.
@@ -501,7 +501,9 @@ narrative('Test Flow with IDs', 'FLOW-123', () => {
501
501
  should('allow filtering');
502
502
  });
503
503
  })
504
- .server(() => {});
504
+ .server(() => {
505
+ specs('Product data specs', () => {});
506
+ });
505
507
  });
506
508
  `);
507
509
  });
@@ -2031,4 +2033,398 @@ narrative('Todo List Summary', 'TODO-001', () => {
2031
2033
  expect(code).not.toContain('.when({})');
2032
2034
  expect(code).not.toContain('.when<');
2033
2035
  });
2036
+
2037
+ describe('projection DSL generation', () => {
2038
+ it('should generate fromSingletonProjection for singleton projections', async () => {
2039
+ const modelWithSingletonProjection: Model = {
2040
+ variant: 'specs',
2041
+ narratives: [
2042
+ {
2043
+ name: 'Todo Summary Flow',
2044
+ id: 'TODO-SUMMARY',
2045
+ slices: [
2046
+ {
2047
+ name: 'views todo summary',
2048
+ id: 'SUMMARY-SLICE',
2049
+ type: 'query',
2050
+ client: {
2051
+ description: 'Summary client',
2052
+ },
2053
+ server: {
2054
+ description: 'Summary server',
2055
+ data: [
2056
+ {
2057
+ target: {
2058
+ type: 'State',
2059
+ name: 'TodoListSummary',
2060
+ },
2061
+ origin: {
2062
+ type: 'projection',
2063
+ name: 'TodoSummary',
2064
+ singleton: true,
2065
+ },
2066
+ },
2067
+ ],
2068
+ specs: {
2069
+ name: 'Summary Rules',
2070
+ rules: [],
2071
+ },
2072
+ },
2073
+ },
2074
+ ],
2075
+ },
2076
+ ],
2077
+ messages: [
2078
+ {
2079
+ type: 'state',
2080
+ name: 'TodoListSummary',
2081
+ fields: [
2082
+ { name: 'summaryId', type: 'string', required: true },
2083
+ { name: 'totalTodos', type: 'number', required: true },
2084
+ ],
2085
+ metadata: { version: 1 },
2086
+ },
2087
+ ],
2088
+ integrations: [],
2089
+ };
2090
+
2091
+ const code = await modelToNarrative(modelWithSingletonProjection);
2092
+
2093
+ expect(code).toEqual(`import { data, narrative, query, source, specs } from '@auto-engineer/narrative';
2094
+ import type { State } from '@auto-engineer/narrative';
2095
+ type TodoListSummary = State<
2096
+ 'TodoListSummary',
2097
+ {
2098
+ summaryId: string;
2099
+ totalTodos: number;
2100
+ }
2101
+ >;
2102
+ narrative('Todo Summary Flow', 'TODO-SUMMARY', () => {
2103
+ query('views todo summary', 'SUMMARY-SLICE').server(() => {
2104
+ data([source().state('TodoListSummary').fromSingletonProjection('TodoSummary')]);
2105
+ specs('Summary Rules', () => {});
2106
+ });
2107
+ });
2108
+ `);
2109
+ });
2110
+
2111
+ it('should generate fromProjection with single idField for regular projections', async () => {
2112
+ const modelWithRegularProjection: Model = {
2113
+ variant: 'specs',
2114
+ narratives: [
2115
+ {
2116
+ name: 'Todo Flow',
2117
+ id: 'TODO-FLOW',
2118
+ slices: [
2119
+ {
2120
+ name: 'views todo',
2121
+ id: 'TODO-SLICE',
2122
+ type: 'query',
2123
+ client: {
2124
+ description: 'Todo client',
2125
+ },
2126
+ server: {
2127
+ description: 'Todo server',
2128
+ data: [
2129
+ {
2130
+ target: {
2131
+ type: 'State',
2132
+ name: 'TodoState',
2133
+ },
2134
+ origin: {
2135
+ type: 'projection',
2136
+ name: 'Todos',
2137
+ idField: 'todoId',
2138
+ },
2139
+ },
2140
+ ],
2141
+ specs: {
2142
+ name: 'Todo Rules',
2143
+ rules: [],
2144
+ },
2145
+ },
2146
+ },
2147
+ ],
2148
+ },
2149
+ ],
2150
+ messages: [
2151
+ {
2152
+ type: 'state',
2153
+ name: 'TodoState',
2154
+ fields: [
2155
+ { name: 'todoId', type: 'string', required: true },
2156
+ { name: 'description', type: 'string', required: true },
2157
+ ],
2158
+ metadata: { version: 1 },
2159
+ },
2160
+ ],
2161
+ integrations: [],
2162
+ };
2163
+
2164
+ const code = await modelToNarrative(modelWithRegularProjection);
2165
+
2166
+ expect(code).toEqual(`import { data, narrative, query, source, specs } from '@auto-engineer/narrative';
2167
+ import type { State } from '@auto-engineer/narrative';
2168
+ type TodoState = State<
2169
+ 'TodoState',
2170
+ {
2171
+ todoId: string;
2172
+ description: string;
2173
+ }
2174
+ >;
2175
+ narrative('Todo Flow', 'TODO-FLOW', () => {
2176
+ query('views todo', 'TODO-SLICE').server(() => {
2177
+ data([source().state('TodoState').fromProjection('Todos', 'todoId')]);
2178
+ specs('Todo Rules', () => {});
2179
+ });
2180
+ });
2181
+ `);
2182
+ });
2183
+
2184
+ it('should generate fromCompositeProjection with array idField for composite key projections', async () => {
2185
+ const modelWithCompositeProjection: Model = {
2186
+ variant: 'specs',
2187
+ narratives: [
2188
+ {
2189
+ name: 'User Project Flow',
2190
+ id: 'USER-PROJECT-FLOW',
2191
+ slices: [
2192
+ {
2193
+ name: 'views user project',
2194
+ id: 'USER-PROJECT-SLICE',
2195
+ type: 'query',
2196
+ client: {
2197
+ description: 'User project client',
2198
+ },
2199
+ server: {
2200
+ description: 'User project server',
2201
+ data: [
2202
+ {
2203
+ target: {
2204
+ type: 'State',
2205
+ name: 'UserProjectState',
2206
+ },
2207
+ origin: {
2208
+ type: 'projection',
2209
+ name: 'UserProjects',
2210
+ idField: ['userId', 'projectId'],
2211
+ },
2212
+ },
2213
+ ],
2214
+ specs: {
2215
+ name: 'User Project Rules',
2216
+ rules: [],
2217
+ },
2218
+ },
2219
+ },
2220
+ ],
2221
+ },
2222
+ ],
2223
+ messages: [
2224
+ {
2225
+ type: 'state',
2226
+ name: 'UserProjectState',
2227
+ fields: [
2228
+ { name: 'userId', type: 'string', required: true },
2229
+ { name: 'projectId', type: 'string', required: true },
2230
+ { name: 'role', type: 'string', required: true },
2231
+ ],
2232
+ metadata: { version: 1 },
2233
+ },
2234
+ ],
2235
+ integrations: [],
2236
+ };
2237
+
2238
+ const code = await modelToNarrative(modelWithCompositeProjection);
2239
+
2240
+ expect(code).toEqual(`import { data, narrative, query, source, specs } from '@auto-engineer/narrative';
2241
+ import type { State } from '@auto-engineer/narrative';
2242
+ type UserProjectState = State<
2243
+ 'UserProjectState',
2244
+ {
2245
+ userId: string;
2246
+ projectId: string;
2247
+ role: string;
2248
+ }
2249
+ >;
2250
+ narrative('User Project Flow', 'USER-PROJECT-FLOW', () => {
2251
+ query('views user project', 'USER-PROJECT-SLICE').server(() => {
2252
+ data([source().state('UserProjectState').fromCompositeProjection('UserProjects', ['userId', 'projectId'])]);
2253
+ specs('User Project Rules', () => {});
2254
+ });
2255
+ });
2256
+ `);
2257
+ });
2258
+
2259
+ it('should generate all three projection types in a single narrative', async () => {
2260
+ const modelWithAllProjectionTypes: Model = {
2261
+ variant: 'specs',
2262
+ narratives: [
2263
+ {
2264
+ name: 'All Projection Types',
2265
+ id: 'ALL-PROJ',
2266
+ slices: [
2267
+ {
2268
+ name: 'views summary',
2269
+ id: 'SUMMARY-SLICE',
2270
+ type: 'query',
2271
+ client: {
2272
+ description: 'Summary client',
2273
+ },
2274
+ server: {
2275
+ description: 'Summary server',
2276
+ data: [
2277
+ {
2278
+ target: {
2279
+ type: 'State',
2280
+ name: 'TodoListSummary',
2281
+ },
2282
+ origin: {
2283
+ type: 'projection',
2284
+ name: 'TodoSummary',
2285
+ singleton: true,
2286
+ },
2287
+ },
2288
+ ],
2289
+ specs: {
2290
+ name: 'Summary Rules',
2291
+ rules: [],
2292
+ },
2293
+ },
2294
+ },
2295
+ {
2296
+ name: 'views todo',
2297
+ id: 'TODO-SLICE',
2298
+ type: 'query',
2299
+ client: {
2300
+ description: 'Todo client',
2301
+ },
2302
+ server: {
2303
+ description: 'Todo server',
2304
+ data: [
2305
+ {
2306
+ target: {
2307
+ type: 'State',
2308
+ name: 'TodoState',
2309
+ },
2310
+ origin: {
2311
+ type: 'projection',
2312
+ name: 'Todos',
2313
+ idField: 'todoId',
2314
+ },
2315
+ },
2316
+ ],
2317
+ specs: {
2318
+ name: 'Todo Rules',
2319
+ rules: [],
2320
+ },
2321
+ },
2322
+ },
2323
+ {
2324
+ name: 'views user project todos',
2325
+ id: 'USER-PROJECT-SLICE',
2326
+ type: 'query',
2327
+ client: {
2328
+ description: 'User project client',
2329
+ },
2330
+ server: {
2331
+ description: 'User project server',
2332
+ data: [
2333
+ {
2334
+ target: {
2335
+ type: 'State',
2336
+ name: 'UserProjectTodos',
2337
+ },
2338
+ origin: {
2339
+ type: 'projection',
2340
+ name: 'UserProjectTodos',
2341
+ idField: ['userId', 'projectId'],
2342
+ },
2343
+ },
2344
+ ],
2345
+ specs: {
2346
+ name: 'User Project Rules',
2347
+ rules: [],
2348
+ },
2349
+ },
2350
+ },
2351
+ ],
2352
+ },
2353
+ ],
2354
+ messages: [
2355
+ {
2356
+ type: 'state',
2357
+ name: 'TodoListSummary',
2358
+ fields: [
2359
+ { name: 'summaryId', type: 'string', required: true },
2360
+ { name: 'totalTodos', type: 'number', required: true },
2361
+ ],
2362
+ metadata: { version: 1 },
2363
+ },
2364
+ {
2365
+ type: 'state',
2366
+ name: 'TodoState',
2367
+ fields: [
2368
+ { name: 'todoId', type: 'string', required: true },
2369
+ { name: 'description', type: 'string', required: true },
2370
+ ],
2371
+ metadata: { version: 1 },
2372
+ },
2373
+ {
2374
+ type: 'state',
2375
+ name: 'UserProjectTodos',
2376
+ fields: [
2377
+ { name: 'userId', type: 'string', required: true },
2378
+ { name: 'projectId', type: 'string', required: true },
2379
+ { name: 'todos', type: 'Array<string>', required: true },
2380
+ ],
2381
+ metadata: { version: 1 },
2382
+ },
2383
+ ],
2384
+ integrations: [],
2385
+ };
2386
+
2387
+ const code = await modelToNarrative(modelWithAllProjectionTypes);
2388
+
2389
+ expect(code).toEqual(`import { data, narrative, query, source, specs } from '@auto-engineer/narrative';
2390
+ import type { State } from '@auto-engineer/narrative';
2391
+ type TodoListSummary = State<
2392
+ 'TodoListSummary',
2393
+ {
2394
+ summaryId: string;
2395
+ totalTodos: number;
2396
+ }
2397
+ >;
2398
+ type TodoState = State<
2399
+ 'TodoState',
2400
+ {
2401
+ todoId: string;
2402
+ description: string;
2403
+ }
2404
+ >;
2405
+ type UserProjectTodos = State<
2406
+ 'UserProjectTodos',
2407
+ {
2408
+ userId: string;
2409
+ projectId: string;
2410
+ todos: string[];
2411
+ }
2412
+ >;
2413
+ narrative('All Projection Types', 'ALL-PROJ', () => {
2414
+ query('views summary', 'SUMMARY-SLICE').server(() => {
2415
+ data([source().state('TodoListSummary').fromSingletonProjection('TodoSummary')]);
2416
+ specs('Summary Rules', () => {});
2417
+ });
2418
+ query('views todo', 'TODO-SLICE').server(() => {
2419
+ data([source().state('TodoState').fromProjection('Todos', 'todoId')]);
2420
+ specs('Todo Rules', () => {});
2421
+ });
2422
+ query('views user project todos', 'USER-PROJECT-SLICE').server(() => {
2423
+ data([source().state('UserProjectTodos').fromCompositeProjection('UserProjectTodos', ['userId', 'projectId'])]);
2424
+ specs('User Project Rules', () => {});
2425
+ });
2426
+ });
2427
+ `);
2428
+ });
2429
+ });
2034
2430
  });
package/src/schema.ts CHANGED
@@ -49,7 +49,18 @@ export const OriginSchema = z
49
49
  z.object({
50
50
  type: z.literal('projection'),
51
51
  name: z.string(),
52
- idField: z.string().describe('Field from event used as the projection’s unique identifier'),
52
+ idField: z
53
+ .union([z.string(), z.array(z.string())])
54
+ .optional()
55
+ .describe(
56
+ 'Field(s) from event used as the projection unique identifier. Can be single field or array for composite keys. Omit for singleton projections.',
57
+ ),
58
+ singleton: z
59
+ .boolean()
60
+ .optional()
61
+ .describe(
62
+ 'True if this is a singleton projection that aggregates data from multiple entities into one document',
63
+ ),
53
64
  }),
54
65
  z.object({
55
66
  type: z.literal('readModel'),
@@ -110,6 +110,42 @@ function addDestinationToChain(f: tsNS.NodeFactory, chain: tsNS.Expression, dest
110
110
  }
111
111
  }
112
112
 
113
+ function buildProjectionCall(
114
+ f: tsNS.NodeFactory,
115
+ baseCall: tsNS.Expression,
116
+ origin: { type: 'projection'; name: string; idField?: string | string[]; singleton?: boolean },
117
+ ): tsNS.Expression {
118
+ if (origin.singleton === true) {
119
+ return f.createCallExpression(
120
+ f.createPropertyAccessExpression(baseCall, f.createIdentifier('fromSingletonProjection')),
121
+ undefined,
122
+ [f.createStringLiteral(origin.name)],
123
+ );
124
+ }
125
+ if (Array.isArray(origin.idField)) {
126
+ return f.createCallExpression(
127
+ f.createPropertyAccessExpression(baseCall, f.createIdentifier('fromCompositeProjection')),
128
+ undefined,
129
+ [
130
+ f.createStringLiteral(origin.name),
131
+ f.createArrayLiteralExpression(origin.idField.map((field) => f.createStringLiteral(field))),
132
+ ],
133
+ );
134
+ }
135
+ if (typeof origin.idField === 'string' && origin.idField.length > 0) {
136
+ return f.createCallExpression(
137
+ f.createPropertyAccessExpression(baseCall, f.createIdentifier('fromProjection')),
138
+ undefined,
139
+ [f.createStringLiteral(origin.name), f.createStringLiteral(origin.idField)],
140
+ );
141
+ }
142
+ return f.createCallExpression(
143
+ f.createPropertyAccessExpression(baseCall, f.createIdentifier('fromSingletonProjection')),
144
+ undefined,
145
+ [f.createStringLiteral(origin.name)],
146
+ );
147
+ }
148
+
113
149
  function buildStateCall(
114
150
  ts: typeof import('typescript'),
115
151
  f: tsNS.NodeFactory,
@@ -135,11 +171,7 @@ function buildStateCall(
135
171
  );
136
172
  }
137
173
  case 'projection':
138
- return f.createCallExpression(
139
- f.createPropertyAccessExpression(baseStateCall, f.createIdentifier('fromProjection')),
140
- undefined,
141
- [f.createStringLiteral(origin.name), f.createStringLiteral(origin.idField)],
142
- );
174
+ return buildProjectionCall(f, baseStateCall, origin);
143
175
  case 'database': {
144
176
  const args: tsNS.Expression[] = [f.createStringLiteral(origin.collection)];
145
177
  if (origin.query !== null && origin.query !== undefined) {
@@ -173,10 +205,39 @@ function buildStateCall(
173
205
  }
174
206
  }
175
207
 
208
+ function buildProjectionArgs(
209
+ f: tsNS.NodeFactory,
210
+ origin: { name: string; idField?: string | string[]; singleton?: boolean },
211
+ ): tsNS.Expression[] {
212
+ if (origin.singleton === true) {
213
+ return [f.createStringLiteral(origin.name)];
214
+ }
215
+ if (Array.isArray(origin.idField)) {
216
+ return [
217
+ f.createStringLiteral(origin.name),
218
+ f.createArrayLiteralExpression(origin.idField.map((field) => f.createStringLiteral(field))),
219
+ ];
220
+ }
221
+ if (typeof origin.idField === 'string' && origin.idField.length > 0) {
222
+ return [f.createStringLiteral(origin.name), f.createStringLiteral(origin.idField)];
223
+ }
224
+ return [f.createStringLiteral(origin.name)];
225
+ }
226
+
227
+ function getProjectionMethodName(origin: { idField?: string | string[]; singleton?: boolean }): string {
228
+ if (origin.singleton === true) {
229
+ return 'fromSingletonProjection';
230
+ }
231
+ if (Array.isArray(origin.idField)) {
232
+ return 'fromCompositeProjection';
233
+ }
234
+ return 'fromProjection';
235
+ }
236
+
176
237
  function buildOriginArgs(ts: typeof import('typescript'), f: tsNS.NodeFactory, origin: Origin): tsNS.Expression[] {
177
238
  switch (origin.type) {
178
239
  case 'projection':
179
- return [f.createStringLiteral(origin.name), f.createStringLiteral(origin.idField)];
240
+ return buildProjectionArgs(f, origin);
180
241
  case 'integration': {
181
242
  const [sys] = origin.systems;
182
243
  return [f.createIdentifier(sys)];
@@ -205,7 +266,7 @@ function buildOriginArgs(ts: typeof import('typescript'), f: tsNS.NodeFactory, o
205
266
  function getOriginMethodName(origin: Origin): string {
206
267
  switch (origin.type) {
207
268
  case 'projection':
208
- return 'fromProjection';
269
+ return getProjectionMethodName(origin);
209
270
  case 'integration':
210
271
  return 'fromIntegration';
211
272
  case 'database':
@@ -454,27 +515,25 @@ function buildServerStatements(
454
515
  const ruleGroups = buildRuleGroups(server.specs.rules as RuleType[]);
455
516
  const allRuleStatements = buildConsolidatedRules(ts, f, ruleGroups, sliceType, messages);
456
517
 
457
- if (allRuleStatements.length > 0) {
458
- const arrowFunction = f.createArrowFunction(
459
- undefined,
460
- undefined,
461
- [],
462
- undefined,
463
- f.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
464
- f.createBlock(allRuleStatements, true),
465
- );
466
-
467
- const args: tsNS.Expression[] = [];
468
- if (server.specs.name && server.specs.name.trim() !== '') {
469
- args.push(f.createStringLiteral(server.specs.name));
470
- }
471
- args.push(arrowFunction);
518
+ const arrowFunction = f.createArrowFunction(
519
+ undefined,
520
+ undefined,
521
+ [],
522
+ undefined,
523
+ f.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
524
+ f.createBlock(allRuleStatements, true),
525
+ );
472
526
 
473
- const specsStatement = f.createExpressionStatement(
474
- f.createCallExpression(f.createIdentifier('specs'), undefined, args),
475
- );
476
- statements.push(specsStatement);
527
+ const args: tsNS.Expression[] = [];
528
+ if (server.specs.name && server.specs.name.trim() !== '') {
529
+ args.push(f.createStringLiteral(server.specs.name));
477
530
  }
531
+ args.push(arrowFunction);
532
+
533
+ const specsStatement = f.createExpressionStatement(
534
+ f.createCallExpression(f.createIdentifier('specs'), undefined, args),
535
+ );
536
+ statements.push(specsStatement);
478
537
  }
479
538
 
480
539
  return statements;
package/src/types.ts CHANGED
@@ -87,9 +87,11 @@ export const createDatabaseDestination = (collection: string): DatabaseDestinati
87
87
  });
88
88
  export const createTopicDestination = (name: string): TopicDestination => ({ type: 'topic', name });
89
89
 
90
- export interface ProjectionOrigin {
90
+ export interface ProjectionOriginWithId {
91
91
  type: 'projection';
92
92
  name: string;
93
+ idField?: string | string[];
94
+ singleton?: boolean;
93
95
  }
94
96
 
95
97
  export interface ReadModelOrigin {
@@ -116,14 +118,8 @@ export interface IntegrationOrigin {
116
118
 
117
119
  export type Origin = ProjectionOriginWithId | ReadModelOrigin | DatabaseOrigin | ApiOrigin | IntegrationOrigin;
118
120
 
119
- export interface ProjectionOriginWithId {
120
- type: 'projection';
121
- name: string;
122
- idField: string;
123
- }
124
-
125
121
  // Helper functions to create origins
126
- export const createProjectionOrigin = (name: string): ProjectionOrigin => ({ type: 'projection', name });
122
+ export const createProjectionOrigin = (name: string): ProjectionOriginWithId => ({ type: 'projection', name });
127
123
  export const createReadModelOrigin = (name: string): ReadModelOrigin => ({ type: 'readModel', name });
128
124
  export const createDatabaseOrigin = (collection: string, query?: Record<string, unknown>): DatabaseOrigin => ({
129
125
  type: 'database',
@@ -161,7 +157,7 @@ export interface DataItem {
161
157
  __type: 'sink' | 'source';
162
158
  }
163
159
 
164
- type DefaultRecord = Record<string, unknown>;
160
+ export type DefaultRecord = Record<string, unknown>;
165
161
 
166
162
  export type State<
167
163
  StateType extends string = string,
@@ -224,3 +220,5 @@ export type Event<
224
220
  > & {
225
221
  readonly kind?: 'Event';
226
222
  };
223
+
224
+ export type ExtractStateData<T> = T extends State<string, infer Data, DefaultRecord | undefined> ? Data : never;