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.
- package/GETTING_STARTED.md +803 -258
- package/README.md +19 -475
- package/REFERENCE.md +891 -0
- package/package.json +3 -1
- package/src/server/index.js +10 -1
package/GETTING_STARTED.md
CHANGED
|
@@ -1,34 +1,73 @@
|
|
|
1
|
-
# Getting Started with DZQL
|
|
1
|
+
# Getting Started with DZQL - Practical Guide
|
|
2
2
|
|
|
3
|
-
DZQL is a PostgreSQL
|
|
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**
|
|
33
|
+
- **Bun** 1.0+ (Node.js not required)
|
|
34
|
+
- **Docker** and **Docker Compose**
|
|
9
35
|
- A code editor
|
|
10
36
|
|
|
11
|
-
## Quick Start (
|
|
37
|
+
## Quick Start (10 minutes)
|
|
12
38
|
|
|
13
|
-
### 1. Create
|
|
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.
|
|
51
|
+
### 2. Project Structure
|
|
22
52
|
|
|
23
|
-
```
|
|
24
|
-
|
|
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.
|
|
67
|
+
### 3. Docker Setup
|
|
28
68
|
|
|
29
|
-
Create
|
|
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
|
-
|
|
42
|
-
- node_modules/dzql/src/database/migrations/
|
|
43
|
-
- node_modules/dzql/src/database/migrations/
|
|
44
|
-
- node_modules/dzql/src/database/migrations/
|
|
45
|
-
- node_modules/dzql/src/database/migrations/
|
|
46
|
-
- node_modules/dzql/src/database/migrations/
|
|
47
|
-
- node_modules/dzql/src/database/migrations/
|
|
48
|
-
- node_modules/dzql/src/database/migrations/
|
|
49
|
-
- node_modules/dzql/src/database/migrations/
|
|
50
|
-
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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
|
|
113
|
+
Create `init_db/001_domain.sql`:
|
|
94
114
|
|
|
95
115
|
```sql
|
|
96
|
-
|
|
97
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
116
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
98
117
|
id SERIAL PRIMARY KEY,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 := '
|
|
107
|
-
p_label_field := '
|
|
108
|
-
p_searchable_fields := array['
|
|
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
|
-
|
|
135
|
+
### 5. Server
|
|
114
136
|
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
staticPath: './public'
|
|
145
|
+
routes: {
|
|
146
|
+
"/": index,
|
|
147
|
+
},
|
|
136
148
|
});
|
|
137
149
|
|
|
138
|
-
console.log(`🚀 Server running on
|
|
150
|
+
console.log(`🚀 DZQL Server running on http://localhost:${server.port}`);
|
|
139
151
|
```
|
|
140
152
|
|
|
141
|
-
### 6.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
223
|
+
### 7. CSS
|
|
148
224
|
|
|
149
|
-
|
|
225
|
+
Create `public/index.css`:
|
|
150
226
|
|
|
151
|
-
|
|
152
|
-
|
|
227
|
+
```css
|
|
228
|
+
* {
|
|
229
|
+
box-sizing: border-box;
|
|
230
|
+
margin: 0;
|
|
231
|
+
padding: 0;
|
|
232
|
+
}
|
|
153
233
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
404
|
+
### 8. JavaScript
|
|
181
405
|
|
|
182
|
-
|
|
406
|
+
Create `public/app.js`:
|
|
183
407
|
|
|
184
408
|
```javascript
|
|
185
|
-
import {
|
|
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
|
-
|
|
188
|
-
const
|
|
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
|
-
|
|
191
|
-
|
|
463
|
+
function escapeHtml(text) {
|
|
464
|
+
const div = document.createElement("div");
|
|
465
|
+
div.textContent = text;
|
|
466
|
+
return div.innerHTML;
|
|
467
|
+
}
|
|
192
468
|
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
236
|
-
import { db, sql } from 'dzql';
|
|
651
|
+
## Run
|
|
237
652
|
|
|
238
|
-
|
|
239
|
-
|
|
653
|
+
```bash
|
|
654
|
+
# Start database
|
|
655
|
+
bun run db:up
|
|
240
656
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
664
|
+
Access at `http://localhost:3000`
|
|
665
|
+
|
|
666
|
+
## Testing with Playwright
|
|
248
667
|
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
|
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
|
|
834
|
+
p_name TEXT DEFAULT 'World'
|
|
835
|
+
) RETURNS JSONB
|
|
836
|
+
LANGUAGE plpgsql
|
|
837
|
+
SECURITY DEFINER
|
|
838
|
+
AS $$
|
|
349
839
|
BEGIN
|
|
350
|
-
|
|
840
|
+
-- Your logic here
|
|
841
|
+
RETURN jsonb_build_object('message', 'Hello, ' || p_name);
|
|
351
842
|
END;
|
|
352
|
-
|
|
843
|
+
$$;
|
|
353
844
|
```
|
|
354
845
|
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
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, ${
|
|
865
|
+
message: `Hello, ${name}!`,
|
|
367
866
|
user_id: userId
|
|
368
867
|
};
|
|
369
868
|
}
|
|
370
869
|
```
|
|
371
870
|
|
|
372
|
-
|
|
871
|
+
Pass to server:
|
|
373
872
|
```javascript
|
|
374
|
-
|
|
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
|
-
##
|
|
975
|
+
## Server-Side API Usage
|
|
976
|
+
|
|
977
|
+
For backend/Bun scripts:
|
|
425
978
|
|
|
426
979
|
```javascript
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
##
|
|
1011
|
+
## Error Handling
|
|
456
1012
|
|
|
457
|
-
```
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
509
|
-
'todos',
|
|
510
|
-
'title',
|
|
511
|
-
array['title'],
|
|
512
|
-
'{"user": "users"}'::jsonb
|
|
513
|
-
);
|
|
514
|
-
```
|
|
1068
|
+
## DRY Principles Used
|
|
515
1069
|
|
|
516
|
-
**
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
port: 3000
|
|
522
|
-
});
|
|
523
|
-
```
|
|
1076
|
+
## Key Takeaways for Claude
|
|
524
1077
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
await ws.api.save.todos({
|
|
545
|
-
id: todo.id,
|
|
546
|
-
completed: true
|
|
547
|
-
});
|
|
1089
|
+
## Next Steps
|
|
548
1090
|
|
|
549
|
-
|
|
550
|
-
|
|
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! 🚀
|