@auto-engineer/server-generator-apollo-emmett 1.26.0 → 1.27.0

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 (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +37 -0
  5. package/dist/src/codegen/scaffoldFromSchema.d.ts +1 -0
  6. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  7. package/dist/src/codegen/scaffoldFromSchema.js +3 -3
  8. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  9. package/dist/src/codegen/templates/command/decide.specs.specs.ts +69 -0
  10. package/dist/src/codegen/templates/command/decide.specs.ts +73 -61
  11. package/dist/src/codegen/templates/command/decide.ts.ejs +7 -0
  12. package/dist/src/codegen/templates/query/projection.specs.specs.ts +386 -20
  13. package/dist/src/codegen/templates/query/projection.specs.ts +86 -75
  14. package/dist/src/codegen/templates/query/projection.ts.ejs +19 -0
  15. package/dist/src/codegen/templates/react/react.specs.specs.ts +127 -1
  16. package/dist/src/codegen/templates/react/react.specs.ts +4 -0
  17. package/dist/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  18. package/dist/src/codegen/templates/react/react.ts.ejs +16 -0
  19. package/dist/src/codegen/templates/react/react.ts.specs.ts +194 -0
  20. package/dist/tsconfig.tsbuildinfo +1 -1
  21. package/package.json +5 -5
  22. package/src/codegen/formatTsValueSimple.specs.ts +48 -0
  23. package/src/codegen/scaffoldFromSchema.ts +3 -3
  24. package/src/codegen/templates/command/decide.specs.specs.ts +69 -0
  25. package/src/codegen/templates/command/decide.specs.ts +73 -61
  26. package/src/codegen/templates/command/decide.ts.ejs +7 -0
  27. package/src/codegen/templates/query/projection.specs.specs.ts +386 -20
  28. package/src/codegen/templates/query/projection.specs.ts +86 -75
  29. package/src/codegen/templates/query/projection.ts.ejs +19 -0
  30. package/src/codegen/templates/react/react.specs.specs.ts +127 -1
  31. package/src/codegen/templates/react/react.specs.ts +4 -0
  32. package/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  33. package/src/codegen/templates/react/react.ts.ejs +16 -0
  34. package/src/codegen/templates/react/react.ts.specs.ts +194 -0
@@ -1566,27 +1566,30 @@ describe('projection.specs.ts.ejs', () => {
1566
1566
  switch (event.type) {
1567
1567
  case 'WorkoutRecorded': {
1568
1568
  /**
1569
- * ## IMPLEMENTATION INSTRUCTIONS ##
1570
- * Implement how this event updates the projection.
1571
- *
1572
- * **IMPORTANT - Internal State Pattern:**
1573
- * If you need to track state beyond the public WorkoutHistory type (e.g., to calculate
1574
- * aggregations, track previous values, etc.), follow this pattern:
1575
- *
1576
- * 1. Define an extended interface BEFORE the projection:
1577
- * interface InternalWorkoutHistory extends WorkoutHistory {
1578
- * internalField: SomeType;
1579
- * }
1580
- *
1581
- * 2. Cast document parameter to extended type:
1582
- * const current: InternalWorkoutHistory = document ?? { ...defaults };
1583
- *
1584
- * 3. Cast return values to extended type:
1585
- * return { ...allFields, internalField } as InternalWorkoutHistory;
1586
- *
1587
- * This keeps internal state separate from the public GraphQL schema.
1588
- */
1569
+ * ## IMPLEMENTATION INSTRUCTIONS ##
1570
+ * Implement how this event updates the projection.
1571
+ *
1572
+ * **IMPORTANT - Internal State Pattern:**
1573
+ * If you need to track state beyond the public WorkoutHistory type (e.g., to calculate
1574
+ * aggregations, track previous values, etc.), follow this pattern:
1575
+ *
1576
+ * 1. Define an extended interface BEFORE the projection:
1577
+ * interface InternalWorkoutHistory extends WorkoutHistory {
1578
+ * internalField: SomeType;
1579
+ * }
1580
+ *
1581
+ * 2. Cast document parameter to extended type:
1582
+ * const current: InternalWorkoutHistory = document ?? { ...defaults };
1583
+ *
1584
+ * 3. Cast return values to extended type:
1585
+ * return { ...allFields, internalField } as InternalWorkoutHistory;
1586
+ *
1587
+ * This keeps internal state separate from the public GraphQL schema.
1588
+
1589
+ * Event (WorkoutRecorded) fields: memberId: string, caloriesBurned: number
1590
+ */
1589
1591
  return {
1592
+ ...document,
1590
1593
  memberId: /* TODO: map from event.data */ '',
1591
1594
  totalCalories: /* TODO: map from event.data */ 0,
1592
1595
  };
@@ -1601,4 +1604,367 @@ describe('projection.specs.ts.ejs', () => {
1601
1604
  "
1602
1605
  `);
1603
1606
  });
1607
+
1608
+ it('should include ...document spread in non-removal evolve cases', async () => {
1609
+ const spec: SpecsSchema = {
1610
+ variant: 'specs',
1611
+ narratives: [
1612
+ {
1613
+ name: 'item-flow',
1614
+ slices: [
1615
+ {
1616
+ type: 'command',
1617
+ name: 'manage-item',
1618
+ stream: 'items-${itemId}',
1619
+ client: { specs: [] },
1620
+ server: {
1621
+ description: '',
1622
+ specs: [
1623
+ {
1624
+ type: 'gherkin',
1625
+ feature: 'Manage item command',
1626
+ rules: [
1627
+ {
1628
+ name: 'Should manage items',
1629
+ examples: [
1630
+ {
1631
+ name: 'Item created',
1632
+ steps: [
1633
+ {
1634
+ keyword: 'When',
1635
+ text: 'CreateItem',
1636
+ docString: { itemId: 'item_1', name: 'Widget' },
1637
+ },
1638
+ {
1639
+ keyword: 'Then',
1640
+ text: 'ItemCreated',
1641
+ docString: { itemId: 'item_1', name: 'Widget' },
1642
+ },
1643
+ ],
1644
+ },
1645
+ {
1646
+ name: 'Item removed',
1647
+ steps: [
1648
+ {
1649
+ keyword: 'When',
1650
+ text: 'RemoveItem',
1651
+ docString: { itemId: 'item_1' },
1652
+ },
1653
+ {
1654
+ keyword: 'Then',
1655
+ text: 'ItemRemoved',
1656
+ docString: {},
1657
+ },
1658
+ ],
1659
+ },
1660
+ ],
1661
+ },
1662
+ ],
1663
+ },
1664
+ ],
1665
+ },
1666
+ },
1667
+ {
1668
+ type: 'query',
1669
+ name: 'view-items',
1670
+ stream: 'items',
1671
+ client: { specs: [] },
1672
+ server: {
1673
+ description: '',
1674
+ data: {
1675
+ items: [
1676
+ {
1677
+ target: { type: 'State', name: 'ItemView' },
1678
+ origin: { type: 'projection', name: 'ItemsProjection', idField: 'itemId' },
1679
+ },
1680
+ ],
1681
+ },
1682
+ specs: [
1683
+ {
1684
+ type: 'gherkin',
1685
+ feature: 'View items query',
1686
+ rules: [
1687
+ {
1688
+ name: 'Should project items',
1689
+ examples: [
1690
+ {
1691
+ name: 'Item created shows in view',
1692
+ steps: [
1693
+ {
1694
+ keyword: 'When',
1695
+ text: 'ItemCreated',
1696
+ docString: { itemId: 'item_1', name: 'Widget' },
1697
+ },
1698
+ {
1699
+ keyword: 'Then',
1700
+ text: 'ItemView',
1701
+ docString: { itemId: 'item_1', name: 'Widget' },
1702
+ },
1703
+ ],
1704
+ },
1705
+ {
1706
+ name: 'Item removed disappears from view',
1707
+ steps: [
1708
+ {
1709
+ keyword: 'Given',
1710
+ text: 'ItemCreated',
1711
+ docString: { itemId: 'item_1', name: 'Widget' },
1712
+ },
1713
+ {
1714
+ keyword: 'When',
1715
+ text: 'ItemRemoved',
1716
+ docString: {},
1717
+ },
1718
+ {
1719
+ keyword: 'Then',
1720
+ text: 'ItemView',
1721
+ docString: {},
1722
+ },
1723
+ ],
1724
+ },
1725
+ ],
1726
+ },
1727
+ ],
1728
+ },
1729
+ ],
1730
+ },
1731
+ },
1732
+ ],
1733
+ },
1734
+ ],
1735
+ messages: [
1736
+ {
1737
+ type: 'command',
1738
+ name: 'CreateItem',
1739
+ fields: [
1740
+ { name: 'itemId', type: 'string', required: true },
1741
+ { name: 'name', type: 'string', required: true },
1742
+ ],
1743
+ },
1744
+ {
1745
+ type: 'command',
1746
+ name: 'RemoveItem',
1747
+ fields: [{ name: 'itemId', type: 'string', required: true }],
1748
+ },
1749
+ {
1750
+ type: 'event',
1751
+ name: 'ItemCreated',
1752
+ source: 'internal',
1753
+ fields: [
1754
+ { name: 'itemId', type: 'string', required: true },
1755
+ { name: 'name', type: 'string', required: true },
1756
+ ],
1757
+ },
1758
+ {
1759
+ type: 'event',
1760
+ name: 'ItemRemoved',
1761
+ source: 'internal',
1762
+ fields: [{ name: 'itemId', type: 'string', required: true }],
1763
+ },
1764
+ {
1765
+ type: 'state',
1766
+ name: 'ItemView',
1767
+ fields: [
1768
+ { name: 'itemId', type: 'string', required: true },
1769
+ { name: 'name', type: 'string', required: true },
1770
+ ],
1771
+ },
1772
+ ],
1773
+ } as SpecsSchema;
1774
+
1775
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
1776
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-items/projection.ts'));
1777
+ expect(projectionFile?.contents).toBeDefined();
1778
+
1779
+ // Non-removal evolve case should spread existing document
1780
+ expect(projectionFile?.contents).toContain('...document,');
1781
+
1782
+ // Removal event case should return null, not spread
1783
+ expect(projectionFile?.contents).toContain('return null;');
1784
+
1785
+ // Should include event field-type hints in comments
1786
+ expect(projectionFile?.contents).toContain('Event (ItemCreated) fields:');
1787
+ });
1788
+
1789
+ it('should warn when events lack the idField used by getDocumentId', async () => {
1790
+ const spec: SpecsSchema = {
1791
+ variant: 'specs',
1792
+ narratives: [
1793
+ {
1794
+ name: 'order-flow',
1795
+ slices: [
1796
+ {
1797
+ type: 'command',
1798
+ name: 'manage-order',
1799
+ stream: 'orders-${orderId}',
1800
+ client: { specs: [] },
1801
+ server: {
1802
+ description: '',
1803
+ specs: [
1804
+ {
1805
+ type: 'gherkin',
1806
+ feature: 'Manage order',
1807
+ rules: [
1808
+ {
1809
+ name: 'Should manage orders',
1810
+ examples: [
1811
+ {
1812
+ name: 'Order placed',
1813
+ steps: [
1814
+ {
1815
+ keyword: 'When',
1816
+ text: 'PlaceOrder',
1817
+ docString: { orderId: 'o1', total: 100 },
1818
+ },
1819
+ {
1820
+ keyword: 'Then',
1821
+ text: 'OrderPlaced',
1822
+ docString: { orderId: 'o1', total: 100 },
1823
+ },
1824
+ ],
1825
+ },
1826
+ {
1827
+ name: 'Payment received',
1828
+ steps: [
1829
+ {
1830
+ keyword: 'When',
1831
+ text: 'ReceivePayment',
1832
+ docString: { orderId: 'o1', amount: 100 },
1833
+ },
1834
+ {
1835
+ keyword: 'Then',
1836
+ text: 'PaymentReceived',
1837
+ docString: { amount: 100, paidAt: '2030-01-01' },
1838
+ },
1839
+ ],
1840
+ },
1841
+ ],
1842
+ },
1843
+ ],
1844
+ },
1845
+ ],
1846
+ },
1847
+ },
1848
+ {
1849
+ type: 'query',
1850
+ name: 'view-orders',
1851
+ stream: 'orders',
1852
+ client: { specs: [] },
1853
+ server: {
1854
+ description: '',
1855
+ data: {
1856
+ items: [
1857
+ {
1858
+ target: { type: 'State', name: 'OrderView' },
1859
+ origin: { type: 'projection', name: 'OrdersProjection', idField: 'orderId' },
1860
+ },
1861
+ ],
1862
+ },
1863
+ specs: [
1864
+ {
1865
+ type: 'gherkin',
1866
+ feature: 'View orders',
1867
+ rules: [
1868
+ {
1869
+ name: 'Should project orders',
1870
+ examples: [
1871
+ {
1872
+ name: 'Order placed shows in view',
1873
+ steps: [
1874
+ {
1875
+ keyword: 'When',
1876
+ text: 'OrderPlaced',
1877
+ docString: { orderId: 'o1', total: 100 },
1878
+ },
1879
+ {
1880
+ keyword: 'Then',
1881
+ text: 'OrderView',
1882
+ docString: { orderId: 'o1', total: 100, paid: false },
1883
+ },
1884
+ ],
1885
+ },
1886
+ {
1887
+ name: 'Payment marks order as paid',
1888
+ steps: [
1889
+ {
1890
+ keyword: 'Given',
1891
+ text: 'OrderPlaced',
1892
+ docString: { orderId: 'o1', total: 100 },
1893
+ },
1894
+ {
1895
+ keyword: 'When',
1896
+ text: 'PaymentReceived',
1897
+ docString: { amount: 100, paidAt: '2030-01-01' },
1898
+ },
1899
+ {
1900
+ keyword: 'Then',
1901
+ text: 'OrderView',
1902
+ docString: { orderId: 'o1', total: 100, paid: true },
1903
+ },
1904
+ ],
1905
+ },
1906
+ ],
1907
+ },
1908
+ ],
1909
+ },
1910
+ ],
1911
+ },
1912
+ },
1913
+ ],
1914
+ },
1915
+ ],
1916
+ messages: [
1917
+ {
1918
+ type: 'command',
1919
+ name: 'PlaceOrder',
1920
+ fields: [
1921
+ { name: 'orderId', type: 'string', required: true },
1922
+ { name: 'total', type: 'number', required: true },
1923
+ ],
1924
+ },
1925
+ {
1926
+ type: 'command',
1927
+ name: 'ReceivePayment',
1928
+ fields: [
1929
+ { name: 'orderId', type: 'string', required: true },
1930
+ { name: 'amount', type: 'number', required: true },
1931
+ ],
1932
+ },
1933
+ {
1934
+ type: 'event',
1935
+ name: 'OrderPlaced',
1936
+ source: 'internal',
1937
+ fields: [
1938
+ { name: 'orderId', type: 'string', required: true },
1939
+ { name: 'total', type: 'number', required: true },
1940
+ ],
1941
+ },
1942
+ {
1943
+ type: 'event',
1944
+ name: 'PaymentReceived',
1945
+ source: 'internal',
1946
+ fields: [
1947
+ { name: 'amount', type: 'number', required: true },
1948
+ { name: 'paidAt', type: 'string', required: true },
1949
+ ],
1950
+ },
1951
+ {
1952
+ type: 'state',
1953
+ name: 'OrderView',
1954
+ fields: [
1955
+ { name: 'orderId', type: 'string', required: true },
1956
+ { name: 'total', type: 'number', required: true },
1957
+ { name: 'paid', type: 'boolean', required: true },
1958
+ ],
1959
+ },
1960
+ ],
1961
+ } as SpecsSchema;
1962
+
1963
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
1964
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-orders/projection.ts'));
1965
+ expect(projectionFile?.contents).toBeDefined();
1966
+
1967
+ // PaymentReceived lacks 'orderId' field, so should have a warning
1968
+ expect(projectionFile?.contents).toContain("WARNING: These events lack field 'orderId': PaymentReceived");
1969
+ });
1604
1970
  });
@@ -238,27 +238,30 @@ describe('projection.ts.ejs', () => {
238
238
  switch (event.type) {
239
239
  case 'ListingCreated': {
240
240
  /**
241
- * ## IMPLEMENTATION INSTRUCTIONS ##
242
- * Implement how this event updates the projection.
243
- *
244
- * **IMPORTANT - Internal State Pattern:**
245
- * If you need to track state beyond the public AvailableListings type (e.g., to calculate
246
- * aggregations, track previous values, etc.), follow this pattern:
247
- *
248
- * 1. Define an extended interface BEFORE the projection:
249
- * interface InternalAvailableListings extends AvailableListings {
250
- * internalField: SomeType;
251
- * }
252
- *
253
- * 2. Cast document parameter to extended type:
254
- * const current: InternalAvailableListings = document ?? { ...defaults };
255
- *
256
- * 3. Cast return values to extended type:
257
- * return { ...allFields, internalField } as InternalAvailableListings;
258
- *
259
- * This keeps internal state separate from the public GraphQL schema.
260
- */
241
+ * ## IMPLEMENTATION INSTRUCTIONS ##
242
+ * Implement how this event updates the projection.
243
+ *
244
+ * **IMPORTANT - Internal State Pattern:**
245
+ * If you need to track state beyond the public AvailableListings type (e.g., to calculate
246
+ * aggregations, track previous values, etc.), follow this pattern:
247
+ *
248
+ * 1. Define an extended interface BEFORE the projection:
249
+ * interface InternalAvailableListings extends AvailableListings {
250
+ * internalField: SomeType;
251
+ * }
252
+ *
253
+ * 2. Cast document parameter to extended type:
254
+ * const current: InternalAvailableListings = document ?? { ...defaults };
255
+ *
256
+ * 3. Cast return values to extended type:
257
+ * return { ...allFields, internalField } as InternalAvailableListings;
258
+ *
259
+ * This keeps internal state separate from the public GraphQL schema.
260
+
261
+ * Event (ListingCreated) fields: propertyId: string, title: string, pricePerNight: number, location: string, maxGuests: number
262
+ */
261
263
  return {
264
+ ...document,
262
265
  propertyId: /* TODO: map from event.data */ '',
263
266
  title: /* TODO: map from event.data */ '',
264
267
  pricePerNight: /* TODO: map from event.data */ 0,
@@ -269,13 +272,15 @@ describe('projection.ts.ejs', () => {
269
272
 
270
273
  case 'ListingRemoved': {
271
274
  /**
272
- * ## IMPLEMENTATION INSTRUCTIONS ##
273
- * This event might indicate removal of a AvailableListings.
274
- *
275
- * - If the intent is to **remove the document**, return \`null\`.
276
- * - If the intent is to **soft delete**, consider adding a \`status\` field (e.g., \`status: 'removed'\`).
277
- * - Ensure consumers of this projection (e.g., UI) handle the chosen approach appropriately.
278
- */
275
+ * ## IMPLEMENTATION INSTRUCTIONS ##
276
+ * This event might indicate removal of a AvailableListings.
277
+ *
278
+ * - If the intent is to **remove the document**, return \`null\`.
279
+ * - If the intent is to **soft delete**, consider adding a \`status\` field (e.g., \`status: 'removed'\`).
280
+ * - Ensure consumers of this projection (e.g., UI) handle the chosen approach appropriately.
281
+
282
+ * Event (ListingRemoved) fields: propertyId: string
283
+ */
279
284
  return null;
280
285
  }
281
286
  default:
@@ -565,35 +570,38 @@ describe('projection.ts.ejs', () => {
565
570
  switch (event.type) {
566
571
  case 'TodoAdded': {
567
572
  /**
568
- * ## IMPLEMENTATION INSTRUCTIONS ##
569
- * **SINGLETON AGGREGATION PATTERN**
570
- *
571
- * This projection maintains ONE document aggregating data from MANY entities.
572
- *
573
- * CRITICAL: Use internal state to track individual entity information:
574
- *
575
- * 1. Access current state:
576
- * const current: InternalTodoSummary = document ?? { ...initialState, _entities: {} };
577
- *
578
- * 2. Track entity changes:
579
- * // a) Extract the unique identifier that distinguishes this entity
580
- * // Examine event.data to find the ID field (often 'id' or '<entity>Id')
581
- * const entityId = event.data.[ENTITY_ID_FIELD];
582
- *
583
- * // b) Store/update entity state with relevant properties from event.data
584
- * // Include only fields needed for aggregation calculations
585
- * current._entities[entityId] = { [field]: value, ... };
586
- *
587
- * 3. Calculate aggregates from entity states:
588
- * const counts = Object.values(current._entities).reduce((acc, entity) => {
589
- * acc[entity.status] = (acc[entity.status] || 0) + 1;
590
- * return acc;
591
- * }, {});
592
- *
593
- * 4. Return with internal state:
594
- * return { ...publicFields, _entities: current._entities } as InternalTodoSummary;
595
- */
573
+ * ## IMPLEMENTATION INSTRUCTIONS ##
574
+ * **SINGLETON AGGREGATION PATTERN**
575
+ *
576
+ * This projection maintains ONE document aggregating data from MANY entities.
577
+ *
578
+ * CRITICAL: Use internal state to track individual entity information:
579
+ *
580
+ * 1. Access current state:
581
+ * const current: InternalTodoSummary = document ?? { ...initialState, _entities: {} };
582
+ *
583
+ * 2. Track entity changes:
584
+ * // a) Extract the unique identifier that distinguishes this entity
585
+ * // Examine event.data to find the ID field (often 'id' or '<entity>Id')
586
+ * const entityId = event.data.[ENTITY_ID_FIELD];
587
+ *
588
+ * // b) Store/update entity state with relevant properties from event.data
589
+ * // Include only fields needed for aggregation calculations
590
+ * current._entities[entityId] = { [field]: value, ... };
591
+ *
592
+ * 3. Calculate aggregates from entity states:
593
+ * const counts = Object.values(current._entities).reduce((acc, entity) => {
594
+ * acc[entity.status] = (acc[entity.status] || 0) + 1;
595
+ * return acc;
596
+ * }, {});
597
+ *
598
+ * 4. Return with internal state:
599
+ * return { ...publicFields, _entities: current._entities } as InternalTodoSummary;
600
+
601
+ * Event (TodoAdded) fields: todoId: string, title: string
602
+ */
596
603
  return {
604
+ ...document,
597
605
  totalCount: /* TODO: map from event.data */ 0,
598
606
  };
599
607
  }
@@ -784,27 +792,30 @@ describe('projection.ts.ejs', () => {
784
792
  switch (event.type) {
785
793
  case 'UserJoinedProject': {
786
794
  /**
787
- * ## IMPLEMENTATION INSTRUCTIONS ##
788
- * **COMPOSITE KEY PROJECTION**
789
- *
790
- * This projection uses a composite key: userId + projectId
791
- * Document ID format: \`\${event.data.userId}-\${event.data.projectId}\`
792
- *
793
- * CRITICAL: You MUST include ALL key fields in every return statement:
794
- * - userId: event.data.userId
795
- * - projectId: event.data.projectId
796
- *
797
- * Missing even one key field will cause the projection to fail.
798
- * Key fields typically map directly from event data (no transformation needed).
799
- *
800
- * Example implementation:
801
- * return {
802
- * userId: event.data.userId,
803
- * projectId: event.data.projectId,
804
- * // ... other fields
805
- * };
806
- */
795
+ * ## IMPLEMENTATION INSTRUCTIONS ##
796
+ * **COMPOSITE KEY PROJECTION**
797
+ *
798
+ * This projection uses a composite key: userId + projectId
799
+ * Document ID format: \`\${event.data.userId}-\${event.data.projectId}\`
800
+ *
801
+ * CRITICAL: You MUST include ALL key fields in every return statement:
802
+ * - userId: event.data.userId
803
+ * - projectId: event.data.projectId
804
+ *
805
+ * Missing even one key field will cause the projection to fail.
806
+ * Key fields typically map directly from event data (no transformation needed).
807
+ *
808
+ * Example implementation:
809
+ * return {
810
+ * userId: event.data.userId,
811
+ * projectId: event.data.projectId,
812
+ * // ... other fields
813
+ * };
814
+
815
+ * Event (UserJoinedProject) fields: userId: string, projectId: string, role: string
816
+ */
807
817
  return {
818
+ ...document,
808
819
  userId: /* TODO: map from event.data */ '',
809
820
  projectId: /* TODO: map from event.data */ '',
810
821
  role: /* TODO: map from event.data */ '',
@@ -70,6 +70,20 @@ if (isSingleton) {
70
70
  %>event.data.<%= singleIdField %><%
71
71
  }
72
72
  %>,
73
+ <%
74
+ if (!isSingleton && !isCompositeKey && typeof idField === 'string') {
75
+ const missing = events.filter(e => {
76
+ const def = messages.find(m => m.name === e.type);
77
+ return def && !def.fields?.some(f => f.name === idField);
78
+ });
79
+ if (missing.length > 0) {
80
+ %>
81
+ // WARNING: These events lack field '<%= idField %>': <%= missing.map(e => e.type).join(', ') %>
82
+ // The projection cannot route these events to the correct document.
83
+ // Fix: add '<%= idField %>' to these events in the model.
84
+ <% }
85
+ }
86
+ -%>
73
87
  evolve: (
74
88
  document: <%= pascalCase(slice.server?.data?.items?.[0]?.target?.name || 'UnknownState') %> | null,
75
89
  event: ReadEvent<AllEvents, InMemoryReadEventMetadata>
@@ -176,6 +190,10 @@ case '<%= event.type %>': {
176
190
  *
177
191
  * This keeps internal state separate from the public GraphQL schema.
178
192
  <% } -%>
193
+ <% const evtDef = messages.find(m => m.name === event.type); -%>
194
+ <% if (evtDef?.fields?.length) { %>
195
+ * Event (<%= event.type %>) fields: <%= evtDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
196
+ <% } -%>
179
197
  */
180
198
  <% if (isRemovalEvent) { -%>
181
199
  return null;
@@ -184,6 +202,7 @@ case '<%= event.type %>': {
184
202
  return null;
185
203
  <% } else { -%>
186
204
  return {
205
+ ...document,
187
206
  <% for (let i = 0; i < usedFields.length; i++) {
188
207
  const field = usedFields[i];
189
208
  const isLast = i === usedFields.length - 1;