@nebulit/embuilder 0.1.39

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 (212) hide show
  1. package/README.md +254 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +138 -0
  4. package/package.json +49 -0
  5. package/templates/.claude/hooks/QUICKSTART.md +256 -0
  6. package/templates/.claude/hooks/README.md +533 -0
  7. package/templates/.claude/hooks/analyze-commit.sh +22 -0
  8. package/templates/.claude/hooks/analyze-commit.ts +518 -0
  9. package/templates/.claude/hooks/analyzers/README.md +198 -0
  10. package/templates/.claude/hooks/analyzers/code-quality-checker.ts +154 -0
  11. package/templates/.claude/hooks/analyzers/code-quality.md +54 -0
  12. package/templates/.claude/hooks/analyzers/commit-blocker-example.ts.disabled +110 -0
  13. package/templates/.claude/hooks/analyzers/commit-policy.md +49 -0
  14. package/templates/.claude/hooks/analyzers/event-model-validator.md +49 -0
  15. package/templates/.claude/hooks/analyzers/event-model-validator.ts +169 -0
  16. package/templates/.claude/hooks/analyzers/example-logger.ts +70 -0
  17. package/templates/.claude/hooks/analyzers/slice-scope-validator.md +81 -0
  18. package/templates/.claude/hooks/check-review-result.sh +47 -0
  19. package/templates/.claude/hooks/prepare-review.sh +34 -0
  20. package/templates/.claude/hooks/review-agent-prompt.md +42 -0
  21. package/templates/.claude/hooks/run-review-agent.sh +124 -0
  22. package/templates/.claude/settings.local.json +37 -0
  23. package/templates/.claude/skills/help/README.md +84 -0
  24. package/templates/.claude/skills/help/SKILL.md +393 -0
  25. package/templates/.claude/skills/help/templates/demo-config.json +6753 -0
  26. package/templates/.claude/skills/sample-slices/SKILL.md +8 -0
  27. package/templates/.claude/skills/sample-slices/templates/.slices/Library/addbook/code-slice.json +124 -0
  28. package/templates/.claude/skills/sample-slices/templates/.slices/Library/addbook/slice.json +255 -0
  29. package/templates/.claude/skills/sample-slices/templates/.slices/Library/availablebooks/slice.json +107 -0
  30. package/templates/.claude/skills/sample-slices/templates/.slices/index.json +20 -0
  31. package/templates/.claude/skills/sample-slices/templates/Cart/additem/slice.json +979 -0
  32. package/templates/.claude/skills/sample-slices/templates/Cart/archiveitem/slice.json +529 -0
  33. package/templates/.claude/skills/sample-slices/templates/Cart/cartitems/slice.json +1072 -0
  34. package/templates/.claude/skills/sample-slices/templates/Cart/cartwithproducts/slice.json +394 -0
  35. package/templates/.claude/skills/sample-slices/templates/Cart/changedprices/slice.json +88 -0
  36. package/templates/.claude/skills/sample-slices/templates/Cart/changeinventory/slice.json +264 -0
  37. package/templates/.claude/skills/sample-slices/templates/Cart/changeprice/slice.json +308 -0
  38. package/templates/.claude/skills/sample-slices/templates/Cart/clearcart/slice.json +358 -0
  39. package/templates/.claude/skills/sample-slices/templates/Cart/inventories/slice.json +203 -0
  40. package/templates/.claude/skills/sample-slices/templates/Cart/publishcart/slice.json +876 -0
  41. package/templates/.claude/skills/sample-slices/templates/Cart/removeitem/slice.json +560 -0
  42. package/templates/.claude/skills/sample-slices/templates/Cart/submitcart/slice.json +708 -0
  43. package/templates/.claude/skills/sample-slices/templates/Cart/submittedcartdata/slice.json +399 -0
  44. package/templates/.claude/skills/sample-slices/templates/index.json +108 -0
  45. package/templates/.claude/skills/slice-automation/SKILL.md +49 -0
  46. package/templates/.claude/skills/slice-state-change/SKILL.md +369 -0
  47. package/templates/.claude/skills/slice-state-change/templates/AddLocation/AddLocation.test.ts.sample +76 -0
  48. package/templates/.claude/skills/slice-state-change/templates/AddLocation/AddLocationCommand.ts.sample +84 -0
  49. package/templates/.claude/skills/slice-state-change/templates/AddLocation/routes.ts.sample +73 -0
  50. package/templates/.claude/skills/slice-state-change/templates/README.md +46 -0
  51. package/templates/.claude/skills/slice-state-view/SKILL.md +336 -0
  52. package/templates/.claude/skills/slice-state-view/templates/Locations/Locations.test.ts.sample +84 -0
  53. package/templates/.claude/skills/slice-state-view/templates/Locations/LocationsProjection.ts.sample +50 -0
  54. package/templates/.claude/skills/slice-state-view/templates/Locations/routes.ts.sample +46 -0
  55. package/templates/.claude/skills/slice-state-view/templates/README.md +109 -0
  56. package/templates/.claude/skills/slice-state-view/templates/Tables/Tables.test.ts.sample +104 -0
  57. package/templates/.claude/skills/slice-state-view/templates/Tables/TablesProjection.ts.sample +59 -0
  58. package/templates/.claude/skills/slice-state-view/templates/Tables/routes.ts.sample +46 -0
  59. package/templates/.claude/skills/slice-state-view/templates/V2__tables.sql +7 -0
  60. package/templates/.claude/skills/slice-state-view/templates/V8__locations.sql +7 -0
  61. package/templates/.claude/skills/test-analyzer/SKILL.md +373 -0
  62. package/templates/.claude/skills/test-analyzer/examples/specification-format.md +143 -0
  63. package/templates/.claude/skills/test-analyzer/examples/state-change-example.md +111 -0
  64. package/templates/.claude/skills/test-analyzer/examples/state-view-example.md +122 -0
  65. package/templates/AGENTS.md +110 -0
  66. package/templates/Claude.md +58 -0
  67. package/templates/README.md +178 -0
  68. package/templates/backend/.env +9 -0
  69. package/templates/backend/BACKEND_AUTH_SETUP.md +183 -0
  70. package/templates/backend/SWAGGER.md +213 -0
  71. package/templates/backend/eslint.config.mjs +31 -0
  72. package/templates/backend/flyway.conf +17 -0
  73. package/templates/backend/package.json +44 -0
  74. package/templates/backend/prd.json.example +64 -0
  75. package/templates/backend/public/assets/images/banner.png +0 -0
  76. package/templates/backend/public/assets/logo.png +0 -0
  77. package/templates/backend/public/file.svg +4 -0
  78. package/templates/backend/public/globe.svg +12 -0
  79. package/templates/backend/public/next.svg +6 -0
  80. package/templates/backend/public/vercel.svg +3 -0
  81. package/templates/backend/public/window.svg +5 -0
  82. package/templates/backend/server.ts +129 -0
  83. package/templates/backend/setup-env.sh +50 -0
  84. package/templates/backend/src/common/assertions.ts +6 -0
  85. package/templates/backend/src/common/db.ts +1 -0
  86. package/templates/backend/src/common/loadPostgresEventstore.ts +16 -0
  87. package/templates/backend/src/common/parseEndpoint.ts +51 -0
  88. package/templates/backend/src/common/replay.ts +9 -0
  89. package/templates/backend/src/common/routes.ts +19 -0
  90. package/templates/backend/src/common/testHelpers.ts +53 -0
  91. package/templates/backend/src/core/readmodel.ts +28 -0
  92. package/templates/backend/src/core/types.ts +26 -0
  93. package/templates/backend/src/process/process.ts +53 -0
  94. package/templates/backend/src/supabase/LoginHandler.ts +36 -0
  95. package/templates/backend/src/supabase/ProtectedPageProps.ts +21 -0
  96. package/templates/backend/src/supabase/README.md +171 -0
  97. package/templates/backend/src/supabase/api.ts +63 -0
  98. package/templates/backend/src/supabase/authMiddleware.ts +53 -0
  99. package/templates/backend/src/supabase/component.ts +12 -0
  100. package/templates/backend/src/supabase/requireUser.ts +72 -0
  101. package/templates/backend/src/supabase/serverProps.ts +25 -0
  102. package/templates/backend/src/supabase/staticProps.ts +10 -0
  103. package/templates/backend/src/swagger.ts +34 -0
  104. package/templates/backend/src/util/assertions.ts +6 -0
  105. package/templates/backend/supabase/config.toml +295 -0
  106. package/templates/backend/supabase/migrations/20260121155918593_catalogentries.sql.sample +23 -0
  107. package/templates/backend/supabase/seed.sql +1 -0
  108. package/templates/backend/tsconfig.json +31 -0
  109. package/templates/frontend/.env.development +3 -0
  110. package/templates/frontend/AGENTS.md +7 -0
  111. package/templates/frontend/README.md +73 -0
  112. package/templates/frontend/components.json +20 -0
  113. package/templates/frontend/eslint.config.js +26 -0
  114. package/templates/frontend/index.html +18 -0
  115. package/templates/frontend/package-lock.json +8347 -0
  116. package/templates/frontend/package.json +94 -0
  117. package/templates/frontend/postcss.config.js +6 -0
  118. package/templates/frontend/public/favicon.ico +0 -0
  119. package/templates/frontend/public/logo.png +0 -0
  120. package/templates/frontend/public/placeholder.svg +1 -0
  121. package/templates/frontend/public/robots.txt +14 -0
  122. package/templates/frontend/src/App.css +42 -0
  123. package/templates/frontend/src/App.tsx +47 -0
  124. package/templates/frontend/src/components/NavLink.tsx +28 -0
  125. package/templates/frontend/src/components/ProtectedRoute.tsx +24 -0
  126. package/templates/frontend/src/components/calendar/Calendar.tsx +302 -0
  127. package/templates/frontend/src/components/layout/DashboardLayout.tsx +21 -0
  128. package/templates/frontend/src/components/layout/Header.tsx +45 -0
  129. package/templates/frontend/src/components/layout/Sidebar.tsx +82 -0
  130. package/templates/frontend/src/components/tables/ReservationTemplates.tsx +189 -0
  131. package/templates/frontend/src/components/ui/accordion.tsx +52 -0
  132. package/templates/frontend/src/components/ui/alert-dialog.tsx +104 -0
  133. package/templates/frontend/src/components/ui/alert.tsx +43 -0
  134. package/templates/frontend/src/components/ui/aspect-ratio.tsx +5 -0
  135. package/templates/frontend/src/components/ui/avatar.tsx +38 -0
  136. package/templates/frontend/src/components/ui/badge.tsx +29 -0
  137. package/templates/frontend/src/components/ui/breadcrumb.tsx +90 -0
  138. package/templates/frontend/src/components/ui/button.tsx +47 -0
  139. package/templates/frontend/src/components/ui/calendar.tsx +54 -0
  140. package/templates/frontend/src/components/ui/card.tsx +43 -0
  141. package/templates/frontend/src/components/ui/carousel.tsx +224 -0
  142. package/templates/frontend/src/components/ui/chart.tsx +303 -0
  143. package/templates/frontend/src/components/ui/checkbox.tsx +26 -0
  144. package/templates/frontend/src/components/ui/collapsible.tsx +9 -0
  145. package/templates/frontend/src/components/ui/command.tsx +132 -0
  146. package/templates/frontend/src/components/ui/context-menu.tsx +178 -0
  147. package/templates/frontend/src/components/ui/dialog.tsx +95 -0
  148. package/templates/frontend/src/components/ui/drawer.tsx +87 -0
  149. package/templates/frontend/src/components/ui/dropdown-menu.tsx +179 -0
  150. package/templates/frontend/src/components/ui/form.tsx +129 -0
  151. package/templates/frontend/src/components/ui/hover-card.tsx +27 -0
  152. package/templates/frontend/src/components/ui/input-otp.tsx +61 -0
  153. package/templates/frontend/src/components/ui/input.tsx +22 -0
  154. package/templates/frontend/src/components/ui/label.tsx +17 -0
  155. package/templates/frontend/src/components/ui/menubar.tsx +207 -0
  156. package/templates/frontend/src/components/ui/navigation-menu.tsx +120 -0
  157. package/templates/frontend/src/components/ui/pagination.tsx +81 -0
  158. package/templates/frontend/src/components/ui/popover.tsx +29 -0
  159. package/templates/frontend/src/components/ui/progress.tsx +23 -0
  160. package/templates/frontend/src/components/ui/radio-group.tsx +36 -0
  161. package/templates/frontend/src/components/ui/resizable.tsx +37 -0
  162. package/templates/frontend/src/components/ui/scroll-area.tsx +38 -0
  163. package/templates/frontend/src/components/ui/select.tsx +143 -0
  164. package/templates/frontend/src/components/ui/separator.tsx +20 -0
  165. package/templates/frontend/src/components/ui/sheet.tsx +107 -0
  166. package/templates/frontend/src/components/ui/sidebar.tsx +637 -0
  167. package/templates/frontend/src/components/ui/skeleton.tsx +7 -0
  168. package/templates/frontend/src/components/ui/slider.tsx +23 -0
  169. package/templates/frontend/src/components/ui/sonner.tsx +27 -0
  170. package/templates/frontend/src/components/ui/stat-card.tsx +44 -0
  171. package/templates/frontend/src/components/ui/switch.tsx +27 -0
  172. package/templates/frontend/src/components/ui/table.tsx +72 -0
  173. package/templates/frontend/src/components/ui/tabs.tsx +53 -0
  174. package/templates/frontend/src/components/ui/textarea.tsx +21 -0
  175. package/templates/frontend/src/components/ui/toast.tsx +111 -0
  176. package/templates/frontend/src/components/ui/toaster.tsx +24 -0
  177. package/templates/frontend/src/components/ui/toggle-group.tsx +49 -0
  178. package/templates/frontend/src/components/ui/toggle.tsx +37 -0
  179. package/templates/frontend/src/components/ui/tooltip.tsx +28 -0
  180. package/templates/frontend/src/components/ui/use-toast.ts +3 -0
  181. package/templates/frontend/src/contexts/AuthContext.tsx +94 -0
  182. package/templates/frontend/src/contexts/RefreshContext.tsx +236 -0
  183. package/templates/frontend/src/hooks/api/index.ts +2 -0
  184. package/templates/frontend/src/hooks/api/useLocations.ts +15 -0
  185. package/templates/frontend/src/hooks/use-mobile.tsx +19 -0
  186. package/templates/frontend/src/hooks/use-toast.ts +186 -0
  187. package/templates/frontend/src/hooks/useApiContext.ts +11 -0
  188. package/templates/frontend/src/index.css +118 -0
  189. package/templates/frontend/src/integrations/supabase/client.ts +9 -0
  190. package/templates/frontend/src/lib/api-client.ts +136 -0
  191. package/templates/frontend/src/lib/api.ts +1028 -0
  192. package/templates/frontend/src/lib/utils.ts +6 -0
  193. package/templates/frontend/src/main.tsx +5 -0
  194. package/templates/frontend/src/pages/Auth.tsx +408 -0
  195. package/templates/frontend/src/pages/Dashboard.tsx +168 -0
  196. package/templates/frontend/src/pages/Menus.tsx +224 -0
  197. package/templates/frontend/src/pages/NotFound.tsx +24 -0
  198. package/templates/frontend/src/pages/Register.tsx +285 -0
  199. package/templates/frontend/src/test/example.test.ts +0 -0
  200. package/templates/frontend/src/test/setup.ts +15 -0
  201. package/templates/frontend/src/types/index.ts +8 -0
  202. package/templates/frontend/src/vite-env.d.ts +1 -0
  203. package/templates/frontend/tailwind.config.ts +101 -0
  204. package/templates/frontend/tsconfig.app.json +31 -0
  205. package/templates/frontend/tsconfig.json +16 -0
  206. package/templates/frontend/tsconfig.node.json +22 -0
  207. package/templates/frontend/vite.config.ts +21 -0
  208. package/templates/frontend/vitest.config.ts +16 -0
  209. package/templates/init.sh +1 -0
  210. package/templates/prompt.md +139 -0
  211. package/templates/ralph.sh +120 -0
  212. package/templates/server.mjs +505 -0
