dzql 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
@@ -1,34 +1,73 @@
1
- # Getting Started with DZQL
1
+ # Getting Started with DZQL - Practical Guide
2
2
 
3
- DZQL is a PostgreSQL-powered framework that provides automatic CRUD operations via WebSocket RPC with zero boilerplate. This guide walks you through setting up your first DZQL project.
3
+ DZQL is a PostgreSQL framework that gives you **atomic real-time updates** via WebSocket. Every database change broadcasts instantly to all connected clients. Zero boilerplate.
4
+
5
+ > **See also:** [REFERENCE.md](REFERENCE.md) for complete API documentation | [CLAUDE.md](../../CLAUDE.md) for AI development guide
6
+
7
+ ## The Core Pattern
8
+
9
+ 1. **Schema = API**: Define a table → DZQL auto-creates CRUD endpoints
10
+ 2. **Atomic Updates**: Every change is one transaction, broadcasts to all clients
11
+ 3. **Real-time Sync**: Listen to broadcasts, update local state, re-render
12
+ 4. **No Polling**: Changes propagate instantly, no stale data
13
+
14
+ ```javascript
15
+ // Listen to broadcasts
16
+ ws.onBroadcast((method, params) => {
17
+ if (method === "todos:insert") state.todos.push(params.data)
18
+ else if (method === "todos:update") {
19
+ const idx = state.todos.findIndex(t => t.id === params.data.id)
20
+ if (idx !== -1) state.todos[idx] = params.data
21
+ }
22
+ else if (method === "todos:delete") {
23
+ state.todos = state.todos.filter(t => t.id !== params.data.id)
24
+ }
25
+ render() // One render function, called on every change
26
+ })
27
+ ```
28
+
29
+ That's the entire pattern. All clients stay in sync automatically.
4
30
 
5
31
  ## Prerequisites
6
32
 
7
- - **Bun** 1.0+ (Node.js not required!)
8
- - **Docker** and **Docker Compose** (for PostgreSQL)
33
+ - **Bun** 1.0+ (Node.js not required)
34
+ - **Docker** and **Docker Compose**
9
35
  - A code editor
10
36
 
11
- ## Quick Start (5 minutes)
37
+ ## Quick Start (10 minutes)
12
38
 
13
- ### 1. Create a New Project
39
+ ### 1. Create Project
14
40
 
15
41
  ```bash
16
42
  mkdir my-dzql-app
17
43
  cd my-dzql-app
18
44
  bun init
45
+ bun add dzql
46
+
47
+ # Create directories
48
+ mkdir -p public init_db
19
49
  ```
20
50
 
21
- ### 2. Install DZQL
51
+ ### 2. Project Structure
22
52
 
23
- ```bash
24
- bun add dzql
53
+ ```
54
+ my-dzql-app/
55
+ ├── index.js # DZQL server
56
+ ├── public/
57
+ │ ├── index.html # HTML markup
58
+ │ ├── index.css # Styles
59
+ │ └── app.js # JavaScript
60
+ ├── init_db/
61
+ │ └── 001_domain.sql # Your schema
62
+ ├── compose.yml # PostgreSQL config
63
+ ├── package.json
64
+ └── app.test.ts # Tests (optional)
25
65
  ```
26
66
 
27
- ### 3. Set Up PostgreSQL with Docker
67
+ ### 3. Docker Setup
28
68
 
29
- Create a `docker-compose.yml`:
69
+ Create `compose.yml`:
30
70
 
31
- **For standalone projects (using npm/bun):**
32
71
  ```yaml
33
72
  services:
34
73
  postgres:
@@ -38,232 +77,677 @@ services:
38
77
  POSTGRES_PASSWORD: dzql
39
78
  POSTGRES_DB: dzql
40
79
  volumes:
41
- # DZQL Core System migrations
42
- - node_modules/dzql/src/database/migrations/001_schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
43
- - node_modules/dzql/src/database/migrations/002_functions.sql:/docker-entrypoint-initdb.d/002_functions.sql:ro
44
- - node_modules/dzql/src/database/migrations/003_operations.sql:/docker-entrypoint-initdb.d/003_operations.sql:ro
45
- - node_modules/dzql/src/database/migrations/004_search.sql:/docker-entrypoint-initdb.d/004_search.sql:ro
46
- - node_modules/dzql/src/database/migrations/005_entities.sql:/docker-entrypoint-initdb.d/005_entities.sql:ro
47
- - node_modules/dzql/src/database/migrations/006_auth.sql:/docker-entrypoint-initdb.d/006_auth.sql:ro
48
- - node_modules/dzql/src/database/migrations/007_events.sql:/docker-entrypoint-initdb.d/007_events.sql:ro
49
- - node_modules/dzql/src/database/migrations/008_hello.sql:/docker-entrypoint-initdb.d/008_hello.sql:ro
50
- - node_modules/dzql/src/database/migrations/008a_meta.sql:/docker-entrypoint-initdb.d/008a_meta.sql:ro
51
- # Your domain-specific migrations
52
- - ./init_db:/docker-entrypoint-initdb.d/init_db:ro
80
+ - ./node_modules/dzql/src/database/migrations/001_schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
81
+ - ./node_modules/dzql/src/database/migrations/002_functions.sql:/docker-entrypoint-initdb.d/002_functions.sql:ro
82
+ - ./node_modules/dzql/src/database/migrations/003_operations.sql:/docker-entrypoint-initdb.d/003_operations.sql:ro
83
+ - ./node_modules/dzql/src/database/migrations/004_search.sql:/docker-entrypoint-initdb.d/004_search.sql:ro
84
+ - ./node_modules/dzql/src/database/migrations/005_entities.sql:/docker-entrypoint-initdb.d/005_entities.sql:ro
85
+ - ./node_modules/dzql/src/database/migrations/006_auth.sql:/docker-entrypoint-initdb.d/006_auth.sql:ro
86
+ - ./node_modules/dzql/src/database/migrations/007_events.sql:/docker-entrypoint-initdb.d/007_events.sql:ro
87
+ - ./node_modules/dzql/src/database/migrations/008_hello.sql:/docker-entrypoint-initdb.d/008_hello.sql:ro
88
+ - ./node_modules/dzql/src/database/migrations/008a_meta.sql:/docker-entrypoint-initdb.d/008a_meta.sql:ro
89
+ - ./init_db/001_domain.sql:/docker-entrypoint-initdb.d/010_domain.sql:ro
53
90
  ports:
