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.
- package/GETTING_STARTED.md +801 -258
- package/README.md +9 -1
- package/package.json +2 -1
- package/src/server/index.js +10 -1
package/GETTING_STARTED.md
CHANGED
|
@@ -1,34 +1,71 @@
|
|
|
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
|
+
## 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**
|
|
31
|
+
- **Bun** 1.0+ (Node.js not required)
|
|
32
|
+
- **Docker** and **Docker Compose**
|
|
9
33
|
- A code editor
|
|
10
34
|
|
|
11
|
-
## Quick Start (
|
|
35
|
+
## Quick Start (10 minutes)
|
|
12
36
|
|
|
13
|
-
### 1. Create
|
|
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.
|
|
49
|
+
### 2. Project Structure
|
|
22
50
|
|
|
23
|
-
```
|
|
24
|
-
|
|
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.
|
|
65
|
+
### 3. Docker Setup
|
|
28
66
|
|
|
29
|
-
Create
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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.
|
|
109
|
+
### 4. Database Schema
|
|
90
110
|
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
114
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
98
115
|
id SERIAL PRIMARY KEY,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 := '
|
|
107
|
-
p_label_field := '
|
|
108
|
-
p_searchable_fields := array['
|
|
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
|
-
|
|
133
|
+
### 5. Server
|
|
114
134
|
|
|
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`:
|
|
135
|
+
Create `index.js`:
|
|
126
136
|
|
|
127
137
|
```javascript
|
|
128
|
-
import { createServer } from
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
staticPath: './public'
|
|
143
|
+
routes: {
|
|
144
|
+
"/": index,
|
|
145
|
+
},
|
|
136
146
|
});
|
|
137
147
|
|
|
138
|
-
console.log(`🚀 Server running on
|
|
148
|
+
console.log(`🚀 DZQL Server running on http://localhost:${server.port}`);
|
|
139
149
|
```
|
|
140
150
|
|
|
141
|
-
### 6.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
221
|
+
### 7. CSS
|
|
148
222
|
|
|
149
|
-
|
|
223
|
+
Create `public/index.css`:
|
|
150
224
|
|
|
151
|
-
|
|
152
|
-
|
|
225
|
+
```css
|
|
226
|
+
* {
|
|
227
|
+
box-sizing: border-box;
|
|
228
|
+
margin: 0;
|
|
229
|
+
padding: 0;
|
|
230
|
+
}
|
|
153
231
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
402
|
+
### 8. JavaScript
|
|
181
403
|
|
|
182
|
-
|
|
404
|
+
Create `public/app.js`:
|
|
183
405
|
|
|
184
406
|
```javascript
|
|
185
|
-
import {
|
|
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
|
-
|
|
188
|
-
const
|
|
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
|
-
|
|
191
|
-
|
|
461
|
+
function escapeHtml(text) {
|
|
462
|
+
const div = document.createElement("div");
|
|
463
|
+
div.textContent = text;
|
|
464
|
+
return div.innerHTML;
|
|
465
|
+
}
|
|
192
466
|
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
236
|
-
import { db, sql } from 'dzql';
|
|
649
|
+
## Run
|
|
237
650
|
|
|
238
|
-
|
|
239
|
-
|
|
651
|
+
```bash
|
|
652
|
+
# Start database
|
|
653
|
+
bun run db:up
|
|
240
654
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
662
|
+
Access at `http://localhost:3000`
|
|
663
|
+
|
|
664
|
+
## Testing with Playwright
|
|
248
665
|
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
832
|
+
p_name TEXT DEFAULT 'World'
|
|
833
|
+
) RETURNS JSONB
|
|
834
|
+
LANGUAGE plpgsql
|
|
835
|
+
SECURITY DEFINER
|
|
836
|
+
AS $$
|
|
349
837
|
BEGIN
|
|
350
|
-
|
|
838
|
+
-- Your logic here
|
|
839
|
+
RETURN jsonb_build_object('message', 'Hello, ' || p_name);
|
|
351
840
|
END;
|
|
352
|
-
|
|
841
|
+
$$;
|
|
353
842
|
```
|
|
354
843
|
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
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, ${
|
|
863
|
+
message: `Hello, ${name}!`,
|
|
367
864
|
user_id: userId
|
|
368
865
|
};
|
|
369
866
|
}
|
|
370
867
|
```
|
|
371
868
|
|
|
372
|
-
|
|
869
|
+
Pass to server:
|
|
373
870
|
```javascript
|
|
374
|
-
|
|
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
|
-
##
|
|
973
|
+
## Server-Side API Usage
|
|
974
|
+
|
|
975
|
+
For backend/Bun scripts:
|
|
425
976
|
|
|
426
977
|
```javascript
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
##
|
|
1009
|
+
## Error Handling
|
|
456
1010
|
|
|
457
|
-
```
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
509
|
-
'todos',
|
|
510
|
-
'title',
|
|
511
|
-
array['title'],
|
|
512
|
-
'{"user": "users"}'::jsonb
|
|
513
|
-
);
|
|
514
|
-
```
|
|
1066
|
+
## DRY Principles Used
|
|
515
1067
|
|
|
516
|
-
**
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
port: 3000
|
|
522
|
-
});
|
|
523
|
-
```
|
|
1074
|
+
## Key Takeaways for Claude
|
|
524
1075
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
await ws.api.save.todos({
|
|
545
|
-
id: todo.id,
|
|
546
|
-
completed: true
|
|
547
|
-
});
|
|
1087
|
+
## Next Steps
|
|
548
1088
|
|
|
549
|
-
|
|
550
|
-
|
|
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'
|
|
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.
|
|
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",
|
package/src/server/index.js
CHANGED
|
@@ -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,
|