@@ -0,0 +1,336 @@
1
+ ---
2
+ name: slice-state-view
3
+ description: builds a state-view slice from an event model
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ State View Slices are read model projections that build table-based views from events. They consume events and project them into queryable database tables using PostgreSQL.
9
+
10
+ If the processors-array in the slice json is not empty. Treat this as an AUTOMATION Slice. Load the skill for automation slice.
11
+
12
+ ## Critical Requirements
13
+
14
+ ### Restaurant ID Requirement
15
+ - **CRITICAL**: ALL database tables MUST have a `restaurant_id` column (snake_case)
16
+ - **CRITICAL**: ALL events MUST have `restaurantId` in their metadata (camelCase)
17
+ - **NEVER** use `locationId` or `location_id` - these are outdated and forbidden
18
+ - This ensures proper multi-tenancy and data isolation
19
+
20
+ ## Implementation Steps
21
+
22
+ When creating a state-view slice, you MUST create the following files:
23
+
24
+ 1. **src/slices/{SliceName}/{SliceName}Projection.ts** - Projection handler
25
+ 2. **src/slices/{SliceName}/{SliceName}.test.ts** - Projection tests
26
+ 3. **src/slices/{SliceName}/routes.ts** - Query API endpoint
27
+ 4. **supabase/migrations/V{N}__{tablename}.sql** - Database migration file
28
+ 5. **src/slices/{SliceName}/ui-prompt.md - prompt to build the UI
29
+
30
+ ## Projection Structure
31
+
32
+ Every projection file follows this pattern:
33
+
34
+ ### 1. Imports
35
+ ```typescript
36
+ import {postgreSQLRawSQLProjection} from '@event-driven-io/emmett-postgresql';
37
+ import {sql} from '@event-driven-io/dumbo';
38
+ import knex, {Knex} from 'knex';
39
+ import {EventType} from '../../events/EventType';
40
+ ```
41
+
42
+ ### 2. Read Model Types
43
+ Define TypeScript types for the read model:
44
+ ```typescript
45
+ export type {Name}ReadModelItem = {
46
+ field1?: type,
47
+ field2?: type,
48
+ // fields from projection
49
+ }
50
+
51
+ export type {Name}ReadModel = {
52
+ data: {Name}ReadModelItem[],
53
+ }
54
+
55
+ export const tableName = 'table_name';
56
+ ```
57
+
58
+ ### 3. Knex Instance Helper
59
+ ```typescript
60
+ export const getKnexInstance = (connectionString: string): Knex => {
61
+ return knex({
62
+ client: 'pg',
63
+ connection: connectionString,
64
+ });
65
+ };
66
+ ```
67
+
68
+ ### 4. Projection Definition
69
+ ```typescript
70
+ export const {Name}Projection = postgreSQLRawSQLProjection<EventType>({
71
+ canHandle: ["Event1", "Event2"], // events this projection handles
72
+ evolve: (event, context) => {
73
+ const {type, data} = event;
74
+ const db = getKnexInstance(context.connection.connectionString);
75
+
76
+ switch (type) {
77
+ case "Event1":
78
+ return sql(db(tableName)
79
+ .withSchema('public')
80
+ .insert({
81
+ field1: data.field1,
82
+ field2: data.field2,
83
+ })
84
+ .onConflict('id_field') // upsert on conflict
85
+ .merge({field1: data.field1, field2: data.field2})
86
+ .toQuery());
87
+
88
+ case "Event2":
89
+ return sql(db(tableName)
90
+ .withSchema('public')
91
+ .where('id_field', data.id)
92
+ .update({
93
+ field1: data.field1,
94
+ })
95
+ .toQuery());
96
+
97
+ default:
98
+ return [];
99
+ }
100
+ }
101
+ });
102
+ ```
103
+
104
+ ## Key Patterns
105
+
106
+ ### Insert with Upsert (Merge on Conflict)
107
+ Use this pattern for events that create or update records:
108
+ ```typescript
109
+ return sql(db(tableName)
110
+ .withSchema('public')
111
+ .insert({ /* fields */ })
112
+ .onConflict('id_field')
113
+ .merge({ /* fields to update */ })
114
+ .toQuery());
115
+ ```
116
+
117
+ ### Update Pattern
118
+ Use this for events that only update existing records:
119
+ ```typescript
120
+ return sql(db(tableName)
121
+ .withSchema('public')
122
+ .where('id_field', data.id)
123
+ .update({ /* fields */ })
124
+ .toQuery());
125
+ ```
126
+
127
+ ### Delete Pattern
128
+ Use this for events that remove records:
129
+ ```typescript
130
+ return sql(db(tableName)
131
+ .withSchema('public')
132
+ .where('id_field', data.id)
133
+ .delete()
134
+ .toQuery());
135
+ ```
136
+
137
+ ## Testing
138
+
139
+ Every projection MUST have tests using PostgreSQLProjectionSpec with Testcontainers:
140
+
141
+ ```typescript
142
+ import {before, after, describe, it} from "node:test";
143
+ import {PostgreSQLProjectionAssert, PostgreSQLProjectionSpec} from "@event-driven-io/emmett-postgresql";
144
+ import {{Name}Projection} from "./{Name}Projection";
145
+ import {PostgreSqlContainer, StartedPostgreSqlContainer} from "@testcontainers/postgresql";
146
+ import {EventType} from "../../events/EventType"
147
+ import knex, {Knex} from 'knex';
148
+ import assert from 'assert';
149
+ import {runFlywayMigrations} from "../../common/testHelpers";
150
+
151
+ describe('{Name} Specification', () => {
152
+ let postgres: StartedPostgreSqlContainer;
153
+ let connectionString: string;
154
+ let db: Knex;
155
+
156
+ let given: PostgreSQLProjectionSpec<EventType>
157
+
158
+ before(async () => {
159
+ postgres = await new PostgreSqlContainer("postgres").start();
160
+ connectionString = postgres.getConnectionUri();
161
+
162
+ db = knex({
163
+ client: 'pg',
164
+ connection: connectionString,
165
+ });
166
+
167
+ await runFlywayMigrations(connectionString);
168
+
169
+ given = PostgreSQLProjectionSpec.for({
170
+ projection: {Name}Projection,
171
+ connectionString,
172
+ });
173
+ });
174
+
175
+ after(async () => {
176
+ await db?.destroy();
177
+ await postgres?.stop();
178
+ });
179
+
180
+ it('spec: {Name} - scenario', async () => {
181
+ const assertReadModel: PostgreSQLProjectionAssert = async ({connectionString: connStr}) => {
182
+ const queryDb = knex({
183
+ client: 'pg',
184
+ connection: connStr,
185
+ });
186
+
187
+ try {
188
+ const result = await queryDb('table_name')
189
+ .withSchema('public')
190
+ .select('*');
191
+
192
+ assert.strictEqual(result.length, 1);
193
+ // add more assertions
194
+ } finally {
195
+ await queryDb.destroy();
196
+ }
197
+ };
198
+
199
+ await given([{
200
+ type: 'EventName',
201
+ data: { /* event data */ },
202
+ metadata: {streamName: 'stream-id'}
203
+ }])
204
+ .when([]) // additional events to process
205
+ .then(assertReadModel);
206
+ });
207
+ });
208
+ ```
209
+
210
+ ## Query API Routes
211
+
212
+ Every read model exposes a GET endpoint to fetch data:
213
+
214
+ ```typescript
215
+ import {Request, Response, Router} from 'express';
216
+ import {{Name}ReadModel, tableName} from "./{Name}Projection";
217
+ import {WebApiSetup} from "@event-driven-io/emmett-expressjs";
218
+ import createClient from "../../supabase/api";
219
+ import {readmodel} from "../../core/readmodel";
220
+ import {requireUser} from "../../supabase/requireUser";
221
+
222
+ export const api =
223
+ (
224
+ // external dependencies
225
+ ): WebApiSetup =>
226
+ (router: Router): void => {
227
+ router.get('/api/query/{name}-collection', async (req: Request, res: Response) => {
228
+ try {
229
+ const principal = await requireUser(req, res, true);
230
+ if (principal.error) {
231
+ return;
232
+ }
233
+
234
+ const userId = principal.user.id;
235
+ const id = req.query._id?.toString();
236
+
237
+ const supabase = createClient()
238
+
239
+ const query: any = {};
240
+ delete query._id;
241
+
242
+ const data: {Name}ReadModel | {Name}ReadModel[] | null =
243
+ id ? await readmodel(tableName, supabase).findById<{Name}ReadModel>("id_field", id) :
244
+ await readmodel(tableName, supabase).findAll<{Name}ReadModel>(query)
245
+
246
+ // Serialize, handling bigint properly
247
+ const sanitized = JSON.parse(
248
+ JSON.stringify(data || [], (key, value) =>
249
+ typeof value === 'bigint' ? value.toString() : value
250
+ )
251
+ );
252
+
253
+ return res.status(200).json(sanitized);
254
+ } catch (err) {
255
+ console.error(err);
256
+ return res.status(500).json({ok: false, error: 'Server error'});
257
+ }
258
+ });
259
+ };
260
+ ```
261
+
262
+ ## Database Migrations
263
+
264
+ Each read model requires a migration file in `supabase/migrations/`:
265
+
266
+ **Naming Convention:** `V{N}__{tablename}.sql`
267
+ - `V{N}` - Version number (sequential: V1, V2, V3, etc.)
268
+ - `{tablename}` - Lowercase table name matching the projection's `tableName`
269
+
270
+ **Example:** `V8__locations.sql`
271
+
272
+ ```sql
273
+ -- Create {tablename} table
274
+ CREATE TABLE IF NOT EXISTS "public"."{tablename}"
275
+ (
276
+ id_field TEXT PRIMARY KEY,
277
+ field1 TEXT,
278
+ field2 INTEGER,
279
+ field3 TEXT,
280
+ restaurant_id uuid NOT NULL
281
+ );
282
+ ```
283
+
284
+ ### Migration Guidelines:
285
+ - Use `IF NOT EXISTS` for idempotency
286
+ - Define PRIMARY KEY on the ID field used in `onConflict()`
287
+ - **CRITICAL**: ALWAYS include `restaurant_id uuid NOT NULL` column (required for multi-tenancy)
288
+ - Use appropriate SQL types (TEXT, INTEGER, BOOLEAN, TIMESTAMP, etc.)
289
+ - Keep column names in snake_case (PostgreSQL convention)
290
+ - Place files in `supabase/migrations/` directory
291
+ - Migrations run automatically via Flyway in tests
292
+
293
+ ## File Structure
294
+
295
+ ```
296
+ src/slices/{SliceName}/
297
+ ├── {SliceName}Projection.ts # Projection logic
298
+ ├── {SliceName}.test.ts # Tests
299
+ └── routes.ts # Query endpoint
300
+
301
+ supabase/migrations/
302
+ └── V{N}__{tablename}.sql # Database schema
303
+ ```
304
+ ## slice json
305
+ in each slice folder, generate a file .slice.json
306
+ ```
307
+ {
308
+ "id" : "<slice id>",
309
+ "slice": "<slice title>",
310
+ "context": "<contextx>",
311
+ "link": "https://miro.com/app/board/<board-id>=/?moveToWidget=<slice id>"
312
+ }
313
+ ```
314
+
315
+
316
+ ## References
317
+
318
+ - See `templates/Locations/` for simple single-event projection example
319
+ - See `templates/Tables/` for multi-event projection with updates
320
+ - See `templates/V8__locations.sql` for migration example
321
+ - See `templates/V2__tables.sql` for migration example
322
+
323
+
324
+ ## UI Prompt
325
+
326
+ to build the UI prompt, list the following facts:
327
+
328
+ ```
329
+ to build the UI - use this table "<schema>.<table_name>"
330
+
331
+ Payload example:
332
+ <payload example as JSON>
333
+
334
+ this is the table definition:
335
+ <table definition as SQL DDL>
336
+ ```
@@ -0,0 +1,84 @@
1
+ import {after, before, describe, it} from "node:test";
2
+ import {PostgreSQLProjectionAssert, PostgreSQLProjectionSpec} from "@event-driven-io/emmett-postgresql";
3
+ import {LocationsProjection} from "./LocationsProjection";
4
+ import {PostgreSqlContainer, StartedPostgreSqlContainer} from "@testcontainers/postgresql";
5
+ import {LocationAdded} from "../../events/LocationAdded"
6
+ import knex, {Knex} from "knex";
7
+ import assert from "node:assert";
8
+ import {runFlywayMigrations} from "../../common/testHelpers";
9
+
10
+ describe('Locations Specification', () => {
11
+ let postgres: StartedPostgreSqlContainer;
12
+ let connectionString: string;
13
+ let db: Knex;
14
+
15
+ let given: PostgreSQLProjectionSpec<LocationAdded>
16
+
17
+ before(async () => {
18
+ postgres = await new PostgreSqlContainer("postgres").start();
19
+ connectionString = postgres.getConnectionUri();
20
+
21
+ db = knex({
22
+ client: 'pg',
23
+ connection: connectionString,
24
+ });
25
+
26
+ await runFlywayMigrations(connectionString);
27
+
28
+ given = PostgreSQLProjectionSpec.for({
29
+ projection: LocationsProjection,
30
+ connectionString,
31
+ });
32
+ });
33
+
34
+ after(async () => {
35
+ await db?.destroy();
36
+ await postgres?.stop();
37
+ });
38
+
39
+ it('spec: Locations - scenario', async () => {
40
+ const city = "New York"
41
+ const housenumber = "123"
42
+ const location_id = "7771b53c-b378-4f10-a6b7-16f837316960"
43
+ const name = "Main Office"
44
+ const street = "Broadway"
45
+ const zipCode = "10001"
46
+
47
+ const assertLocations: PostgreSQLProjectionAssert = async ({connectionString: connStr}) => {
48
+ const queryDb = knex({
49
+ client: 'pg',
50
+ connection: connStr,
51
+ });
52
+
53
+ try {
54
+ const result = await queryDb('locations')
55
+ .withSchema('public')
56
+ .select('*');
57
+
58
+ assert.strictEqual(result.length, 1, 'Should have 1 location');
59
+ assert.strictEqual(result[0].location_id, location_id);
60
+ assert.strictEqual(result[0].name, name);
61
+ assert.strictEqual(result[0].zip_code, zipCode);
62
+ assert.strictEqual(result[0].city, city);
63
+ } finally {
64
+ await queryDb.destroy();
65
+ }
66
+ };
67
+
68
+ await given([{
69
+ type: 'LocationAdded',
70
+ data: {
71
+ city: city,
72
+ housenumber: housenumber,
73
+ name: name,
74
+ street: street,
75
+ zipCode: zipCode,
76
+ location_id: location_id
77
+ },
78
+ metadata: {streamName: 'fbf165d3-5fe2-4a34-af24-f0ad64ca8412'}
79
+ }])
80
+ .when([])
81
+ .then(assertLocations);
82
+ });
83
+
84
+ });
@@ -0,0 +1,50 @@
1
+ import {postgreSQLRawSQLProjection} from '@event-driven-io/emmett-postgresql';
2
+ import {sql} from '@event-driven-io/dumbo';
3
+ import knex, {Knex} from 'knex';
4
+ import {LocationAdded} from '../../events/LocationAdded';
5
+
6
+ export type LocationsReadModelItem = {
7
+ name?: string,
8
+ zipCode?: string,
9
+ city?: string,
10
+ location_id?: string,
11
+ }
12
+
13
+ export type LocationsReadModel = {
14
+ data: LocationsReadModelItem[],
15
+ }
16
+
17
+ export const tableName = 'locations';
18
+
19
+ export const getKnexInstance = (connectionString: string): Knex => {
20
+ return knex({
21
+ client: 'pg',
22
+ connection: connectionString,
23
+ });
24
+ };
25
+
26
+ export const LocationsProjection = postgreSQLRawSQLProjection<LocationAdded>({
27
+ canHandle: ["LocationAdded"],
28
+ evolve: (event, context) => {
29
+ const {type, data} = event;
30
+ const db = getKnexInstance(context.connection.connectionString);
31
+
32
+ switch (type) {
33
+ case "LocationAdded":
34
+ return sql(db(tableName)
35
+ .withSchema('public')
36
+ .insert({
37
+ location_id: data.location_id,
38
+ name: data.name,
39
+ zip_code: data.zipCode,
40
+ city: data.city,
41
+ })
42
+ .onConflict('location_id')
43
+ .merge({name: data.name, zip_code: data.zipCode, city: data.city})
44
+ .toQuery());
45
+
46
+ default:
47
+ return [];
48
+ }
49
+ }
50
+ });
@@ -0,0 +1,46 @@
1
+ import {Request, Response, Router} from 'express';
2
+ import {LocationsReadModel, tableName} from "./LocationsProjection";
3
+ import {WebApiSetup} from "@event-driven-io/emmett-expressjs";
4
+ import createClient from "../../supabase/api";
5
+ import {readmodel} from "../../core/readmodel";
6
+ import {requireUser} from "../../supabase/requireUser";
7
+
8
+ export const api =
9
+ (
10
+ // external dependencies
11
+ ): WebApiSetup =>
12
+ (router: Router): void => {
13
+ router.get('/api/query/locations-collection', async (req: Request, res: Response) => {
14
+ try {
15
+ const principal = await requireUser(req, res, true);
16
+ if (principal.error) {
17
+ return;
18
+ }
19
+
20
+ const userId = principal.user.id;
21
+ const id = req.query._id?.toString();
22
+
23
+ const supabase = createClient()
24
+
25
+ const query: any = {...req.query, user_id: userId};
26
+ delete query._id;
27
+
28
+ const data: LocationsReadModel | LocationsReadModel[] | null =
29
+ id ? await readmodel(tableName, supabase).findById<LocationsReadModel>("location_id", id) :
30
+ await readmodel(tableName, supabase).findAll<LocationsReadModel>(query)
31
+
32
+ // Serialize, handling bigint properly
33
+ const sanitized = JSON.parse(
34
+ JSON.stringify(data || [], (key, value) =>
35
+ typeof value === 'bigint' ? value.toString() : value
36
+ )
37
+ );
38
+
39
+ return res.status(200).json(sanitized);
40
+ } catch (err) {
41
+ console.error(err);
42
+ return res.status(500).json({ok: false, error: 'Server error'});
43
+ }
44
+ });
45
+
46
+ };
@@ -0,0 +1,109 @@
1
+ # State-View Slice Templates
2
+
3
+ This folder contains real working examples from the codebase to use as templates for read model projections.
4
+
5
+ ## Simple Example: Locations
6
+
7
+ **Files:**
8
+ - `Locations/LocationsProjection.ts.sample` - Single-event projection
9
+ - `Locations/Locations.test.ts.sample` - Projection test
10
+ - `Locations/routes.ts.sample` - Query API endpoint
11
+ - `V8__locations.sql` - Database migration
12
+
13
+ **Use this when:**
14
+ - Single event type creates/updates the read model
15
+ - Simple insert with upsert (merge on conflict)
16
+ - Basic read model with a few fields
17
+ - No complex transformations needed
18
+
19
+ **Key features:**
20
+ - `canHandle: ["LocationAdded"]` - handles one event type
21
+ - Insert with `.onConflict().merge()` for upsert behavior
22
+ - Simple field mapping from event to database
23
+ - Query by ID or list all
24
+
25
+ ## Complex Example: Tables
26
+
27
+ **Files:**
28
+ - `Tables/TablesProjection.ts.sample` - Multi-event projection
29
+ - `Tables/Tables.test.ts.sample` - Test with multiple events
30
+ - `Tables/routes.ts.sample` - Query API endpoint
31
+ - `V2__tables.sql` - Database migration
32
+
33
+ **Use this when:**
34
+ - Multiple event types update the same read model
35
+ - Different operations (insert, update, delete)
36
+ - Need to handle event sequences
37
+ - More complex state management
38
+
39
+ **Key features:**
40
+ - `canHandle: ['TableAdded', 'TableUpdated']` - handles multiple events
41
+ - Different SQL operations per event type:
42
+ - `TableAdded` - insert with upsert
43
+ - `TableUpdated` - direct update by ID
44
+ - Test uses `given([...]).when([...]).then(...)` pattern for event sequences
45
+ - Shows how to verify multiple records and updates
46
+
47
+ ## Migration Files
48
+
49
+ **V8__locations.sql** - Simple table with TEXT fields
50
+ **V2__tables.sql** - Table with mixed types (TEXT, INTEGER)
51
+
52
+ All tables need the restaurant_id
53
+
54
+ ### Migration Naming:
55
+ - Format: `V{N}__{tablename}.sql`
56
+ - Sequential numbers (V1, V2, V3, etc.)
57
+ - Double underscore before table name
58
+ - Lowercase table name
59
+
60
+ ### Migration Patterns:
61
+ ```sql
62
+ -- Always use IF NOT EXISTS
63
+ CREATE TABLE IF NOT EXISTS "public"."table_name"
64
+ (
65
+ -- Primary key matching the onConflict field
66
+ id_field TEXT PRIMARY KEY,
67
+
68
+ -- Use appropriate SQL types
69
+ text_field TEXT,
70
+ number_field INTEGER,
71
+ bool_field BOOLEAN,
72
+ date_field TIMESTAMP
73
+ );
74
+ ```
75
+
76
+ ## Query Endpoint Patterns
77
+
78
+ Both examples show the standard query endpoint pattern:
79
+ - GET `/api/query/{name}-collection`
80
+ - Optional `?_id=xxx` parameter for single record
81
+ - Returns array or single object
82
+ - Requires authentication via `requireUser`
83
+ - Uses `readmodel` helper for database queries
84
+ - Handles bigint serialization
85
+
86
+ ## Testing Patterns
87
+
88
+ ### Setup:
89
+ - Use Testcontainers for PostgreSQL
90
+ - Run Flyway migrations before tests
91
+ - Create Knex instance for assertions
92
+
93
+ ### Assertion Pattern:
94
+ ```typescript
95
+ const assertReadModel: PostgreSQLProjectionAssert = async ({connectionString}) => {
96
+ const queryDb = knex({ client: 'pg', connection: connectionString });
97
+ try {
98
+ const result = await queryDb('table_name').select('*');
99
+ // assertions
100
+ } finally {
101
+ await queryDb.destroy();
102
+ }
103
+ };
104
+ ```
105
+
106
+ ### Event Flow:
107
+ - `given([...])` - initial events to set up state
108
+ - `when([...])` - events to process during test
109
+ - `then(assertReadModel)` - verify final state