@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.
- package/package.json +1 -1
- package/templates/.claude/skills/build-automation/SKILL.md +260 -0
- package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
- package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
- package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
- package/templates/.claude/skills/load-slice/SKILL.md +69 -14
- package/templates/realtime-agent/src/index.js +12 -17
- package/templates/root/.env.example +22 -0
- package/templates/root/Claude.md +58 -0
- package/templates/root/agent.sh +15 -0
- package/templates/root/backend-prompt.md +139 -0
- package/templates/root/flyway.conf +17 -0
- package/templates/root/package.json +52 -0
- package/templates/root/ralph.sh +47 -26
- package/templates/root/server.ts +213 -0
- package/templates/root/setup-env.sh +55 -0
- package/templates/root/src/common/assertions.ts +6 -0
- package/templates/root/src/common/db.ts +32 -0
- package/templates/root/src/common/loadPostgresEventstore.ts +39 -0
- package/templates/root/src/common/parseEndpoint.ts +51 -0
- package/templates/root/src/common/processorDlq.ts +28 -0
- package/templates/root/src/common/realtimeBroadcast.ts +19 -0
- package/templates/root/src/common/replay.ts +16 -0
- package/templates/root/src/common/routes.ts +19 -0
- package/templates/root/src/common/testHelpers.ts +54 -0
- package/templates/root/src/slices/example/routes.ts +134 -0
- package/templates/root/src/supabase/LoginHandler.ts +36 -0
- package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
- package/templates/root/src/supabase/README.md +171 -0
- package/templates/root/src/supabase/api.ts +56 -0
- package/templates/root/src/supabase/component.ts +12 -0
- package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
- package/templates/root/src/supabase/requireUser.ts +72 -0
- package/templates/root/src/supabase/serverProps.ts +25 -0
- package/templates/root/src/supabase/staticProps.ts +10 -0
- package/templates/root/src/swagger.ts +34 -0
- package/templates/root/src/util/assertions.ts +6 -0
- package/templates/root/src/util/hash.ts +9 -0
- package/templates/root/src/util/sanitize.ts +23 -0
- package/templates/root/supabase/config.toml +295 -0
- package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
- package/templates/root/supabase/seed.sql +1 -0
- package/templates/root/tsconfig.json +32 -0
- package/templates/root/vercel.json +8 -0
- 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
|