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