@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +6 -6
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +37 -0
- package/dist/src/codegen/scaffoldFromSchema.d.ts +1 -0
- package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.js +3 -3
- package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
- package/dist/src/codegen/templates/command/decide.specs.specs.ts +69 -0
- package/dist/src/codegen/templates/command/decide.specs.ts +73 -61
- package/dist/src/codegen/templates/command/decide.ts.ejs +7 -0
- package/dist/src/codegen/templates/query/projection.specs.specs.ts +386 -20
- package/dist/src/codegen/templates/query/projection.specs.ts +86 -75
- package/dist/src/codegen/templates/query/projection.ts.ejs +19 -0
- package/dist/src/codegen/templates/react/react.specs.specs.ts +127 -1
- package/dist/src/codegen/templates/react/react.specs.ts +4 -0
- package/dist/src/codegen/templates/react/react.specs.ts.ejs +2 -2
- package/dist/src/codegen/templates/react/react.ts.ejs +16 -0
- package/dist/src/codegen/templates/react/react.ts.specs.ts +194 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/codegen/formatTsValueSimple.specs.ts +48 -0
- package/src/codegen/scaffoldFromSchema.ts +3 -3
- package/src/codegen/templates/command/decide.specs.specs.ts +69 -0
- package/src/codegen/templates/command/decide.specs.ts +73 -61
- package/src/codegen/templates/command/decide.ts.ejs +7 -0
- package/src/codegen/templates/query/projection.specs.specs.ts +386 -20
- package/src/codegen/templates/query/projection.specs.ts +86 -75
- package/src/codegen/templates/query/projection.ts.ejs +19 -0
- package/src/codegen/templates/react/react.specs.specs.ts +127 -1
- package/src/codegen/templates/react/react.specs.ts +4 -0
- package/src/codegen/templates/react/react.specs.ts.ejs +2 -2
- package/src/codegen/templates/react/react.ts.ejs +16 -0
- 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
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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;
|