dzql 0.1.0-alpha.1 → 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.
@@ -0,0 +1,1102 @@
1
+ # Getting Started with DZQL - Practical Guide
2
+
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.
28
+
29
+ ## Prerequisites
30
+
31
+ - **Bun** 1.0+ (Node.js not required)
32
+ - **Docker** and **Docker Compose**
33
+ - A code editor
34
+
35
+ ## Quick Start (10 minutes)
36
+
37
+ ### 1. Create Project
38
+
39
+ ```bash
40
+ mkdir my-dzql-app
41
+ cd my-dzql-app
42
+ bun init
43
+ bun add dzql
44
+
45
+ # Create directories
46
+ mkdir -p public init_db
47
+ ```
48
+
49
+ ### 2. Project Structure
50
+
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)
63
+ ```
64
+
65
+ ### 3. Docker Setup
66
+
67
+ Create `compose.yml`:
68
+
69
+ ```yaml
70
+ services:
71
+ postgres:
72
+ image: postgres:latest
73
+ environment:
74
+ POSTGRES_USER: dzql
75
+ POSTGRES_PASSWORD: dzql
76
+ POSTGRES_DB: dzql
77
+ volumes:
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
88
+ ports:
89
+ - "5432:5432"
90
+ healthcheck:
91
+ test: ["CMD-SHELL", "pg_isready -U dzql"]
92
+ interval: 10s
93
+ timeout: 5s
94
+ retries: 5
95
+ ```
96
+
97
+ **For monorepo projects** (like the venues example), use relative paths:
98
+ ```yaml
99
+ volumes:
100
+ - ../../dzql/src/database/migrations/001_schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
101
+ # ... etc
102
+ ```
103
+
104
+ Start PostgreSQL:
105
+ ```bash
106
+ docker compose up -d
107
+ ```
108
+
109
+ ### 4. Database Schema
110
+
111
+ Create `init_db/001_domain.sql`:
112
+
113
+ ```sql
114
+ CREATE TABLE IF NOT EXISTS todos (
115
+ id SERIAL PRIMARY KEY,
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()
122
+ );
123
+
124
+ CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);
125
+
126
+ SELECT dzql.register_entity(
127
+ p_table_name := 'todos',
128
+ p_label_field := 'title',
129
+ p_searchable_fields := array['title', 'description']
130
+ );
131
+ ```
132
+
133
+ ### 5. Server
134
+
135
+ Create `index.js`:
136
+
137
+ ```javascript
138
+ import { createServer } from "dzql";
139
+ import index from "./public/index.html";
140
+
141
+ const server = createServer({
142
+ port: process.env.PORT || 3000,
143
+ routes: {
144
+ "/": index,
145
+ },
146
+ });
147
+
148
+ console.log(`🚀 DZQL Server running on http://localhost:${server.port}`);
149
+ ```
150
+
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>
219
+ ```
220
+
221
+ ### 7. CSS
222
+
223
+ Create `public/index.css`:
224
+
225
+ ```css
226
+ * {
227
+ box-sizing: border-box;
228
+ margin: 0;
229
+ padding: 0;
230
+ }
231
+
232
+ body {
233
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
234
+ background: #f5f5f5;
235
+ padding: 20px;
236
+ }
237
+
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
+ }
246
+
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
+ }
400
+ ```
401
+
402
+ ### 8. JavaScript
403
+
404
+ Create `public/app.js`:
405
+
406
+ ```javascript
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
+ }
420
+
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
+ }
460
+
461
+ function escapeHtml(text) {
462
+ const div = document.createElement("div");
463
+ div.textContent = text;
464
+ return div.innerHTML;
465
+ }
466
+
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
+ }
473
+
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
+ }
496
+
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
+ }
509
+
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
+ }
532
+
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
+ }
547
+
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
+ }
556
+
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
+ }
599
+
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();
622
+ ```
623
+
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
+ ```
648
+
649
+ ## Run
650
+
651
+ ```bash
652
+ # Start database
653
+ bun run db:up
654
+
655
+ # Start server (with hot reload)
656
+ bun run dev
657
+
658
+ # In another terminal, run tests (optional)
659
+ bun run test
660
+ ```
661
+
662
+ Access at `http://localhost:3000`
663
+
664
+ ## Testing with Playwright
665
+
666
+ Install Playwright:
667
+ ```bash
668
+ bun add -d @playwright/test
669
+ bunx playwright install
670
+ ```
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
+ });
691
+ ```
692
+
693
+ Run tests:
694
+ ```bash
695
+ bunx playwright test --headed # see browser
696
+ bunx playwright test # headless
697
+ ```
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
+
749
+ ## The 5 DZQL Operations
750
+
751
+ Every registered entity automatically gets these 5 operations:
752
+
753
+ ### 1. GET - Single Record
754
+ ```javascript
755
+ const record = await ws.api.get.tableName({ id: 1 });
756
+ // Throws "record not found" error if not exists
757
+ ```
758
+
759
+ ### 2. SAVE - Upsert
760
+ ```javascript
761
+ const record = await ws.api.save.tableName({
762
+ id: 1, // Optional - omit for insert
763
+ name: 'Updated'
764
+ });
765
+ ```
766
+
767
+ ### 3. DELETE - Remove Record
768
+ ```javascript
769
+ const deleted = await ws.api.delete.tableName({ id: 1 });
770
+ ```
771
+
772
+ ### 4. LOOKUP - Autocomplete
773
+ ```javascript
774
+ const options = await ws.api.lookup.tableName({
775
+ p_filter: 'search term'
776
+ });
777
+ // Returns: [{ label: 'Display Name', value: 1 }, ...]
778
+ ```
779
+
780
+ ### 5. SEARCH - Advanced Search
781
+ ```javascript
782
+ const results = await ws.api.search.tableName({
783
+ filters: {
784
+ name: { ilike: '%john%' },
785
+ age: { gte: 18 },
786
+ city: ['NYC', 'LA'],
787
+ active: true
788
+ },
789
+ sort: { field: 'name', order: 'asc' },
790
+ page: 1,
791
+ limit: 25
792
+ });
793
+ // Returns: { data: [...], total: 100, page: 1, limit: 25 }
794
+ ```
795
+
796
+ ## Entity Registration
797
+
798
+ Before DZQL can work with a table, you must register it with `dzql.register_entity()`.
799
+
800
+ **Important:** This function is provided by DZQL's core migrations, which run automatically when PostgreSQL starts via compose.yml.
801
+
802
+ ```sql
803
+ SELECT dzql.register_entity(
804
+ p_table_name := 'venues', -- Your table name
805
+ p_label_field := 'name', -- Used by LOOKUP
806
+ p_searchable_fields := array['name', 'address'], -- Used by SEARCH
807
+ p_fk_includes := '{"org": "organisations"}'::jsonb, -- Dereference FKs
808
+ p_graph_rules := '{}'::jsonb -- Optional: automation
809
+ );
810
+ ```
811
+
812
+ **Parameters:**
813
+ - `p_table_name`: Your PostgreSQL table name
814
+ - `p_label_field`: Which field to use for display (LOOKUP)
815
+ - `p_searchable_fields`: Fields searchable by SEARCH
816
+ - `p_fk_includes`: Foreign keys to auto-dereference (optional)
817
+ - `p_graph_rules`: Graph rules for automation (optional)
818
+
819
+ See the [venues example](https://github.com/blueshed/dzql/blob/main/packages/venues/database/init_db/009_venues_domain.sql) for a complete schema with multiple entity registrations.
820
+
821
+ ## Custom API Functions
822
+
823
+ Add custom functions that work alongside DZQL operations.
824
+
825
+ ### PostgreSQL Functions
826
+
827
+ Create stored procedures that execute server-side:
828
+
829
+ ```sql
830
+ CREATE OR REPLACE FUNCTION my_function(
831
+ p_user_id INT,
832
+ p_name TEXT DEFAULT 'World'
833
+ ) RETURNS JSONB
834
+ LANGUAGE plpgsql
835
+ SECURITY DEFINER
836
+ AS $$
837
+ BEGIN
838
+ -- Your logic here
839
+ RETURN jsonb_build_object('message', 'Hello, ' || p_name);
840
+ END;
841
+ $$;
842
+ ```
843
+
844
+ Call from client:
845
+ ```javascript
846
+ const result = await ws.api.my_function({ name: 'World' });
847
+ // Returns: { message: 'Hello, World' }
848
+ ```
849
+
850
+ ### Bun Functions
851
+
852
+ Create JavaScript functions that run in the Bun server:
853
+
854
+ ```javascript
855
+ // server/api.js
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
+
862
+ return {
863
+ message: `Hello, ${name}!`,
864
+ user_id: userId
865
+ };
866
+ }
867
+ ```
868
+
869
+ Pass to server:
870
+ ```javascript
871
+ import { createServer } from 'dzql';
872
+ import * as customApi from './server/api.js';
873
+
874
+ const server = createServer({
875
+ port: 3000,
876
+ customApi
877
+ });
878
+ ```
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
+
922
+ ## Real-time Events
923
+
924
+ DZQL broadcasts changes in real-time via WebSocket:
925
+
926
+ ```javascript
927
+ // Listen for all events
928
+ const unsubscribe = ws.onBroadcast((method, params) => {
929
+ console.log(`Event: ${method}`, params);
930
+ // method: "users:insert", "users:update", "users:delete"
931
+ // params: { table, op, pk, before, after, user_id, at }
932
+ });
933
+
934
+ // Stop listening
935
+ unsubscribe();
936
+ ```
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
+
944
+ ## Authentication
945
+
946
+ DZQL provides built-in user authentication:
947
+
948
+ ```javascript
949
+ // Register
950
+ const result = await ws.api.register_user({
951
+ email: 'user@example.com',
952
+ password: 'secure-password'
953
+ });
954
+
955
+ // Login
956
+ const result = await ws.api.login_user({
957
+ email: 'user@example.com',
958
+ password: 'secure-password'
959
+ });
960
+ // Returns: { token, profile, user_id }
961
+
962
+ // Logout
963
+ await ws.api.logout();
964
+
965
+ // Save token for auto-login
966
+ localStorage.setItem('dzql_token', result.token);
967
+
968
+ // Auto-connect with token
969
+ const ws = new WebSocketManager();
970
+ await ws.connect(); // Automatically uses token from localStorage
971
+ ```
972
+
973
+ ## Server-Side API Usage
974
+
975
+ For backend/Bun scripts:
976
+
977
+ ```javascript
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);
987
+ ```
988
+
989
+ **Key difference:** Server-side requires explicit `userId` as second parameter; client-side auto-injects from JWT.
990
+
991
+ ## Environment Variables
992
+
993
+ ```bash
994
+ # Server
995
+ PORT=3000
996
+ DATABASE_URL=postgresql://dzql:dzql@localhost:5432/dzql
997
+ NODE_ENV=development
998
+ LOG_LEVEL=INFO
999
+
1000
+ # JWT
1001
+ JWT_SECRET=your-secret-key
1002
+ JWT_EXPIRES_IN=7d
1003
+
1004
+ # WebSocket
1005
+ WS_PING_INTERVAL=30000
1006
+ WS_PING_TIMEOUT=5000
1007
+ ```
1008
+
1009
+ ## Error Handling
1010
+
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
+ }
1020
+ ```
1021
+
1022
+ ## Troubleshooting
1023
+
1024
+ ### Database won't connect
1025
+ ```bash
1026
+ # Check if PostgreSQL is running
1027
+ docker ps
1028
+
1029
+ # Check logs
1030
+ docker compose logs postgres
1031
+
1032
+ # Restart database (fresh start)
1033
+ docker compose down -v && docker compose up -d
1034
+ ```
1035
+
1036
+ ### WebSocket connection fails
1037
+ - Ensure server is running: `http://localhost:3000`
1038
+ - Check firewall for port 3000
1039
+ - Check browser console for errors
1040
+ - Verify WebSocket URL in client code
1041
+
1042
+ ### Entity not found errors
1043
+ - Verify table is created in database
1044
+ - Verify entity is registered with `dzql.register_entity()`
1045
+ - Check `p_table_name` matches table name exactly
1046
+ - Check migrations ran: `docker compose logs postgres`
1047
+
1048
+ ### Permission denied errors
1049
+ - Implement permission rules in your entity registration
1050
+ - Check user authentication status
1051
+ - Verify user_id is being passed correctly
1052
+ - See venues example for permission path syntax
1053
+
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`
1059
+
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)
1065
+
1066
+ ## DRY Principles Used
1067
+
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
1073
+
1074
+ ## Key Takeaways for Claude
1075
+
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
1084
+
1085
+ The app should feel simple. If it feels complex, something is wrong.
1086
+
1087
+ ## Next Steps
1088
+
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
1095
+
1096
+ ## Support
1097
+
1098
+ - **GitHub**: https://github.com/blueshed/dzql
1099
+ - **Issues**: https://github.com/blueshed/dzql/issues
1100
+ - **Documentation**: See README.md and CLAUDE.md in the package
1101
+
1102
+ Happy building! 🚀