create-seiro 0.1.1 → 0.1.3

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 CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "create-seiro",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Scaffold a new Seiro project",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-seiro": "./index.ts"
8
8
  },
9
9
  "scripts": {
10
- "test": "bun test --ignore-pattern 'template/**/*'"
10
+ "test": "bun test"
11
11
  },
12
12
  "files": [
13
13
  "index.ts",
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: cqrs-document
3
+ description: Document-first CQRS design through conversation. Use when designing systems where users see documents (views) that are kept up to date by commands. Outputs SQL migrations and TypeScript types directly - no intermediate DSL. Triggers on discussions about documents, commands, entities, CQRS, Seiro protocol, or system design conversations that involve "what does the user see" questions.
4
+ ---
5
+
6
+ # Document-First CQRS Design
7
+
8
+ Design systems by starting with what the user sees (the document), then deriving entities and commands.
9
+
10
+ ## Core Principle
11
+
12
+ A document is a composition of entity views. Commands exist to transition the document from one valid state to another. Entities are derived from what's needed to support the documents.
13
+
14
+ ## Conversation Workflow
15
+
16
+ ### 1. Establish the Document
17
+
18
+ Ask: "What does the user see?"
19
+
20
+ Capture the shape:
21
+ ```
22
+ Document Venue {
23
+ id, name, location
24
+ sites: [Site { id, name, position, dimensions, area_id? }]
25
+ areas: [Area { id, name, parent_id? }]
26
+ }
27
+ ```
28
+
29
+ - Nesting implies joins
30
+ - `?` means optional/nullable
31
+ - `[]` means array/list
32
+ - Use entity names, subset fields for views
33
+
34
+ ### 2. Define Commands
35
+
36
+ Commands that transition the document:
37
+ ```
38
+ commands {
39
+ save_venue(name, location, id?)
40
+ delete_venue(id)
41
+ save_site(venue_id, name, position, dimensions, area_id?, id?)
42
+ delete_site(id)
43
+ }
44
+ ```
45
+
46
+ Conventions:
47
+ - `save_*` for create/update (id absent = create, id present = update)
48
+ - Required parameters first, optional `id?` last
49
+ - Commands return `{ id }` on success
50
+ - `delete_*` with cascade logic documented
51
+
52
+ ### 3. Derive Entities
53
+
54
+ Extract the minimal tables:
55
+ ```
56
+ venues (id, name, location)
57
+ sites (id, venue_id FK, name, position, dimensions, area_id?)
58
+ areas (id, venue_id FK, name, parent_id?)
59
+ ```
60
+
61
+ ### 4. Output Code
62
+
63
+ Once document is agreed, write directly:
64
+
65
+ **SQL Migration** - Tables, constraints, command functions with pg_notify
66
+ **TypeScript Types** - Document shapes, command/query types for Seiro
67
+
68
+ ## Output Conventions
69
+
70
+ ### SQL Command Functions
71
+
72
+ ```sql
73
+ CREATE FUNCTION cmd_entity_save(
74
+ p_required_field text,
75
+ p_optional_field text DEFAULT NULL,
76
+ p_id int DEFAULT NULL
77
+ ) RETURNS jsonb AS $$
78
+ DECLARE
79
+ v_entity entities%ROWTYPE;
80
+ BEGIN
81
+ IF p_id IS NULL THEN
82
+ INSERT INTO entities (...) VALUES (...) RETURNING * INTO v_entity;
83
+ ELSE
84
+ UPDATE entities SET ... WHERE id = p_id RETURNING * INTO v_entity;
85
+ END IF;
86
+
87
+ PERFORM pg_notify('entity_saved', jsonb_build_object(...));
88
+ RETURN jsonb_build_object('id', v_entity.id);
89
+ END;
90
+ $$ LANGUAGE plpgsql;
91
+ ```
92
+
93
+ ### SQL Delete with Cascade
94
+
95
+ ```sql
96
+ CREATE FUNCTION cmd_entity_delete(p_id int) RETURNS void AS $$
97
+ BEGIN
98
+ -- Nullify references before delete
99
+ UPDATE children SET entity_id = NULL WHERE entity_id = p_id;
100
+ DELETE FROM entities WHERE id = p_id;
101
+ PERFORM pg_notify('entity_deleted', jsonb_build_object('id', p_id));
102
+ END;
103
+ $$ LANGUAGE plpgsql;
104
+ ```
105
+
106
+ ### TypeScript Document Type
107
+
108
+ ```typescript
109
+ export type VenueDocument = {
110
+ id: number
111
+ name: string
112
+ location: string
113
+ sites: Site[]
114
+ areas: Area[]
115
+ }
116
+
117
+ export type Site = {
118
+ id: number
119
+ name: string
120
+ position: { x: number, y: number }
121
+ dimensions: { w: number, h: number }
122
+ area_id: number | null
123
+ }
124
+ ```
125
+
126
+ ## What NOT To Do
127
+
128
+ - No intermediate DSL or spec files
129
+ - No compiler or code generator to maintain
130
+ - No UML diagrams
131
+ - No abstract entity modelling before documents are clear
132
+
133
+ ## Iteration Pattern
134
+
135
+ When requirements change:
136
+ 1. Update the document shape in conversation
137
+ 2. Identify entity/command changes
138
+ 3. Write migration SQL
139
+ 4. Update TypeScript types
140
+
141
+ The code is the spec. PostgreSQL enforces it. TypeScript checks it.
@@ -0,0 +1,366 @@
1
+ # Seiro Protocol Reference
2
+
3
+ CQRS over WebSocket with Bun + Preact Signals + Web Components.
4
+
5
+ ## Wire Format
6
+
7
+ ```
8
+ ← { profile } sent on connect (User or null)
9
+
10
+ → { cmd, cid, data } command request
11
+ ← { cid, result } command success
12
+ ← { cid, err } command error
13
+
14
+ → { q, id, params } query request
15
+ ← { id, row } query row (repeated)
16
+ ← { id } query end
17
+ ← { id, err } query error
18
+
19
+ ← { ev, data } event broadcast
20
+
21
+ → { sub: "pattern" } subscribe to events
22
+ → { unsub: "pattern" } unsubscribe
23
+ ```
24
+
25
+ ## Type Definitions
26
+
27
+ Use `Command<D, R>` and `Query<P, R>` helpers:
28
+
29
+ ```typescript
30
+ import type { Command, Query } from "seiro";
31
+
32
+ export type Entity = {
33
+ id: number;
34
+ name: string;
35
+ };
36
+
37
+ export type EntityCommands = {
38
+ "entity.create": Command<{ name: string }, { id: number }>;
39
+ "entity.save": Command<{ id: number; name: string }, { id: number }>;
40
+ };
41
+
42
+ export type EntityQueries = {
43
+ "entities.all": Query<void, Entity>;
44
+ "entity.detail": Query<{ id: number }, EntityDetail>;
45
+ };
46
+
47
+ export type EntityEvents = {
48
+ entity_created: Entity;
49
+ entity_updated: Entity;
50
+ entity_deleted: { id: number }; // different payload type
51
+ };
52
+ ```
53
+
54
+ ## Server Setup
55
+
56
+ ```typescript
57
+ import postgres from "postgres";
58
+ import { createServer } from "seiro/server";
59
+ import * as auth from "./auth/server";
60
+ import * as entity from "./entity/server";
61
+
62
+ const sql = postgres(DATABASE_URL);
63
+
64
+ const server = createServer({
65
+ port: 3000,
66
+ auth: {
67
+ verify: auth.verifyToken,
68
+ public: ["auth.register", "auth.login"],
69
+ },
70
+ healthCheck: async () => {
71
+ await sql`SELECT 1`;
72
+ return true;
73
+ },
74
+ });
75
+
76
+ // Register domain handlers
77
+ auth.register(server, sql);
78
+ await entity.register(server, sql, listener); // with pg_notify listener
79
+
80
+ await server.start({ "/": homepage });
81
+ ```
82
+
83
+ ## Server Handler Pattern
84
+
85
+ ```typescript
86
+ import type { Sql } from "postgres";
87
+ import type { Server } from "seiro";
88
+ import type { Entity, EntityCommands, EntityQueries, EntityEvents } from "./types";
89
+
90
+ export async function register<
91
+ C extends EntityCommands,
92
+ Q extends EntityQueries,
93
+ E extends EntityEvents,
94
+ >(server: Server<C, Q, E>, sql: Sql, listener?: Sql) {
95
+ // Listen to postgres notifications (if listener provided)
96
+ if (listener) {
97
+ await listener.listen("entity_created", (payload: string) => {
98
+ try {
99
+ server.emit("entity_created", JSON.parse(payload) as Entity);
100
+ } catch (e) {
101
+ console.error("Failed to parse entity_created payload:", payload, e);
102
+ }
103
+ });
104
+
105
+ await listener.listen("entity_updated", (payload: string) => {
106
+ try {
107
+ server.emit("entity_updated", JSON.parse(payload) as Entity);
108
+ } catch (e) {
109
+ console.error("Failed to parse entity_updated payload:", payload, e);
110
+ }
111
+ });
112
+
113
+ // Different payload type for delete - just the id
114
+ await listener.listen("entity_deleted", (payload: string) => {
115
+ try {
116
+ server.emit("entity_deleted", JSON.parse(payload) as { id: number });
117
+ } catch (e) {
118
+ console.error("Failed to parse entity_deleted payload:", payload, e);
119
+ }
120
+ });
121
+ }
122
+
123
+ // Command with typed result
124
+ server.command("entity.save", async (data, ctx) => {
125
+ if (!ctx.userId) throw new Error("Not authenticated");
126
+ const [row] = await sql<[{ result: { id: number } }]>`
127
+ SELECT cmd_entity_save(${ctx.userId}, ${sql.json(data)}) as result
128
+ `;
129
+ return row?.result;
130
+ });
131
+
132
+ // Query with typed rows (generator)
133
+ server.query("entities.all", async function* (_params, ctx) {
134
+ if (!ctx.userId) throw new Error("Not authenticated");
135
+ const rows = await sql<{ query_entities_all: Entity }[]>`
136
+ SELECT query_entities_all(${ctx.userId})
137
+ `;
138
+ for (const row of rows) {
139
+ yield row.query_entities_all;
140
+ }
141
+ });
142
+ }
143
+ ```
144
+
145
+ ## Client Setup
146
+
147
+ ```typescript
148
+ import { createClient, effect } from "seiro/client";
149
+ import type { Commands, Queries, Events, User } from "./types";
150
+
151
+ const client = createClient<Commands, Queries, Events>(wsUrl);
152
+
153
+ // Connect returns profile (User | null)
154
+ const profile = await client.connect<User>();
155
+
156
+ // Set up event listeners before subscribing
157
+ client.on("entity_created", (data) => updateList(data));
158
+
159
+ // Start receiving events
160
+ client.subscribe();
161
+ ```
162
+
163
+ ## Client API
164
+
165
+ ```typescript
166
+ // Command with callbacks
167
+ client.cmd("entity.save", { id: 1, name: "Updated" }, {
168
+ onSuccess: (result) => navigate(`#/entities/${result.id}`),
169
+ onError: (err) => showError(err),
170
+ });
171
+
172
+ // Query (async iterator)
173
+ for await (const row of client.query("entities.all")) {
174
+ items.push(row);
175
+ }
176
+
177
+ // Query all at once
178
+ const items = await client.queryAll("entities.all");
179
+
180
+ // Event subscription (returns unsubscribe function)
181
+ const unsubscribe = client.on("entity_*", (data) => handle(data));
182
+
183
+ // Sync events to a signal with reducer
184
+ const entities = client.sync("entity_updated", initialState, (state, event) => {
185
+ return { ...state, [event.id]: event };
186
+ });
187
+
188
+ // Sync events to a Map signal
189
+ const entityMap = client.syncMap("entity_updated", (e) => e.id);
190
+
191
+ // Connection state (reactive signal)
192
+ effect(() => {
193
+ if (client.connected.value) {
194
+ console.log("Connected");
195
+ }
196
+ });
197
+
198
+ // Auth token management
199
+ client.setToken(token);
200
+ client.getToken();
201
+ client.logout(); // clears token
202
+
203
+ // Connection management
204
+ client.close();
205
+ await client.reconnect();
206
+ ```
207
+
208
+ ## Web Component Pattern
209
+
210
+ ```typescript
211
+ import { signal, effect } from "seiro/client";
212
+ import type { Client } from "seiro";
213
+ import type { Commands, Queries, Events, Entity } from "../types";
214
+
215
+ type AppClient = Client<Commands, Queries, Events>;
216
+
217
+ // Module-level state
218
+ const entities = signal<Map<number, Entity>>(new Map());
219
+ let client: AppClient;
220
+
221
+ export function initEntity(c: AppClient) {
222
+ client = c;
223
+ client.on("entity_created", (entity) => {
224
+ const next = new Map(entities.value);
225
+ next.set(entity.id, entity);
226
+ entities.value = next;
227
+ });
228
+ }
229
+
230
+ class EntityList extends HTMLElement {
231
+ connectedCallback() {
232
+ this.innerHTML = `<div data-list></div>`;
233
+ const list = this.querySelector("[data-list]")!;
234
+
235
+ effect(() => {
236
+ const items = Array.from(entities.value.values());
237
+ list.innerHTML = items
238
+ .map(e => `<div>${e.name}</div>`)
239
+ .join("") || "<p>None yet</p>";
240
+ });
241
+ }
242
+ }
243
+
244
+ customElements.define("entity-list", EntityList);
245
+ ```
246
+
247
+ ## File Structure
248
+
249
+ ```
250
+ project/
251
+ types.ts # Combined Commands, Queries, Events
252
+ server.ts # Server setup, registers handlers
253
+ app.ts # Client setup, routing
254
+ init_db/ # SQL migrations
255
+ {domain}/
256
+ types.ts # Domain types using Command/Query helpers
257
+ server.ts # Domain handlers with typed SQL
258
+ components/
259
+ {domain}.ts # Web Components with signals
260
+ shared/
261
+ modal.ts # Shared modal utilities
262
+ router.ts # Hash-based routing
263
+ ```
264
+
265
+ ## SQL Conventions
266
+
267
+ Commands return `{ id }` via JSONB:
268
+
269
+ ```sql
270
+ CREATE FUNCTION cmd_entity_save(p_user_id int, data jsonb)
271
+ RETURNS jsonb AS $$
272
+ DECLARE
273
+ v_entity entities%ROWTYPE;
274
+ BEGIN
275
+ INSERT INTO entities (user_id, name)
276
+ VALUES (p_user_id, data->>'name')
277
+ RETURNING * INTO v_entity;
278
+
279
+ PERFORM pg_notify('entity_created', row_to_json(v_entity)::text);
280
+ RETURN jsonb_build_object('id', v_entity.id);
281
+ END;
282
+ $$ LANGUAGE plpgsql;
283
+ ```
284
+
285
+ Queries return SETOF jsonb:
286
+
287
+ ```sql
288
+ CREATE FUNCTION query_entities_all(p_user_id int)
289
+ RETURNS SETOF jsonb AS $$
290
+ SELECT jsonb_build_object('id', id, 'name', name)
291
+ FROM entities
292
+ WHERE user_id = p_user_id
293
+ ORDER BY created_at DESC;
294
+ $$ LANGUAGE sql;
295
+ ```
296
+
297
+ ## Streaming Queries
298
+
299
+ Queries stream rows over the WebSocket - each row is sent as soon as the server yields it, and the client can process rows as they arrive.
300
+
301
+ ### How It Works
302
+
303
+ **Server:** Each `yield` sends a message immediately:
304
+
305
+ ```typescript
306
+ server.query("logs.recent", async function* (params, ctx) {
307
+ const rows = await sql`SELECT * FROM logs LIMIT 1000`;
308
+ for (const row of rows) {
309
+ yield row; // sent to client immediately
310
+ }
311
+ });
312
+ ```
313
+
314
+ **Wire:** Rows stream as individual messages:
315
+
316
+ ```
317
+ → { q: "logs.recent", id: 1, params: {} }
318
+ ← { id: 1, row: { id: 1, message: "..." } } // immediate
319
+ ← { id: 1, row: { id: 2, message: "..." } } // immediate
320
+ ← { id: 1, row: { id: 3, message: "..." } } // immediate
321
+ ...
322
+ ← { id: 1 } // end marker
323
+ ```
324
+
325
+ **Client:** Process rows as they arrive:
326
+
327
+ ```typescript
328
+ // Streaming - handle each row immediately
329
+ for await (const row of client.query("logs.recent")) {
330
+ appendToUI(row); // renders while more rows are coming
331
+ }
332
+
333
+ // Or collect all (waits for stream to complete)
334
+ const all = await client.queryAll("logs.recent");
335
+ ```
336
+
337
+ ### Use Cases
338
+
339
+ - **Large datasets:** Render first results while fetching more
340
+ - **Progress feedback:** Show items appearing one by one
341
+ - **Memory efficiency:** Process rows without holding all in memory
342
+ - **Responsive UI:** User sees data immediately, not after full load
343
+
344
+ ### True End-to-End Streaming
345
+
346
+ The example above streams WebSocket delivery, but SQL fetches all rows first. For true streaming from database to client, use cursors:
347
+
348
+ ```typescript
349
+ server.query("logs.stream", async function* (params, ctx) {
350
+ // Cursor-based streaming from postgres
351
+ const cursor = sql`SELECT * FROM logs`.cursor(100);
352
+ for await (const rows of cursor) {
353
+ for (const row of rows) {
354
+ yield row;
355
+ }
356
+ }
357
+ });
358
+ ```
359
+
360
+ ## Conventions
361
+
362
+ - Commands return `{ id }` for create/save operations
363
+ - Queries stream rows - each `yield` sends immediately, end with empty `{ id }`
364
+ - Use typed SQL: `sql<[{ result: Type }]>` or `sql<{ fn_name: Type }[]>`
365
+ - Events broadcast full data via pg_notify
366
+ - Pattern subscriptions support wildcards: `entity_*`
@@ -11,9 +11,9 @@ bun run check # typecheck
11
11
  bun test # run tests
12
12
  ```
