@eventmodelers/node-kit 0.0.10 → 0.0.12

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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/.claude/skills/build-automation/SKILL.md +260 -0
  3. package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
  4. package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
  5. package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
  6. package/templates/.claude/skills/load-slice/SKILL.md +69 -14
  7. package/templates/realtime-agent/src/index.js +12 -17
  8. package/templates/root/.env.example +22 -0
  9. package/templates/root/Claude.md +58 -0
  10. package/templates/root/agent.sh +15 -0
  11. package/templates/root/backend-prompt.md +139 -0
  12. package/templates/root/flyway.conf +17 -0
  13. package/templates/root/package.json +52 -0
  14. package/templates/root/ralph.sh +47 -26
  15. package/templates/root/server.ts +213 -0
  16. package/templates/root/setup-env.sh +55 -0
  17. package/templates/root/src/common/assertions.ts +6 -0
  18. package/templates/root/src/common/db.ts +32 -0
  19. package/templates/root/src/common/loadPostgresEventstore.ts +39 -0
  20. package/templates/root/src/common/parseEndpoint.ts +51 -0
  21. package/templates/root/src/common/processorDlq.ts +28 -0
  22. package/templates/root/src/common/realtimeBroadcast.ts +19 -0
  23. package/templates/root/src/common/replay.ts +16 -0
  24. package/templates/root/src/common/routes.ts +19 -0
  25. package/templates/root/src/common/testHelpers.ts +54 -0
  26. package/templates/root/src/slices/example/routes.ts +134 -0
  27. package/templates/root/src/supabase/LoginHandler.ts +36 -0
  28. package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
  29. package/templates/root/src/supabase/README.md +171 -0
  30. package/templates/root/src/supabase/api.ts +56 -0
  31. package/templates/root/src/supabase/component.ts +12 -0
  32. package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
  33. package/templates/root/src/supabase/requireUser.ts +72 -0
  34. package/templates/root/src/supabase/serverProps.ts +25 -0
  35. package/templates/root/src/supabase/staticProps.ts +10 -0
  36. package/templates/root/src/swagger.ts +34 -0
  37. package/templates/root/src/util/assertions.ts +6 -0
  38. package/templates/root/src/util/hash.ts +9 -0
  39. package/templates/root/src/util/sanitize.ts +23 -0
  40. package/templates/root/supabase/config.toml +295 -0
  41. package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
  42. package/templates/root/supabase/seed.sql +1 -0
  43. package/templates/root/tsconfig.json +32 -0
  44. package/templates/root/vercel.json +8 -0
  45. package/templates/root/model.md +0 -1
