dzql 0.1.0-alpha.3 → 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 +2 -0
- package/README.md +19 -483
- package/REFERENCE.md +891 -0
- package/package.json +2 -1
package/GETTING_STARTED.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+
> **See also:** [REFERENCE.md](REFERENCE.md) for complete API documentation | [CLAUDE.md](../../CLAUDE.md) for AI development guide
|
|
6
|
+
|
|
5
7
|
## The Core Pattern
|
|
6
8
|
|
|
7
9
|
1. **Schema = API**: Define a table → DZQL auto-creates CRUD endpoints
|
package/README.md
CHANGED
|
@@ -1,91 +1,27 @@
|
|
|
1
|
-
# DZQL
|
|
1
|
+
# DZQL
|
|
2
2
|
|
|
3
|
-
PostgreSQL-powered framework with automatic CRUD operations
|
|
3
|
+
PostgreSQL-powered framework with automatic CRUD operations and real-time WebSocket synchronization.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
npm install dzql
|
|
7
|
-
# or with Bun (no Node.js required)
|
|
8
|
-
bun add dzql
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Why DZQL?
|
|
12
|
-
|
|
13
|
-
### Before DZQL
|
|
14
|
-
```javascript
|
|
15
|
-
// Traditional approach: Write everything
|
|
16
|
-
app.post('/api/users', authenticate, validate, async (req, res) => {
|
|
17
|
-
try {
|
|
18
|
-
const user = await db.query('INSERT INTO users (...) VALUES (...)', [...]);
|
|
19
|
-
res.json(user);
|
|
20
|
-
} catch (error) {
|
|
21
|
-
res.status(500).json({ error: error.message });
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
// Repeat for GET, PUT, DELETE, SEARCH, LOOKUP... = 50+ lines of boilerplate
|
|
25
|
-
```
|
|
5
|
+
## Documentation
|
|
26
6
|
|
|
27
|
-
|
|
28
|
-
```javascript
|
|
29
|
-
// That's it. All 5 operations work automatically.
|
|
30
|
-
// GET, SAVE, DELETE, LOOKUP, SEARCH
|
|
31
|
-
const user = await ws.api.save.users({ name: 'John' });
|
|
32
|
-
const results = await ws.api.search.users({ filters: {name: 'john'} });
|
|
33
|
-
```
|
|
7
|
+
All documentation is maintained in the repository root:
|
|
34
8
|
|
|
35
|
-
|
|
9
|
+
- **[README.md](../../README.md)** - Project overview and quick start
|
|
10
|
+
- **[GETTING_STARTED.md](GETTING_STARTED.md)** - Complete tutorial with working todo app
|
|
11
|
+
- **[REFERENCE.md](REFERENCE.md)** - Complete API reference
|
|
12
|
+
- **[CLAUDE.md](../../CLAUDE.md)** - Development guide for AI assistants
|
|
13
|
+
- **[Venues Example](../venues/)** - Full working application
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
✅ **Real-time WebSocket** - Automatic change notifications to all clients
|
|
39
|
-
✅ **PostgreSQL-native** - Leverage full SQL power when needed
|
|
40
|
-
✅ **Graph Rules** - Cascading operations without joins
|
|
41
|
-
✅ **Permissions & RLS** - Row-level security built-in
|
|
42
|
-
✅ **Full-text Search** - Built-in search with filters & pagination
|
|
43
|
-
✅ **Type-safe** - Uses PostgreSQL as source of truth
|
|
44
|
-
✅ **Framework-agnostic** - Works with any frontend (React, Vue, Svelte, plain JS)
|
|
45
|
-
✅ **Bun Native** - No Node.js required
|
|
15
|
+
## Quick Install
|
|
46
16
|
|
|
47
|
-
## Quick Start
|
|
48
|
-
|
|
49
|
-
### 1. Install
|
|
50
17
|
```bash
|
|
51
18
|
bun add dzql
|
|
19
|
+
# or
|
|
20
|
+
npm install dzql
|
|
52
21
|
```
|
|
53
22
|
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
docker run -d \
|
|
57
|
-
-e POSTGRES_PASSWORD=dzql \
|
|
58
|
-
-e POSTGRES_DB=dzql \
|
|
59
|
-
-p 5432:5432 \
|
|
60
|
-
postgres:latest
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 3. Create Server
|
|
64
|
-
```javascript
|
|
65
|
-
import { createServer } from 'dzql';
|
|
66
|
-
|
|
67
|
-
const server = createServer({ port: 3000 });
|
|
68
|
-
console.log('🚀 Server on ws://localhost:3000/ws');
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### 4. Initialize Database
|
|
72
|
-
```bash
|
|
73
|
-
# Apply DZQL core migrations (included in package)
|
|
74
|
-
psql -h localhost -U postgres -d dzql < node_modules/dzql/src/database/migrations/*.sql
|
|
75
|
-
|
|
76
|
-
# Register your entities
|
|
77
|
-
psql -h localhost -U postgres -d dzql << EOF
|
|
78
|
-
CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT, email TEXT);
|
|
79
|
-
|
|
80
|
-
SELECT dzql.register_entity(
|
|
81
|
-
'users',
|
|
82
|
-
'name',
|
|
83
|
-
array['name', 'email']
|
|
84
|
-
);
|
|
85
|
-
EOF
|
|
86
|
-
```
|
|
23
|
+
## Quick Example
|
|
87
24
|
|
|
88
|
-
### 5. Use from Client
|
|
89
25
|
```javascript
|
|
90
26
|
import { WebSocketManager } from 'dzql/client';
|
|
91
27
|
|
|
@@ -93,416 +29,16 @@ const ws = new WebSocketManager();
|
|
|
93
29
|
await ws.connect();
|
|
94
30
|
|
|
95
31
|
// All 5 operations work automatically
|
|
96
|
-
const user = await ws.api.save.users({ name: 'Alice'
|
|
97
|
-
const results = await ws.api.search.users({ filters: { name:
|
|
98
|
-
const deleted = await ws.api.delete.users({ id: user.id });
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## The 5 Operations
|
|
102
|
-
|
|
103
|
-
Every registered entity automatically gets these 5 operations:
|
|
104
|
-
|
|
105
|
-
### GET - Retrieve Single Record
|
|
106
|
-
```javascript
|
|
107
|
-
const user = await ws.api.get.users({ id: 1 });
|
|
108
|
-
// Server: await db.api.get.users({ id: 1 }, userId);
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### SAVE - Create or Update
|
|
112
|
-
```javascript
|
|
113
|
-
const user = await ws.api.save.users({
|
|
114
|
-
id: 1, // Optional - omit for insert
|
|
115
|
-
name: 'Alice',
|
|
116
|
-
email: 'alice@example.com'
|
|
117
|
-
});
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
### DELETE - Remove Record
|
|
121
|
-
```javascript
|
|
122
|
-
const deleted = await ws.api.delete.users({ id: 1 });
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
### LOOKUP - Autocomplete/Label Lookup
|
|
126
|
-
```javascript
|
|
127
|
-
const options = await ws.api.lookup.users({ p_filter: 'ali' });
|
|
128
|
-
// Returns: [{ label: 'Alice', value: 1 }]
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### SEARCH - Advanced Search with Pagination
|
|
132
|
-
```javascript
|
|
133
|
-
const results = await ws.api.search.users({
|
|
134
|
-
filters: {
|
|
135
|
-
name: { ilike: '%alice%' },
|
|
136
|
-
email: 'alice@example.com',
|
|
137
|
-
created_at: { gte: '2025-01-01' }
|
|
138
|
-
},
|
|
139
|
-
sort: { field: 'name', order: 'asc' },
|
|
140
|
-
page: 1,
|
|
141
|
-
limit: 25
|
|
142
|
-
});
|
|
143
|
-
// Returns: { data: [...], total: 42, page: 1, limit: 25 }
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
## Entity Registration
|
|
147
|
-
|
|
148
|
-
Before DZQL works with a table, register it:
|
|
149
|
-
|
|
150
|
-
```sql
|
|
151
|
-
SELECT dzql.register_entity(
|
|
152
|
-
p_table_name := 'users',
|
|
153
|
-
p_label_field := 'name', -- For LOOKUP display
|
|
154
|
-
p_searchable_fields := array['name', 'email'], -- For SEARCH
|
|
155
|
-
p_fk_includes := '{"department": "departments"}'::jsonb, -- Dereference FKs
|
|
156
|
-
p_graph_rules := '{
|
|
157
|
-
"on_delete": {
|
|
158
|
-
"cascade": {
|
|
159
|
-
"actions": [{
|
|
160
|
-
"type": "delete",
|
|
161
|
-
"entity": "posts",
|
|
162
|
-
"condition": "user_id = @id"
|
|
163
|
-
}]
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}'::jsonb
|
|
167
|
-
);
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
## Core API
|
|
171
|
-
|
|
172
|
-
### Server-Side (Bun/Node)
|
|
173
|
-
|
|
174
|
-
```javascript
|
|
175
|
-
import { createServer, db, sql } from 'dzql';
|
|
176
|
-
|
|
177
|
-
// Direct SQL access
|
|
178
|
-
const users = await sql`SELECT * FROM users WHERE active = true`;
|
|
179
|
-
|
|
180
|
-
// DZQL operations (require userId for permissions)
|
|
181
|
-
const user = await db.api.get.users({ id: 1 }, userId);
|
|
182
|
-
const saved = await db.api.save.users({ name: 'Bob' }, userId);
|
|
183
|
-
const searched = await db.api.search.users(
|
|
184
|
-
{ filters: { name: 'bob' } },
|
|
185
|
-
userId
|
|
186
|
-
);
|
|
187
|
-
const deleted = await db.api.delete.users({ id: 1 }, userId);
|
|
188
|
-
const options = await db.api.lookup.users({ p_filter: 'bo' }, userId);
|
|
189
|
-
|
|
190
|
-
// Custom functions
|
|
191
|
-
const result = await db.api.myCustomFunction({ param: 'value' }, userId);
|
|
192
|
-
|
|
193
|
-
// Start server
|
|
194
|
-
const server = createServer({
|
|
195
|
-
port: 3000,
|
|
196
|
-
customApi: {}, // Optional: add custom functions
|
|
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
|
-
}
|
|
206
|
-
});
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
### Client-Side (Browser/Bun)
|
|
210
|
-
|
|
211
|
-
```javascript
|
|
212
|
-
import { WebSocketManager } from 'dzql/client';
|
|
213
|
-
|
|
214
|
-
// Create connection
|
|
215
|
-
const ws = new WebSocketManager();
|
|
216
|
-
await ws.connect();
|
|
217
|
-
|
|
218
|
-
// Authentication
|
|
219
|
-
const auth = await ws.api.login_user({
|
|
220
|
-
email: 'user@example.com',
|
|
221
|
-
password: 'password'
|
|
222
|
-
});
|
|
223
|
-
// Returns: { token, profile, user_id }
|
|
224
|
-
|
|
225
|
-
// All DZQL operations
|
|
226
|
-
const user = await ws.api.get.users({ id: 1 });
|
|
227
|
-
const saved = await ws.api.save.users({ name: 'Charlie' });
|
|
228
|
-
const deleted = await ws.api.delete.users({ id: 1 });
|
|
229
|
-
const lookup = await ws.api.lookup.users({ p_filter: 'char' });
|
|
230
|
-
const search = await ws.api.search.users({ filters: {} });
|
|
231
|
-
|
|
232
|
-
// Custom functions
|
|
233
|
-
const result = await ws.api.myCustomFunction({ foo: 'bar' });
|
|
234
|
-
|
|
235
|
-
// Real-time events
|
|
236
|
-
const unsubscribe = ws.onBroadcast((method, params) => {
|
|
237
|
-
console.log(`${method}:`, params.data);
|
|
238
|
-
// Events: "users:insert", "users:update", "users:delete"
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// Cleanup
|
|
242
|
-
ws.cleanDisconnect();
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
## Custom Functions
|
|
246
|
-
|
|
247
|
-
Add functions alongside DZQL operations:
|
|
248
|
-
|
|
249
|
-
### PostgreSQL Function
|
|
250
|
-
```sql
|
|
251
|
-
CREATE OR REPLACE FUNCTION transfer_amount(
|
|
252
|
-
p_user_id INT,
|
|
253
|
-
p_from_account INT,
|
|
254
|
-
p_to_account INT,
|
|
255
|
-
p_amount DECIMAL
|
|
256
|
-
) RETURNS TABLE (success BOOLEAN, message TEXT) AS $$
|
|
257
|
-
BEGIN
|
|
258
|
-
-- Your logic here
|
|
259
|
-
RETURN QUERY SELECT true, 'Transfer complete';
|
|
260
|
-
END;
|
|
261
|
-
$$ LANGUAGE plpgsql;
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
### Bun Function
|
|
265
|
-
```javascript
|
|
266
|
-
// server/api.js
|
|
267
|
-
export async function transfer_amount(userId, params) {
|
|
268
|
-
const { from_account, to_account, amount } = params;
|
|
269
|
-
// Your logic here
|
|
270
|
-
return { success: true, message: 'Transfer complete' };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// server/index.js
|
|
274
|
-
const customApi = await import('./api.js');
|
|
275
|
-
const server = createServer({ customApi });
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
### Usage
|
|
279
|
-
```javascript
|
|
280
|
-
const result = await ws.api.transfer_amount({
|
|
281
|
-
from_account: 1,
|
|
282
|
-
to_account: 2,
|
|
283
|
-
amount: 100
|
|
284
|
-
});
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
## Real-time Events
|
|
288
|
-
|
|
289
|
-
Listen for database changes in real-time:
|
|
290
|
-
|
|
291
|
-
```javascript
|
|
292
|
-
ws.onBroadcast((method, params) => {
|
|
293
|
-
if (method === 'users:insert') {
|
|
294
|
-
console.log('New user:', params.data);
|
|
295
|
-
// params: { op: 'insert', table: 'users', data: {...}, notify_users: [...] }
|
|
296
|
-
}
|
|
297
|
-
if (method === 'users:update') {
|
|
298
|
-
console.log('User updated:', params.data);
|
|
299
|
-
}
|
|
300
|
-
if (method === 'users:delete') {
|
|
301
|
-
console.log('User deleted:', params.data);
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
## Graph Rules - Cascading Operations
|
|
307
|
-
|
|
308
|
-
Automatically cascade changes through relationships:
|
|
309
|
-
|
|
310
|
-
```sql
|
|
311
|
-
SELECT dzql.register_entity(
|
|
312
|
-
p_table_name := 'organisations',
|
|
313
|
-
p_label_field := 'name',
|
|
314
|
-
p_searchable_fields := array['name'],
|
|
315
|
-
p_graph_rules := '{
|
|
316
|
-
"on_delete": {
|
|
317
|
-
"cascade_to_teams": {
|
|
318
|
-
"actions": [{
|
|
319
|
-
"type": "delete",
|
|
320
|
-
"entity": "teams",
|
|
321
|
-
"condition": "org_id = @id"
|
|
322
|
-
}]
|
|
323
|
-
}
|
|
324
|
-
},
|
|
325
|
-
"on_create": {
|
|
326
|
-
"create_default_team": {
|
|
327
|
-
"actions": [{
|
|
328
|
-
"type": "create",
|
|
329
|
-
"entity": "teams",
|
|
330
|
-
"data": {
|
|
331
|
-
"org_id": "@id",
|
|
332
|
-
"name": "Default Team"
|
|
333
|
-
}
|
|
334
|
-
}]
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}'::jsonb
|
|
338
|
-
);
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
Available actions: `create`, `update`, `delete`, `insert`, `call_function`
|
|
342
|
-
|
|
343
|
-
## Permissions & Row-Level Security
|
|
344
|
-
|
|
345
|
-
Implement permissions in your entity registration:
|
|
346
|
-
|
|
347
|
-
```sql
|
|
348
|
-
SELECT dzql.register_entity(
|
|
349
|
-
p_table_name := 'posts',
|
|
350
|
-
p_label_field := 'title',
|
|
351
|
-
p_searchable_fields := array['title', 'content'],
|
|
352
|
-
p_permission_rules := '{
|
|
353
|
-
"view": {
|
|
354
|
-
"public_posts": {
|
|
355
|
-
"condition": "public = true OR author_id = @user_id"
|
|
356
|
-
}
|
|
357
|
-
},
|
|
358
|
-
"edit": {
|
|
359
|
-
"own_posts": {
|
|
360
|
-
"condition": "author_id = @user_id"
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}'::jsonb
|
|
364
|
-
);
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
## Search Filter Operators
|
|
368
|
-
|
|
369
|
-
```javascript
|
|
370
|
-
const results = await ws.api.search.venues({
|
|
371
|
-
filters: {
|
|
372
|
-
// Exact match
|
|
373
|
-
name: 'Madison Square Garden',
|
|
374
|
-
|
|
375
|
-
// Comparison operators
|
|
376
|
-
capacity: { gt: 1000 }, // Greater than
|
|
377
|
-
capacity: { gte: 1000 }, // Greater or equal
|
|
378
|
-
capacity: { lt: 50000 }, // Less than
|
|
379
|
-
capacity: { lte: 50000 }, // Less or equal
|
|
380
|
-
capacity: { neq: 5000 }, // Not equal
|
|
381
|
-
|
|
382
|
-
// Range
|
|
383
|
-
capacity: { between: [1000, 50000] },
|
|
384
|
-
|
|
385
|
-
// Pattern matching
|
|
386
|
-
name: { like: '%garden%' }, // Case-sensitive
|
|
387
|
-
name: { ilike: '%GARDEN%' }, // Case-insensitive
|
|
388
|
-
|
|
389
|
-
// NULL checks
|
|
390
|
-
description: null, // IS NULL
|
|
391
|
-
description: { not_null: true }, // IS NOT NULL
|
|
392
|
-
|
|
393
|
-
// Arrays
|
|
394
|
-
categories: ['sports', 'music'], // IN array
|
|
395
|
-
categories: { not_in: ['adult'] }, // NOT IN array
|
|
396
|
-
|
|
397
|
-
// Text search (across searchable_fields)
|
|
398
|
-
_search: 'madison garden'
|
|
399
|
-
},
|
|
400
|
-
page: 1,
|
|
401
|
-
limit: 25
|
|
402
|
-
});
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
## Project Structure
|
|
406
|
-
|
|
407
|
-
```
|
|
408
|
-
my-app/
|
|
409
|
-
├── server/
|
|
410
|
-
│ ├── index.js # Server entry point
|
|
411
|
-
│ └── api.js # Custom API functions (optional)
|
|
412
|
-
├── database/
|
|
413
|
-
│ ├── docker-compose.yml # PostgreSQL setup
|
|
414
|
-
│ ├── init_db/
|
|
415
|
-
│ │ ├── 001_schema.sql # Your tables
|
|
416
|
-
│ │ └── 002_entities.sql # Entity registration
|
|
417
|
-
│ └── seeds/ # Sample data (optional)
|
|
418
|
-
├── client/
|
|
419
|
-
│ └── index.html # Frontend (optional)
|
|
420
|
-
├── tests/
|
|
421
|
-
│ └── app.test.js
|
|
422
|
-
├── package.json
|
|
423
|
-
└── bunfig.toml # Bun config (optional)
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
## Environment Variables
|
|
427
|
-
|
|
428
|
-
```bash
|
|
429
|
-
# Database
|
|
430
|
-
DATABASE_URL=postgresql://dzql:dzql@localhost:5432/dzql
|
|
431
|
-
|
|
432
|
-
# Server
|
|
433
|
-
PORT=3000
|
|
434
|
-
NODE_ENV=development
|
|
435
|
-
|
|
436
|
-
# JWT
|
|
437
|
-
JWT_SECRET=your-secret-key-min-32-chars
|
|
438
|
-
JWT_EXPIRES_IN=7d
|
|
439
|
-
|
|
440
|
-
# WebSocket
|
|
441
|
-
WS_PING_INTERVAL=30000 # Keep-alive ping (Heroku safe: <55s)
|
|
442
|
-
WS_PING_TIMEOUT=5000
|
|
443
|
-
|
|
444
|
-
# Logging
|
|
445
|
-
LOG_LEVEL=INFO # ERROR, WARN, INFO, DEBUG, TRACE
|
|
446
|
-
LOG_CATEGORIES=ws:debug,db:debug
|
|
32
|
+
const user = await ws.api.save.users({ name: 'Alice' });
|
|
33
|
+
const results = await ws.api.search.users({ filters: { name: 'alice' } });
|
|
447
34
|
```
|
|
448
35
|
|
|
449
|
-
##
|
|
450
|
-
|
|
451
|
-
See the [venues example](https://github.com/blueshed/dzql/tree/main/packages/venues) for a complete working application.
|
|
452
|
-
|
|
453
|
-
### Todo App
|
|
454
|
-
```javascript
|
|
455
|
-
// Schema
|
|
456
|
-
CREATE TABLE todos (
|
|
457
|
-
id SERIAL PRIMARY KEY,
|
|
458
|
-
user_id INT REFERENCES users(id),
|
|
459
|
-
title TEXT NOT NULL,
|
|
460
|
-
completed BOOLEAN DEFAULT FALSE,
|
|
461
|
-
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
462
|
-
);
|
|
463
|
-
|
|
464
|
-
SELECT dzql.register_entity('todos', 'title', array['title']);
|
|
465
|
-
|
|
466
|
-
// Client
|
|
467
|
-
const todo = await ws.api.save.todos({ title: 'Learn DZQL' });
|
|
468
|
-
const list = await ws.api.search.todos({
|
|
469
|
-
filters: { completed: false },
|
|
470
|
-
limit: 100
|
|
471
|
-
});
|
|
472
|
-
await ws.api.save.todos({ id: todo.id, completed: true });
|
|
473
|
-
await ws.api.delete.todos({ id: todo.id });
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
## Error Handling
|
|
36
|
+
## License
|
|
477
37
|
|
|
478
|
-
|
|
479
|
-
try {
|
|
480
|
-
const user = await ws.api.get.users({ id: 999 });
|
|
481
|
-
} catch (error) {
|
|
482
|
-
// Common errors:
|
|
483
|
-
// "record not found" - Record doesn't exist
|
|
484
|
-
// "Permission denied: view on users" - Access denied
|
|
485
|
-
// "entity users not configured" - Entity not registered
|
|
486
|
-
// "Column foo does not exist in table users" - Invalid column
|
|
487
|
-
console.error(error.message);
|
|
488
|
-
}
|
|
489
|
-
```
|
|
38
|
+
MIT
|
|
490
39
|
|
|
491
|
-
##
|
|
40
|
+
## Links
|
|
492
41
|
|
|
493
|
-
- **Documentation**: [GETTING_STARTED.md](./GETTING_STARTED.md)
|
|
494
42
|
- **GitHub**: https://github.com/blueshed/dzql
|
|
495
43
|
- **Issues**: https://github.com/blueshed/dzql/issues
|
|
496
|
-
- **
|
|
497
|
-
|
|
498
|
-
## License
|
|
499
|
-
|
|
500
|
-
MIT - See LICENSE file
|
|
501
|
-
|
|
502
|
-
## Authors
|
|
503
|
-
|
|
504
|
-
Created by [Blueshed](https://blueshed.com)
|
|
505
|
-
|
|
506
|
-
---
|
|
507
|
-
|
|
508
|
-
**Ready to build?** Start with [GETTING_STARTED.md](./GETTING_STARTED.md) 🚀
|
|
44
|
+
- **npm**: https://www.npmjs.com/package/dzql
|
package/REFERENCE.md
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
# DZQL API Reference
|
|
2
|
+
|
|
3
|
+
Complete API documentation for DZQL framework. For tutorials, see [GETTING_STARTED.md](GETTING_STARTED.md). For AI development guide, see [CLAUDE.md](../../CLAUDE.md).
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [The 5 Operations](#the-5-operations)
|
|
8
|
+
- [Entity Registration](#entity-registration)
|
|
9
|
+
- [Search Operators](#search-operators)
|
|
10
|
+
- [Graph Rules](#graph-rules)
|
|
11
|
+
- [Permission & Notification Paths](#permission--notification-paths)
|
|
12
|
+
- [Custom Functions](#custom-functions)
|
|
13
|
+
- [Authentication](#authentication)
|
|
14
|
+
- [Real-time Events](#real-time-events)
|
|
15
|
+
- [Temporal Relationships](#temporal-relationships)
|
|
16
|
+
- [Error Messages](#error-messages)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## The 5 Operations
|
|
21
|
+
|
|
22
|
+
Every registered entity automatically gets these 5 operations via the proxy API:
|
|
23
|
+
|
|
24
|
+
### GET - Retrieve Single Record
|
|
25
|
+
|
|
26
|
+
Fetch a single record by primary key with foreign keys dereferenced.
|
|
27
|
+
|
|
28
|
+
**Client:**
|
|
29
|
+
```javascript
|
|
30
|
+
const record = await ws.api.get.{entity}({id: 1});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Server:**
|
|
34
|
+
```javascript
|
|
35
|
+
const record = await db.api.get.{entity}({id: 1}, userId);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Parameters:**
|
|
39
|
+
| Field | Type | Required | Description |
|
|
40
|
+
|-------|------|----------|-------------|
|
|
41
|
+
| `id` | any | yes | Primary key value |
|
|
42
|
+
| `on_date` | string | no | Temporal filtering (ISO 8601 date) |
|
|
43
|
+
|
|
44
|
+
**Returns:** Object with all fields + dereferenced FKs
|
|
45
|
+
|
|
46
|
+
**Throws:** `"record not found"` if not exists
|
|
47
|
+
|
|
48
|
+
**Example:**
|
|
49
|
+
```javascript
|
|
50
|
+
const venue = await ws.api.get.venues({id: 1});
|
|
51
|
+
// {id: 1, name: "MSG", org: {id: 3, name: "Org"}, sites: [...]}
|
|
52
|
+
|
|
53
|
+
// With temporal filtering
|
|
54
|
+
const historical = await ws.api.get.venues({id: 1, on_date: '2023-01-01'});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### SAVE - Create or Update (Upsert)
|
|
60
|
+
|
|
61
|
+
Insert new record (no `id`) or update existing (with `id`).
|
|
62
|
+
|
|
63
|
+
**Client:**
|
|
64
|
+
```javascript
|
|
65
|
+
const record = await ws.api.save.{entity}({...fields});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Server:**
|
|
69
|
+
```javascript
|
|
70
|
+
const record = await db.api.save.{entity}({...fields}, userId);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Parameters:**
|
|
74
|
+
| Field | Type | Required | Description |
|
|
75
|
+
|-------|------|----------|-------------|
|
|
76
|
+
| `id` | any | no | Omit for insert, include for update |
|
|
77
|
+
| ...fields | any | varies | Entity-specific fields |
|
|
78
|
+
|
|
79
|
+
**Returns:** Created/updated record
|
|
80
|
+
|
|
81
|
+
**Behavior:**
|
|
82
|
+
- **No `id`**: INSERT new record
|
|
83
|
+
- **With `id`**: UPDATE existing record (partial update supported)
|
|
84
|
+
- Triggers graph rules if configured
|
|
85
|
+
- Generates real-time event
|
|
86
|
+
|
|
87
|
+
**Example:**
|
|
88
|
+
```javascript
|
|
89
|
+
// Insert
|
|
90
|
+
const venue = await ws.api.save.venues({
|
|
91
|
+
name: 'Madison Square Garden',
|
|
92
|
+
address: 'NYC',
|
|
93
|
+
org_id: 1
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Update (partial)
|
|
97
|
+
const updated = await ws.api.save.venues({
|
|
98
|
+
id: 1,
|
|
99
|
+
name: 'Updated Name' // Only updates name
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### DELETE - Remove Record
|
|
106
|
+
|
|
107
|
+
Delete a record by primary key.
|
|
108
|
+
|
|
109
|
+
**Client:**
|
|
110
|
+
```javascript
|
|
111
|
+
const result = await ws.api.delete.{entity}({id: 1});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Server:**
|
|
115
|
+
```javascript
|
|
116
|
+
const result = await db.api.delete.{entity}({id: 1}, userId);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Parameters:**
|
|
120
|
+
| Field | Type | Required | Description |
|
|
121
|
+
|-------|------|----------|-------------|
|
|
122
|
+
| `id` | any | yes | Primary key value |
|
|
123
|
+
|
|
124
|
+
**Returns:** Deleted record
|
|
125
|
+
|
|
126
|
+
**Behavior:**
|
|
127
|
+
- Hard delete (unless soft delete configured)
|
|
128
|
+
- Triggers graph rules if configured
|
|
129
|
+
- Generates real-time event
|
|
130
|
+
|
|
131
|
+
**Example:**
|
|
132
|
+
```javascript
|
|
133
|
+
const deleted = await ws.api.delete.venues({id: 1});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### LOOKUP - Autocomplete/Typeahead
|
|
139
|
+
|
|
140
|
+
Get label-value pairs for autocomplete inputs.
|
|
141
|
+
|
|
142
|
+
**Client:**
|
|
143
|
+
```javascript
|
|
144
|
+
const options = await ws.api.lookup.{entity}({p_filter: 'search'});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Server:**
|
|
148
|
+
```javascript
|
|
149
|
+
const options = await db.api.lookup.{entity}({p_filter: 'search'}, userId);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Parameters:**
|
|
153
|
+
| Field | Type | Required | Description |
|
|
154
|
+
|-------|------|----------|-------------|
|
|
155
|
+
| `p_filter` | string | no | Search term (matches label field) |
|
|
156
|
+
|
|
157
|
+
**Returns:** Array of `{label, value}` objects
|
|
158
|
+
|
|
159
|
+
**Example:**
|
|
160
|
+
```javascript
|
|
161
|
+
const options = await ws.api.lookup.venues({p_filter: 'madison'});
|
|
162
|
+
// [{label: "Madison Square Garden", value: 1}, ...]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### SEARCH - Advanced Search with Pagination
|
|
168
|
+
|
|
169
|
+
Search with filters, sorting, and pagination.
|
|
170
|
+
|
|
171
|
+
**Client:**
|
|
172
|
+
```javascript
|
|
173
|
+
const results = await ws.api.search.{entity}({
|
|
174
|
+
filters: {...},
|
|
175
|
+
sort: {field, order},
|
|
176
|
+
page: 1,
|
|
177
|
+
limit: 25
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Server:**
|
|
182
|
+
```javascript
|
|
183
|
+
const results = await db.api.search.{entity}({...}, userId);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Parameters:**
|
|
187
|
+
| Field | Type | Required | Description |
|
|
188
|
+
|-------|------|----------|-------------|
|
|
189
|
+
| `filters` | object | no | See [Search Operators](#search-operators) |
|
|
190
|
+
| `sort` | object | no | `{field: 'name', order: 'asc' | 'desc'}` |
|
|
191
|
+
| `page` | number | no | Page number (1-indexed, default: 1) |
|
|
192
|
+
| `limit` | number | no | Records per page (default: 25) |
|
|
193
|
+
|
|
194
|
+
**Returns:**
|
|
195
|
+
```javascript
|
|
196
|
+
{
|
|
197
|
+
data: [...], // Array of records
|
|
198
|
+
total: 100, // Total matching records
|
|
199
|
+
page: 1, // Current page
|
|
200
|
+
limit: 25 // Records per page
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Example:**
|
|
205
|
+
```javascript
|
|
206
|
+
const results = await ws.api.search.venues({
|
|
207
|
+
filters: {
|
|
208
|
+
city: 'New York',
|
|
209
|
+
capacity: {gte: 1000, lt: 5000},
|
|
210
|
+
name: {ilike: '%garden%'},
|
|
211
|
+
_search: 'madison' // Text search across searchable fields
|
|
212
|
+
},
|
|
213
|
+
sort: {field: 'name', order: 'asc'},
|
|
214
|
+
page: 1,
|
|
215
|
+
limit: 25
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Entity Registration
|
|
222
|
+
|
|
223
|
+
Register an entity to enable all 5 operations via `dzql.register_entity()`.
|
|
224
|
+
|
|
225
|
+
### Full Signature
|
|
226
|
+
|
|
227
|
+
```sql
|
|
228
|
+
SELECT dzql.register_entity(
|
|
229
|
+
p_table_name TEXT,
|
|
230
|
+
p_label_field TEXT,
|
|
231
|
+
p_searchable_fields TEXT[],
|
|
232
|
+
p_fk_includes JSONB DEFAULT '{}'::jsonb,
|
|
233
|
+
p_soft_delete BOOLEAN DEFAULT false,
|
|
234
|
+
p_temporal_fields JSONB DEFAULT '{}'::jsonb,
|
|
235
|
+
p_notification_paths JSONB DEFAULT '{}'::jsonb,
|
|
236
|
+
p_permission_paths JSONB DEFAULT '{}'::jsonb,
|
|
237
|
+
p_graph_rules JSONB DEFAULT '{}'::jsonb
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Parameters
|
|
242
|
+
|
|
243
|
+
| Parameter | Type | Required | Description |
|
|
244
|
+
|-----------|------|----------|-------------|
|
|
245
|
+
| `p_table_name` | TEXT | **yes** | Table name in database |
|
|
246
|
+
| `p_label_field` | TEXT | **yes** | Field used for LOOKUP display |
|
|
247
|
+
| `p_searchable_fields` | TEXT[] | **yes** | Fields searchable by SEARCH (min: 1) |
|
|
248
|
+
| `p_fk_includes` | JSONB | no | Foreign keys to dereference in GET |
|
|
249
|
+
| `p_soft_delete` | BOOLEAN | no | Enable soft delete (default: false) |
|
|
250
|
+
| `p_temporal_fields` | JSONB | no | Temporal field config (valid_from/valid_to) |
|
|
251
|
+
| `p_notification_paths` | JSONB | no | Who receives real-time updates |
|
|
252
|
+
| `p_permission_paths` | JSONB | no | CRUD permission rules |
|
|
253
|
+
| `p_graph_rules` | JSONB | no | Automatic relationship management |
|
|
254
|
+
|
|
255
|
+
### FK Includes
|
|
256
|
+
|
|
257
|
+
Configure which foreign keys to dereference in GET operations:
|
|
258
|
+
|
|
259
|
+
```sql
|
|
260
|
+
-- Single object dereference
|
|
261
|
+
'{"org": "organisations"}' -- venue.org_id -> full org object
|
|
262
|
+
|
|
263
|
+
-- Child array inclusion
|
|
264
|
+
'{"sites": "sites"}' -- Include all child sites (auto-detects FK)
|
|
265
|
+
|
|
266
|
+
-- Multiple
|
|
267
|
+
'{"org": "organisations", "sites": "sites", "venue": "venues"}'
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Result example:**
|
|
271
|
+
```javascript
|
|
272
|
+
{
|
|
273
|
+
id: 1,
|
|
274
|
+
name: "Madison Square Garden",
|
|
275
|
+
org_id: 3,
|
|
276
|
+
org: {id: 3, name: "Venue Management", ...}, // Dereferenced
|
|
277
|
+
sites: [ // Child array
|
|
278
|
+
{id: 1, name: "Main Entrance", ...},
|
|
279
|
+
{id: 2, name: "Concourse", ...}
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Temporal Fields
|
|
285
|
+
|
|
286
|
+
Enable temporal relationships with `valid_from`/`valid_to`:
|
|
287
|
+
|
|
288
|
+
```sql
|
|
289
|
+
'{
|
|
290
|
+
"valid_from": "valid_from", -- Column name for start date
|
|
291
|
+
"valid_to": "valid_to" -- Column name for end date
|
|
292
|
+
}'
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Usage:**
|
|
296
|
+
```javascript
|
|
297
|
+
// Current relationships (default)
|
|
298
|
+
const rights = await ws.api.get.contractor_rights({id: 1});
|
|
299
|
+
|
|
300
|
+
// Historical relationships
|
|
301
|
+
const past = await ws.api.get.contractor_rights({id: 1, on_date: '2023-01-01'});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Example Registration
|
|
305
|
+
|
|
306
|
+
```sql
|
|
307
|
+
SELECT dzql.register_entity(
|
|
308
|
+
'venues', -- table name
|
|
309
|
+
'name', -- label field
|
|
310
|
+
array['name', 'address', 'description'], -- searchable
|
|
311
|
+
'{"org": "organisations", "sites": "sites"}', -- FK includes
|
|
312
|
+
false, -- soft delete
|
|
313
|
+
'{}', -- temporal (none)
|
|
314
|
+
'{ -- notifications
|
|
315
|
+
"ownership": ["@org_id->acts_for[org_id=$]{active}.user_id"]
|
|
316
|
+
}',
|
|
317
|
+
'{ -- permissions
|
|
318
|
+
"create": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
319
|
+
"update": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
320
|
+
"delete": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
321
|
+
"view": []
|
|
322
|
+
}',
|
|
323
|
+
'{ -- graph rules
|
|
324
|
+
"on_create": {
|
|
325
|
+
"establish_site": {
|
|
326
|
+
"description": "Create default site",
|
|
327
|
+
"actions": [{
|
|
328
|
+
"type": "create",
|
|
329
|
+
"entity": "sites",
|
|
330
|
+
"data": {"name": "Main Site", "venue_id": "@id"}
|
|
331
|
+
}]
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}'
|
|
335
|
+
);
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Search Operators
|
|
341
|
+
|
|
342
|
+
The SEARCH operation supports advanced filtering via the `filters` object.
|
|
343
|
+
|
|
344
|
+
### Operator Reference
|
|
345
|
+
|
|
346
|
+
| Operator | Syntax | Description | Example |
|
|
347
|
+
|----------|--------|-------------|---------|
|
|
348
|
+
| **Exact match** | `field: value` | Equality | `{name: 'Alice'}` |
|
|
349
|
+
| **Greater than** | `{gt: n}` | `>` | `{age: {gt: 18}}` |
|
|
350
|
+
| **Greater or equal** | `{gte: n}` | `>=` | `{age: {gte: 18}}` |
|
|
351
|
+
| **Less than** | `{lt: n}` | `<` | `{age: {lt: 65}}` |
|
|
352
|
+
| **Less or equal** | `{lte: n}` | `<=` | `{age: {lte: 65}}` |
|
|
353
|
+
| **Not equal** | `{neq: v}` | `!=` | `{status: {neq: 'deleted'}}` |
|
|
354
|
+
| **Between** | `{between: [a, b]}` | `BETWEEN a AND b` | `{age: {between: [18, 65]}}` |
|
|
355
|
+
| **LIKE** | `{like: 'pattern'}` | Case-sensitive pattern | `{name: {like: '%Garden%'}}` |
|
|
356
|
+
| **ILIKE** | `{ilike: 'pattern'}` | Case-insensitive pattern | `{name: {ilike: '%garden%'}}` |
|
|
357
|
+
| **IS NULL** | `field: null` | NULL check | `{description: null}` |
|
|
358
|
+
| **IS NOT NULL** | `{not_null: true}` | NOT NULL check | `{description: {not_null: true}}` |
|
|
359
|
+
| **IN array** | `field: [...]` | `IN (...)` | `{city: ['NYC', 'LA']}` |
|
|
360
|
+
| **NOT IN array** | `{not_in: [...]}` | `NOT IN (...)` | `{status: {not_in: ['deleted']}}` |
|
|
361
|
+
| **Text search** | `_search: 'terms'` | Across searchable fields | `{_search: 'madison garden'}` |
|
|
362
|
+
|
|
363
|
+
### Complete Example
|
|
364
|
+
|
|
365
|
+
```javascript
|
|
366
|
+
const results = await ws.api.search.venues({
|
|
367
|
+
filters: {
|
|
368
|
+
// Exact match
|
|
369
|
+
city: 'New York',
|
|
370
|
+
|
|
371
|
+
// Comparison
|
|
372
|
+
capacity: {gte: 1000, lt: 5000},
|
|
373
|
+
|
|
374
|
+
// Pattern matching
|
|
375
|
+
name: {ilike: '%garden%'},
|
|
376
|
+
|
|
377
|
+
// NULL checks
|
|
378
|
+
description: {not_null: true},
|
|
379
|
+
|
|
380
|
+
// Arrays
|
|
381
|
+
categories: ['sports', 'music'],
|
|
382
|
+
status: {not_in: ['deleted', 'closed']},
|
|
383
|
+
|
|
384
|
+
// Text search (across all searchable_fields)
|
|
385
|
+
_search: 'madison square'
|
|
386
|
+
},
|
|
387
|
+
sort: {field: 'capacity', order: 'desc'},
|
|
388
|
+
page: 1,
|
|
389
|
+
limit: 25
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Graph Rules
|
|
396
|
+
|
|
397
|
+
Automatically manage entity relationships when data changes.
|
|
398
|
+
|
|
399
|
+
### Structure
|
|
400
|
+
|
|
401
|
+
```jsonb
|
|
402
|
+
{
|
|
403
|
+
"on_create": {
|
|
404
|
+
"rule_name": {
|
|
405
|
+
"description": "Human-readable description",
|
|
406
|
+
"actions": [
|
|
407
|
+
{
|
|
408
|
+
"type": "create|update|delete",
|
|
409
|
+
"entity": "target_table",
|
|
410
|
+
"data": {"field": "@variable"}, // for create/update
|
|
411
|
+
"match": {"field": "@variable"} // for update/delete
|
|
412
|
+
}
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
"on_update": { /* same structure */ },
|
|
417
|
+
"on_delete": { /* same structure */ }
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Action Types
|
|
422
|
+
|
|
423
|
+
| Type | Fields | Description |
|
|
424
|
+
|------|--------|-------------|
|
|
425
|
+
| `create` | `entity`, `data` | INSERT new record |
|
|
426
|
+
| `update` | `entity`, `match`, `data` | UPDATE matching records |
|
|
427
|
+
| `delete` | `entity`, `match` | DELETE matching records |
|
|
428
|
+
|
|
429
|
+
### Variables
|
|
430
|
+
|
|
431
|
+
Variables reference data from the triggering operation:
|
|
432
|
+
|
|
433
|
+
| Variable | Description | Example |
|
|
434
|
+
|----------|-------------|---------|
|
|
435
|
+
| `@user_id` | Current authenticated user | `"created_by": "@user_id"` |
|
|
436
|
+
| `@id` | Primary key of the record | `"org_id": "@id"` |
|
|
437
|
+
| `@field_name` | Any field from the record | `"org_id": "@org_id"` |
|
|
438
|
+
| `@now` | Current timestamp | `"created_at": "@now"` |
|
|
439
|
+
| `@today` | Current date | `"valid_from": "@today"` |
|
|
440
|
+
|
|
441
|
+
### Common Patterns
|
|
442
|
+
|
|
443
|
+
#### Creator Becomes Owner
|
|
444
|
+
```jsonb
|
|
445
|
+
{
|
|
446
|
+
"on_create": {
|
|
447
|
+
"establish_ownership": {
|
|
448
|
+
"description": "Creator becomes member of organisation",
|
|
449
|
+
"actions": [{
|
|
450
|
+
"type": "create",
|
|
451
|
+
"entity": "acts_for",
|
|
452
|
+
"data": {
|
|
453
|
+
"user_id": "@user_id",
|
|
454
|
+
"org_id": "@id",
|
|
455
|
+
"valid_from": "@today"
|
|
456
|
+
}
|
|
457
|
+
}]
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
#### Cascade Delete
|
|
464
|
+
```jsonb
|
|
465
|
+
{
|
|
466
|
+
"on_delete": {
|
|
467
|
+
"cascade_venues": {
|
|
468
|
+
"description": "Delete all venues when org is deleted",
|
|
469
|
+
"actions": [{
|
|
470
|
+
"type": "delete",
|
|
471
|
+
"entity": "venues",
|
|
472
|
+
"match": {"org_id": "@id"}
|
|
473
|
+
}]
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
#### Temporal Transition
|
|
480
|
+
```jsonb
|
|
481
|
+
{
|
|
482
|
+
"on_create": {
|
|
483
|
+
"expire_previous": {
|
|
484
|
+
"description": "End previous temporal relationship",
|
|
485
|
+
"actions": [{
|
|
486
|
+
"type": "update",
|
|
487
|
+
"entity": "acts_for",
|
|
488
|
+
"match": {
|
|
489
|
+
"user_id": "@user_id",
|
|
490
|
+
"org_id": "@org_id",
|
|
491
|
+
"valid_to": null
|
|
492
|
+
},
|
|
493
|
+
"data": {
|
|
494
|
+
"valid_to": "@valid_from"
|
|
495
|
+
}
|
|
496
|
+
}]
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Execution
|
|
503
|
+
|
|
504
|
+
- **Atomic**: All rules execute in the same transaction
|
|
505
|
+
- **Sequential**: Actions execute in order within each rule
|
|
506
|
+
- **Rollback**: If any action fails, entire transaction rolls back
|
|
507
|
+
- **Events**: Each action generates its own audit event
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Permission & Notification Paths
|
|
512
|
+
|
|
513
|
+
Paths use a unified syntax for both permissions and notifications.
|
|
514
|
+
|
|
515
|
+
### Path Syntax
|
|
516
|
+
|
|
517
|
+
```
|
|
518
|
+
@field->table[filter]{temporal}.target_field
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Components:**
|
|
522
|
+
- `@field` - Start from a field in the current record
|
|
523
|
+
- `->table` - Navigate to related table
|
|
524
|
+
- `[filter]` - WHERE clause (`$` = current field value)
|
|
525
|
+
- `{temporal}` - Apply temporal filtering (`{active}` = valid now)
|
|
526
|
+
- `.target_field` - Extract this field as result
|
|
527
|
+
|
|
528
|
+
### Permission Paths
|
|
529
|
+
|
|
530
|
+
Control who can perform CRUD operations:
|
|
531
|
+
|
|
532
|
+
```sql
|
|
533
|
+
'{
|
|
534
|
+
"create": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
535
|
+
"update": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
536
|
+
"delete": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
537
|
+
"view": [] -- Empty array = public access
|
|
538
|
+
}'
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
**Permission types:**
|
|
542
|
+
- `create` - Who can create records
|
|
543
|
+
- `update` - Who can modify records
|
|
544
|
+
- `delete` - Who can remove records
|
|
545
|
+
- `view` - Who can read records (empty = public)
|
|
546
|
+
|
|
547
|
+
**Behavior:**
|
|
548
|
+
- User's `user_id` must be in resolved set of user_ids
|
|
549
|
+
- Checked before operation executes
|
|
550
|
+
- Empty array = allow all
|
|
551
|
+
- Missing permission type = deny all
|
|
552
|
+
|
|
553
|
+
### Notification Paths
|
|
554
|
+
|
|
555
|
+
Determine who receives real-time updates:
|
|
556
|
+
|
|
557
|
+
```sql
|
|
558
|
+
'{
|
|
559
|
+
"ownership": ["@org_id->acts_for[org_id=$]{active}.user_id"],
|
|
560
|
+
"sponsorship": ["@sponsor_org_id->acts_for[org_id=$]{active}.user_id"]
|
|
561
|
+
}'
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**Behavior:**
|
|
565
|
+
- Resolves to array of user_ids or `null`
|
|
566
|
+
- `null` = broadcast to all authenticated users
|
|
567
|
+
- Array = send only to specified users
|
|
568
|
+
- Multiple paths = union of all resolved user_ids
|
|
569
|
+
|
|
570
|
+
### Path Examples
|
|
571
|
+
|
|
572
|
+
```sql
|
|
573
|
+
-- Direct user reference
|
|
574
|
+
'@user_id'
|
|
575
|
+
|
|
576
|
+
-- Via organization
|
|
577
|
+
'@org_id->acts_for[org_id=$]{active}.user_id'
|
|
578
|
+
|
|
579
|
+
-- Via nested relationship
|
|
580
|
+
'@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id'
|
|
581
|
+
|
|
582
|
+
-- Via multiple relationships
|
|
583
|
+
'@package_id->packages.owner_org_id->acts_for[org_id=$]{active}.user_id'
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Custom Functions
|
|
589
|
+
|
|
590
|
+
Extend DZQL with custom PostgreSQL or Bun functions.
|
|
591
|
+
|
|
592
|
+
### PostgreSQL Functions
|
|
593
|
+
|
|
594
|
+
Create stored procedures and call via proxy API:
|
|
595
|
+
|
|
596
|
+
```sql
|
|
597
|
+
CREATE OR REPLACE FUNCTION my_function(
|
|
598
|
+
p_user_id INT, -- REQUIRED: First parameter
|
|
599
|
+
p_param TEXT DEFAULT 'default'
|
|
600
|
+
) RETURNS JSONB
|
|
601
|
+
LANGUAGE plpgsql
|
|
602
|
+
SECURITY DEFINER
|
|
603
|
+
AS $$
|
|
604
|
+
BEGIN
|
|
605
|
+
-- Your logic here
|
|
606
|
+
RETURN jsonb_build_object('result', p_param);
|
|
607
|
+
END;
|
|
608
|
+
$$;
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Call:**
|
|
612
|
+
```javascript
|
|
613
|
+
const result = await ws.api.my_function({param: 'value'});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
**Conventions:**
|
|
617
|
+
- First parameter **must** be `p_user_id INT`
|
|
618
|
+
- Can access full PostgreSQL ecosystem
|
|
619
|
+
- Automatically transactional
|
|
620
|
+
- Optional registration in `dzql.registry`
|
|
621
|
+
|
|
622
|
+
### Bun Functions
|
|
623
|
+
|
|
624
|
+
Create JavaScript functions in server:
|
|
625
|
+
|
|
626
|
+
```javascript
|
|
627
|
+
// server/api.js
|
|
628
|
+
export async function myBunFunction(userId, params = {}) {
|
|
629
|
+
const { param = 'default' } = params;
|
|
630
|
+
|
|
631
|
+
// Can use db.api for database access
|
|
632
|
+
// const data = await db.api.get.users({id: userId}, userId);
|
|
633
|
+
|
|
634
|
+
return { result: param };
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Server setup:**
|
|
639
|
+
```javascript
|
|
640
|
+
import * as customApi from './server/api.js';
|
|
641
|
+
const server = createServer({ customApi });
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
**Call:**
|
|
645
|
+
```javascript
|
|
646
|
+
const result = await ws.api.myBunFunction({param: 'value'});
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**Conventions:**
|
|
650
|
+
- First parameter is `userId` (number)
|
|
651
|
+
- Second parameter is `params` object
|
|
652
|
+
- Can access `db.api.*` operations
|
|
653
|
+
- Can use any npm packages
|
|
654
|
+
- Return JSON-serializable data
|
|
655
|
+
|
|
656
|
+
### Function Comparison
|
|
657
|
+
|
|
658
|
+
| Feature | PostgreSQL | Bun |
|
|
659
|
+
|---------|-----------|-----|
|
|
660
|
+
| **Language** | SQL/PL/pgSQL | JavaScript |
|
|
661
|
+
| **Access to** | Database only | Database + npm ecosystem |
|
|
662
|
+
| **Transaction** | Automatic | Manual (via db.api) |
|
|
663
|
+
| **Performance** | Faster (no network) | Slower (WebSocket overhead) |
|
|
664
|
+
| **Use case** | Data-heavy operations | Complex business logic |
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## Authentication
|
|
669
|
+
|
|
670
|
+
JWT-based authentication with automatic user_id injection.
|
|
671
|
+
|
|
672
|
+
### Register User
|
|
673
|
+
|
|
674
|
+
```javascript
|
|
675
|
+
const result = await ws.api.register_user({
|
|
676
|
+
email: 'user@example.com',
|
|
677
|
+
password: 'secure-password'
|
|
678
|
+
});
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**Returns:**
|
|
682
|
+
```javascript
|
|
683
|
+
{
|
|
684
|
+
user_id: 1,
|
|
685
|
+
email: 'user@example.com',
|
|
686
|
+
token: 'eyJ...',
|
|
687
|
+
profile: {...}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
### Login
|
|
692
|
+
|
|
693
|
+
```javascript
|
|
694
|
+
const result = await ws.api.login_user({
|
|
695
|
+
email: 'user@example.com',
|
|
696
|
+
password: 'password'
|
|
697
|
+
});
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**Returns:** Same as register
|
|
701
|
+
|
|
702
|
+
### Logout
|
|
703
|
+
|
|
704
|
+
```javascript
|
|
705
|
+
await ws.api.logout();
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
### Token Storage
|
|
709
|
+
|
|
710
|
+
```javascript
|
|
711
|
+
// Save token
|
|
712
|
+
localStorage.setItem('dzql_token', result.token);
|
|
713
|
+
|
|
714
|
+
// Auto-connect with token
|
|
715
|
+
const ws = new WebSocketManager();
|
|
716
|
+
await ws.connect(); // Automatically uses token from localStorage
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### User ID Injection
|
|
720
|
+
|
|
721
|
+
- **Client**: `user_id` automatically injected from JWT
|
|
722
|
+
- **Server**: `user_id` must be passed explicitly as second parameter
|
|
723
|
+
|
|
724
|
+
```javascript
|
|
725
|
+
// Client
|
|
726
|
+
const user = await ws.api.get.users({id: 1}); // userId auto-injected
|
|
727
|
+
|
|
728
|
+
// Server
|
|
729
|
+
const user = await db.api.get.users({id: 1}, userId); // userId explicit
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## Real-time Events
|
|
735
|
+
|
|
736
|
+
All database changes trigger WebSocket events.
|
|
737
|
+
|
|
738
|
+
### Event Flow
|
|
739
|
+
|
|
740
|
+
1. Database trigger fires on INSERT/UPDATE/DELETE
|
|
741
|
+
2. Notification paths resolve affected user_ids
|
|
742
|
+
3. Event written to `dzql.events` table
|
|
743
|
+
4. PostgreSQL NOTIFY on 'dzql' channel
|
|
744
|
+
5. Bun server filters by `notify_users`
|
|
745
|
+
6. WebSocket message sent to affected clients
|
|
746
|
+
|
|
747
|
+
### Listening for Events
|
|
748
|
+
|
|
749
|
+
```javascript
|
|
750
|
+
const unsubscribe = ws.onBroadcast((method, params) => {
|
|
751
|
+
console.log(`Event: ${method}`, params);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Stop listening
|
|
755
|
+
unsubscribe();
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
### Event Format
|
|
759
|
+
|
|
760
|
+
**Method:** `"{table}:{operation}"`
|
|
761
|
+
- Examples: `"users:insert"`, `"venues:update"`, `"sites:delete"`
|
|
762
|
+
|
|
763
|
+
**Params:**
|
|
764
|
+
```javascript
|
|
765
|
+
{
|
|
766
|
+
table: 'venues',
|
|
767
|
+
op: 'insert' | 'update' | 'delete',
|
|
768
|
+
pk: {id: 1}, // Primary key
|
|
769
|
+
before: {...}, // Old values (null for insert)
|
|
770
|
+
after: {...}, // New values (null for delete)
|
|
771
|
+
user_id: 123, // Who made the change
|
|
772
|
+
at: '2025-01-01T...', // Timestamp
|
|
773
|
+
notify_users: [1, 2] // Who to notify (null = all)
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### Event Handling Pattern
|
|
778
|
+
|
|
779
|
+
```javascript
|
|
780
|
+
ws.onBroadcast((method, params) => {
|
|
781
|
+
const data = params.after || params.before;
|
|
782
|
+
|
|
783
|
+
if (method === 'todos:insert') {
|
|
784
|
+
state.todos.push(data);
|
|
785
|
+
} else if (method === 'todos:update') {
|
|
786
|
+
const idx = state.todos.findIndex(t => t.id === data.id);
|
|
787
|
+
if (idx !== -1) state.todos[idx] = data;
|
|
788
|
+
} else if (method === 'todos:delete') {
|
|
789
|
+
state.todos = state.todos.filter(t => t.id !== data.id);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
render();
|
|
793
|
+
});
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
## Temporal Relationships
|
|
799
|
+
|
|
800
|
+
Handle time-based relationships with `valid_from`/`valid_to` fields.
|
|
801
|
+
|
|
802
|
+
### Configuration
|
|
803
|
+
|
|
804
|
+
```sql
|
|
805
|
+
SELECT dzql.register_entity(
|
|
806
|
+
'contractor_rights',
|
|
807
|
+
'contractor_name',
|
|
808
|
+
array['contractor_name'],
|
|
809
|
+
'{"contractor_org": "organisations", "venue": "venues"}',
|
|
810
|
+
false,
|
|
811
|
+
'{
|
|
812
|
+
"valid_from": "valid_from",
|
|
813
|
+
"valid_to": "valid_to"
|
|
814
|
+
}',
|
|
815
|
+
'{}', '{}'
|
|
816
|
+
);
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### Usage
|
|
820
|
+
|
|
821
|
+
```javascript
|
|
822
|
+
// Get current relationships (default)
|
|
823
|
+
const rights = await ws.api.get.contractor_rights({id: 1});
|
|
824
|
+
|
|
825
|
+
// Get historical relationships
|
|
826
|
+
const past = await ws.api.get.contractor_rights({
|
|
827
|
+
id: 1,
|
|
828
|
+
on_date: '2023-01-01'
|
|
829
|
+
});
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
### Path Syntax with Temporal
|
|
833
|
+
|
|
834
|
+
```sql
|
|
835
|
+
-- Current relationships only
|
|
836
|
+
'@org_id->acts_for[org_id=$]{active}.user_id'
|
|
837
|
+
|
|
838
|
+
-- All relationships (past and present)
|
|
839
|
+
'@org_id->acts_for[org_id=$].user_id'
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
844
|
+
## Error Messages
|
|
845
|
+
|
|
846
|
+
Common error messages and their meanings:
|
|
847
|
+
|
|
848
|
+
| Error | Cause | Solution |
|
|
849
|
+
|-------|-------|----------|
|
|
850
|
+
| `"record not found"` | GET on non-existent ID | Check ID exists, handle 404 |
|
|
851
|
+
| `"Permission denied: view on users"` | User not in permission path | Check permissions, authenticate |
|
|
852
|
+
| `"entity users not configured"` | Entity not registered | Call `dzql.register_entity()` |
|
|
853
|
+
| `"Column foo does not exist in table users"` | Invalid filter field | Check searchable_fields config |
|
|
854
|
+
| `"Invalid function name: foo"` | Function doesn't exist | Create function or check spelling |
|
|
855
|
+
| `"Function not found"` | Custom function not registered | Export from api.js or create SQL function |
|
|
856
|
+
| `"Authentication required"` | Not logged in | Call `login_user()` first |
|
|
857
|
+
| `"Invalid token"` | Expired/invalid JWT | Re-authenticate |
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
861
|
+
## Server-Side API
|
|
862
|
+
|
|
863
|
+
For backend/Bun scripts, use `db.api`:
|
|
864
|
+
|
|
865
|
+
```javascript
|
|
866
|
+
import { db, sql } from 'dzql';
|
|
867
|
+
|
|
868
|
+
// Direct SQL queries
|
|
869
|
+
const users = await sql`SELECT * FROM users WHERE active = true`;
|
|
870
|
+
|
|
871
|
+
// DZQL operations (require explicit userId)
|
|
872
|
+
const user = await db.api.get.users({id: 1}, userId);
|
|
873
|
+
const saved = await db.api.save.users({name: 'John'}, userId);
|
|
874
|
+
const results = await db.api.search.users({filters: {}}, userId);
|
|
875
|
+
const deleted = await db.api.delete.users({id: 1}, userId);
|
|
876
|
+
const options = await db.api.lookup.users({p_filter: 'jo'}, userId);
|
|
877
|
+
|
|
878
|
+
// Custom functions
|
|
879
|
+
const result = await db.api.myCustomFunction({param: 'value'}, userId);
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
**Key difference:** Server-side requires explicit `userId` as second parameter; client-side auto-injects from JWT.
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
## See Also
|
|
887
|
+
|
|
888
|
+
- [GETTING_STARTED.md](GETTING_STARTED.md) - Hands-on tutorial
|
|
889
|
+
- [CLAUDE.md](../../CLAUDE.md) - AI development guide
|
|
890
|
+
- [README.md](../../README.md) - Project overview
|
|
891
|
+
- [Venues Example](../venues/) - Complete working application
|
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.4",
|
|
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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"src/database/migrations/**/*.sql",
|
|
16
16
|
"README.md",
|
|
17
17
|
"GETTING_STARTED.md",
|
|
18
|
+
"REFERENCE.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"scripts": {
|