13
13
 
14
- ## Adding Entities
14
+ ## Adding Features
15
15
 
16
- Use the new-entity skill to add new entities with proper types and handlers.
16
+ Use the `cqrs-document` skill for document-first CQRS design through conversation.
17
17
 
18
18
  ## Type Patterns
19
19
 
@@ -1,169 +0,0 @@
1
- # Entity UI Skill
2
-
3
- Create UI components for entities using Web Components and Preact Signals.
4
-
5
- ## Critical: Research First
6
-
7
- 1. **Read existing components** - Look at `components/auth.ts` for the pattern
8
- 2. **Check app.ts** - See how views are switched
9
- 3. **Use shared utilities** - Don't reinvent modals, routing
10
-
11
- ## Component Architecture
12
-
13
- ### Initialization Pattern
14
-
15
- Components are initialized with the client after connection:
16
-
17
- ```typescript
18
- // component file
19
- import { signal, effect } from "seiro/client";
20
- import type { Client } from "seiro";
21
- import type { Commands, Queries, Events } from "../types";
22
-
23
- type AppClient = Client<Commands, Queries, Events>;
24
-
25
- let client: AppClient;
26
-
27
- export function initMyEntity(c: AppClient) {
28
- client = c;
29
-
30
- // Subscribe to events
31
- client.on("entity_created", handleCreated);
32
- client.on("entity_updated", handleUpdated);
33
- }
34
-
35
- // app.ts - initialize after connect
36
- const profile = await client.connect<User>();
37
- initMyEntity(client);
38
- initAuth(client, profile); // Auth last - triggers rendering
39
- client.subscribe();
40
- ```
41
-
42
- ### State with Signals
43
-
44
- Use signals for reactive state that drives UI updates:
45
-
46
- ```typescript
47
- import { signal, effect } from "seiro/client";
48
-
49
- // Module-level state
50
- const entities = signal<Map<number, Entity>>(new Map());
51
-
52
- // Update by creating new Map (immutable pattern)
53
- function addEntity(entity: Entity) {
54
- const next = new Map(entities.value);
55
- next.set(entity.id, entity);
56
- entities.value = next;
57
- }
58
-
59
- // Load from query
60
- async function loadEntities() {
61
- const next = new Map<number, Entity>();
62
- for await (const row of client.query("entities.all")) {
63
- next.set(row.id, row);
64
- }
65
- entities.value = next;
66
- }
67
- ```
68
-
69
- ### Web Component Structure
70
-
71
- ```typescript
72
- class EntityList extends HTMLElement {
73
- private disposeEffects: (() => void)[] = [];
74
-
75
- connectedCallback() {
76
- this.innerHTML = `<div data-list></div>`;
77
- const list = this.querySelector("[data-list]")!;
78
-
79
- // React to signal changes
80
- this.disposeEffects.push(
81
- effect(() => {
82
- const items = Array.from(entities.value.values());
83
- list.innerHTML = items
84
- .map(e => `<div>${e.name}</div>`)
85
- .join("") || "<p>None yet</p>";
86
- })
87
- );
88
- }
89
-
90
- disconnectedCallback() {
91
- // Clean up effects to prevent memory leaks
92
- for (const dispose of this.disposeEffects) dispose();
93
- this.disposeEffects = [];
94
- }
95
- }
96
-
97
- customElements.define("entity-list", EntityList);
98
- ```
99
-
100
- ## Routing
101
-
102
- ### Adding a Route
103
-
104
- Routes are handled in `app.ts` with hash-based navigation:
105
-
106
- ```typescript
107
- import { route, navigate } from "./components/shared/router";
108
-
109
- effect(() => {
110
- const currentRoute = route.value;
111
-
112
- if (currentRoute.startsWith("#/myview")) {
113
- main.innerHTML = `<my-component></my-component>`;
114
- }
115
- });
116
- ```
117
-
118
- ### Navigation Links
119
-
120
- ```html
121
- <a href="#/myview" class="text-zinc-500 hover:text-zinc-300">My View</a>
122
- ```
123
-
124
- ## Shared Utilities
125
-
126
- ### Modals (components/shared/modal.ts)
127
-
128
- ```typescript
129
- import { showToast, showInputModal, showConfirmModal, showFormModal } from "./shared/modal";
130
-
131
- // Toast notification
132
- showToast("Saved successfully");
133
-
134
- // Input modal - returns string or null
135
- const name = await showInputModal("Enter name", "Default value");
136
-
137
- // Confirm modal - returns boolean
138
- const confirmed = await showConfirmModal("Delete?", "This cannot be undone.");
139
-
140
- // Form modal - returns object or null
141
- const result = await showFormModal("Edit User", [
142
- { name: "name", label: "Name", required: true },
143
- { name: "email", label: "Email" },
144
- ]);
145
- ```
146
-
147
- ### Router (components/shared/router.ts)
148
-
149
- ```typescript
150
- import { route, navigate } from "./shared/router";
151
-
152
- // Read current route (reactive)
153
- effect(() => {
154
- if (route.value === "#/myview") {
155
- // show my view
156
- }
157
- });
158
-
159
- // Navigate programmatically
160
- navigate("#/myview/123");
161
- ```
162
-
163
- ## Checklist
164
-
165
- - [ ] Component has `initX(client)` function called from app.ts
166
- - [ ] Event subscriptions set up in init function
167
- - [ ] Signal state with immutable updates (new Map)
168
- - [ ] Effects cleaned up in disconnectedCallback
169
- - [ ] Routes added to app.ts effect
@@ -1,198 +0,0 @@
1
- # New Entity Skill
2
-
3
- Create a new entity with database, server handlers, and client types.
4
-
5
- ## Critical: Research First, Copy Patterns
6
-
7
- **Before writing any code:**
8
-
9
- 1. **Read existing similar code** - Look at `auth/` for the pattern
10
- 2. **Copy patterns exactly** - Don't invent new approaches, follow what already works
11
- 3. **Check the wire protocol** - Review `CLAUDE.md` for message formats
12
-
13
- ## Type Definitions
14
-
15
- Use `Command<D, R>` and `Query<P, R>` helpers for type-safe definitions:
16
-
17
- ```typescript
18
- // {entity}/types.ts
19
- import type { Command, Query } from "seiro";
20
-
21
- export type {Entity} = {
22
- id: number;
23
- name: string;
24
- // fields...
25
- };
26
-
27
- export type {Entity}Commands = {
28
- "{entity}.create": Command<{ name: string }, { id: number }>;
29
- "{entity}.save": Command<{ id: number; name: string }, { id: number }>;
30
- "{entity}.delete": Command<{ id: number }, void>;
31
- };
32
-
33
- export type {Entity}Queries = {
34
- "{entity}s.all": Query<void, {Entity}>;
35
- "{entity}.detail": Query<{ id: number }, {Entity}Detail>;
36
- };
37
-
38
- export type {Entity}Events = {
39
- {entity}_created: {Entity};
40
- {entity}_updated: {Entity};
41
- };
42
- ```
43
-
44
- ## Typed SQL Pattern
45
-
46
- Use postgres library's type parameter:
47
-
48
- ```typescript
49
- // Query returning multiple rows
50
- server.query("{entity}s.all", async function* (_params, ctx) {
51
- if (!ctx.userId) throw new Error("Not authenticated");
52
- const rows = await sql<{ query_{entity}s_all: {Entity} }[]>`
53
- SELECT query_{entity}s_all(${ctx.userId})
54
- `;
55
- for (const row of rows) {
56
- yield row.query_{entity}s_all;
57
- }
58
- });
59
-
60
- // Command returning result
61
- server.command("{entity}.save", async (data, ctx) => {
62
- if (!ctx.userId) throw new Error("Not authenticated");
63
- const [row] = await sql<[{ result: { id: number } }]>`
64
- SELECT cmd_{entity}_save(${ctx.userId}, ${sql.json(data)}) as result
65
- `;
66
- return row?.result;
67
- });
68
- ```
69
-
70
- ## Steps
71
-
72
- ### 1. Types ({entity}/types.ts)
73
-
74
- ```typescript
75
- import type { Command, Query } from "seiro";
76
-
77
- export type {Entity} = {
78
- id: number;
79
- name: string;
80
- };
81
-
82
- export type {Entity}Commands = {
83
- "{entity}.create": Command<{ name: string }, { id: number }>;
84
- "{entity}.save": Command<{ id?: number; name: string }, { id: number }>;
85
- "{entity}.delete": Command<{ id: number }, void>;
86
- };
87
-
88
- export type {Entity}Queries = {
89
- "{entity}s.all": Query<void, {Entity}>;
90
- };
91
-
92
- export type {Entity}Events = {
93
- {entity}_created: {Entity};
94
- {entity}_updated: {Entity};
95
- };
96
- ```
97
-
98
- ### 2. Database (init_db/)
99
-
100
- Create a new numbered SQL file (e.g., `04_{entity}_tables.sql`):
101
-
102
- ```sql
103
- -- Table
104
- CREATE TABLE {entity}s (
105
- id SERIAL PRIMARY KEY,
106
- user_id INTEGER NOT NULL REFERENCES users(id),
107
- name TEXT NOT NULL,
108
- created_at TIMESTAMPTZ DEFAULT now()
109
- );
110
-
111
- -- Command: create
112
- CREATE OR REPLACE FUNCTION cmd_{entity}_create(p_user_id INTEGER, data JSONB)
113
- RETURNS JSONB AS $$
114
- DECLARE
115
- v_result {entity}s;
116
- BEGIN
117
- INSERT INTO {entity}s (user_id, name)
118
- VALUES (p_user_id, data->>'name')
119
- RETURNING * INTO v_result;
120
-
121
- RETURN jsonb_build_object('id', v_result.id);
122
- END;
123
- $$ LANGUAGE plpgsql;
124
-
125
- -- Query: all for user
126
- CREATE OR REPLACE FUNCTION query_{entity}s_all(p_user_id INTEGER)
127
- RETURNS SETOF JSONB AS $$
128
- SELECT jsonb_build_object('id', id, 'name', name)
129
- FROM {entity}s
130
- WHERE user_id = p_user_id
131
- ORDER BY created_at DESC;
132
- $$ LANGUAGE sql;
133
- ```
134
-
135
- ### 3. Server Handlers ({entity}/server.ts)
136
-
137
- ```typescript
138
- import type { Sql } from "postgres";
139
- import type { Server } from "seiro";
140
- import type {
141
- {Entity},
142
- {Entity}Commands,
143
- {Entity}Queries,
144
- {Entity}Events,
145
- } from "./types";
146
-
147
- export function register<
148
- C extends {Entity}Commands,
149
- Q extends {Entity}Queries,
150
- E extends {Entity}Events,
151
- >(server: Server<C, Q, E>, sql: Sql) {
152
- server.command("{entity}.create", async (data, ctx) => {
153
- if (!ctx.userId) throw new Error("Not authenticated");
154
- const [row] = await sql<[{ result: { id: number } }]>`
155
- SELECT cmd_{entity}_create(${ctx.userId}, ${sql.json(data)}) as result
156
- `;
157
- return row?.result;
158
- });
159
-
160
- server.query("{entity}s.all", async function* (_params, ctx) {
161
- if (!ctx.userId) throw new Error("Not authenticated");
162
- const rows = await sql<{ query_{entity}s_all: {Entity} }[]>`
163
- SELECT query_{entity}s_all(${ctx.userId})
164
- `;
165
- for (const row of rows) {
166
- yield row.query_{entity}s_all;
167
- }
168
- });
169
- }
170
-
171
- export const channels = ["{entity}_created", "{entity}_updated"] as const;
172
- ```
173
-
174
- ### 4. Register Types (types.ts)
175
-
176
- ```typescript
177
- import type { {Entity}Commands, {Entity}Queries, {Entity}Events } from "./{entity}/types";
178
- export type { {Entity} } from "./{entity}/types";
179
-
180
- export type Commands = AuthCommands & {Entity}Commands;
181
- export type Queries = AuthQueries & {Entity}Queries;
182
- export type Events = AuthEvents & {Entity}Events;
183
- ```
184
-
185
- ### 5. Register in Server (server.ts)
186
-
187
- ```typescript
188
- import * as {entity} from "./{entity}/server";
189
-
190
- {entity}.register(server, sql);
191
- ```
192
-
193
- ## Checklist
194
-
195
- - [ ] Types use `Command<D, R>` and `Query<P, R>` helpers
196
- - [ ] Server handlers use typed SQL: `sql<[{ result: Type }]>`
197
- - [ ] Database functions return JSONB with `jsonb_build_object()`
198
- - [ ] Tests written and passing