54
91
  - "5432:5432"
92
+ healthcheck:
93
+ test: ["CMD-SHELL", "pg_isready -U dzql"]
94
+ interval: 10s
95
+ timeout: 5s
96
+ retries: 5
55
97
  ```
56
98
 
57
- **For monorepo projects (like the venues example):**
99
+ **For monorepo projects** (like the venues example), use relative paths:
58
100
  ```yaml
59
- services:
60
- postgres:
61
- image: postgres:latest
62
- environment:
63
- POSTGRES_USER: dzql
64
- POSTGRES_PASSWORD: dzql
65
- POSTGRES_DB: dzql
66
- volumes:
67
- # DZQL Core System migrations (relative path from monorepo root)
68
- - ../../dzql/src/database/migrations/001_schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
69
- - ../../dzql/src/database/migrations/002_functions.sql:/docker-entrypoint-initdb.d/002_functions.sql:ro
70
- - ../../dzql/src/database/migrations/003_operations.sql:/docker-entrypoint-initdb.d/003_operations.sql:ro
71
- - ../../dzql/src/database/migrations/004_search.sql:/docker-entrypoint-initdb.d/004_search.sql:ro
72
- - ../../dzql/src/database/migrations/005_entities.sql:/docker-entrypoint-initdb.d/005_entities.sql:ro
73
- - ../../dzql/src/database/migrations/006_auth.sql:/docker-entrypoint-initdb.d/006_auth.sql:ro
74
- - ../../dzql/src/database/migrations/007_events.sql:/docker-entrypoint-initdb.d/007_events.sql:ro
75
- - ../../dzql/src/database/migrations/008_hello.sql:/docker-entrypoint-initdb.d/008_hello.sql:ro
76
- - ../../dzql/src/database/migrations/008a_meta.sql:/docker-entrypoint-initdb.d/008a_meta.sql:ro
77
- # Your domain-specific migrations
78
- - ./init_db:/docker-entrypoint-initdb.d/init_db:ro
79
- ports:
80
- - "5432:5432"
101
+ volumes:
102
+ - ../../dzql/src/database/migrations/001_schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
103
+ # ... etc
81
104
  ```
82
105
 
83
106
  Start PostgreSQL:
84
-
85
107
  ```bash
86
108
  docker compose up -d
87
109
  ```
88
110
 
89
- ### 4. Initialize Database
90
-
91
- The docker-compose.yml automatically runs DZQL core migrations from `node_modules/dzql/src/database/migrations/`.
111
+ ### 4. Database Schema
92
112
 
93
- Create your domain-specific migrations in `database/init_db/001_domain.sql`:
113
+ Create `init_db/001_domain.sql`:
94
114
 
95
115
  ```sql
