@eventmodelers/cli 0.0.1
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/README.md +175 -0
- package/cli.js +676 -0
- package/package.json +35 -0
- package/shared/build-kit/code-export.mjs +560 -0
- package/shared/build-kit/lib/ollama-agent.js +147 -0
- package/shared/build-kit/package.json +11 -0
- package/shared/build-kit/ralph-ollama.js +39 -0
- package/shared/build-kit/realtime-agent.js +18 -0
- package/stacks/axon/templates/.claude/skills/build-automation/SKILL.md +407 -0
- package/stacks/axon/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +114 -0
- package/stacks/axon/templates/.claude/skills/build-automation-workflow/SKILL.md +496 -0
- package/stacks/axon/templates/.claude/skills/build-automation-workflow/references/axon-workflow-api.md +279 -0
- package/stacks/axon/templates/.claude/skills/build-automation-workflow/references/feature-flag-patterns.md +114 -0
- package/stacks/axon/templates/.claude/skills/build-state-change/SKILL.md +396 -0
- package/stacks/axon/templates/.claude/skills/build-state-change/references/axon-test-fixture-patterns.md +162 -0
- package/stacks/axon/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +56 -0
- package/stacks/axon/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +115 -0
- package/stacks/axon/templates/.claude/skills/build-state-view/SKILL.md +282 -0
- package/stacks/axon/templates/.claude/skills/connect/SKILL.md +178 -0
- package/stacks/axon/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +611 -0
- package/stacks/axon/templates/.claude/skills/load-slice/SKILL.md +141 -0
- package/stacks/axon/templates/.claude/skills/update-slice-status/SKILL.md +105 -0
- package/stacks/axon/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/axon/templates/build-kit/lib/agent.sh +20 -0
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +158 -0
- package/stacks/axon/templates/build-kit/lib/prompt.md +126 -0
- package/stacks/axon/templates/build-kit/lib/ralph.js +369 -0
- package/stacks/axon/templates/build-kit/ralph-claude.js +44 -0
- package/stacks/axon/templates/build-kit/ralph.sh +98 -0
- package/stacks/axon/templates/root/.env.example +5 -0
- package/stacks/axon/templates/root/CLAUDE.md +60 -0
- package/stacks/axon/templates/root/README.md +44 -0
- package/stacks/axon/templates/root/docker-compose.yml +64 -0
- package/stacks/axon/templates/root/mvnw +259 -0
- package/stacks/axon/templates/root/mvnw.cmd +149 -0
- package/stacks/axon/templates/root/pom.xml +125 -0
- package/stacks/axon/templates/root/src/main/java/io/axoniq/quickstart/QuickstartApplication.java +128 -0
- package/stacks/axon/templates/root/src/main/java/io/axoniq/quickstart/config/WebConfig.java +155 -0
- package/stacks/axon/templates/root/src/main/resources/application.properties +2 -0
- package/stacks/axon/templates/root/src/main/resources/static/bg-body.png +0 -0
- package/stacks/axon/templates/root/src/main/resources/static/bg-header.png +0 -0
- package/stacks/axon/templates/root/src/main/resources/static/index.html +596 -0
- package/stacks/cratis-csharp/templates/.claude/skills/_shared/cratis-conventions.md +251 -0
- package/stacks/cratis-csharp/templates/.claude/skills/build-automation/SKILL.md +99 -0
- package/stacks/cratis-csharp/templates/.claude/skills/build-automation/references/patterns.md +115 -0
- package/stacks/cratis-csharp/templates/.claude/skills/build-state-change/SKILL.md +163 -0
- package/stacks/cratis-csharp/templates/.claude/skills/build-state-change/references/patterns.md +234 -0
- package/stacks/cratis-csharp/templates/.claude/skills/build-state-view/SKILL.md +120 -0
- package/stacks/cratis-csharp/templates/.claude/skills/build-state-view/references/patterns.md +166 -0
- package/stacks/cratis-csharp/templates/.claude/skills/connect/SKILL.md +169 -0
- package/stacks/cratis-csharp/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
- package/stacks/cratis-csharp/templates/.claude/skills/load-slice/SKILL.md +141 -0
- package/stacks/cratis-csharp/templates/.claude/skills/update-slice-status/SKILL.md +105 -0
- package/stacks/cratis-csharp/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/cratis-csharp/templates/build-kit/lib/agent.sh +20 -0
- package/stacks/cratis-csharp/templates/build-kit/lib/backend-prompt.md +129 -0
- package/stacks/cratis-csharp/templates/build-kit/lib/prompt.md +124 -0
- package/stacks/cratis-csharp/templates/build-kit/lib/ralph.js +302 -0
- package/stacks/cratis-csharp/templates/build-kit/ralph-claude.js +37 -0
- package/stacks/cratis-csharp/templates/build-kit/ralph.sh +98 -0
- package/stacks/cratis-csharp/templates/root/.frontend/index.css +29 -0
- package/stacks/cratis-csharp/templates/root/.frontend/index.html +17 -0
- package/stacks/cratis-csharp/templates/root/.frontend/main.tsx +18 -0
- package/stacks/cratis-csharp/templates/root/.frontend/tsconfig.json +42 -0
- package/stacks/cratis-csharp/templates/root/.frontend/tsconfig.node.json +11 -0
- package/stacks/cratis-csharp/templates/root/.frontend/vite.config.ts +56 -0
- package/stacks/cratis-csharp/templates/root/App.tsx +23 -0
- package/stacks/cratis-csharp/templates/root/CLAUDE.md +62 -0
- package/stacks/cratis-csharp/templates/root/CratisApp.csproj +25 -0
- package/stacks/cratis-csharp/templates/root/CratisApp.sln +18 -0
- package/stacks/cratis-csharp/templates/root/GlobalUsings.cs +3 -0
- package/stacks/cratis-csharp/templates/root/Home.tsx +102 -0
- package/stacks/cratis-csharp/templates/root/Program.cs +26 -0
- package/stacks/cratis-csharp/templates/root/README.md +192 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Listing/AllListings.ts +47 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Listing/Listing.cs +11 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Listing/Listing.ts +12 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Listing/ListingDataTable.tsx +17 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Listing/index.ts +1 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Registration/Register.ts +51 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Registration/RegisterDialog.tsx +18 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Registration/Registration.cs +27 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/Registration/index.ts +1 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/SomeFeature.tsx +22 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/SomeName.cs +3 -0
- package/stacks/cratis-csharp/templates/root/SomeModule/SomeFeature/index.ts +1 -0
- package/stacks/cratis-csharp/templates/root/appsettings.Development.json +9 -0
- package/stacks/cratis-csharp/templates/root/appsettings.json +26 -0
- package/stacks/cratis-csharp/templates/root/docker-compose.yml +23 -0
- package/stacks/cratis-csharp/templates/root/package.json +33 -0
- package/stacks/cratis-csharp/templates/root/tsconfig.json +3 -0
- package/stacks/modeling-kit/templates/.claude/settings.local.json +13 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +215 -0
- package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +176 -0
- package/stacks/modeling-kit/templates/.claude/skills/connect/SKILL.md +182 -0
- package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +383 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-applying-conways-law/SKILL.md +465 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +587 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/facilitating-event-modeling-workshops.md +626 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +542 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-designing-event-models/SKILL.md +497 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +783 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +604 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +699 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-integrating-legacy-systems/SKILL.md +674 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-optimizing-stream-design/SKILL.md +231 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-optimizing-stream-design/references/domain-patterns.md +150 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-optimizing-stream-design/references/patterns.md +329 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-optimizing-stream-design/references/snapshotting.md +204 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +352 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/project-planning-with-event-modeling.md +341 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/SKILL.md +153 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +136 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/examples.md +78 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/patterns.md +85 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +655 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-translating-external-events/SKILL.md +479 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +234 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/SKILL.md +353 -0
- package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +200 -0
- package/stacks/modeling-kit/templates/.claude/skills/handle-comment/SKILL.md +94 -0
- package/stacks/modeling-kit/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +441 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +290 -0
- package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +212 -0
- package/stacks/modeling-kit/templates/.claude/skills/storyboard-screen/SKILL.md +123 -0
- package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +353 -0
- package/stacks/modeling-kit/templates/.claude/skills/update-slice-status/SKILL.md +110 -0
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +134 -0
- package/stacks/modeling-kit/templates/kit/lib/agent.sh +20 -0
- package/stacks/modeling-kit/templates/kit/lib/ollama-agent.js +147 -0
- package/stacks/modeling-kit/templates/kit/lib/ralph.js +286 -0
- package/stacks/modeling-kit/templates/kit/package.json +12 -0
- package/stacks/modeling-kit/templates/kit/ralph-claude.js +50 -0
- package/stacks/modeling-kit/templates/kit/ralph-ollama.js +38 -0
- package/stacks/modeling-kit/templates/kit/ralph.sh +61 -0
- package/stacks/modeling-kit/templates/kit/realtime-agent.js +18 -0
- package/stacks/modeling-kit/templates/root/CLAUDE.md +56 -0
- package/stacks/node/templates/.claude/skills/build-automation/SKILL.md +266 -0
- package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +344 -0
- package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +412 -0
- package/stacks/node/templates/.claude/skills/connect/SKILL.md +178 -0
- package/stacks/node/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +628 -0
- package/stacks/node/templates/.claude/skills/load-slice/SKILL.md +143 -0
- package/stacks/node/templates/.claude/skills/update-slice-status/SKILL.md +110 -0
- package/stacks/node/templates/build-kit/README.md +86 -0
- package/stacks/node/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/node/templates/build-kit/lib/agent.sh +20 -0
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +152 -0
- package/stacks/node/templates/build-kit/lib/prompt.md +122 -0
- package/stacks/node/templates/build-kit/lib/ralph.js +369 -0
- package/stacks/node/templates/build-kit/ralph-claude.js +44 -0
- package/stacks/node/templates/build-kit/ralph.sh +98 -0
- package/stacks/node/templates/root/.env.example +15 -0
- package/stacks/node/templates/root/CLAUDE.md +60 -0
- package/stacks/node/templates/root/docker-compose.yml +15 -0
- package/stacks/node/templates/root/flyway.conf +17 -0
- package/stacks/node/templates/root/migrations/V1__schema.sql.example +12 -0
- package/stacks/node/templates/root/package.json +50 -0
- package/stacks/node/templates/root/server.ts +130 -0
- package/stacks/node/templates/root/setup-env.sh +53 -0
- package/stacks/node/templates/root/src/common/assertions.ts +6 -0
- package/stacks/node/templates/root/src/common/db.ts +32 -0
- package/stacks/node/templates/root/src/common/loadPostgresEventstore.ts +23 -0
- package/stacks/node/templates/root/src/common/parseEndpoint.ts +51 -0
- package/stacks/node/templates/root/src/common/processorDlq.ts +28 -0
- package/stacks/node/templates/root/src/common/realtimeBroadcast.ts +12 -0
- package/stacks/node/templates/root/src/common/replay.ts +16 -0
- package/stacks/node/templates/root/src/common/routes.ts +19 -0
- package/stacks/node/templates/root/src/common/testHelpers.ts +44 -0
- package/stacks/node/templates/root/src/swagger.ts +34 -0
- package/stacks/node/templates/root/src/util/assertions.ts +6 -0
- package/stacks/node/templates/root/src/util/hash.ts +9 -0
- package/stacks/node/templates/root/src/util/sanitize.ts +23 -0
- package/stacks/node/templates/root/tsconfig.json +32 -0
- package/stacks/node/templates/root/vercel.json +8 -0
- package/stacks/supabase/templates/.claude/skills/build-automation/SKILL.md +266 -0
- package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +344 -0
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +390 -0
- package/stacks/supabase/templates/.claude/skills/build-webhook/SKILL.md +303 -0
- package/stacks/supabase/templates/.claude/skills/connect/SKILL.md +178 -0
- package/stacks/supabase/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +628 -0
- package/stacks/supabase/templates/.claude/skills/load-slice/SKILL.md +143 -0
- package/stacks/supabase/templates/.claude/skills/update-slice-status/SKILL.md +110 -0
- package/stacks/supabase/templates/build-kit/README.md +86 -0
- package/stacks/supabase/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/supabase/templates/build-kit/lib/agent.sh +20 -0
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +152 -0
- package/stacks/supabase/templates/build-kit/lib/prompt.md +123 -0
- package/stacks/supabase/templates/build-kit/lib/ralph.js +369 -0
- package/stacks/supabase/templates/build-kit/ralph-claude.js +44 -0
- package/stacks/supabase/templates/build-kit/ralph.sh +98 -0
- package/stacks/supabase/templates/root/.env.example +22 -0
- package/stacks/supabase/templates/root/CLAUDE.md +61 -0
- package/stacks/supabase/templates/root/flyway.conf +17 -0
- package/stacks/supabase/templates/root/package.json +52 -0
- package/stacks/supabase/templates/root/server.ts +177 -0
- package/stacks/supabase/templates/root/setup-env.sh +53 -0
- package/stacks/supabase/templates/root/src/common/assertions.ts +6 -0
- package/stacks/supabase/templates/root/src/common/db.ts +32 -0
- package/stacks/supabase/templates/root/src/common/loadPostgresEventstore.ts +23 -0
- package/stacks/supabase/templates/root/src/common/parseEndpoint.ts +51 -0
- package/stacks/supabase/templates/root/src/common/processorDlq.ts +28 -0
- package/stacks/supabase/templates/root/src/common/realtimeBroadcast.ts +19 -0
- package/stacks/supabase/templates/root/src/common/replay.ts +16 -0
- package/stacks/supabase/templates/root/src/common/routes.ts +23 -0
- package/stacks/supabase/templates/root/src/common/testHelpers.ts +54 -0
- package/stacks/supabase/templates/root/src/supabase/README.md +141 -0
- package/stacks/supabase/templates/root/src/supabase/api.ts +56 -0
- package/stacks/supabase/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
- package/stacks/supabase/templates/root/src/supabase/requireSysUser.ts +40 -0
- package/stacks/supabase/templates/root/src/supabase/requireUser.ts +72 -0
- package/stacks/supabase/templates/root/src/swagger.ts +34 -0
- package/stacks/supabase/templates/root/src/util/assertions.ts +6 -0
- package/stacks/supabase/templates/root/src/util/hash.ts +9 -0
- package/stacks/supabase/templates/root/src/util/sanitize.ts +23 -0
- package/stacks/supabase/templates/root/supabase/config.toml +295 -0
- package/stacks/supabase/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
- package/stacks/supabase/templates/root/supabase/seed.sql +1 -0
- package/stacks/supabase/templates/root/tsconfig.json +32 -0
- package/stacks/supabase/templates/root/vercel.json +8 -0
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
> **Comments & description**: Each element (commands, events, readmodels, processors, screens, tables) carries a `comments: string[]` array (board comments on that node) and a `description` field. The slice itself also has `comments: string[]`. Use these as implementation hints — pass them as code comments, documentation, or validation logic where they add value. When done, resolve each used comment: `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve` (get comment IDs first via GET on the same path without the last two segments).
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Step 2 — Create the migration
|
|
31
|
+
|
|
32
|
+
File: `supabase/migrations/V{N}__{tablename}.sql`
|
|
33
|
+
|
|
34
|
+
Choose the next available version number by checking existing migration files.
|
|
35
|
+
|
|
36
|
+
```sql
|
|
37
|
+
CREATE TABLE IF NOT EXISTS "public"."{tablename}"
|
|
38
|
+
(
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
-- other columns from read model fields in slice.json
|
|
41
|
+
-- use snake_case for all column names
|
|
42
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
43
|
+
);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Column type guide:**
|
|
47
|
+
|
|
48
|
+
| Field type | SQL type |
|
|
49
|
+
|-----------|---------|
|
|
50
|
+
| string / UUID | `TEXT` |
|
|
51
|
+
| number (integer) | `INTEGER` |
|
|
52
|
+
| number (float) | `NUMERIC` |
|
|
53
|
+
| boolean | `BOOLEAN` |
|
|
54
|
+
| date | `TIMESTAMP` |
|
|
55
|
+
| nullable number | `INTEGER` (allow NULL) |
|
|
56
|
+
|
|
57
|
+
The PRIMARY KEY column is the one used in `.onConflict(...)` in the projection.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Step 3 — Create `{SliceName}Projection.ts`
|
|
62
|
+
|
|
63
|
+
File: `src/slices/{context}/{SliceName}/{SliceName}Projection.ts`
|
|
64
|
+
|
|
65
|
+
### Full structure
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import {postgreSQLRawSQLProjection} from '@event-driven-io/emmett-postgresql';
|
|
69
|
+
import {sql, SQL} from '@event-driven-io/dumbo';
|
|
70
|
+
import knex, {Knex} from 'knex';
|
|
71
|
+
import {type {EventA}, type {EventB}} from '../{Context}Events';
|
|
72
|
+
|
|
73
|
+
export const tableName = '{tablename}';
|
|
74
|
+
|
|
75
|
+
// TypeScript shape of one row in the read model
|
|
76
|
+
export type {SliceName}ReadModel = {
|
|
77
|
+
id: string;
|
|
78
|
+
// ... fields from slice.json readModel
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const getKnexInstance = (connectionString: string): Knex =>
|
|
82
|
+
knex({client: 'pg', connection: connectionString, pool: {min: 0, max: 1}});
|
|
83
|
+
|
|
84
|
+
type {SliceName}Events = {EventA} | {EventB};
|
|
85
|
+
|
|
86
|
+
export const {SliceName}Projection = postgreSQLRawSQLProjection<{SliceName}Events>({
|
|
87
|
+
name: '{SliceName}Projection',
|
|
88
|
+
canHandle: ['{EventA}', '{EventB}'],
|
|
89
|
+
evolve: async (event, context): Promise<SQL[]> => {
|
|
90
|
+
const db = getKnexInstance(context.connection.connectionString);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
switch (event.type) {
|
|
94
|
+
case '{EventA}':
|
|
95
|
+
// Insert with upsert — use for create/update events
|
|
96
|
+
return [sql(db(tableName)
|
|
97
|
+
.withSchema('public')
|
|
98
|
+
.insert({
|
|
99
|
+
id: event.data.id,
|
|
100
|
+
field1: event.data.field1,
|
|
101
|
+
field2: event.data.field2,
|
|
102
|
+
})
|
|
103
|
+
.onConflict('id')
|
|
104
|
+
.merge(['field1', 'field2'])
|
|
105
|
+
.toQuery())];
|
|
106
|
+
|
|
107
|
+
case '{EventB}':
|
|
108
|
+
// Delete — use for cancellation/removal events
|
|
109
|
+
return [sql(db(tableName)
|
|
110
|
+
.withSchema('public')
|
|
111
|
+
.where({id: event.data.id})
|
|
112
|
+
.delete()
|
|
113
|
+
.toQuery())];
|
|
114
|
+
|
|
115
|
+
default:
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
await db.destroy();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### SQL operation patterns
|
|
126
|
+
|
|
127
|
+
**Insert with upsert (create or update):**
|
|
128
|
+
```typescript
|
|
129
|
+
return [sql(db(tableName)
|
|
130
|
+
.withSchema('public')
|
|
131
|
+
.insert({ id: event.data.id, field: event.data.field })
|
|
132
|
+
.onConflict('id')
|
|
133
|
+
.merge(['field']) // list only columns to update on conflict
|
|
134
|
+
.toQuery())];
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Update only (record already exists):**
|
|
138
|
+
```typescript
|
|
139
|
+
return [sql(db(tableName)
|
|
140
|
+
.withSchema('public')
|
|
141
|
+
.where({id: event.data.id})
|
|
142
|
+
.update({field: event.data.field})
|
|
143
|
+
.toQuery())];
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Delete:**
|
|
147
|
+
```typescript
|
|
148
|
+
return [sql(db(tableName)
|
|
149
|
+
.withSchema('public')
|
|
150
|
+
.where({id: event.data.id})
|
|
151
|
+
.delete()
|
|
152
|
+
.toQuery())];
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Async DB lookup before update** (when you need to read current state first):
|
|
156
|
+
```typescript
|
|
157
|
+
const row = await db(tableName)
|
|
158
|
+
.withSchema('public')
|
|
159
|
+
.where({id: event.data.id})
|
|
160
|
+
.select('field')
|
|
161
|
+
.first();
|
|
162
|
+
|
|
163
|
+
if (!row) return [];
|
|
164
|
+
|
|
165
|
+
const newValue = row.field + delta;
|
|
166
|
+
return [sql(db(tableName)
|
|
167
|
+
.withSchema('public')
|
|
168
|
+
.where({id: event.data.id})
|
|
169
|
+
.update({field: newValue})
|
|
170
|
+
.toQuery())];
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Always wrap in `try/finally` and call `db.destroy()` in the `finally` block.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Step 4 — Register the projection in the event store
|
|
178
|
+
|
|
179
|
+
File: `src/common/loadPostgresEventstore.ts`
|
|
180
|
+
|
|
181
|
+
Add the new projection to the `projections.inline([...])` array:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import {{SliceName}Projection} from '../slices/{context}/{SliceName}/{SliceName}Projection';
|
|
185
|
+
|
|
186
|
+
// inside getPostgreSQLEventStore options:
|
|
187
|
+
projections: projections.inline([
|
|
188
|
+
// ... existing projections ...
|
|
189
|
+
{SliceName}Projection,
|
|
190
|
+
]),
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Step 5 — Create `{SliceName}.test.ts`
|
|
196
|
+
|
|
197
|
+
File: `src/slices/{context}/{SliceName}/{SliceName}.test.ts`
|
|
198
|
+
|
|
199
|
+
Uses `PostgreSQLProjectionSpec` with a real PostgreSQL container (Testcontainers). Flyway runs actual migrations so the schema matches production exactly.
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import {before, after, describe, it} from 'node:test';
|
|
203
|
+
import {PostgreSQLProjectionAssert, PostgreSQLProjectionSpec} from '@event-driven-io/emmett-postgresql';
|
|
204
|
+
import {{SliceName}Projection} from './{SliceName}Projection';
|
|
205
|
+
import {PostgreSqlContainer, StartedPostgreSqlContainer} from '@testcontainers/postgresql';
|
|
206
|
+
import knex, {Knex} from 'knex';
|
|
207
|
+
import assert from 'assert';
|
|
208
|
+
import {runFlywayMigrations} from '../../../common/testHelpers';
|
|
209
|
+
|
|
210
|
+
const TEST_ID = 'test-id-001';
|
|
211
|
+
|
|
212
|
+
describe('{SliceName} Specification', () => {
|
|
213
|
+
let postgres: StartedPostgreSqlContainer;
|
|
214
|
+
let connectionString: string;
|
|
215
|
+
let db: Knex;
|
|
216
|
+
let given: PostgreSQLProjectionSpec<any>;
|
|
217
|
+
|
|
218
|
+
before(async () => {
|
|
219
|
+
postgres = await new PostgreSqlContainer('postgres').start();
|
|
220
|
+
connectionString = postgres.getConnectionUri();
|
|
221
|
+
|
|
222
|
+
db = knex({client: 'pg', connection: connectionString});
|
|
223
|
+
|
|
224
|
+
await runFlywayMigrations(connectionString);
|
|
225
|
+
|
|
226
|
+
// Insert any prerequisite rows required by foreign keys:
|
|
227
|
+
// await db('parent_table').withSchema('public').insert({...});
|
|
228
|
+
|
|
229
|
+
given = PostgreSQLProjectionSpec.for({
|
|
230
|
+
projection: {SliceName}Projection,
|
|
231
|
+
connectionString,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
after(async () => {
|
|
236
|
+
await db?.destroy();
|
|
237
|
+
await postgres?.stop();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('spec: {SliceName} - inserts row on {EventA}', async () => {
|
|
241
|
+
const assertReadModel: PostgreSQLProjectionAssert = async ({connectionString: connStr}) => {
|
|
242
|
+
const queryDb = knex({client: 'pg', connection: connStr});
|
|
243
|
+
try {
|
|
244
|
+
const result = await queryDb('{tablename}')
|
|
245
|
+
.withSchema('public')
|
|
246
|
+
.where({id: TEST_ID})
|
|
247
|
+
.first();
|
|
248
|
+
|
|
249
|
+
assert.ok(result, 'row should exist');
|
|
250
|
+
assert.strictEqual(result.id, TEST_ID);
|
|
251
|
+
assert.strictEqual(result.field1, 'expected-value');
|
|
252
|
+
} finally {
|
|
253
|
+
await queryDb.destroy();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
await given([{
|
|
258
|
+
type: '{EventA}',
|
|
259
|
+
data: {id: TEST_ID, field1: 'expected-value'},
|
|
260
|
+
metadata: {stream_name: `{context}-${TEST_ID}`},
|
|
261
|
+
}])
|
|
262
|
+
.when([])
|
|
263
|
+
.then(assertReadModel);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('spec: {SliceName} - removes row on {EventB}', async () => {
|
|
267
|
+
const assertReadModel: PostgreSQLProjectionAssert = async ({connectionString: connStr}) => {
|
|
268
|
+
const queryDb = knex({client: 'pg', connection: connStr});
|
|
269
|
+
try {
|
|
270
|
+
const result = await queryDb('{tablename}')
|
|
271
|
+
.withSchema('public')
|
|
272
|
+
.where({id: TEST_ID})
|
|
273
|
+
.first();
|
|
274
|
+
|
|
275
|
+
assert.strictEqual(result, undefined, 'row should be deleted');
|
|
276
|
+
} finally {
|
|
277
|
+
await queryDb.destroy();
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
await given([
|
|
282
|
+
{
|
|
283
|
+
type: '{EventA}',
|
|
284
|
+
data: {id: TEST_ID, field1: 'value'},
|
|
285
|
+
metadata: {stream_name: `{context}-${TEST_ID}`},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
type: '{EventB}',
|
|
289
|
+
data: {id: TEST_ID},
|
|
290
|
+
metadata: {stream_name: `{context}-${TEST_ID}`},
|
|
291
|
+
},
|
|
292
|
+
])
|
|
293
|
+
.when([])
|
|
294
|
+
.then(assertReadModel);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Write one `it` block per specification in the slice.json. Use `given([events]).when([]).then(assertReadModel)`.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Step 6 — Create `routes.ts`
|
|
304
|
+
|
|
305
|
+
File: `src/slices/{context}/{SliceName}/routes.ts`
|
|
306
|
+
|
|
307
|
+
> **Concrete example**: `src/slices/example/routes.ts` — shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import {Request, Response, Router} from 'express';
|
|
311
|
+
import {WebApiSetup} from '@event-driven-io/emmett-expressjs';
|
|
312
|
+
import {requireUser} from '../../../supabase/requireUser';
|
|
313
|
+
import {{SliceName}ReadModel, tableName} from './{SliceName}Projection';
|
|
314
|
+
import {readmodel} from '../../../core/readmodel';
|
|
315
|
+
import createClient from '../../../supabase/api';
|
|
316
|
+
|
|
317
|
+
export const api = (): WebApiSetup => (router: Router): void => {
|
|
318
|
+
|
|
319
|
+
router.get('/api/query/{slicename}-collection', async (req: Request, res: Response) => {
|
|
320
|
+
try {
|
|
321
|
+
const principal = await requireUser(req, res, true);
|
|
322
|
+
if (principal.error) return;
|
|
323
|
+
|
|
324
|
+
const id = req.query._id?.toString();
|
|
325
|
+
const supabase = createClient();
|
|
326
|
+
|
|
327
|
+
const data: {SliceName}ReadModel | {SliceName}ReadModel[] | null =
|
|
328
|
+
id
|
|
329
|
+
? await readmodel(tableName, supabase).findById<{SliceName}ReadModel>('id', id)
|
|
330
|
+
: await readmodel(tableName, supabase).findAll<{SliceName}ReadModel>({});
|
|
331
|
+
|
|
332
|
+
const sanitized = JSON.parse(
|
|
333
|
+
JSON.stringify(data ?? [], (_, value) =>
|
|
334
|
+
typeof value === 'bigint' ? value.toString() : value,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return res.status(200).json(sanitized);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error(err);
|
|
341
|
+
return res.status(500).json({ok: false, error: 'Server error'});
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Step 7 — Wire up the route
|
|
350
|
+
|
|
351
|
+
Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
import {api as {SliceName}Api} from './slices/{context}/{SliceName}/routes';
|
|
355
|
+
|
|
356
|
+
{SliceName}Api()(router);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Files to create / modify
|
|
362
|
+
|
|
363
|
+
```
|
|
364
|
+
src/slices/{context}/{SliceName}/
|
|
365
|
+
├── {SliceName}Projection.ts ← projection logic
|
|
366
|
+
├── {SliceName}.test.ts ← PostgreSQLProjectionSpec tests
|
|
367
|
+
└── routes.ts ← GET query endpoint
|
|
368
|
+
|
|
369
|
+
supabase/migrations/
|
|
370
|
+
└── V{N}__{tablename}.sql ← table DDL
|
|
371
|
+
|
|
372
|
+
src/common/
|
|
373
|
+
└── loadPostgresEventstore.ts ← add projection to inline([...]) list
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Checklist
|
|
379
|
+
|
|
380
|
+
- [ ] Migration file created with correct version number and all columns
|
|
381
|
+
- [ ] `tableName` constant matches the migration table name exactly
|
|
382
|
+
- [ ] Projection registered in `loadPostgresEventstore.ts`
|
|
383
|
+
- [ ] `canHandle` lists every event type the projection reacts to
|
|
384
|
+
- [ ] `finally { await db.destroy() }` present in every `evolve` handler
|
|
385
|
+
- [ ] Tests use `runFlywayMigrations()` to apply the real schema
|
|
386
|
+
- [ ] One test scenario per specification in slice.json
|
|
387
|
+
- [ ] Every field in the read model definition in slice.json has a column in the migration and a field in the TypeScript type — no invented columns
|
|
388
|
+
- [ ] Every event type in `events[]` is listed in the projection's `canHandle` — no assumed events
|
|
389
|
+
- [ ] No extra columns or fields were added beyond what slice.json defines
|
|
390
|
+
- [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-webhook
|
|
3
|
+
description: Implements a webhook slice as a self-contained Supabase Edge Function using the Emmett framework — exposes an HTTP endpoint, fires a command, emits events
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build Webhook Slice (Supabase Edge Function)
|
|
7
|
+
|
|
8
|
+
> Before doing anything else, read the slice definition from `.build-kit/.slices/{Context}/{slicename}/slice.json`. This file is the **source of truth** for all commands, events, and endpoint behaviour.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## What a Webhook Slice is
|
|
14
|
+
|
|
15
|
+
A webhook slice exposes an HTTP endpoint (Supabase Edge Function) that accepts an external request (e.g. payment provider callback), translates it into an Emmett command, and appends events to the PostgreSQL event store.
|
|
16
|
+
|
|
17
|
+
Architecture:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
HTTP Request (external webhook / API call)
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
supabase/functions/{name}/index.ts ← edge function — parse, build command, handle
|
|
24
|
+
│
|
|
25
|
+
▼
|
|
26
|
+
CommandHandler (Emmett) ← evolve state, decide events
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
PostgreSQL Event Store ← new events appended
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The edge function is **self-contained** — it does not import from the Node.js backend. Emmett imports use Deno's `npm:` specifier; the runtime scaffold uses `jsr:@supabase/server`.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Step 1 — Read the slice.json
|
|
37
|
+
|
|
38
|
+
Extract:
|
|
39
|
+
- **sliceName** — becomes the Command name and function folder name (PascalCase / kebab-case)
|
|
40
|
+
- **context** — bounded context
|
|
41
|
+
- **commands[]** — command data fields (build the request body shape from these)
|
|
42
|
+
- **events[]** — events emitted when the command succeeds
|
|
43
|
+
- **endpoint** (if present) — preferred URL path; default to kebab-case slice name
|
|
44
|
+
> **Comments & description**: Each element carries a `comments: string[]` array and a `description` field. The slice also has `comments: string[]`. Use as implementation hints. When done, resolve each used comment: `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve` (get IDs first via GET on same path).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Step 2 — Ensure the shared events union exists
|
|
49
|
+
|
|
50
|
+
Check whether `src/slices/{context}/{Context}Events.ts` already has the event types. If not, add them using the same pattern as `build-state-change`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// src/slices/{context}/{Context}Events.ts
|
|
54
|
+
import type {Event} from '@event-driven-io/emmett';
|
|
55
|
+
|
|
56
|
+
type CommonMeta = {
|
|
57
|
+
stream_name?: string;
|
|
58
|
+
userId?: string;
|
|
59
|
+
correlation_id?: string;
|
|
60
|
+
causation_id?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type {EventName} = Event<'{EventName}', {
|
|
64
|
+
id: string;
|
|
65
|
+
// fields from slice.json events[]
|
|
66
|
+
}, CommonMeta>;
|
|
67
|
+
|
|
68
|
+
export type {Context}Events = /* existing */ | {EventName};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This keeps backend event types in sync even though the edge function is self-contained.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Step 3 — Create the Supabase Edge Function
|
|
76
|
+
|
|
77
|
+
File: `supabase/functions/{function-name}/index.ts`
|
|
78
|
+
|
|
79
|
+
Function name is the slice name in kebab-case (e.g. `activate-license`).
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// Setup type definitions for built-in Supabase Runtime APIs
|
|
83
|
+
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
|
84
|
+
import {withSupabase} from "jsr:@supabase/server@^1";
|
|
85
|
+
import {CommandHandler, type Command} from 'npm:@event-driven-io/emmett';
|
|
86
|
+
import {getPostgresEventStore} from 'npm:@event-driven-io/emmett-postgresql';
|
|
87
|
+
|
|
88
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
type CommonMeta = {
|
|
91
|
+
correlation_id?: string;
|
|
92
|
+
causation_id?: string;
|
|
93
|
+
userId?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Event type(s) — mirror src/slices/{context}/{Context}Events.ts
|
|
97
|
+
type {EventName} = {
|
|
98
|
+
type: '{EventName}';
|
|
99
|
+
data: {
|
|
100
|
+
id: string;
|
|
101
|
+
// fields from slice.json events[]
|
|
102
|
+
};
|
|
103
|
+
metadata: CommonMeta;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type {Context}Events = {EventName}; // extend union if multiple events
|
|
107
|
+
|
|
108
|
+
// Command type — fields from slice.json commands[]
|
|
109
|
+
type {SliceName}Command = Command<'{SliceName}', {
|
|
110
|
+
id: string;
|
|
111
|
+
// other fields from slice.json commands[].fields
|
|
112
|
+
}, CommonMeta>;
|
|
113
|
+
|
|
114
|
+
// Request body shape — derived from commands[].fields
|
|
115
|
+
interface {SliceName}Payload {
|
|
116
|
+
id?: string;
|
|
117
|
+
// other fields from slice.json commands[].fields
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── State (idempotency) ───────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
type {SliceName}State = {
|
|
123
|
+
processed: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const initialState = (): {SliceName}State => ({processed: false});
|
|
127
|
+
|
|
128
|
+
const evolve = (state: {SliceName}State, event: {Context}Events): {SliceName}State => {
|
|
129
|
+
switch (event.type) {
|
|
130
|
+
case '{EventName}':
|
|
131
|
+
return {...state, processed: true};
|
|
132
|
+
default:
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const decide = (
|
|
138
|
+
command: {SliceName}Command,
|
|
139
|
+
state: {SliceName}State,
|
|
140
|
+
): {Context}Events[] => {
|
|
141
|
+
if (state.processed) {
|
|
142
|
+
throw {code: 'already_processed', message: 'Already processed'};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [{
|
|
146
|
+
type: '{EventName}',
|
|
147
|
+
data: {
|
|
148
|
+
id: command.data.id,
|
|
149
|
+
// map all fields from command.data per slice.json
|
|
150
|
+
},
|
|
151
|
+
metadata: {
|
|
152
|
+
correlation_id: command.metadata?.correlation_id,
|
|
153
|
+
causation_id: command.metadata?.causation_id,
|
|
154
|
+
},
|
|
155
|
+
}];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ── Handler ───────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const {SliceName}Handler = CommandHandler<{SliceName}State, {Context}Events>({
|
|
161
|
+
evolve,
|
|
162
|
+
initialState,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
async function handle{SliceName}(id: string, command: {SliceName}Command) {
|
|
166
|
+
const connectionString = Deno.env.get('SUPABASE_DB_URL');
|
|
167
|
+
if (!connectionString) throw new Error('SUPABASE_DB_URL not set');
|
|
168
|
+
|
|
169
|
+
const eventStore = getPostgresEventStore(connectionString);
|
|
170
|
+
const result = await {SliceName}Handler(
|
|
171
|
+
eventStore,
|
|
172
|
+
id,
|
|
173
|
+
(state: {SliceName}State) => decide(command, state),
|
|
174
|
+
);
|
|
175
|
+
return {
|
|
176
|
+
nextExpectedStreamVersion: result.nextExpectedStreamVersion?.toString(),
|
|
177
|
+
lastEventGlobalPosition: result.lastEventGlobalPosition?.toString(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── HTTP Handler ──────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
console.info('{function-name} started');
|
|
184
|
+
|
|
185
|
+
export default {
|
|
186
|
+
fetch: withSupabase({auth: ['publishable', 'secret']}, async (req, _ctx) => {
|
|
187
|
+
try {
|
|
188
|
+
const body: {SliceName}Payload = await req.json();
|
|
189
|
+
const id = body.id ?? crypto.randomUUID();
|
|
190
|
+
|
|
191
|
+
const command: {SliceName}Command = {
|
|
192
|
+
type: '{SliceName}',
|
|
193
|
+
data: {
|
|
194
|
+
id,
|
|
195
|
+
// map fields from body per slice.json commands[].fields
|
|
196
|
+
},
|
|
197
|
+
metadata: {
|
|
198
|
+
correlation_id: req.headers.get('x-correlation-id') ?? id,
|
|
199
|
+
causation_id: id,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const result = await handle{SliceName}(id, command);
|
|
204
|
+
|
|
205
|
+
return Response.json({ok: true, id, ...result}, {status: 201});
|
|
206
|
+
} catch (err: unknown) {
|
|
207
|
+
const error = err as {code?: string; message?: string};
|
|
208
|
+
if (error.code) {
|
|
209
|
+
return Response.json({error: error.message}, {status: 409});
|
|
210
|
+
}
|
|
211
|
+
console.error('[{function-name}] Unhandled error:', err);
|
|
212
|
+
return Response.json({error: 'Internal server error'}, {status: 500});
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Key decisions when filling in the template
|
|
219
|
+
|
|
220
|
+
**Function name** — use kebab-case of the slice name as the directory (e.g. `activate-license`). This becomes the function URL: `/functions/v1/activate-license`.
|
|
221
|
+
|
|
222
|
+
**`withSupabase` auth modes** — `"publishable"` allows calls with the anon key; `"secret"` allows calls with the service role key. Use `ctx.authMode` to branch on privileged vs. unprivileged behaviour if needed. For payment webhooks where the provider cannot supply a Supabase key, you may need to verify a provider-specific signature header instead (e.g. Stripe's `x-stripe-signature`).
|
|
223
|
+
|
|
224
|
+
**`id` field** — always use the aggregate identifier as both the stream key and response `id`. Generate with `crypto.randomUUID()` if not provided in the request body.
|
|
225
|
+
|
|
226
|
+
**Idempotency** — `state.processed` guards against replaying the same command. Match the guard to the emitted event: the `evolve` case for `{EventName}` sets `processed: true`.
|
|
227
|
+
|
|
228
|
+
**Error codes** — throw `{code: 'snake_case_code', message: '...'}` from `decide`. The handler maps those to `409 Conflict`.
|
|
229
|
+
|
|
230
|
+
**`SUPABASE_DB_URL`** — PostgreSQL connection string. Available automatically as a built-in secret in Supabase Edge Functions — no manual configuration needed.
|
|
231
|
+
|
|
232
|
+
**CORS** — `withSupabase` handles preflight automatically. No manual `OPTIONS` handler needed.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Step 4 — Verify event store schema migration
|
|
237
|
+
|
|
238
|
+
The Emmett schema must be migrated before the edge function can write events. This happens once in the **backend startup**. Confirm that `src/common/loadPostgresEventstore.ts` calls `schema.migrate()`:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
await eventStoreInstance.schema.migrate(); // must be present
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
If the project has no Node.js backend yet, run the migration manually via a one-off script before deploying the edge function.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Step 5 — Register the function in supabase config
|
|
249
|
+
|
|
250
|
+
Add to `supabase/config.toml`:
|
|
251
|
+
|
|
252
|
+
```toml
|
|
253
|
+
[functions.{function-name}]
|
|
254
|
+
verify_jwt = false # false for public webhooks; true for internal API calls
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Payment providers (Stripe, Paddle, etc.) cannot supply a Supabase JWT, so `verify_jwt = false` is the correct choice for webhook endpoints. Verify the provider's own signature header in the handler body instead.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Step 6 — Test the edge function locally
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
supabase functions serve {function-name} --env-file .env.local
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Then call it:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
curl -i -X POST http://localhost:54321/functions/v1/{function-name} \
|
|
271
|
+
-H "Content-Type: application/json" \
|
|
272
|
+
-d '{ "id": "test-123", ... }'
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Checklist
|
|
278
|
+
|
|
279
|
+
- [ ] `supabase/functions/{function-name}/index.ts` created
|
|
280
|
+
- [ ] All `{SliceName}`, `{EventName}`, `{Context}`, `{function-name}` placeholders replaced
|
|
281
|
+
- [ ] Fields in `command.data` match `commands[].fields` from slice.json exactly
|
|
282
|
+
- [ ] Fields in emitted event `data` match `events[].fields` from slice.json exactly
|
|
283
|
+
- [ ] Idempotency guard in `decide` (throws `already_processed` on duplicate)
|
|
284
|
+
- [ ] Error `code` used in `throw` so the 409 mapping works
|
|
285
|
+
- [ ] Event type added to `src/slices/{context}/{Context}Events.ts`
|
|
286
|
+
- [ ] `supabase/config.toml` entry added with correct `verify_jwt` setting
|
|
287
|
+
- [ ] `schema.migrate()` confirmed in backend startup (or run manually)
|
|
288
|
+
- [ ] Local test with `supabase functions serve` passes
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Files to create / modify
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
supabase/functions/{function-name}/
|
|
296
|
+
└── index.ts ← self-contained Deno edge function
|
|
297
|
+
|
|
298
|
+
src/slices/{context}/
|
|
299
|
+
└── {Context}Events.ts ← add new event type and update union
|
|
300
|
+
|
|
301
|
+
supabase/
|
|
302
|
+
└── config.toml ← register the new function
|
|
303
|
+
```
|