@@ -0,0 +1,384 @@
1
+ ---
2
+ name: build-state-view
3
+ description: Implements an emmett state-view slice (projection, tests, route, migration) from a slice.json definition
4
+ ---
5
+
6
+ # Build State View Slice
7
+
8
+ > Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`. This file is the **source of truth** for all fields, events, and read model shape. Never invent fields not defined there.
9
+
10
+ ---
11
+
12
+ ## What a State View Slice is
13
+
14
+ A state-view slice is a **read model projection**. It listens to events from the event store and materializes them into a queryable PostgreSQL table. It does not emit events or process commands.
15
+
16
+ ---
17
+
18
+ ## Step 1 — Read the slice.json
19
+
20
+ From the slice definition, extract:
21
+ - **sliceName** — the projection name
22
+ - **context** — bounded context
23
+ - **events[]** — events this projection handles (its `canHandle` list)
24
+ - **readModel / fields** — the columns of the output table
25
+
26
+ ---
27
+
28
+ ## Step 2 — Create the migration
29
+
30
+ File: `supabase/migrations/V{N}__{tablename}.sql`
31
+
32
+ Choose the next available version number by checking existing migration files.
33
+
34
+ ```sql
35
+ CREATE TABLE IF NOT EXISTS "public"."{tablename}"
36
+ (
37
+ id TEXT PRIMARY KEY,
38
+ -- other columns from read model fields in slice.json
39
+ -- use snake_case for all column names
40
+ created_at TIMESTAMP DEFAULT NOW()
41
+ );
42
+ ```
43
+
44
+ **Column type guide:**
45
+
46
+ | Field type | SQL type |
47
+ |-----------|---------|
48
+ | string / UUID | `TEXT` |
49
+ | number (integer) | `INTEGER` |
50
+ | number (float) | `NUMERIC` |
51
+ | boolean | `BOOLEAN` |
52
+ | date | `TIMESTAMP` |
53
+ | nullable number | `INTEGER` (allow NULL) |
54
+
55
+ The PRIMARY KEY column is the one used in `.onConflict(...)` in the projection.
56
+
57
+ ---
58
+
59
+ ## Step 3 — Create `{SliceName}Projection.ts`
60
+
61
+ File: `src/slices/{context}/{SliceName}/{SliceName}Projection.ts`
62
+
63
+ ### Full structure
64
+
65
+ ```typescript
66
+ import {postgreSQLRawSQLProjection} from '@event-driven-io/emmett-postgresql';
67
+ import {sql, SQL} from '@event-driven-io/dumbo';
68
+ import knex, {Knex} from 'knex';
69
+ import {type {EventA}, type {EventB}} from '../{Context}Events';
70
+
71
+ export const tableName = '{tablename}';
72
+
73
+ // TypeScript shape of one row in the read model
74
+ export type {SliceName}ReadModel = {
75
+ id: string;
76
+ // ... fields from slice.json readModel
77
+ };
78
+
79
+ export const getKnexInstance = (connectionString: string): Knex =>
80
+ knex({client: 'pg', connection: connectionString, pool: {min: 0, max: 1}});
81
+
82
+ type {SliceName}Events = {EventA} | {EventB};
83
+
84
+ export const {SliceName}Projection = postgreSQLRawSQLProjection<{SliceName}Events>({
85
+ name: '{SliceName}Projection',
86
+ canHandle: ['{EventA}', '{EventB}'],
87
+ evolve: async (event, context): Promise<SQL[]> => {
88
+ const db = getKnexInstance(context.connection.connectionString);
89
+
90
+ try {
91
+ switch (event.type) {
92
+ case '{EventA}':
93
+ // Insert with upsert — use for create/update events
94
+ return [sql(db(tableName)
95
+ .withSchema('public')
96
+ .insert({
97
+ id: event.data.id,
98
+ field1: event.data.field1,
99
+ field2: event.data.field2,
100
+ })
101
+ .onConflict('id')
102
+ .merge(['field1', 'field2'])
103
+ .toQuery())];
104
+
105
+ case '{EventB}':
106
+ // Delete — use for cancellation/removal events
107
+ return [sql(db(tableName)
108
+ .withSchema('public')
109
+ .where({id: event.data.id})
110
+ .delete()
111
+ .toQuery())];
112
+
113
+ default:
114
+ return [];
115
+ }
116
+ } finally {
117
+ await db.destroy();
118
+ }
119
+ },
120
+ });
121
+ ```
122
+
123
+ ### SQL operation patterns
124
+
125
+ **Insert with upsert (create or update):**
126
+ ```typescript
127
+ return [sql(db(tableName)
128
+ .withSchema('public')
129
+ .insert({ id: event.data.id, field: event.data.field })
130
+ .onConflict('id')
131
+ .merge(['field']) // list only columns to update on conflict
132
+ .toQuery())];
133
+ ```
134
+
135
+ **Update only (record already exists):**
136
+ ```typescript
137
+ return [sql(db(tableName)
138
+ .withSchema('public')
139
+ .where({id: event.data.id})
140
+ .update({field: event.data.field})
141
+ .toQuery())];
142
+ ```
143
+
144
+ **Delete:**
145
+ ```typescript
146
+ return [sql(db(tableName)
147
+ .withSchema('public')
148
+ .where({id: event.data.id})
149
+ .delete()
150
+ .toQuery())];
151
+ ```
152
+
153
+ **Async DB lookup before update** (when you need to read current state first):
154
+ ```typescript
155
+ const row = await db(tableName)
156
+ .withSchema('public')
157
+ .where({id: event.data.id})
158
+ .select('field')
159
+ .first();
160
+
161
+ if (!row) return [];
162
+
163
+ const newValue = row.field + delta;
164
+ return [sql(db(tableName)
165
+ .withSchema('public')
166
+ .where({id: event.data.id})
167
+ .update({field: newValue})
168
+ .toQuery())];
169
+ ```
170
+
171
+ Always wrap in `try/finally` and call `db.destroy()` in the `finally` block.
172
+
173
+ ---
174
+
175
+ ## Step 4 — Register the projection in the event store
176
+
177
+ File: `src/common/loadPostgresEventstore.ts`
178
+
179
+ Add the new projection to the `projections.inline([...])` array:
180
+
181
+ ```typescript
182
+ import {{SliceName}Projection} from '../slices/{context}/{SliceName}/{SliceName}Projection';
183
+
184
+ // inside getPostgreSQLEventStore options:
185
+ projections: projections.inline([
186
+ // ... existing projections ...
187
+ {SliceName}Projection,
188
+ ]),
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Step 5 — Create `{SliceName}.test.ts`
194
+
195
+ File: `src/slices/{context}/{SliceName}/{SliceName}.test.ts`
196
+
197
+ Uses `PostgreSQLProjectionSpec` with a real PostgreSQL container (Testcontainers). Flyway runs actual migrations so the schema matches production exactly.
198
+
199
+ ```typescript
200
+ import {before, after, describe, it} from 'node:test';
201
+ import {PostgreSQLProjectionAssert, PostgreSQLProjectionSpec} from '@event-driven-io/emmett-postgresql';
202
+ import {{SliceName}Projection} from './{SliceName}Projection';
203
+ import {PostgreSqlContainer, StartedPostgreSqlContainer} from '@testcontainers/postgresql';
204
+ import knex, {Knex} from 'knex';
205
+ import assert from 'assert';
206
+ import {runFlywayMigrations} from '../../../common/testHelpers';
207
+
208
+ const TEST_ID = 'test-id-001';
209
+
210
+ describe('{SliceName} Specification', () => {
211
+ let postgres: StartedPostgreSqlContainer;
212
+ let connectionString: string;
213
+ let db: Knex;
214
+ let given: PostgreSQLProjectionSpec<any>;
215
+
216
+ before(async () => {
217
+ postgres = await new PostgreSqlContainer('postgres').start();
218
+ connectionString = postgres.getConnectionUri();
219
+
220
+ db = knex({client: 'pg', connection: connectionString});
221
+
222
+ await runFlywayMigrations(connectionString);
223
+
224
+ // Insert any prerequisite rows required by foreign keys:
225
+ // await db('parent_table').withSchema('public').insert({...});
226
+
227
+ given = PostgreSQLProjectionSpec.for({
228
+ projection: {SliceName}Projection,
229
+ connectionString,
230
+ });
231
+ });
232
+
233
+ after(async () => {
234
+ await db?.destroy();
235
+ await postgres?.stop();
236
+ });
237
+
238
+ it('spec: {SliceName} - inserts row on {EventA}', async () => {
239
+ const assertReadModel: PostgreSQLProjectionAssert = async ({connectionString: connStr}) => {
240
+ const queryDb = knex({client: 'pg', connection: connStr});
241
+ try {
242
+ const result = await queryDb('{tablename}')
243
+ .withSchema('public')
244
+ .where({id: TEST_ID})
245
+ .first();
246
+
247
+ assert.ok(result, 'row should exist');
248
+ assert.strictEqual(result.id, TEST_ID);
249
+ assert.strictEqual(result.field1, 'expected-value');
250
+ } finally {
251
+ await queryDb.destroy();
252
+ }
253
+ };
254
+
255
+ await given([{
256
+ type: '{EventA}',
257
+ data: {id: TEST_ID, field1: 'expected-value'},
258
+ metadata: {stream_name: `{context}-${TEST_ID}`},
259
+ }])
260
+ .when([])
261
+ .then(assertReadModel);
262
+ });
263
+
264
+ it('spec: {SliceName} - removes row on {EventB}', async () => {
265
+ const assertReadModel: PostgreSQLProjectionAssert = async ({connectionString: connStr}) => {
266
+ const queryDb = knex({client: 'pg', connection: connStr});
267
+ try {
268
+ const result = await queryDb('{tablename}')
269
+ .withSchema('public')
270
+ .where({id: TEST_ID})
271
+ .first();
272
+
273
+ assert.strictEqual(result, undefined, 'row should be deleted');
274
+ } finally {
275
+ await queryDb.destroy();
276
+ }
277
+ };
278
+
279
+ await given([
280
+ {
281
+ type: '{EventA}',
282
+ data: {id: TEST_ID, field1: 'value'},
283
+ metadata: {stream_name: `{context}-${TEST_ID}`},
284
+ },
285
+ {
286
+ type: '{EventB}',
287
+ data: {id: TEST_ID},
288
+ metadata: {stream_name: `{context}-${TEST_ID}`},
289
+ },
290
+ ])
291
+ .when([])
292
+ .then(assertReadModel);
293
+ });
294
+ });
295
+ ```
296
+
297
+ Write one `it` block per specification in the slice.json. Use `given([events]).when([]).then(assertReadModel)`.
298
+
299
+ ---
300
+
301
+ ## Step 6 — Create `routes.ts`
302
+
303
+ File: `src/slices/{context}/{SliceName}/routes.ts`
304
+
305
+ > **Concrete example**: `src/slices/example/routes.ts` — shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
306
+
307
+ ```typescript
308
+ import {Request, Response, Router} from 'express';
309
+ import {WebApiSetup} from '@event-driven-io/emmett-expressjs';
310
+ import {requireUser} from '../../../supabase/requireUser';
311
+ import {{SliceName}ReadModel, tableName} from './{SliceName}Projection';
312
+ import {readmodel} from '../../../core/readmodel';
313
+ import createClient from '../../../supabase/api';
314
+
315
+ export const api = (): WebApiSetup => (router: Router): void => {
316
+
317
+ router.get('/api/query/{slicename}-collection', async (req: Request, res: Response) => {
318
+ try {
319
+ const principal = await requireUser(req, res, true);
320
+ if (principal.error) return;
321
+
322
+ const id = req.query._id?.toString();
323
+ const supabase = createClient();
324
+
325
+ const data: {SliceName}ReadModel | {SliceName}ReadModel[] | null =
326
+ id
327
+ ? await readmodel(tableName, supabase).findById<{SliceName}ReadModel>('id', id)
328
+ : await readmodel(tableName, supabase).findAll<{SliceName}ReadModel>({});
329
+
330
+ const sanitized = JSON.parse(
331
+ JSON.stringify(data ?? [], (_, value) =>
332
+ typeof value === 'bigint' ? value.toString() : value,
333
+ ),
334
+ );
335
+
336
+ return res.status(200).json(sanitized);
337
+ } catch (err) {
338
+ console.error(err);
339
+ return res.status(500).json({ok: false, error: 'Server error'});
340
+ }
341
+ });
342
+ };
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Step 7 — Wire up the route
348
+
349
+ Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
350
+
351
+ ```typescript
352
+ import {api as {SliceName}Api} from './slices/{context}/{SliceName}/routes';
353
+
354
+ {SliceName}Api()(router);
355
+ ```
356
+
357
+ ---
358
+
359
+ ## Files to create / modify
360
+
361
+ ```
362
+ src/slices/{context}/{SliceName}/
363
+ ├── {SliceName}Projection.ts ← projection logic
364
+ ├── {SliceName}.test.ts ← PostgreSQLProjectionSpec tests
365
+ └── routes.ts ← GET query endpoint
366
+
367
+ supabase/migrations/
368
+ └── V{N}__{tablename}.sql ← table DDL
369
+
370
+ src/common/
371
+ └── loadPostgresEventstore.ts ← add projection to inline([...]) list
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Checklist
377
+
378
+ - [ ] Migration file created with correct version number and all columns
379
+ - [ ] `tableName` constant matches the migration table name exactly
380
+ - [ ] Projection registered in `loadPostgresEventstore.ts`
381
+ - [ ] `canHandle` lists every event type the projection reacts to
382
+ - [ ] `finally { await db.destroy() }` present in every `evolve` handler
383
+ - [ ] Tests use `runFlywayMigrations()` to apply the real schema
384
+ - [ ] One test scenario per specification in slice.json