96
- -- Create your tables (after DZQL core is set up)
97
- CREATE TABLE IF NOT EXISTS users (
116
+ CREATE TABLE IF NOT EXISTS todos (
98
117
  id SERIAL PRIMARY KEY,
99
- name TEXT NOT NULL,
100
- email TEXT NOT NULL UNIQUE,
101
- created_at TIMESTAMPTZ DEFAULT NOW()
118
+ user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
119
+ title TEXT NOT NULL,
120
+ description TEXT,
121
+ completed BOOLEAN DEFAULT FALSE,
122
+ created_at TIMESTAMPTZ DEFAULT NOW(),
123
+ updated_at TIMESTAMPTZ DEFAULT NOW()
102
124
  );
103
125
 
104
- -- Register your entities with DZQL
126
+ CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
127
+
105
128
  SELECT dzql.register_entity(
106
- p_table_name := 'users',
107
- p_label_field := 'name',
108
- p_searchable_fields := array['name', 'email'],
109
- p_fk_includes := '{}'::jsonb
129
+ p_table_name := 'todos',
130
+ p_label_field := 'title',
131
+ p_searchable_fields := array['title', 'description']
110
132
  );
111
133
  ```
112
134
 
113
- **Important:** Your migrations run AFTER the DZQL core migrations, so entity registration functions are available.
135
+ ### 5. Server
114
136
 
115
- Start PostgreSQL:
116
-
117
- ```bash
118
- docker compose up -d
119
- ```
120
-
121
- The Docker Compose file will automatically run all DZQL core migrations first, then your domain migrations.
122
-
123
- ### 5. Create Your Server
124
-
125
- Create `server/index.js`:
137
+ Create `index.js`:
126
138
 
127
139
  ```javascript
128
- import { createServer } from 'dzql';
140
+ import { createServer } from "dzql";
141
+ import index from "./public/index.html";
129
142
 
130
143
  const server = createServer({
131
144
  port: process.env.PORT || 3000,
132
- // Optional: custom API functions
133
- customApi: {},
134
- // Optional: static file serving
135
- staticPath: './public'
145
+ routes: {
146
+ "/": index,
147
+ },
136
148
  });
137
149
 
138
- console.log(`🚀 Server running on port ${server.port}`);
150
+ console.log(`🚀 DZQL Server running on http://localhost:${server.port}`);
139
151
  ```
140
152
 
141
- ### 6. Start Your Server
142
-
143
- ```bash
144
- bun server/index.js
153
+ ### 6. HTML
154
+
155
+ Create `public/index.html`:
156
+
157
+ ```html
158
+ <!doctype html>
159
+ <html lang="en">
160
+ <head>
161
+ <meta charset="UTF-8" />
162
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
163
+ <title>DZQL Todo App</title>
164
+ <link rel="stylesheet" href="./index.css" />
165
+ </head>
166
+ <body>
167
+ <div class="container">
168
+ <h1>✓ Todo App</h1>
169
+ <div id="status" class="status disconnected">Connecting...</div>
170
+ <div id="error" class="error hidden"></div>
171
+
172
+ <div id="authSection" class="section">
173
+ <h2>Login or Register</h2>
174
+ <form id="authForm" onsubmit="handleLoginSubmit(event)">
175
+ <div class="form-group">
176
+ <label>Email</label>
177
+ <input type="email" id="email" placeholder="user@example.com" required />
178
+ </div>
179
+ <div class="form-group">
180
+ <label>Password</label>
181
+ <input type="password" id="password" placeholder="••••••••" required />
182
+ </div>
183
+ <div class="btn-group">
184
+ <button type="submit" class="btn-primary">Login</button>
185
+ <button type="button" class="btn-secondary" onclick="handleRegister()">Register</button>
186
+ </div>
187
+ </form>
188
+ </div>
189
+
190
+ <div id="appSection" class="section hidden">
191
+ <button class="btn-secondary" style="width: 100%; margin-bottom: 20px" onclick="handleLogout()">
192
+ Logout
193
+ </button>
194
+
195
+ <div class="section">
196
+ <h2>Create Todo</h2>
197
+ <form id="todoForm" onsubmit="event.preventDefault(); handleAddTodo()">
198
+ <div class="form-group">
199
+ <label>Title</label>
200
+ <input type="text" id="todoTitle" placeholder="What needs to be done?" required />
201
+ </div>
202
+ <div class="form-group">
203
+ <label>Description</label>
204
+ <textarea id="todoDescription" placeholder="Add details..." rows="2"></textarea>
205
+ </div>
206
+ <button type="submit" class="btn-primary" style="width: 100%">Add Todo</button>
207
+ </form>
208
+ </div>
209
+
210
+ <div class="section">
211
+ <h2>Todos</h2>
212
+ <div id="todoList" class="todo-list"></div>
213
+ <div id="emptyState" class="empty-state">No todos yet</div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <script type="module" src="./app.js"></script>
219
+ </body>
220
+ </html>
145
221
  ```
146
222
 
147
- Your DZQL server is now running at `ws://localhost:3000/ws`!
223
+ ### 7. CSS
148
224
 
149
- ## Project Setup: Standalone vs Monorepo
225
+ Create `public/index.css`:
150
226
 
151
- ### Standalone Project (Recommended for Most Users)
152
- Use `node_modules/dzql/src/database/migrations/` paths in your docker-compose.yml
227
+ ```css
228
+ * {
229
+ box-sizing: border-box;
230
+ margin: 0;
231
+ padding: 0;
232
+ }
153
233
 
154
- ```
155
- my-app/
156
- ├── database/
157
- │ ├── docker-compose.yml # Uses node_modules paths
158
- │ └── init_db/
159
- │ └── 001_domain.sql
160
- ├── server/
161
- ├── client/
162
- └── package.json
163
- ```
234
+ body {
235
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
236
+ background: #f5f5f5;
237
+ padding: 20px;
238
+ }
164
239
 
165
- ### Monorepo Project (Like the Venues Example)
166
- Use relative paths `../../dzql/src/database/migrations/` in docker-compose.yml
240
+ .container {
241
+ max-width: 600px;
242
+ margin: 0 auto;
243
+ background: white;
244
+ padding: 30px;
245
+ border-radius: 8px;
246
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
247
+ }
167
248
 
168
- ```
169
- monorepo/
170
- ├── packages/
171
- │ ├── dzql/ # Framework package
172
- │ │ └── src/database/migrations/ # Core migrations
173
- │ └── my-app/ # Your app
174
- │ ├── database/
175
- │ │ └── docker-compose.yml # References ../../dzql/...
176
- │ ├── server/
177
- │ └── package.json
249
+ h1 {
250
+ margin-bottom: 20px;
251
+ color: #333;
252
+ }
253
+
254
+ .status {
255
+ padding: 10px;
256
+ border-radius: 4px;
257
+ margin-bottom: 20px;
258
+ font-weight: 500;
259
+ }
260
+
261
+ .status.connected {
262
+ background: #d4edda;
263
+ color: #155724;
264
+ }
265
+
266
+ .status.disconnected {
267
+ background: #f8d7da;
268
+ color: #721c24;
269
+ }
270
+
271
+ .error {
272
+ padding: 10px;
273
+ background: #f8d7da;
274
+ color: #721c24;
275
+ border-radius: 4px;
276
+ margin-bottom: 20px;
277
+ }
278
+
279
+ .hidden {
280
+ display: none;
281
+ }
282
+
283
+ .section {
284
+ margin-bottom: 30px;
285
+ }
286
+
287
+ .form-group {
288
+ margin-bottom: 15px;
289
+ }
290
+
291
+ .form-group label {
292
+ display: block;
293
+ margin-bottom: 5px;
294
+ font-weight: 500;
295
+ color: #333;
296
+ }
297
+
298
+ .form-group input,
299
+ .form-group textarea {
300
+ width: 100%;
301
+ padding: 10px;
302
+ border: 1px solid #ddd;
303
+ border-radius: 4px;
304
+ font-size: 14px;
305
+ }
306
+
307
+ .btn-group {
308
+ display: flex;
309
+ gap: 10px;
310
+ }
311
+
312
+ button {
313
+ padding: 10px 20px;
314
+ border: none;
315
+ border-radius: 4px;
316
+ cursor: pointer;
317
+ font-size: 14px;
318
+ font-weight: 500;
319
+ }
320
+
321
+ .btn-primary {
322
+ background: #007bff;
323
+ color: white;
324
+ flex: 1;
325
+ }
326
+
327
+ .btn-primary:hover {
328
+ background: #0056b3;
329
+ }
330
+
331
+ .btn-secondary {
332
+ background: #6c757d;
333
+ color: white;
334
+ flex: 1;
335
+ }
336
+
337
+ .btn-secondary:hover {
338
+ background: #545b62;
339
+ }
340
+
341
+ .btn-danger {
342
+ background: #dc3545;
343
+ color: white;
344
+ padding: 5px 10px;
345
+ font-size: 12px;
346
+ }
347
+
348
+ .btn-danger:hover {
349
+ background: #c82333;
350
+ }
351
+
352
+ .todo-list {
353
+ display: flex;
354
+ flex-direction: column;
355
+ gap: 10px;
356
+ }
357
+
358
+ .todo-item {
359
+ display: flex;
360
+ align-items: center;
361
+ gap: 10px;
362
+ padding: 15px;
363
+ background: #f8f9fa;
364
+ border-radius: 4px;
365
+ border: 1px solid #e9ecef;
366
+ }
367
+
368
+ .todo-item.completed {
369
+ opacity: 0.6;
370
+ }
371
+
372
+ .todo-item.completed .todo-title {
373
+ text-decoration: line-through;
374
+ }
375
+
376
+ .todo-checkbox {
377
+ width: 20px;
378
+ height: 20px;
379
+ cursor: pointer;
380
+ }
381
+
382
+ .todo-content {
383
+ flex: 1;
384
+ }
385
+
386
+ .todo-title {
387
+ font-weight: 500;
388
+ margin-bottom: 5px;
389
+ }
390
+
391
+ .todo-description {
392
+ font-size: 14px;
393
+ color: #666;
394
+ }
395
+
396
+ .empty-state {
397
+ text-align: center;
398
+ color: #999;
399
+ padding: 40px;
400
+ font-style: italic;
401
+ }
178
402
  ```
179
403
 
180
- ## Using DZQL in Your Client
404
+ ### 8. JavaScript
181
405
 
182
- ### Browser/Frontend
406
+ Create `public/app.js`:
183
407
 
184
408
  ```javascript
185
- import { useWs, WebSocketManager } from 'dzql/client';
409
+ import { WebSocketManager } from "dzql/client";
410
+
411
+ let ws = null;
412
+ let state = {
413
+ connected: false,
414
+ loggedIn: false,
415
+ userId: null,
416
+ todos: [],
417
+ };
418
+
419
+ function query(selector) {
420
+ return document.getElementById(selector);
421
+ }
186
422
 
187
- // Create a fresh WebSocket connection
188
- const ws = new WebSocketManager();
423
+ function render() {
424
+ const status = query("status");
425
+ status.textContent = state.connected
426
+ ? state.loggedIn
427
+ ? "✓ Connected"
428
+ : "✓ Connected (not logged in)"
429
+ : "✗ Disconnected";
430
+ status.className = `status ${state.connected ? "connected" : "disconnected"}`;
431
+
432
+ query("authSection").classList.toggle("hidden", state.loggedIn);
433
+ query("appSection").classList.toggle("hidden", !state.loggedIn);
434
+
435
+ if (state.loggedIn) {
436
+ const list = query("todoList");
437
+ const empty = query("emptyState");
438
+
439
+ if (state.todos.length === 0) {
440
+ list.innerHTML = "";
441
+ empty.classList.remove("hidden");
442
+ } else {
443
+ empty.classList.add("hidden");
444
+ list.innerHTML = state.todos
445
+ .map(
446
+ (todo) => `
447
+ <div class="todo-item ${todo.completed ? "completed" : ""}">
448
+ <input type="checkbox" class="todo-checkbox" ${todo.completed ? "checked" : ""}
449
+ onchange="handleToggleTodo(${todo.id})">
450
+ <div class="todo-content">
451
+ <div class="todo-title">${escapeHtml(todo.title)}</div>
452
+ ${todo.description ? `<div class="todo-description">${escapeHtml(todo.description)}</div>` : ""}
453
+ </div>
454
+ <button class="btn-danger" onclick="handleDeleteTodo(${todo.id})">Delete</button>
455
+ </div>
456
+ `,
457
+ )
458
+ .join("");
459
+ }
460
+ }
461
+ }
189
462
 
190
- // Connect to server
191
- await ws.connect();
463
+ function escapeHtml(text) {
464
+ const div = document.createElement("div");
465
+ div.textContent = text;
466
+ return div.innerHTML;
467
+ }
192
468
 
193
- // Login
194
- const result = await ws.api.login_user({
195
- email: 'user@example.com',
196
- password: 'password123'
197
- });
469
+ function error(msg) {
470
+ const el = query("error");
471
+ el.textContent = msg;
472
+ el.classList.remove("hidden");
473
+ setTimeout(() => el.classList.add("hidden"), 5000);
474
+ }
198
475
 
199
- // Use DZQL operations - all 5 operations work the same way:
200
- // GET, SAVE, DELETE, LOOKUP, SEARCH
476
+ async function auth(type) {
477
+ const email = query("email").value;
478
+ const password = query("password").value;
479
+
480
+ if (!email || !password) {
481
+ error("Email and password required");
482
+ return;
483
+ }
484
+
485
+ try {
486
+ const result = await ws.api[type]({ email, password });
487
+ if (result.token) {
488
+ localStorage.setItem("dzql_token", result.token);
489
+ state.loggedIn = true;
490
+ state.userId = result.user_id;
491
+ await loadTodos();
492
+ render();
493
+ }
494
+ } catch (err) {
495
+ error(err.message || `${type} failed`);
496
+ }
497
+ }
201
498
 
202
- // GET - Retrieve a single record
203
- const user = await ws.api.get.users({ id: 1 });
499
+ async function loadTodos() {
500
+ try {
501
+ const result = await ws.api.search.todos({
502
+ filters: { user_id: state.userId },
503
+ limit: 100,
504
+ });
505
+ state.todos = result.data || [];
506
+ } catch (err) {
507
+ error("Failed to load todos");
508
+ throw err;
509
+ }
510
+ }
204
511
 
205
- // SAVE - Create or update (upsert)
206
- const newUser = await ws.api.save.users({
207
- name: 'John Doe',
208
- email: 'john@example.com'
209
- });
512
+ async function handleAddTodo() {
513
+ const title = query("todoTitle").value.trim();
514
+ const description = query("todoDescription").value.trim();
515
+
516
+ if (!title) {
517
+ error("Title required");
518
+ return;
519
+ }
520
+
521
+ try {
522
+ await ws.api.save.todos({
523
+ user_id: state.userId,
524
+ title,
525
+ description,
526
+ completed: false,
527
+ });
528
+ query("todoTitle").value = "";
529
+ query("todoDescription").value = "";
530
+ } catch (err) {
531
+ error("Failed to create todo");
532
+ }
533
+ }
210
534
 
211
- // LOOKUP - Autocomplete/suggestions
212
- const suggestions = await ws.api.lookup.users({
213
- p_filter: 'john'
214
- });
535
+ async function handleToggleTodo(id) {
536
+ try {
537
+ const todo = state.todos.find((t) => t.id === id);
538
+ if (todo) {
539
+ await ws.api.save.todos({
540
+ id,
541
+ user_id: state.userId,
542
+ completed: !todo.completed,
543
+ });
544
+ }
545
+ } catch (err) {
546
+ error("Failed to update todo");
547
+ }
548
+ }
215
549
 
216
- // SEARCH - Full search with filters and pagination
217
- const results = await ws.api.search.users({
218
- filters: {
219
- name: { ilike: '%john%' },
220
- email: 'john@example.com'
221
- },
222
- page: 1,
223
- limit: 10
224
- });
550
+ async function handleDeleteTodo(id) {
551
+ if (!confirm("Delete this todo?")) return;
552
+ try {
553
+ await ws.api.delete.todos({ id });
554
+ } catch (err) {
555
+ error("Failed to delete todo");
556
+ }
557
+ }
225
558
 
226
- // DELETE - Remove a record
227
- const deleted = await ws.api.delete.users({ id: 1 });
559
+ async function init() {
560
+ try {
561
+ ws = new WebSocketManager();
562
+ await ws.connect();
563
+ state.connected = true;
564
+
565
+ ws.onBroadcast((method, params) => {
566
+ if (!state.loggedIn) return;
567
+
568
+ const data = params.data;
569
+ if (method === "todos:insert") {
570
+ state.todos.push(data);
571
+ } else if (method === "todos:update") {
572
+ const idx = state.todos.findIndex((t) => t.id === data.id);
573
+ if (idx !== -1) state.todos[idx] = data;
574
+ } else if (method === "todos:delete") {
575
+ state.todos = state.todos.filter((t) => t.id !== data.id);
576
+ }
577
+ render();
578
+ });
579
+
580
+ const token = localStorage.getItem("dzql_token");
581
+ if (token) {
582
+ try {
583
+ const payload = JSON.parse(atob(token.split(".")[1]));
584
+ state.userId = payload.user_id;
585
+ await loadTodos();
586
+ state.loggedIn = true;
587
+ } catch (err) {
588
+ localStorage.removeItem("dzql_token");
589
+ state.loggedIn = false;
590
+ state.userId = null;
591
+ }
592
+ }
593
+
594
+ render();
595
+ } catch (err) {
596
+ state.connected = false;
597
+ error("Failed to connect");
598
+ render();
599
+ }
600
+ }
228
601
 
229
- // When done
230
- ws.cleanDisconnect();
602
+ window.handleAddTodo = handleAddTodo;
603
+ window.handleToggleTodo = handleToggleTodo;
604
+ window.handleDeleteTodo = handleDeleteTodo;
605
+ window.handleLoginSubmit = (e) => {
606
+ e.preventDefault();
607
+ auth("login_user");
608
+ };
609
+ window.handleRegister = () => auth("register_user");
610
+ window.handleLogout = async () => {
611
+ try {
612
+ await ws.api.logout();
613
+ localStorage.removeItem("dzql_token");
614
+ state.loggedIn = false;
615
+ state.userId = null;
616
+ state.todos = [];
617
+ render();
618
+ } catch (err) {
619
+ error("Logout failed");
620
+ }
621
+ };
622
+
623
+ init();
231
624
  ```
232
625
 
233
- ### Bun/Backend
626
+ ### 9. Package.json
627
+
628
+ ```json
629
+ {
630
+ "name": "dzql-todo-app",
631
+ "module": "index.js",
632
+ "type": "module",
633
+ "private": true,
634
+ "scripts": {
635
+ "dev": "bun --hot index.js",
636
+ "start": "bun index.js",
637
+ "db:up": "docker compose up -d",
638
+ "db:down": "docker compose down",
639
+ "test": "playwright test"
640
+ },
641
+ "dependencies": {
642
+ "dzql": "latest"
643
+ },
644
+ "devDependencies": {
645
+ "@playwright/test": "^1.56.1",
646
+ "@types/bun": "latest"
647
+ }
648
+ }
649
+ ```
234
650
 
235
- ```javascript
236
- import { db, sql } from 'dzql';
651
+ ## Run
237
652
 
238
- // Direct database queries
239
- const users = await sql`SELECT * FROM users`;
653
+ ```bash
654
+ # Start database
655
+ bun run db:up
240
656
 
241
- // DZQL operations (require userId)
242
- const user = await db.api.get.users({ id: 1 }, userId);
243
- const saved = await db.api.save.users({ name: 'John' }, userId);
244
- const searched = await db.api.search.users({ filters: {} }, userId);
657
+ # Start server (with hot reload)
658
+ bun run dev
659
+
660
+ # In another terminal, run tests (optional)
661
+ bun run test
245
662
  ```
246
663
 
247
- ## Project Structure
664
+ Access at `http://localhost:3000`
665
+
666
+ ## Testing with Playwright
248
667
 
249
- A typical DZQL project looks like:
668
+ Install Playwright:
669
+ ```bash
670
+ bun add -d @playwright/test
671
+ bunx playwright install
672
+ ```
250
673
 
674
+ Create `app.test.ts`:
675
+ ```typescript
676
+ import { test, expect } from "@playwright/test";
677
+
678
+ test("register and create todo", async ({ page }) => {
679
+ await page.goto("http://localhost:3000");
680
+ await page.waitForLoadState("networkidle");
681
+
682
+ // Register
683
+ await page.locator("#email").fill(`test-${Date.now()}@example.com`);
684
+ await page.locator("#password").fill("testpass123");
685
+ await page.locator("button:has-text('Register')").click();
686
+ await page.waitForLoadState("networkidle");
687
+
688
+ // Create todo
689
+ await page.locator("#todoTitle").fill("Buy milk");
690
+ await page.locator("button:has-text('Add Todo')").click();
691
+ await expect(page.locator(".todo-title:has-text('Buy milk')")).toBeVisible();
692
+ });
251
693
  ```
252
- my-dzql-app/
253
- ├── server/
254
- │ ├── index.js # Server entry point
255
- │ └── api.js # Custom API functions (optional)
256
- ├── database/
257
- │ └── init_db/
258
- │ ├── 001_domain.sql # Your schema & entity registration
259
- │ └── 002_seed.sql # Sample data (optional)
260
- ├── public/ # Static files (optional)
261
- │ └── index.html
262
- ├── docker-compose.yml
263
- ├── bunfig.toml # Bun config (optional)
264
- └── package.json
694
+
695
+ Run tests:
696
+ ```bash
697
+ bunx playwright test --headed # see browser
698
+ bunx playwright test # headless
265
699
  ```
266
700
 
701
+ **Testing Tips:**
702
+ - Use unique emails per test: `test-${Date.now()}@example.com`
703
+ - Wait for network: `await page.waitForLoadState("networkidle")`
704
+ - Find elements by id, class, or text: `locator("#id")`, `locator(".class")`, `locator("button:has-text('text')")`
705
+
706
+ ## Key Architecture
707
+
708
+ ### Atomic Updates & Real-time Sync
709
+
710
+ When user A saves a todo, it:
711
+ 1. Updates database atomically
712
+ 2. Broadcasts to all connected clients instantly
713
+ 3. All users see the change immediately
714
+
715
+ ```javascript
716
+ ws.onBroadcast((method, params) => {
717
+ const data = params.data;
718
+ if (method === "todos:insert") {
719
+ state.todos.push(data); // Add new
720
+ } else if (method === "todos:update") {
721
+ const idx = state.todos.findIndex(t => t.id === data.id);
722
+ if (idx !== -1) state.todos[idx] = data; // Replace
723
+ } else if (method === "todos:delete") {
724
+ state.todos = state.todos.filter(t => t.id !== data.id); // Remove
725
+ }
726
+ render(); // Re-render UI with new state
727
+ });
728
+ ```
729
+
730
+ That's it. Every user creating/updating/deleting a todo sees it **instantly** on all other clients. No polling, no re-fetching, no race conditions.
731
+
732
+ ### State Management
733
+ - `state.connected` - WebSocket status
734
+ - `state.loggedIn` - Authentication status
735
+ - `state.userId` - Current user (from JWT on reload)
736
+ - `state.todos` - User's todos array
737
+
738
+ ### Authentication Flow
739
+ 1. User registers/logs in
740
+ 2. Server returns token + user_id
741
+ 3. Token stored in localStorage
742
+ 4. On page reload, JWT decoded to get user_id
743
+ 5. Todos loaded with user filter
744
+
745
+ ### Single Render Function
746
+ - `render()` handles ALL UI updates
747
+ - Called after every state change
748
+ - Also called on every broadcast update
749
+ - No separate update functions needed
750
+
267
751
  ## The 5 DZQL Operations
268
752
 
269
753
  Every registered entity automatically gets these 5 operations:
@@ -313,9 +797,9 @@ const results = await ws.api.search.tableName({
313
797
 
314
798
  ## Entity Registration
315
799
 
316
- Before DZQL can work with a table, you must register it with the `dzql.register_entity()` function.
800
+ Before DZQL can work with a table, you must register it with `dzql.register_entity()`.
317
801
 
318
- **Important:** This function is provided by DZQL's core migrations, which run automatically when PostgreSQL starts via docker-compose.
802
+ **Important:** This function is provided by DZQL's core migrations, which run automatically when PostgreSQL starts via compose.yml.
319
803
 
320
804
  ```sql
321
805
  SELECT dzql.register_entity(
@@ -338,45 +822,105 @@ See the [venues example](https://github.com/blueshed/dzql/blob/main/packages/ven
338
822
 
339
823
  ## Custom API Functions
340
824
 
341
- Add custom functions that work alongside DZQL operations:
825
+ Add custom functions that work alongside DZQL operations.
826
+
827
+ ### PostgreSQL Functions
828
+
829
+ Create stored procedures that execute server-side:
342
830
 
343
- **PostgreSQL Function:**
344
831
  ```sql
345
832
  CREATE OR REPLACE FUNCTION my_function(
346
833
  p_user_id INT,
347
- p_name TEXT
348
- ) RETURNS TABLE (message TEXT) AS $$
834
+ p_name TEXT DEFAULT 'World'
835
+ ) RETURNS JSONB
836
+ LANGUAGE plpgsql
837
+ SECURITY DEFINER
838
+ AS $$
349
839
  BEGIN
350
- RETURN QUERY SELECT 'Hello, ' || p_name;
840
+ -- Your logic here
841
+ RETURN jsonb_build_object('message', 'Hello, ' || p_name);
351
842
  END;
352
- $$ LANGUAGE plpgsql;
843
+ $$;
353
844
  ```
354
845
 
355
- **Call from Client:**
846
+ Call from client:
356
847
  ```javascript
357
848
  const result = await ws.api.my_function({ name: 'World' });
358
849
  // Returns: { message: 'Hello, World' }
359
850
  ```
360
851
 
361
- **Bun Function:**
852
+ ### Bun Functions
853
+
854
+ Create JavaScript functions that run in the Bun server:
855
+
362
856
  ```javascript
363
857
  // server/api.js
364
- export async function my_function(userId, params = {}) {
858
+ export async function myBunFunction(userId, params = {}) {
859
+ const { name = 'World' } = params;
860
+
861
+ // Can use db.api for database access
862
+ // const user = await db.api.get.users({ id: userId }, userId);
863
+
365
864
  return {
366
- message: `Hello, ${params.name}!`,
865
+ message: `Hello, ${name}!`,
367
866
  user_id: userId
368
867
  };
369
868
  }
370
869
  ```
371
870
 
372
- Then pass it to createServer:
871
+ Pass to server:
373
872
  ```javascript
374
- const customApi = await import('./api.js');
873
+ import { createServer } from 'dzql';
874
+ import * as customApi from './server/api.js';
875
+
375
876
  const server = createServer({
877
+ port: 3000,
376
878
  customApi
377
879
  });
378
880
  ```
379
881
 
882
+ Call from client:
883
+ ```javascript
884
+ const result = await ws.api.myBunFunction({ name: 'World' });
885
+ ```
886
+
887
+ **Both types:**
888
+ - First parameter is always `user_id` (auto-injected on client)
889
+ - Require authentication
890
+ - Use same proxy API syntax
891
+ - Return JSON-serializable data
892
+
893
+ ### Advanced Server Setup
894
+
895
+ For routes that need real-time broadcasting:
896
+
897
+ ```javascript
898
+ import { createServer } from 'dzql';
899
+
900
+ const server = createServer({
901
+ port: 3000,
902
+
903
+ // Static routes (don't need broadcast)
904
+ routes: {
905
+ '/health': () => new Response('OK'),
906
+ '/': () => new Response('Hello')
907
+ },
908
+
909
+ // Routes that need broadcasting
910
+ onReady: async (broadcast) => {
911
+ // broadcast(method, params) - send to all clients
912
+
913
+ return {
914
+ '/custom': async (req) => {
915
+ // Your logic
916
+ broadcast('custom:event', { data: 'something' });
917
+ return new Response('Done');
918
+ }
919
+ };
920
+ }
921
+ });
922
+ ```
923
+
380
924
  ## Real-time Events
381
925
 
382
926
  DZQL broadcasts changes in real-time via WebSocket:
@@ -386,12 +930,19 @@ DZQL broadcasts changes in real-time via WebSocket:
386
930
  const unsubscribe = ws.onBroadcast((method, params) => {
387
931
  console.log(`Event: ${method}`, params);
388
932
  // method: "users:insert", "users:update", "users:delete"
933
+ // params: { table, op, pk, before, after, user_id, at }
389
934
  });
390
935
 
391
936
  // Stop listening
392
937
  unsubscribe();
393
938
  ```
394
939
 
940
+ Event format:
941
+ - `method`: `"{table}:{operation}"` (e.g., "todos:insert")
942
+ - `params.data`: The affected record
943
+ - `params.user_id`: User who made the change
944
+ - `params.at`: Timestamp
945
+
395
946
  ## Authentication
396
947
 
397
948
  DZQL provides built-in user authentication:
@@ -421,19 +972,24 @@ const ws = new WebSocketManager();
421
972
  await ws.connect(); // Automatically uses token from localStorage
422
973
  ```
423
974
 
424
- ## Error Handling
975
+ ## Server-Side API Usage
976
+
977
+ For backend/Bun scripts:
425
978
 
426
979
  ```javascript
427
- try {
428
- const user = await ws.api.get.users({ id: 999 });
429
- } catch (error) {
430
- console.error(error.message);
431
- // "record not found" - record doesn't exist
432
- // "Permission denied: view on users" - access denied
433
- // "Function not found" - custom function doesn't exist
434
- }
980
+ import { db, sql } from 'dzql';
981
+
982
+ // Direct SQL queries
983
+ const users = await sql`SELECT * FROM users`;
984
+
985
+ // DZQL operations (require explicit userId)
986
+ const user = await db.api.get.users({ id: 1 }, userId);
987
+ const saved = await db.api.save.users({ name: 'John' }, userId);
988
+ const results = await db.api.search.users({ filters: {} }, userId);
435
989
  ```
436
990
 
991
+ **Key difference:** Server-side requires explicit `userId` as second parameter; client-side auto-injects from JWT.
992
+
437
993
  ## Environment Variables
438
994
 
439
995
  ```bash
@@ -452,10 +1008,17 @@ WS_PING_INTERVAL=30000
452
1008
  WS_PING_TIMEOUT=5000
453
1009
  ```
454
1010
 
455
- ## Running Tests
1011
+ ## Error Handling
456
1012
 
457
- ```bash
458
- bun test tests/
1013
+ ```javascript
1014
+ try {
1015
+ const user = await ws.api.get.users({ id: 999 });
1016
+ } catch (error) {
1017
+ console.error(error.message);
1018
+ // "record not found" - record doesn't exist
1019
+ // "Permission denied: view on users" - access denied
1020
+ // "Function not found" - custom function doesn't exist
1021
+ }
459
1022
  ```
460
1023
 
461
1024
  ## Troubleshooting
@@ -464,9 +1027,11 @@ bun test tests/
464
1027
  ```bash
465
1028
  # Check if PostgreSQL is running
466
1029
  docker ps
1030
+
467
1031
  # Check logs
468
1032
  docker compose logs postgres
469
- # Restart
1033
+
1034
+ # Restart database (fresh start)
470
1035
  docker compose down -v && docker compose up -d
471
1036
  ```
472
1037
 
@@ -474,86 +1039,66 @@ docker compose down -v && docker compose up -d
474
1039
  - Ensure server is running: `http://localhost:3000`
475
1040
  - Check firewall for port 3000
476
1041
  - Check browser console for errors
1042
+ - Verify WebSocket URL in client code
477
1043
 
478
1044
  ### Entity not found errors
479
1045
  - Verify table is created in database
480
1046
  - Verify entity is registered with `dzql.register_entity()`
481
- - Check `p_table_name` matches exactly
1047
+ - Check `p_table_name` matches table name exactly
1048
+ - Check migrations ran: `docker compose logs postgres`
482
1049
 
483
1050
  ### Permission denied errors
484
1051
  - Implement permission rules in your entity registration
485
1052
  - Check user authentication status
486
1053
  - Verify user_id is being passed correctly
1054
+ - See venues example for permission path syntax
487
1055
 
488
- ## Next Steps
489
-
490
- 1. **Read the full documentation**: Check the framework's README
491
- 2. **Explore graph rules**: Add automation with `p_graph_rules`
492
- 3. **Implement permissions**: Use path-based access control
493
- 4. **Add notifications**: Set up `p_notification_path` for real-time updates
494
- 5. **Build your UI**: Connect to any frontend framework (React, Vue, Svelte, etc.)
495
-
496
- ## Example: Complete Todo App
1056
+ ### Migrations not running
1057
+ - Check volume mounts in compose.yml
1058
+ - Ensure migration files exist in node_modules/dzql/
1059
+ - Check PostgreSQL logs: `docker compose logs postgres`
1060
+ - Try fresh start: `docker compose down -v && docker compose up -d`
497
1061
 
498
- **Database Setup:**
499
- ```sql
500
- CREATE TABLE todos (
501
- id SERIAL PRIMARY KEY,
502
- user_id INT NOT NULL REFERENCES users(id),
503
- title TEXT NOT NULL,
504
- completed BOOLEAN DEFAULT FALSE,
505
- created_at TIMESTAMPTZ DEFAULT NOW()
506
- );
1062
+ ### Real-time updates not working
1063
+ - Verify `onBroadcast` handler is set up
1064
+ - Check WebSocket connection status
1065
+ - Check browser console for errors
1066
+ - Verify entity is registered (triggers events)
507
1067
 
508
- SELECT dzql.register_entity(
509
- 'todos',
510
- 'title',
511
- array['title'],
512
- '{"user": "users"}'::jsonb
513
- );
514
- ```
1068
+ ## DRY Principles Used
515
1069
 
516
- **Server:**
517
- ```javascript
518
- import { createServer } from 'dzql';
1070
+ - **Single `auth()` function** handles both login and register
1071
+ - **Single `render()` function** handles all UI updates
1072
+ - **`query()` helper** replaces repeated `getElementById()`
1073
+ - **Broadcast handler** calls `render()` once instead of separate functions
1074
+ - **Combined error handling** with reusable `error()` function
519
1075
 
520
- const server = createServer({
521
- port: 3000
522
- });
523
- ```
1076
+ ## Key Takeaways for Claude
524
1077
 
525
- **Client:**
526
- ```javascript
527
- const ws = new WebSocketManager();
528
- await ws.connect();
529
- await ws.api.login_user({ email: 'user@example.com', password: 'pass' });
1078
+ When building with DZQL:
1079
+ 1. **Schema first** - Table definition automatically gives you API
1080
+ 2. **Broadcasts = Real-time** - Listen to `entity:insert/update/delete` events
1081
+ 3. **One render function** - Update state, call render(), DOM updates
1082
+ 4. **User ID filtering** - Always pass `user_id` in queries and mutations
1083
+ 5. **Token from JWT** - Decode on init: `JSON.parse(atob(token.split(".")[1]))`
1084
+ 6. **DRY helpers** - Use `query()` for DOM, combine auth logic, single error handler
1085
+ 7. **Index pattern** - `const idx = state.todos.findIndex(t => t.id === data.id)` for updates
530
1086
 
531
- // Create todo
532
- const todo = await ws.api.save.todos({
533
- title: 'Learn DZQL',
534
- completed: false
535
- });
536
-
537
- // Get all todos
538
- const results = await ws.api.search.todos({
539
- filters: { completed: false },
540
- limit: 100
541
- });
1087
+ The app should feel simple. If it feels complex, something is wrong.
542
1088
 
543
- // Update todo
544
- await ws.api.save.todos({
545
- id: todo.id,
546
- completed: true
547
- });
1089
+ ## Next Steps
548
1090
 
549
- // Delete todo
550
- await ws.api.delete.todos({ id: todo.id });
551
- ```
1091
+ 1. **Read the full documentation**: Check the framework's README and CLAUDE.md
1092
+ 2. **Explore graph rules**: Add automation with `p_graph_rules`
1093
+ 3. **Implement permissions**: Use path-based access control
1094
+ 4. **Add notifications**: Set up `p_notification_path` for real-time updates
1095
+ 5. **Build your UI**: Connect to any frontend framework (React, Vue, Svelte, etc.)
1096
+ 6. **Study the venues example**: See a complete multi-entity application
552
1097
 
553
1098
  ## Support
554
1099
 
555
1100
  - **GitHub**: https://github.com/blueshed/dzql
556
1101
  - **Issues**: https://github.com/blueshed/dzql/issues
557
- - **Documentation**: See README.md in the package
1102
+ - **Documentation**: See README.md and CLAUDE.md in the package
558
1103
 
559
- Happy building! 🚀
1104
+ Happy building! 🚀