dzql 0.1.0-alpha.2 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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