create-seiro 0.1.0 → 0.1.2
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/index.ts +15 -4
- package/package.json +4 -1
- package/template/.claude/skills/cqrs-document/SKILL.md +141 -0
- package/template/.claude/skills/cqrs-document/references/seiro.md +288 -0
- package/template/CLAUDE.md +2 -2
- package/template/assets.d.ts +9 -0
- package/template/bunfig.toml +2 -0
- package/template/index.html +20 -22
- package/template/package.json +3 -1
- package/template/server.ts +5 -20
- package/template/styles.css +1 -0
- package/template/.claude/skills/entity-ui.md +0 -169
- package/template/.claude/skills/new-entity.md +0 -198
- /package/template/{server.test.ts → server.test.ts.template} +0 -0
package/index.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { $ } from "bun";
|
|
3
3
|
import { cp, readFile, writeFile } from "fs/promises";
|
|
4
|
-
import { join, resolve } from "path";
|
|
4
|
+
import { basename, join, resolve } from "path";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
if (!
|
|
6
|
+
const projectArg = process.argv[2];
|
|
7
|
+
if (!projectArg) {
|
|
8
8
|
console.error("Usage: bunx create-seiro <project-name>");
|
|
9
9
|
process.exit(1);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const targetDir = resolve(
|
|
12
|
+
const targetDir = resolve(projectArg);
|
|
13
|
+
const projectName = basename(targetDir);
|
|
13
14
|
const templateDir = join(import.meta.dir, "template");
|
|
14
15
|
|
|
15
16
|
console.log(`Creating ${projectName}...`);
|
|
@@ -23,6 +24,16 @@ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
|
23
24
|
pkg.name = projectName;
|
|
24
25
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2));
|
|
25
26
|
|
|
27
|
+
// Rename test file template
|
|
28
|
+
const testTemplatePath = join(targetDir, "server.test.ts.template");
|
|
29
|
+
const testPath = join(targetDir, "server.test.ts");
|
|
30
|
+
try {
|
|
31
|
+
await Bun.write(testPath, await Bun.file(testTemplatePath).text());
|
|
32
|
+
await Bun.file(testTemplatePath).unlink();
|
|
33
|
+
} catch {
|
|
34
|
+
// File might not exist
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
// Install dependencies
|
|
27
38
|
console.log("Installing dependencies...");
|
|
28
39
|
await $`cd ${targetDir} && bun install`.quiet();
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-seiro",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Scaffold a new Seiro project",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-seiro": "./index.ts"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"index.ts",
|
|
11
14
|
"template"
|
|
@@ -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,288 @@
|
|
|
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
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Server Setup
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import postgres from "postgres";
|
|
57
|
+
import { createServer } from "seiro/server";
|
|
58
|
+
import * as auth from "./auth/server";
|
|
59
|
+
import * as entity from "./entity/server";
|
|
60
|
+
|
|
61
|
+
const sql = postgres(DATABASE_URL);
|
|
62
|
+
|
|
63
|
+
const server = createServer({
|
|
64
|
+
port: 3000,
|
|
65
|
+
auth: {
|
|
66
|
+
verify: auth.verifyToken,
|
|
67
|
+
public: ["auth.register", "auth.login"],
|
|
68
|
+
},
|
|
69
|
+
healthCheck: async () => {
|
|
70
|
+
await sql`SELECT 1`;
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Register domain handlers
|
|
76
|
+
auth.register(server, sql);
|
|
77
|
+
entity.register(server, sql);
|
|
78
|
+
|
|
79
|
+
await server.start({ "/": homepage });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Server Handler Pattern
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import type { Sql } from "postgres";
|
|
86
|
+
import type { Server } from "seiro";
|
|
87
|
+
import type { Entity, EntityCommands, EntityQueries, EntityEvents } from "./types";
|
|
88
|
+
|
|
89
|
+
export function register<
|
|
90
|
+
C extends EntityCommands,
|
|
91
|
+
Q extends EntityQueries,
|
|
92
|
+
E extends EntityEvents,
|
|
93
|
+
>(server: Server<C, Q, E>, sql: Sql) {
|
|
94
|
+
|
|
95
|
+
// Send profile on connect (auth example)
|
|
96
|
+
server.onOpen(async (ctx) => {
|
|
97
|
+
if (!ctx.userId) {
|
|
98
|
+
ctx.send({ profile: null });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// fetch and send user profile
|
|
102
|
+
ctx.send({ profile: user });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Command with typed result
|
|
106
|
+
server.command("entity.save", async (data, ctx) => {
|
|
107
|
+
if (!ctx.userId) throw new Error("Not authenticated");
|
|
108
|
+
const [row] = await sql<[{ result: { id: number } }]>`
|
|
109
|
+
SELECT cmd_entity_save(${ctx.userId}, ${sql.json(data)}) as result
|
|
110
|
+
`;
|
|
111
|
+
return row?.result;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Query with typed rows (generator)
|
|
115
|
+
server.query("entities.all", async function* (_params, ctx) {
|
|
116
|
+
if (!ctx.userId) throw new Error("Not authenticated");
|
|
117
|
+
const rows = await sql<{ query_entities_all: Entity }[]>`
|
|
118
|
+
SELECT query_entities_all(${ctx.userId})
|
|
119
|
+
`;
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
yield row.query_entities_all;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Broadcast events from server
|
|
127
|
+
server.emit("entity_created", entity);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Client Setup
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { createClient, effect } from "seiro/client";
|
|
134
|
+
import type { Commands, Queries, Events, User } from "./types";
|
|
135
|
+
|
|
136
|
+
const client = createClient<Commands, Queries, Events>(wsUrl);
|
|
137
|
+
|
|
138
|
+
// Connect returns profile (User | null)
|
|
139
|
+
const profile = await client.connect<User>();
|
|
140
|
+
|
|
141
|
+
// Set up event listeners before subscribing
|
|
142
|
+
client.on("entity_created", (data) => updateList(data));
|
|
143
|
+
|
|
144
|
+
// Start receiving events
|
|
145
|
+
client.subscribe();
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Client API
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
// Command with callbacks
|
|
152
|
+
client.cmd("entity.save", { id: 1, name: "Updated" }, {
|
|
153
|
+
onSuccess: (result) => navigate(`#/entities/${result.id}`),
|
|
154
|
+
onError: (err) => showError(err),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Query (async iterator)
|
|
158
|
+
for await (const row of client.query("entities.all")) {
|
|
159
|
+
items.push(row);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Query all at once
|
|
163
|
+
const items = await client.queryAll("entities.all");
|
|
164
|
+
|
|
165
|
+
// Event subscription (returns unsubscribe function)
|
|
166
|
+
const unsubscribe = client.on("entity_*", (data) => handle(data));
|
|
167
|
+
|
|
168
|
+
// Sync events to a signal with reducer
|
|
169
|
+
const entities = client.sync("entity_updated", initialState, (state, event) => {
|
|
170
|
+
return { ...state, [event.id]: event };
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Sync events to a Map signal
|
|
174
|
+
const entityMap = client.syncMap("entity_updated", (e) => e.id);
|
|
175
|
+
|
|
176
|
+
// Connection state (reactive signal)
|
|
177
|
+
effect(() => {
|
|
178
|
+
if (client.connected.value) {
|
|
179
|
+
console.log("Connected");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Auth token management
|
|
184
|
+
client.setToken(token);
|
|
185
|
+
client.getToken();
|
|
186
|
+
client.logout(); // clears token
|
|
187
|
+
|
|
188
|
+
// Connection management
|
|
189
|
+
client.close();
|
|
190
|
+
await client.reconnect();
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Web Component Pattern
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { signal, effect } from "seiro/client";
|
|
197
|
+
import type { Client } from "seiro";
|
|
198
|
+
import type { Commands, Queries, Events, Entity } from "../types";
|
|
199
|
+
|
|
200
|
+
type AppClient = Client<Commands, Queries, Events>;
|
|
201
|
+
|
|
202
|
+
// Module-level state
|
|
203
|
+
const entities = signal<Map<number, Entity>>(new Map());
|
|
204
|
+
let client: AppClient;
|
|
205
|
+
|
|
206
|
+
export function initEntity(c: AppClient) {
|
|
207
|
+
client = c;
|
|
208
|
+
client.on("entity_created", (entity) => {
|
|
209
|
+
const next = new Map(entities.value);
|
|
210
|
+
next.set(entity.id, entity);
|
|
211
|
+
entities.value = next;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
class EntityList extends HTMLElement {
|
|
216
|
+
connectedCallback() {
|
|
217
|
+
this.innerHTML = `<div data-list></div>`;
|
|
218
|
+
const list = this.querySelector("[data-list]")!;
|
|
219
|
+
|
|
220
|
+
effect(() => {
|
|
221
|
+
const items = Array.from(entities.value.values());
|
|
222
|
+
list.innerHTML = items
|
|
223
|
+
.map(e => `<div>${e.name}</div>`)
|
|
224
|
+
.join("") || "<p>None yet</p>";
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
customElements.define("entity-list", EntityList);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## File Structure
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
project/
|
|
236
|
+
types.ts # Combined Commands, Queries, Events
|
|
237
|
+
server.ts # Server setup, registers handlers
|
|
238
|
+
app.ts # Client setup, routing
|
|
239
|
+
init_db/ # SQL migrations
|
|
240
|
+
{domain}/
|
|
241
|
+
types.ts # Domain types using Command/Query helpers
|
|
242
|
+
server.ts # Domain handlers with typed SQL
|
|
243
|
+
components/
|
|
244
|
+
{domain}.ts # Web Components with signals
|
|
245
|
+
shared/
|
|
246
|
+
modal.ts # Shared modal utilities
|
|
247
|
+
router.ts # Hash-based routing
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## SQL Conventions
|
|
251
|
+
|
|
252
|
+
Commands return `{ id }` via JSONB:
|
|
253
|
+
|
|
254
|
+
```sql
|
|
255
|
+
CREATE FUNCTION cmd_entity_save(p_user_id int, data jsonb)
|
|
256
|
+
RETURNS jsonb AS $$
|
|
257
|
+
DECLARE
|
|
258
|
+
v_entity entities%ROWTYPE;
|
|
259
|
+
BEGIN
|
|
260
|
+
INSERT INTO entities (user_id, name)
|
|
261
|
+
VALUES (p_user_id, data->>'name')
|
|
262
|
+
RETURNING * INTO v_entity;
|
|
263
|
+
|
|
264
|
+
PERFORM pg_notify('entity_created', row_to_json(v_entity)::text);
|
|
265
|
+
RETURN jsonb_build_object('id', v_entity.id);
|
|
266
|
+
END;
|
|
267
|
+
$$ LANGUAGE plpgsql;
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Queries return SETOF jsonb:
|
|
271
|
+
|
|
272
|
+
```sql
|
|
273
|
+
CREATE FUNCTION query_entities_all(p_user_id int)
|
|
274
|
+
RETURNS SETOF jsonb AS $$
|
|
275
|
+
SELECT jsonb_build_object('id', id, 'name', name)
|
|
276
|
+
FROM entities
|
|
277
|
+
WHERE user_id = p_user_id
|
|
278
|
+
ORDER BY created_at DESC;
|
|
279
|
+
$$ LANGUAGE sql;
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Conventions
|
|
283
|
+
|
|
284
|
+
- Commands return `{ id }` for create/save operations
|
|
285
|
+
- Queries stream rows, end with empty `{ id }`
|
|
286
|
+
- Use typed SQL: `sql<[{ result: Type }]>` or `sql<{ fn_name: Type }[]>`
|
|
287
|
+
- Events broadcast full data via pg_notify
|
|
288
|
+
- Pattern subscriptions support wildcards: `entity_*`
|
package/template/CLAUDE.md
CHANGED
|
@@ -11,9 +11,9 @@ bun run check # typecheck
|
|
|
11
11
|
bun test # run tests
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
## Adding
|
|
14
|
+
## Adding Features
|
|
15
15
|
|
|
16
|
-
Use the
|
|
16
|
+
Use the `cqrs-document` skill for document-first CQRS design through conversation.
|
|
17
17
|
|
|
18
18
|
## Type Patterns
|
|
19
19
|
|
package/template/index.html
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
<!
|
|
2
|
-
<html
|
|
3
|
-
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
</
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
</
|
|
20
|
-
</
|
|
21
|
-
<script type="module" src="/app.js"></script>
|
|
22
|
-
</body>
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Seiro App</title>
|
|
5
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
6
|
+
<script type="module" src="./app.ts"></script>
|
|
7
|
+
</head>
|
|
8
|
+
<body class="bg-zinc-900 text-zinc-100 min-h-screen">
|
|
9
|
+
<header class="border-b border-zinc-800 p-4">
|
|
10
|
+
<div class="max-w-6xl mx-auto flex items-center justify-between">
|
|
11
|
+
<h1 class="text-xl font-bold">Seiro App</h1>
|
|
12
|
+
<auth-form></auth-form>
|
|
13
|
+
</div>
|
|
14
|
+
</header>
|
|
15
|
+
<main class="max-w-6xl mx-auto p-4">
|
|
16
|
+
<div class="text-center py-12">
|
|
17
|
+
<p class="text-zinc-400">Loading...</p>
|
|
18
|
+
</div>
|
|
19
|
+
</main>
|
|
20
|
+
</body>
|
|
23
21
|
</html>
|
package/template/package.json
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
"test": "bun test server.test.ts"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"seiro": "^0.1.
|
|
13
|
+
"seiro": "^0.1.2",
|
|
14
14
|
"@preact/signals-core": "^1.12.2",
|
|
15
15
|
"postgres": "^3.4.8"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@types/bun": "latest",
|
|
19
|
+
"bun-plugin-tailwind": "^0.1.2",
|
|
20
|
+
"tailwindcss": "^4.1.18",
|
|
19
21
|
"typescript": "^5.9.3"
|
|
20
22
|
}
|
|
21
23
|
}
|
package/template/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import postgres from "postgres";
|
|
2
2
|
import { createServer } from "seiro/server";
|
|
3
|
-
import
|
|
3
|
+
import homepage from "./index.html";
|
|
4
4
|
import * as auth from "./auth/server";
|
|
5
5
|
|
|
6
6
|
const DATABASE_URL =
|
|
@@ -8,7 +8,7 @@ const DATABASE_URL =
|
|
|
8
8
|
|
|
9
9
|
const sql = postgres(DATABASE_URL);
|
|
10
10
|
|
|
11
|
-
const server = createServer
|
|
11
|
+
const server = createServer({
|
|
12
12
|
port: 3000,
|
|
13
13
|
auth: {
|
|
14
14
|
verify: auth.verifyToken,
|
|
@@ -23,22 +23,7 @@ const server = createServer<Commands, Queries, Events>({
|
|
|
23
23
|
// Register auth handlers
|
|
24
24
|
auth.register(server, sql);
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
const indexHtml = await Bun.file("index.html").text();
|
|
26
|
+
const app = await server.start({ "/": homepage });
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
headers: { "Content-Type": "text/html" },
|
|
32
|
-
}),
|
|
33
|
-
"/app.js": async () => {
|
|
34
|
-
const result = await Bun.build({
|
|
35
|
-
entrypoints: ["./app.ts"],
|
|
36
|
-
minify: false,
|
|
37
|
-
});
|
|
38
|
-
return new Response(result.outputs[0], {
|
|
39
|
-
headers: { "Content-Type": "application/javascript" },
|
|
40
|
-
});
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
console.log("Server running on http://localhost:3000");
|
|
28
|
+
console.log(`Server running at ${app.url}`);
|
|
29
|
+
console.log(`WebSocket at ws://localhost:3000/ws`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -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
|
|
File without changes
|