@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.
- package/README.md +254 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +138 -0
- package/package.json +49 -0
- package/templates/.claude/hooks/QUICKSTART.md +256 -0
- package/templates/.claude/hooks/README.md +533 -0
- package/templates/.claude/hooks/analyze-commit.sh +22 -0
- package/templates/.claude/hooks/analyze-commit.ts +518 -0
- package/templates/.claude/hooks/analyzers/README.md +198 -0
- package/templates/.claude/hooks/analyzers/code-quality-checker.ts +154 -0
- package/templates/.claude/hooks/analyzers/code-quality.md +54 -0
- package/templates/.claude/hooks/analyzers/commit-blocker-example.ts.disabled +110 -0
- package/templates/.claude/hooks/analyzers/commit-policy.md +49 -0
- package/templates/.claude/hooks/analyzers/event-model-validator.md +49 -0
- package/templates/.claude/hooks/analyzers/event-model-validator.ts +169 -0
- package/templates/.claude/hooks/analyzers/example-logger.ts +70 -0
- package/templates/.claude/hooks/analyzers/slice-scope-validator.md +81 -0
- package/templates/.claude/hooks/check-review-result.sh +47 -0
- package/templates/.claude/hooks/prepare-review.sh +34 -0
- package/templates/.claude/hooks/review-agent-prompt.md +42 -0
- package/templates/.claude/hooks/run-review-agent.sh +124 -0
- package/templates/.claude/settings.local.json +37 -0
- package/templates/.claude/skills/help/README.md +84 -0
- package/templates/.claude/skills/help/SKILL.md +393 -0
- package/templates/.claude/skills/help/templates/demo-config.json +6753 -0
- package/templates/.claude/skills/sample-slices/SKILL.md +8 -0
- package/templates/.claude/skills/sample-slices/templates/.slices/Library/addbook/code-slice.json +124 -0
- package/templates/.claude/skills/sample-slices/templates/.slices/Library/addbook/slice.json +255 -0
- package/templates/.claude/skills/sample-slices/templates/.slices/Library/availablebooks/slice.json +107 -0
- package/templates/.claude/skills/sample-slices/templates/.slices/index.json +20 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/additem/slice.json +979 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/archiveitem/slice.json +529 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/cartitems/slice.json +1072 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/cartwithproducts/slice.json +394 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/changedprices/slice.json +88 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/changeinventory/slice.json +264 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/changeprice/slice.json +308 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/clearcart/slice.json +358 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/inventories/slice.json +203 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/publishcart/slice.json +876 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/removeitem/slice.json +560 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/submitcart/slice.json +708 -0
- package/templates/.claude/skills/sample-slices/templates/Cart/submittedcartdata/slice.json +399 -0
- package/templates/.claude/skills/sample-slices/templates/index.json +108 -0
- package/templates/.claude/skills/slice-automation/SKILL.md +49 -0
- package/templates/.claude/skills/slice-state-change/SKILL.md +369 -0
- package/templates/.claude/skills/slice-state-change/templates/AddLocation/AddLocation.test.ts.sample +76 -0
- package/templates/.claude/skills/slice-state-change/templates/AddLocation/AddLocationCommand.ts.sample +84 -0
- package/templates/.claude/skills/slice-state-change/templates/AddLocation/routes.ts.sample +73 -0
- package/templates/.claude/skills/slice-state-change/templates/README.md +46 -0
- package/templates/.claude/skills/slice-state-view/SKILL.md +336 -0
- package/templates/.claude/skills/slice-state-view/templates/Locations/Locations.test.ts.sample +84 -0
- package/templates/.claude/skills/slice-state-view/templates/Locations/LocationsProjection.ts.sample +50 -0
- package/templates/.claude/skills/slice-state-view/templates/Locations/routes.ts.sample +46 -0
- package/templates/.claude/skills/slice-state-view/templates/README.md +109 -0
- package/templates/.claude/skills/slice-state-view/templates/Tables/Tables.test.ts.sample +104 -0
- package/templates/.claude/skills/slice-state-view/templates/Tables/TablesProjection.ts.sample +59 -0
- package/templates/.claude/skills/slice-state-view/templates/Tables/routes.ts.sample +46 -0
- package/templates/.claude/skills/slice-state-view/templates/V2__tables.sql +7 -0
- package/templates/.claude/skills/slice-state-view/templates/V8__locations.sql +7 -0
- package/templates/.claude/skills/test-analyzer/SKILL.md +373 -0
- package/templates/.claude/skills/test-analyzer/examples/specification-format.md +143 -0
- package/templates/.claude/skills/test-analyzer/examples/state-change-example.md +111 -0
- package/templates/.claude/skills/test-analyzer/examples/state-view-example.md +122 -0
- package/templates/AGENTS.md +110 -0
- package/templates/Claude.md +58 -0
- package/templates/README.md +178 -0
- package/templates/backend/.env +9 -0
- package/templates/backend/BACKEND_AUTH_SETUP.md +183 -0
- package/templates/backend/SWAGGER.md +213 -0
- package/templates/backend/eslint.config.mjs +31 -0
- package/templates/backend/flyway.conf +17 -0
- package/templates/backend/package.json +44 -0
- package/templates/backend/prd.json.example +64 -0
- package/templates/backend/public/assets/images/banner.png +0 -0
- package/templates/backend/public/assets/logo.png +0 -0
- package/templates/backend/public/file.svg +4 -0
- package/templates/backend/public/globe.svg +12 -0
- package/templates/backend/public/next.svg +6 -0
- package/templates/backend/public/vercel.svg +3 -0
- package/templates/backend/public/window.svg +5 -0
- package/templates/backend/server.ts +129 -0
- package/templates/backend/setup-env.sh +50 -0
- package/templates/backend/src/common/assertions.ts +6 -0
- package/templates/backend/src/common/db.ts +1 -0
- package/templates/backend/src/common/loadPostgresEventstore.ts +16 -0
- package/templates/backend/src/common/parseEndpoint.ts +51 -0
- package/templates/backend/src/common/replay.ts +9 -0
- package/templates/backend/src/common/routes.ts +19 -0
- package/templates/backend/src/common/testHelpers.ts +53 -0
- package/templates/backend/src/core/readmodel.ts +28 -0
- package/templates/backend/src/core/types.ts +26 -0
- package/templates/backend/src/process/process.ts +53 -0
- package/templates/backend/src/supabase/LoginHandler.ts +36 -0
- package/templates/backend/src/supabase/ProtectedPageProps.ts +21 -0
- package/templates/backend/src/supabase/README.md +171 -0
- package/templates/backend/src/supabase/api.ts +63 -0
- package/templates/backend/src/supabase/authMiddleware.ts +53 -0
- package/templates/backend/src/supabase/component.ts +12 -0
- package/templates/backend/src/supabase/requireUser.ts +72 -0
- package/templates/backend/src/supabase/serverProps.ts +25 -0
- package/templates/backend/src/supabase/staticProps.ts +10 -0
- package/templates/backend/src/swagger.ts +34 -0
- package/templates/backend/src/util/assertions.ts +6 -0
- package/templates/backend/supabase/config.toml +295 -0
- package/templates/backend/supabase/migrations/20260121155918593_catalogentries.sql.sample +23 -0
- package/templates/backend/supabase/seed.sql +1 -0
- package/templates/backend/tsconfig.json +31 -0
- package/templates/frontend/.env.development +3 -0
- package/templates/frontend/AGENTS.md +7 -0
- package/templates/frontend/README.md +73 -0
- package/templates/frontend/components.json +20 -0
- package/templates/frontend/eslint.config.js +26 -0
- package/templates/frontend/index.html +18 -0
- package/templates/frontend/package-lock.json +8347 -0
- package/templates/frontend/package.json +94 -0
- package/templates/frontend/postcss.config.js +6 -0
- package/templates/frontend/public/favicon.ico +0 -0
- package/templates/frontend/public/logo.png +0 -0
- package/templates/frontend/public/placeholder.svg +1 -0
- package/templates/frontend/public/robots.txt +14 -0
- package/templates/frontend/src/App.css +42 -0
- package/templates/frontend/src/App.tsx +47 -0
- package/templates/frontend/src/components/NavLink.tsx +28 -0
- package/templates/frontend/src/components/ProtectedRoute.tsx +24 -0
- package/templates/frontend/src/components/calendar/Calendar.tsx +302 -0
- package/templates/frontend/src/components/layout/DashboardLayout.tsx +21 -0
- package/templates/frontend/src/components/layout/Header.tsx +45 -0
- package/templates/frontend/src/components/layout/Sidebar.tsx +82 -0
- package/templates/frontend/src/components/tables/ReservationTemplates.tsx +189 -0
- package/templates/frontend/src/components/ui/accordion.tsx +52 -0
- package/templates/frontend/src/components/ui/alert-dialog.tsx +104 -0
- package/templates/frontend/src/components/ui/alert.tsx +43 -0
- package/templates/frontend/src/components/ui/aspect-ratio.tsx +5 -0
- package/templates/frontend/src/components/ui/avatar.tsx +38 -0
- package/templates/frontend/src/components/ui/badge.tsx +29 -0
- package/templates/frontend/src/components/ui/breadcrumb.tsx +90 -0
- package/templates/frontend/src/components/ui/button.tsx +47 -0
- package/templates/frontend/src/components/ui/calendar.tsx +54 -0
- package/templates/frontend/src/components/ui/card.tsx +43 -0
- package/templates/frontend/src/components/ui/carousel.tsx +224 -0
- package/templates/frontend/src/components/ui/chart.tsx +303 -0
- package/templates/frontend/src/components/ui/checkbox.tsx +26 -0
- package/templates/frontend/src/components/ui/collapsible.tsx +9 -0
- package/templates/frontend/src/components/ui/command.tsx +132 -0
- package/templates/frontend/src/components/ui/context-menu.tsx +178 -0
- package/templates/frontend/src/components/ui/dialog.tsx +95 -0
- package/templates/frontend/src/components/ui/drawer.tsx +87 -0
- package/templates/frontend/src/components/ui/dropdown-menu.tsx +179 -0
- package/templates/frontend/src/components/ui/form.tsx +129 -0
- package/templates/frontend/src/components/ui/hover-card.tsx +27 -0
- package/templates/frontend/src/components/ui/input-otp.tsx +61 -0
- package/templates/frontend/src/components/ui/input.tsx +22 -0
- package/templates/frontend/src/components/ui/label.tsx +17 -0
- package/templates/frontend/src/components/ui/menubar.tsx +207 -0
- package/templates/frontend/src/components/ui/navigation-menu.tsx +120 -0
- package/templates/frontend/src/components/ui/pagination.tsx +81 -0
- package/templates/frontend/src/components/ui/popover.tsx +29 -0
- package/templates/frontend/src/components/ui/progress.tsx +23 -0
- package/templates/frontend/src/components/ui/radio-group.tsx +36 -0
- package/templates/frontend/src/components/ui/resizable.tsx +37 -0
- package/templates/frontend/src/components/ui/scroll-area.tsx +38 -0
- package/templates/frontend/src/components/ui/select.tsx +143 -0
- package/templates/frontend/src/components/ui/separator.tsx +20 -0
- package/templates/frontend/src/components/ui/sheet.tsx +107 -0
- package/templates/frontend/src/components/ui/sidebar.tsx +637 -0
- package/templates/frontend/src/components/ui/skeleton.tsx +7 -0
- package/templates/frontend/src/components/ui/slider.tsx +23 -0
- package/templates/frontend/src/components/ui/sonner.tsx +27 -0
- package/templates/frontend/src/components/ui/stat-card.tsx +44 -0
- package/templates/frontend/src/components/ui/switch.tsx +27 -0
- package/templates/frontend/src/components/ui/table.tsx +72 -0
- package/templates/frontend/src/components/ui/tabs.tsx +53 -0
- package/templates/frontend/src/components/ui/textarea.tsx +21 -0
- package/templates/frontend/src/components/ui/toast.tsx +111 -0
- package/templates/frontend/src/components/ui/toaster.tsx +24 -0
- package/templates/frontend/src/components/ui/toggle-group.tsx +49 -0
- package/templates/frontend/src/components/ui/toggle.tsx +37 -0
- package/templates/frontend/src/components/ui/tooltip.tsx +28 -0
- package/templates/frontend/src/components/ui/use-toast.ts +3 -0
- package/templates/frontend/src/contexts/AuthContext.tsx +94 -0
- package/templates/frontend/src/contexts/RefreshContext.tsx +236 -0
- package/templates/frontend/src/hooks/api/index.ts +2 -0
- package/templates/frontend/src/hooks/api/useLocations.ts +15 -0
- package/templates/frontend/src/hooks/use-mobile.tsx +19 -0
- package/templates/frontend/src/hooks/use-toast.ts +186 -0
- package/templates/frontend/src/hooks/useApiContext.ts +11 -0
- package/templates/frontend/src/index.css +118 -0
- package/templates/frontend/src/integrations/supabase/client.ts +9 -0
- package/templates/frontend/src/lib/api-client.ts +136 -0
- package/templates/frontend/src/lib/api.ts +1028 -0
- package/templates/frontend/src/lib/utils.ts +6 -0
- package/templates/frontend/src/main.tsx +5 -0
- package/templates/frontend/src/pages/Auth.tsx +408 -0
- package/templates/frontend/src/pages/Dashboard.tsx +168 -0
- package/templates/frontend/src/pages/Menus.tsx +224 -0
- package/templates/frontend/src/pages/NotFound.tsx +24 -0
- package/templates/frontend/src/pages/Register.tsx +285 -0
- package/templates/frontend/src/test/example.test.ts +0 -0
- package/templates/frontend/src/test/setup.ts +15 -0
- package/templates/frontend/src/types/index.ts +8 -0
- package/templates/frontend/src/vite-env.d.ts +1 -0
- package/templates/frontend/tailwind.config.ts +101 -0
- package/templates/frontend/tsconfig.app.json +31 -0
- package/templates/frontend/tsconfig.json +16 -0
- package/templates/frontend/tsconfig.node.json +22 -0
- package/templates/frontend/vite.config.ts +21 -0
- package/templates/frontend/vitest.config.ts +16 -0
- package/templates/init.sh +1 -0
- package/templates/prompt.md +139 -0
- package/templates/ralph.sh +120 -0
- 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
|
+
```
|
package/templates/.claude/skills/slice-state-view/templates/Locations/Locations.test.ts.sample
ADDED
|
@@ -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
|
+
});
|
package/templates/.claude/skills/slice-state-view/templates/Locations/LocationsProjection.ts.sample
ADDED
|
@@ -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
|