dzql 0.1.4 → 0.1.5

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/docs/CLAUDE.md ADDED
@@ -0,0 +1,1169 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ DZQL is a PostgreSQL-powered framework that eliminates CRUD boilerplate by providing automatic database operations, real-time WebSocket synchronization, and graph-based relationship management. The core concept: register an entity in PostgreSQL and instantly get 5 standard operations (get, save, delete, lookup, search) plus real-time notifications with zero code.
8
+
9
+ ## Architecture
10
+
11
+ ### Three-Layer Stack
12
+
13
+ ```
14
+ Client (Browser) Server (Bun) Database (PostgreSQL)
15
+ WebSocketManager <-> WebSocket Handler <-> Generic Operations
16
+ Proxy API JSON-RPC Router Stored Procedures
17
+ Real-time Events NOTIFY/LISTEN Graph Rules Engine
18
+ ```
19
+
20
+ ### Key Architectural Patterns
21
+
22
+ 1. **Nested Proxy API**: Both client (`ws.api.save.venues()`) and server (`db.api.save.venues()`) use identical proxy-based APIs that dynamically route to the correct operation
23
+ 2. **Generic Operations**: All CRUD operations flow through `dzql.generic_exec()` which handles permissions, graph rules, and event generation
24
+ 3. **Single Channel NOTIFY**: All real-time events flow through one PostgreSQL NOTIFY channel ('dzql') with intelligent user targeting
25
+ 4. **Graph Rules**: Entity relationships are managed declaratively through JSON configuration that executes automatically on data changes
26
+
27
+ ### Database-Centric Design
28
+
29
+ The framework treats PostgreSQL as the source of truth for:
30
+ - **Entity Configuration**: `dzql.entities` table stores metadata (searchable fields, permissions, temporal config)
31
+ - **Event Log**: `dzql.events` table provides complete audit trail with targeted notification data
32
+ - **Permission Paths**: JSON path expressions resolve which users can perform operations
33
+ - **Notification Paths**: JSON path expressions determine who receives real-time updates
34
+ - **Graph Rules**: Declarative relationship management executed within transactions
35
+
36
+ ## Development Commands
37
+
38
+ ### Venues Example (Primary Development)
39
+ ```bash
40
+ bun venues:db # Start PostgreSQL in Docker (clean slate every time)
41
+ bun venues # Start Bun server with hot reload
42
+ bun venues:test # Run full test suite
43
+ bun venues:logs # View PostgreSQL logs
44
+ ```
45
+
46
+ ### Logging Configuration
47
+
48
+ DZQL uses a category-based logging system with configurable log levels. Configure via environment variables:
49
+
50
+ ```bash
51
+ # Set overall log level (ERROR, WARN, INFO, DEBUG, TRACE)
52
+ LOG_LEVEL=DEBUG bun venues
53
+
54
+ # Set per-category levels
55
+ LOG_CATEGORIES="ws:debug,db:trace,auth:info,server:info,notify:debug" bun venues
56
+
57
+ # Or set all categories to same level
58
+ LOG_CATEGORIES="*:trace" bun venues
59
+
60
+ # Disable colors
61
+ NO_COLOR=1 bun venues
62
+ ```
63
+
64
+ **Available categories:**
65
+ - `ws` - WebSocket connections and RPC calls (green)
66
+ - `db` - Database operations and queries (magenta)
67
+ - `auth` - Authentication events (yellow)
68
+ - `server` - Server startup and shutdown (blue)
69
+ - `notify` - Real-time NOTIFY events (magenta)
70
+
71
+ **Log levels (lowest to highest):**
72
+ - `ERROR` - Errors only
73
+ - `WARN` - Warnings and errors
74
+ - `INFO` - Informational messages (default in development)
75
+ - `DEBUG` - Debug information including request/response
76
+ - `TRACE` - Very detailed tracing
77
+
78
+ **Default behavior:**
79
+ - Development: `INFO` level for all categories
80
+ - Production: `WARN` level for all categories
81
+ - Test: `ERROR` level (suppresses most output)
82
+
83
+ ### Testing Individual Test Files
84
+ ```bash
85
+ cd packages/venues
86
+ bun test tests/domain.test.js # Test basic CRUD operations
87
+ bun test tests/permissions.test.js # Test permission system
88
+ bun test tests/graph_rules.test.js # Test relationship management
89
+ bun test tests/notifications.test.js # Test real-time events
90
+ ```
91
+
92
+ ### Alternative Rights Example
93
+ ```bash
94
+ bun db # Start PostgreSQL for rights example
95
+ bun server # Start rights server
96
+ ```
97
+
98
+ ### Full Stack Development
99
+ ```bash
100
+ bun dev # Run both client and server concurrently
101
+ bun client # Client-only development server
102
+ ```
103
+
104
+ ## Project Structure
105
+
106
+ ```
107
+ packages/
108
+ ├── dzql/ # Core framework
109
+ │ └── src/
110
+ │ ├── database/migrations/ # PostgreSQL migrations (numbered)
111
+ │ │ ├── 001_schema.sql # Core tables (entities, events)
112
+ │ │ ├── 002_functions.sql # Path resolution helpers
113
+ │ │ ├── 003_operations.sql # Generic CRUD + graph rules
114
+ │ │ ├── 004_search.sql # Advanced search functionality
115
+ │ │ ├── 005_entities.sql # Entity registration
116
+ │ │ └── 006_auth.sql # JWT authentication
117
+ │ ├── server/
118
+ │ │ ├── db.js # PostgreSQL connection + proxy API
119
+ │ │ ├── ws.js # WebSocket handlers + JSON-RPC
120
+ │ │ └── index.js # Server factory
121
+ │ └── client/
122
+ │ └── ws.js # WebSocket client + proxy API
123
+ ├── venues/ # Example application
124
+ │ ├── server/
125
+ │ │ ├── index.js # Application entry point
126
+ │ │ └── api.js # Custom Bun functions
127
+ │ ├── database/
128
+ │ │ ├── docker-compose.yml # PostgreSQL setup
129
+ │ │ └── init_db/
130
+ │ │ └── 009_venues_domain.sql # Domain entities
131
+ │ └── tests/ # Comprehensive test suite
132
+ └── client/ # Shared client utilities
133
+ ```
134
+
135
+ ## Core Concepts
136
+
137
+ ### 1. Nested Proxy API Pattern
138
+
139
+ The same API works on both client and server:
140
+
141
+ ```javascript
142
+ // Client (WebSocket)
143
+ const venue = await ws.api.get.venues({id: 1});
144
+ const saved = await ws.api.save.venues({name: 'New Venue'});
145
+
146
+ // Server (Direct PostgreSQL)
147
+ const venue = await db.api.get.venues({id: 1}, userId);
148
+ const saved = await db.api.save.venues({name: 'New Venue'}, userId);
149
+ ```
150
+
151
+ Implementation details:
152
+ - Operations: `get`, `save`, `delete`, `lookup`, `search`
153
+ - Custom functions are accessed directly: `ws.api.customFunction({params})`
154
+ - All operations require authentication (except `login_user` and `register_user`)
155
+ - Server-side requires explicit `userId` parameter; client-side injects automatically
156
+
157
+ ### 2. Entity Registration
158
+
159
+ Entities are configured via `dzql.register_entity()` which sets up everything needed:
160
+
161
+ ```sql
162
+ SELECT dzql.register_entity(
163
+ 'venues', -- table name
164
+ 'name', -- label field for lookups
165
+ array['name', 'address'], -- searchable fields
166
+ '{"org": "organisations", "sites": "sites"}', -- foreign keys to dereference + child arrays
167
+ false, -- soft delete enabled
168
+ '{}', -- temporal fields config
169
+ '{"ownership": ["@org_id->acts_for[org_id=$]{active}.user_id"]}', -- notification paths
170
+ '{"view": [], "create": [...]}', -- permission paths
171
+ '{...}' -- graph rules
172
+ );
173
+ ```
174
+
175
+ **FK Includes Syntax:**
176
+ - Single object dereference: `"org": "organisations"` - Follows FK to get full org object
177
+ - Child array inclusion: `"sites": "sites"` - Includes all child records (auto-detects FK relationship)
178
+ - Example result from `get` operation:
179
+ ```json
180
+ {
181
+ "id": 1,
182
+ "name": "Madison Square Garden",
183
+ "org": { "id": 3, "name": "Venue Management", ... },
184
+ "sites": [
185
+ { "id": 1, "name": "Main Entrance", ... },
186
+ { "id": 2, "name": "Concourse Level", ... }
187
+ ]
188
+ }
189
+ ```
190
+
191
+ ### 3. Path Resolution Syntax
192
+
193
+ Paths are used for both notifications and permissions:
194
+
195
+ ```
196
+ @field->table[filter]{temporal}.target_field
197
+
198
+ Examples:
199
+ @org_id->acts_for[org_id=$]{active}.user_id
200
+ @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
201
+ ```
202
+
203
+ Components:
204
+ - `@field` - Start from record field
205
+ - `->table` - Navigate to related table
206
+ - `[filter]` - WHERE clause (`$` = current value)
207
+ - `{temporal}` - Apply temporal filtering (`{active}` = valid now)
208
+ - `.field` - Extract this field as result
209
+
210
+ ### 4. Graph Rules
211
+
212
+ Automatic relationship management executed in transactions:
213
+
214
+ ```jsonb
215
+ {
216
+ "on_create": {
217
+ "rule_name": {
218
+ "description": "Human-readable description",
219
+ "actions": [{
220
+ "type": "create|update|delete",
221
+ "entity": "target_table",
222
+ "data": {"field": "@variable"}, // for create/update
223
+ "match": {"field": "@variable"} // for update/delete
224
+ }]
225
+ }
226
+ }
227
+ }
228
+ ```
229
+
230
+ Variables available: `@user_id`, `@id`, `@field_name`, `@now`, `@today`
231
+
232
+ **Available Action Types:**
233
+
234
+ | Action | Purpose | Required Fields | Rollback on Error |
235
+ |--------|---------|----------------|-------------------|
236
+ | `create` | Create related record | `entity`, `data` | ✅ Yes |
237
+ | `update` | Update related record | `entity`, `match`, `data` | ✅ Yes |
238
+ | `delete` | Delete related record | `entity`, `match` | ✅ Yes |
239
+ | `validate` | Block operation if validation fails | `function`, `params`, `error_message` | ✅ Yes |
240
+ | `execute` | Fire-and-forget function call | `function`, `params` | ❌ No |
241
+
242
+ #### Graph Rules: Advanced Features
243
+
244
+ **Conditional Execution**
245
+
246
+ Rules can include conditions that determine if they execute:
247
+
248
+ ```jsonb
249
+ {
250
+ "on_update": {
251
+ "prevent_modification": {
252
+ "condition": "@before.status = 'posted'",
253
+ "actions": [{
254
+ "type": "validate",
255
+ "function": "always_false",
256
+ "params": {},
257
+ "error_message": "Cannot modify a posted record"
258
+ }]
259
+ }
260
+ }
261
+ }
262
+ ```
263
+
264
+ **Condition Variables:**
265
+ - `@before.field` - Value before update (null for create)
266
+ - `@after.field` - Value after update/create (null for delete)
267
+ - `@user_id` - Current user ID
268
+ - `@id` - Record ID
269
+ - Standard SQL expressions: `=`, `!=`, `AND`, `OR`, `>`, `<`, `>=`, `<=`
270
+
271
+ **Validate Action**
272
+
273
+ Call validation functions that can block operations:
274
+
275
+ ```jsonb
276
+ {
277
+ "on_create": {
278
+ "validate_positive": {
279
+ "description": "Ensure value is positive",
280
+ "actions": [{
281
+ "type": "validate",
282
+ "function": "validate_positive_value",
283
+ "params": {"p_value": "@value"},
284
+ "error_message": "Value must be positive"
285
+ }]
286
+ }
287
+ }
288
+ }
289
+ ```
290
+
291
+ **Validation function signature:**
292
+ ```sql
293
+ CREATE FUNCTION validate_positive_value(p_value INT)
294
+ RETURNS BOOLEAN
295
+ LANGUAGE sql AS $$
296
+ SELECT p_value > 0;
297
+ $$;
298
+ ```
299
+
300
+ Validation functions must:
301
+ - Return BOOLEAN (true = pass, false = fail)
302
+ - Use named parameters matching the `params` object
303
+ - Be deterministic for consistent results
304
+
305
+ **Execute Action**
306
+
307
+ Call custom functions as side effects (fire-and-forget):
308
+
309
+ ```jsonb
310
+ {
311
+ "on_create": {
312
+ "send_notification": {
313
+ "description": "Notify external system",
314
+ "actions": [{
315
+ "type": "execute",
316
+ "function": "send_email_notification",
317
+ "params": {"p_email": "@email", "p_name": "@name"}
318
+ }]
319
+ }
320
+ }
321
+ }
322
+ ```
323
+
324
+ Execute functions can return JSONB or void. Errors are logged as warnings but don't block the operation or rollback the transaction.
325
+
326
+ #### Complex Validation Example: Double-Entry Bookkeeping
327
+
328
+ **Requirement**: Journal entries must be balanced (debits = credits) before posting
329
+
330
+ **Validation function that queries related tables:**
331
+ ```sql
332
+ CREATE FUNCTION validate_journal_entry_balanced(p_entry_id INT)
333
+ RETURNS BOOLEAN AS $$
334
+ DECLARE
335
+ v_total_debits DECIMAL;
336
+ v_total_credits DECIMAL;
337
+ BEGIN
338
+ -- Query related journal_lines table
339
+ SELECT
340
+ COALESCE(SUM(debit_amount), 0),
341
+ COALESCE(SUM(credit_amount), 0)
342
+ INTO v_total_debits, v_total_credits
343
+ FROM journal_lines
344
+ WHERE entry_id = p_entry_id;
345
+
346
+ -- Check if balanced and non-zero
347
+ RETURN v_total_debits = v_total_credits AND v_total_debits > 0;
348
+ END;
349
+ $$ LANGUAGE plpgsql;
350
+ ```
351
+
352
+ **Entity registration with multiple validation rules:**
353
+ ```sql
354
+ SELECT dzql.register_entity(
355
+ 'journal_entries',
356
+ 'description',
357
+ ARRAY['description'],
358
+ '{"lines": "journal_lines"}', -- Include child lines
359
+ false, '{}', '{}', '{}',
360
+ jsonb_build_object(
361
+ 'on_update', jsonb_build_object(
362
+ -- Rule 1: Validate balanced entry
363
+ 'validate_balanced_on_post', jsonb_build_object(
364
+ 'description', 'Ensure entry is balanced before posting',
365
+ 'condition', '@after.status = ''posted'' AND @before.status = ''draft''',
366
+ 'actions', jsonb_build_array(
367
+ jsonb_build_object(
368
+ 'type', 'validate',
369
+ 'function', 'validate_journal_entry_balanced',
370
+ 'params', jsonb_build_object('p_entry_id', '@id'),
371
+ 'error_message', 'Cannot post unbalanced entry - debits must equal credits'
372
+ )
373
+ )
374
+ ),
375
+ -- Rule 2: Check fiscal period is open
376
+ 'check_fiscal_period_open', jsonb_build_object(
377
+ 'description', 'Prevent posting to closed periods',
378
+ 'condition', '@after.status = ''posted''',
379
+ 'actions', jsonb_build_array(
380
+ jsonb_build_object(
381
+ 'type', 'validate',
382
+ 'function', 'is_fiscal_period_open',
383
+ 'params', jsonb_build_object('p_period_id', '@fiscal_period_id'),
384
+ 'error_message', 'Cannot post to a closed fiscal period'
385
+ )
386
+ )
387
+ ),
388
+ -- Rule 3: Prevent modifying posted entries
389
+ 'prevent_modify_posted', jsonb_build_object(
390
+ 'description', 'Posted entries are immutable',
391
+ 'condition', '@before.status = ''posted''',
392
+ 'actions', jsonb_build_array(
393
+ jsonb_build_object(
394
+ 'type', 'validate',
395
+ 'function', 'always_false',
396
+ 'params', jsonb_build_object(),
397
+ 'error_message', 'Cannot modify a posted journal entry'
398
+ )
399
+ )
400
+ )
401
+ )
402
+ )
403
+ );
404
+ ```
405
+
406
+ **Key features demonstrated:**
407
+ - Multiple validation rules on same trigger
408
+ - Conditions control when validations run
409
+ - Validation functions query related tables
410
+ - Clear error messages for business rules
411
+
412
+ #### Migration from PostgreSQL Triggers to Graph Rules Validation
413
+
414
+ **Before (Trigger approach):**
415
+ ```sql
416
+ CREATE TRIGGER journal_entry_validation
417
+ BEFORE UPDATE ON journal_entries
418
+ FOR EACH ROW
419
+ EXECUTE FUNCTION check_journal_entry_balanced();
420
+
421
+ CREATE OR REPLACE FUNCTION check_journal_entry_balanced()
422
+ RETURNS TRIGGER AS $$
423
+ BEGIN
424
+ IF NEW.status = 'posted' AND OLD.status = 'draft' THEN
425
+ IF NOT validate_journal_entry_balanced(NEW.id) THEN
426
+ RAISE EXCEPTION 'Cannot post unbalanced journal entry';
427
+ END IF;
428
+ END IF;
429
+ RETURN NEW;
430
+ END;
431
+ $$ LANGUAGE plpgsql;
432
+ ```
433
+
434
+ **After (Graph rules approach):**
435
+ ```sql
436
+ SELECT dzql.register_entity(
437
+ 'journal_entries', 'description', ARRAY['description'],
438
+ '{}', false, '{}', '{}', '{}',
439
+ jsonb_build_object(
440
+ 'on_update', jsonb_build_object(
441
+ 'validate_balanced', jsonb_build_object(
442
+ 'condition', '@after.status = ''posted'' AND @before.status = ''draft''',
443
+ 'actions', jsonb_build_array(
444
+ jsonb_build_object(
445
+ 'type', 'validate',
446
+ 'function', 'validate_journal_entry_balanced',
447
+ 'params', jsonb_build_object('p_entry_id', '@id'),
448
+ 'error_message', 'Cannot post unbalanced journal entry'
449
+ )
450
+ )
451
+ )
452
+ )
453
+ )
454
+ );
455
+ ```
456
+
457
+ **Advantages:**
458
+ - ✅ Visible in entity registration (no separate trigger objects)
459
+ - ✅ Declarative and easier to understand
460
+ - ✅ Conditional execution built-in
461
+ - ✅ Testable (can call validation function directly)
462
+ - ✅ All entity config in one place
463
+
464
+ **When to still use triggers:**
465
+ - Complex multi-step validation with loops/cursors
466
+ - Need to modify NEW record before saving
467
+ - Side effects that must happen in same transaction (use execute action instead for side effects)
468
+ - Integration with legacy PostgreSQL systems
469
+
470
+ ### 5. Real-Time Event Flow
471
+
472
+ 1. Database trigger fires on INSERT/UPDATE/DELETE
473
+ 2. Notification paths resolve affected user_ids
474
+ 3. Event written to `dzql.events` with `notify_users` array
475
+ 4. PostgreSQL NOTIFY on 'dzql' channel
476
+ 5. Bun server filters by `notify_users` (null = all authenticated users)
477
+ 6. WebSocket sends event as JSON-RPC method: `{table}:{op}`
478
+
479
+ ## Writing Tests
480
+
481
+ Tests use Bun's built-in test runner with these patterns:
482
+
483
+ ### Test Structure
484
+ ```javascript
485
+ import { test, expect, beforeAll, afterAll } from "bun:test";
486
+ import { sql, db } from "dzql";
487
+
488
+ const PREFIX = `TEST_${Date.now()}`; // Unique prefix for test isolation
489
+ let testUserId;
490
+
491
+ beforeAll(async () => {
492
+ // Create test user
493
+ const result = await sql`SELECT register_user(...)`;
494
+ testUserId = result[0].user_data.user_id;
495
+ });
496
+
497
+ afterAll(async () => {
498
+ // Clean up test data in dependency order (children first)
499
+ await sql`DELETE FROM child_table WHERE parent_id IN (...)`;
500
+ await sql`DELETE FROM parent_table WHERE name LIKE ${PREFIX + '%'}`;
501
+ await sql`DELETE FROM users WHERE id = ${testUserId}`;
502
+ });
503
+ ```
504
+
505
+ ### Key Testing Patterns
506
+ - Use unique PREFIX with timestamp to avoid conflicts
507
+ - Clean up in correct FK dependency order (children before parents)
508
+ - Use `db.api` for testing DZQL operations (not WebSocket)
509
+ - Direct SQL via `sql` tagged template for setup/cleanup
510
+ - Server-side API requires explicit `userId` as second parameter
511
+
512
+ ## Adding New Entities
513
+
514
+ 1. **Create table in domain SQL file** (`packages/venues/database/init_db/009_venues_domain.sql`)
515
+ 2. **Register entity** with `dzql.register_entity()` in same file
516
+ 3. **Configure permissions** using path syntax
517
+ 4. **Add graph rules** if needed for relationship management
518
+ 5. **Write tests** following existing patterns in `packages/venues/tests/`
519
+
520
+ No TypeScript types, API routes, or resolvers needed - everything is handled by generic operations.
521
+
522
+ ## Adding Custom Functions
523
+
524
+ ### PostgreSQL Functions (Stored Procedures)
525
+ ```sql
526
+ CREATE OR REPLACE FUNCTION my_function(p_user_id INT, p_param TEXT DEFAULT 'default')
527
+ RETURNS JSONB
528
+ LANGUAGE plpgsql
529
+ SECURITY DEFINER
530
+ AS $$
531
+ BEGIN
532
+ -- Function logic
533
+ RETURN jsonb_build_object('result', p_param);
534
+ END;
535
+ $$;
536
+ ```
537
+
538
+ Call from client: `await ws.api.my_function({param: 'value'})`
539
+
540
+ ### Bun Functions (JavaScript)
541
+ ```javascript
542
+ // packages/venues/server/api.js
543
+ export async function myBunFunction(userId, params = {}) {
544
+ const { param = 'default' } = params;
545
+ // Can use db.api for database access
546
+ return { result: param };
547
+ }
548
+ ```
549
+
550
+ Call from client: `await ws.api.myBunFunction({param: 'value'})`
551
+
552
+ **Both types:**
553
+ - First parameter is always `user_id` (auto-injected on client)
554
+ - Require authentication
555
+ - Use same proxy API syntax
556
+ - Return JSON-serializable data
557
+
558
+ ## CLI Access (invokej)
559
+
560
+ DZQL operations are available via CLI using `invokej` for testing and scripting:
561
+
562
+ ```bash
563
+ # List all entities
564
+ invokej dzql.entities
565
+
566
+ # Search entities
567
+ invokej dzql.search organisations '{"query": "test"}'
568
+
569
+ # Get entity by ID (use primary key field, usually "id")
570
+ invokej dzql.get venues '{"id": 1}'
571
+
572
+ # Create/update entity
573
+ invokej dzql.save venues '{"name": "New Venue", "org_id": 1, "address": "123 Main St"}'
574
+
575
+ # Delete entity
576
+ invokej dzql.delete venues '{"id": 1}'
577
+
578
+ # Lookup (autocomplete)
579
+ invokej dzql.lookup organisations '{"query": "acme"}'
580
+ ```
581
+
582
+ **CLI Notes:**
583
+ - All commands use default `user_id=1` for permissions
584
+ - Arguments must be valid JSON strings
585
+ - Mirrors MCP server functionality exactly
586
+ - Defined in `tasks.js` at project root
587
+
588
+ ## Important Conventions
589
+
590
+ ### Database
591
+ - Core DZQL tables use `dzql` schema
592
+ - Application tables use `public` schema
593
+ - Migration files are numbered sequentially (001, 002, etc.)
594
+ - Domain-specific SQL files start at 009
595
+ - **No `created_at`/`updated_at` columns** - use `dzql.events` table for complete audit trail
596
+
597
+ ### Code Style
598
+ - ES modules (type: "module" in package.json)
599
+ - Async/await for all database operations
600
+ - Tagged templates for SQL queries (`sql` from postgres package)
601
+ - Proxy patterns for API routing
602
+
603
+ ### Real-time Events
604
+ - Listen using `ws.onBroadcast((method, params) => {})`
605
+ - Method format: `{table}:{operation}` (e.g., "venues:update")
606
+ - Params include: `{table, op, pk, before, after, user_id, at}`
607
+ - Target users via notification paths or broadcast to all
608
+
609
+ ### Permissions
610
+ - Empty view permission array `[]` = public read access
611
+ - Non-empty arrays = restricted to resolved user_ids
612
+ - Permissions checked before operations execute
613
+ - Use path syntax to traverse relationships
614
+
615
+ ## Common Gotchas
616
+
617
+ 1. **Server vs Client API**: Server `db.api` requires explicit `userId` as second parameter; client `ws.api` auto-injects from JWT
618
+ 2. **Test Cleanup Order**: Always delete FK children before parents to avoid constraint violations
619
+ 3. **Temporal Filtering**: Use `{active}` in paths for current relationships; omit for all time
620
+ 4. **Graph Rule Variables**: Use `@` prefix for all variables (`@user_id`, `@id`, `@field_name`)
621
+ 5. **Permission Paths**: Empty array means "allow all", missing permission type means "deny all"
622
+ 6. **NOTIFY Filtering**: `notify_users: null` broadcasts to all authenticated users; array targets specific user_ids
623
+
624
+ ---
625
+
626
+ ## Database Schema Reference
627
+
628
+ ### Core DZQL Tables
629
+
630
+ #### `dzql.entities`
631
+ Stores entity configuration metadata:
632
+ ```sql
633
+ TABLE dzql.entities {
634
+ table_name TEXT PRIMARY KEY,
635
+ label_field TEXT NOT NULL,
636
+ searchable_fields TEXT[] NOT NULL,
637
+ fk_includes JSONB DEFAULT '{}'::jsonb,
638
+ soft_delete BOOLEAN DEFAULT false,
639
+ temporal_fields JSONB DEFAULT '{}'::jsonb,
640
+ notification_paths JSONB DEFAULT '{}'::jsonb,
641
+ permission_paths JSONB DEFAULT '{}'::jsonb,
642
+ graph_rules JSONB DEFAULT '{}'::jsonb
643
+ }
644
+ ```
645
+
646
+ #### `dzql.events`
647
+ Complete audit trail with real-time notification data:
648
+ ```sql
649
+ TABLE dzql.events {
650
+ event_id BIGSERIAL PRIMARY KEY,
651
+ context_id TEXT, -- For catchup queries
652
+ table_name TEXT NOT NULL,
653
+ op TEXT NOT NULL, -- 'insert' | 'update' | 'delete'
654
+ pk JSONB NOT NULL, -- Primary key: {id: 1}
655
+ before JSONB, -- Old values (null for insert)
656
+ after JSONB, -- New values (null for delete)
657
+ user_id INT, -- Who made the change
658
+ notify_users INT[], -- Who to notify (null = all)
659
+ at TIMESTAMPTZ DEFAULT NOW()
660
+ }
661
+ ```
662
+
663
+ #### `dzql.registry`
664
+ Allowed custom functions (optional):
665
+ ```sql
666
+ TABLE dzql.registry {
667
+ function_name TEXT PRIMARY KEY,
668
+ description TEXT
669
+ }
670
+ ```
671
+
672
+ ---
673
+
674
+ ## Common Error Messages
675
+
676
+ ### Error Dictionary
677
+
678
+ | Error Message | Cause | Solution |
679
+ |---------------|-------|----------|
680
+ | `"record not found"` | GET operation on non-existent ID | Check ID exists, handle 404 case |
681
+ | `"Permission denied: view on users"` | User not in permission path result | Verify user has access, check permission paths |
682
+ | `"Permission denied: create on venues"` | User can't create records | Add user to create permission path |
683
+ | `"entity users not configured"` | Table not registered with DZQL | Call `dzql.register_entity()` |
684
+ | `"Column foo does not exist in table users"` | Invalid filter field in SEARCH | Check `searchable_fields` configuration |
685
+ | `"Invalid function name: foo"` | Custom function doesn't exist | Create function or check spelling |
686
+ | `"Function not found"` | Function not exported or not in DB | Export from api.js or CREATE FUNCTION |
687
+ | `"Authentication required"` | Not logged in | Call `login_user()` first |
688
+ | `"Invalid token"` | Expired or malformed JWT | Re-authenticate with `login_user()` |
689
+ | `"Duplicate key violates unique constraint"` | Inserting duplicate unique value | Check for existing record first |
690
+ | `"Foreign key violation"` | Referenced record doesn't exist | Create parent record before child |
691
+ | `"Invalid JSON"` | Malformed JSON in request | Validate JSON syntax |
692
+
693
+ ### Error Handling Pattern
694
+
695
+ ```javascript
696
+ try {
697
+ const user = await ws.api.get.users({id: userId});
698
+ } catch (error) {
699
+ if (error.message === 'record not found') {
700
+ // Handle missing record
701
+ } else if (error.message.includes('Permission denied')) {
702
+ // Handle unauthorized access
703
+ } else {
704
+ // Handle unexpected errors
705
+ }
706
+ }
707
+ ```
708
+
709
+ ---
710
+
711
+ ## Event Structure Reference
712
+
713
+ ### WebSocket Event Format
714
+
715
+ **Method:** `"{table}:{operation}"`
716
+ - Examples: `"venues:insert"`, `"users:update"`, `"sites:delete"`
717
+
718
+ **Params Structure:**
719
+ ```javascript
720
+ {
721
+ table: 'venues', // Table name
722
+ op: 'insert', // Operation: 'insert' | 'update' | 'delete'
723
+ pk: {id: 1}, // Primary key object
724
+ before: { // Old values (null for insert)
725
+ id: 1,
726
+ name: 'Old Name',
727
+ address: 'Old Address'
728
+ },
729
+ after: { // New values (null for delete)
730
+ id: 1,
731
+ name: 'New Name',
732
+ address: 'New Address'
733
+ },
734
+ user_id: 123, // User who made the change
735
+ at: '2025-01-01T12:00:00Z', // Timestamp
736
+ notify_users: [1, 2, 3] // Targeted users (null = all authenticated)
737
+ }
738
+ ```
739
+
740
+ ### Using Event Data
741
+
742
+ ```javascript
743
+ ws.onBroadcast((method, params) => {
744
+ // For insert
745
+ if (method === 'venues:insert') {
746
+ const newRecord = params.after;
747
+ // params.before is null
748
+ }
749
+
750
+ // For update
751
+ if (method === 'venues:update') {
752
+ const oldRecord = params.before;
753
+ const newRecord = params.after;
754
+ // Compare to detect what changed
755
+ }
756
+
757
+ // For delete
758
+ if (method === 'venues:delete') {
759
+ const deletedRecord = params.before;
760
+ // params.after is null
761
+ }
762
+ });
763
+ ```
764
+
765
+ ---
766
+
767
+ ## Decision Trees
768
+
769
+ ### When to Use Graph Rules vs Manual Operations
770
+
771
+ **Use Graph Rules When:**
772
+ - ✅ Pattern repeats consistently (always create X when Y is created)
773
+ - ✅ Relationship is declarative (cascade delete, ownership transfer)
774
+ - ✅ Action is atomic with parent operation
775
+ - ✅ Business rule applies system-wide
776
+ - ✅ Need automatic execution within same transaction
777
+
778
+ **Use Manual Operations When:**
779
+ - ❌ Logic is complex with multiple conditions
780
+ - ❌ Requires external API calls
781
+ - ❌ Need user confirmation before action
782
+ - ❌ Action is optional or contextual
783
+ - ❌ Involves asynchronous processing
784
+
785
+ **Examples:**
786
+
787
+ ✅ **Good for Graph Rules:**
788
+ ```jsonb
789
+ // Creator becomes owner
790
+ "on_create": {
791
+ "establish_ownership": {
792
+ "actions": [{
793
+ "type": "create",
794
+ "entity": "acts_for",
795
+ "data": {"user_id": "@user_id", "org_id": "@id"}
796
+ }]
797
+ }
798
+ }
799
+ ```
800
+
801
+ ❌ **Bad for Graph Rules:**
802
+ ```javascript
803
+ // Send welcome email, create Stripe customer, notify Slack
804
+ // Too many external dependencies - do manually
805
+ export async function createOrganisation(userId, params) {
806
+ const org = await db.api.save.organisations(params, userId);
807
+ await sendWelcomeEmail(org);
808
+ await createStripeCustomer(org);
809
+ await notifySlack(org);
810
+ return org;
811
+ }
812
+ ```
813
+
814
+ ### PostgreSQL Functions vs Bun Functions
815
+
816
+ **Use PostgreSQL Functions When:**
817
+ - ✅ Data-heavy operations (aggregations, complex queries)
818
+ - ✅ Need transactional guarantees
819
+ - ✅ Performance critical (no network overhead)
820
+ - ✅ Pure database logic
821
+ - ✅ Reusable across multiple applications
822
+
823
+ **Use Bun Functions When:**
824
+ - ✅ Complex business logic
825
+ - ✅ Need external API calls
826
+ - ✅ Require npm packages
827
+ - ✅ Easier to test/debug in JavaScript
828
+ - ✅ Rapid prototyping
829
+
830
+ **Example Decision:**
831
+
832
+ ✅ **PostgreSQL - Data aggregation:**
833
+ ```sql
834
+ CREATE FUNCTION get_venue_stats(p_user_id INT, p_venue_id INT)
835
+ RETURNS JSONB AS $$
836
+ SELECT jsonb_build_object(
837
+ 'total_sites', COUNT(s.id),
838
+ 'total_events', COUNT(e.id),
839
+ 'revenue', SUM(e.revenue)
840
+ )
841
+ FROM sites s
842
+ LEFT JOIN events e ON e.site_id = s.id
843
+ WHERE s.venue_id = p_venue_id;
844
+ $$ LANGUAGE sql;
845
+ ```
846
+
847
+ ✅ **Bun - External API integration:**
848
+ ```javascript
849
+ export async function sendInvitation(userId, params) {
850
+ const { email, org_id } = params;
851
+
852
+ // Send via SendGrid
853
+ await sendgrid.send({...});
854
+
855
+ // Create invitation record
856
+ await db.api.save.invitations({
857
+ email, org_id, sent_by: userId
858
+ }, userId);
859
+
860
+ return { success: true };
861
+ }
862
+ ```
863
+
864
+ ### Notification Paths vs Broadcast All
865
+
866
+ **Use Targeted Notification Paths When:**
867
+ - ✅ Only specific users should see the change
868
+ - ✅ Data is sensitive (private org data)
869
+ - ✅ Need to reduce notification noise
870
+ - ✅ Clear ownership/membership model
871
+
872
+ **Use Broadcast All (null) When:**
873
+ - ✅ Public data everyone should see
874
+ - ✅ No ownership model
875
+ - ✅ Simplicity is priority
876
+ - ✅ Small user base
877
+
878
+ **Example:**
879
+
880
+ ✅ **Targeted (private venues):**
881
+ ```sql
882
+ SELECT dzql.register_entity(
883
+ 'venues', 'name', array['name'],
884
+ '{}', false, '{}',
885
+ '{
886
+ "ownership": ["@org_id->acts_for[org_id=$]{active}.user_id"]
887
+ }' -- Only org members notified
888
+ );
889
+ ```
890
+
891
+ ✅ **Broadcast (public events):**
892
+ ```sql
893
+ SELECT dzql.register_entity(
894
+ 'public_events', 'name', array['name'],
895
+ '{}', false, '{}',
896
+ '{}' -- Empty = notify all authenticated users
897
+ );
898
+ ```
899
+
900
+ ---
901
+
902
+ ## Transaction Boundaries
903
+
904
+ ### What Runs Atomically
905
+
906
+ **Single Transaction Includes:**
907
+ 1. Primary operation (save/delete)
908
+ 2. All graph rule actions for that operation
909
+ 3. Event log writing
910
+ 4. Trigger execution
911
+
912
+ **Example Flow:**
913
+ ```javascript
914
+ // User creates organisation
915
+ await ws.api.save.organisations({name: 'Acme Corp'});
916
+
917
+ // PostgreSQL executes in ONE transaction:
918
+ // 1. INSERT INTO organisations
919
+ // 2. Graph rule: INSERT INTO acts_for (creator becomes owner)
920
+ // 3. INSERT INTO dzql.events
921
+ // 4. NOTIFY 'dzql'
922
+ // Either all succeed or all rollback
923
+ ```
924
+
925
+ ### Transaction Rollback
926
+
927
+ If any step fails, **entire transaction rolls back**:
928
+
929
+ ```sql
930
+ -- This graph rule will rollback the org creation if site creation fails
931
+ "on_create": {
932
+ "create_default_site": {
933
+ "actions": [{
934
+ "type": "create",
935
+ "entity": "sites",
936
+ "data": {
937
+ "venue_id": "@id",
938
+ "invalid_field": "@foo" -- ERROR: invalid_field doesn't exist
939
+ }
940
+ }]
941
+ }
942
+ }
943
+ -- Result: Organisation is NOT created, error is thrown
944
+ ```
945
+
946
+ ### Multiple Operations Are Separate Transactions
947
+
948
+ ```javascript
949
+ // These are TWO separate transactions
950
+ const org = await ws.api.save.organisations({name: 'Acme'}); // Transaction 1
951
+ const venue = await ws.api.save.venues({org_id: org.id}); // Transaction 2
952
+
953
+ // If venue creation fails, org still exists
954
+ ```
955
+
956
+ To make multiple operations atomic, use a PostgreSQL function:
957
+
958
+ ```sql
959
+ CREATE FUNCTION create_org_with_venue(p_user_id INT, p_org_name TEXT, p_venue_name TEXT)
960
+ RETURNS JSONB AS $$
961
+ DECLARE
962
+ v_org RECORD;
963
+ v_venue RECORD;
964
+ BEGIN
965
+ INSERT INTO organisations (name) VALUES (p_org_name) RETURNING * INTO v_org;
966
+ INSERT INTO venues (name, org_id) VALUES (p_venue_name, v_org.id) RETURNING * INTO v_venue;
967
+
968
+ RETURN jsonb_build_object('org', to_jsonb(v_org), 'venue', to_jsonb(v_venue));
969
+ END;
970
+ $$ LANGUAGE plpgsql;
971
+ ```
972
+
973
+ ---
974
+
975
+ ## FK Includes Edge Cases
976
+
977
+ ### Circular References
978
+
979
+ **Problem:** A→B and B→A causes infinite recursion
980
+
981
+ ```sql
982
+ -- organisations.parent_org_id -> organisations
983
+ -- organisations.child_orgs <- organisations
984
+ SELECT dzql.register_entity(
985
+ 'organisations', 'name', array['name'],
986
+ '{"parent": "organisations", "children": "organisations"}' -- CIRCULAR!
987
+ );
988
+ ```
989
+
990
+ **Solution:** Only include one direction
991
+ ```sql
992
+ '{"parent": "organisations"}' -- Parent only, not children
993
+ ```
994
+
995
+ ### Deeply Nested Includes
996
+
997
+ **Problem:** Performance degrades with deep nesting
998
+
999
+ ```sql
1000
+ -- venue -> org -> parent_org -> parent_org -> ...
1001
+ '{"org": "organisations"}' -- This only derefs 1 level
1002
+ ```
1003
+
1004
+ **Behavior:**
1005
+ - FK includes dereference **1 level only**
1006
+ - Nested includes (org.parent_org) are NOT automatically included
1007
+ - To include nested, configure in each entity separately
1008
+
1009
+ ### Performance Implications
1010
+
1011
+ | FK Includes | Query Complexity | Recommendation |
1012
+ |-------------|------------------|----------------|
1013
+ | None | SELECT * FROM table | Fastest |
1014
+ | 1-2 single objects | 1-2 JOINs | Good |
1015
+ | 3+ single objects | 3+ JOINs | Consider splitting |
1016
+ | 1 child array | 1 subquery | Good |
1017
+ | 2+ child arrays | 2+ subqueries | Slow - avoid |
1018
+
1019
+ **Optimization Strategy:**
1020
+ - Only include FKs you actually need
1021
+ - Avoid including large child arrays unless necessary
1022
+ - Consider separate queries for child arrays
1023
+ - Use pagination for child arrays
1024
+
1025
+ ---
1026
+
1027
+ ## Security Checklist
1028
+
1029
+ ### When Building DZQL Applications
1030
+
1031
+ **Authentication:**
1032
+ - ✅ Always require `login_user()` before operations
1033
+ - ✅ Store JWT in secure storage (not localStorage for production)
1034
+ - ✅ Set `JWT_EXPIRES_IN` to reasonable duration (7d default)
1035
+ - ✅ Regenerate `JWT_SECRET` for production (never use default)
1036
+ - ✅ Validate token on every operation (automatic in DZQL)
1037
+
1038
+ **Permissions:**
1039
+ - ✅ Configure `permission_paths` for all non-public entities
1040
+ - ✅ Use empty array `[]` only for truly public data
1041
+ - ✅ Test permission paths with different user roles
1042
+ - ✅ Never trust client-side filtering - always enforce server-side
1043
+ - ✅ Use `{active}` temporal filtering in permission paths
1044
+
1045
+ **Graph Rules:**
1046
+ - ✅ Validate graph rule variables exist
1047
+ - ✅ Test rollback behavior (ensure atomicity)
1048
+ - ✅ Avoid complex logic in graph rules (use functions instead)
1049
+ - ✅ Document cascade delete behavior
1050
+
1051
+ **Input Validation:**
1052
+ - ✅ DZQL validates entity schema automatically
1053
+ - ✅ Add custom validation in PostgreSQL functions if needed
1054
+ - ✅ Use CHECK constraints for business rules
1055
+ - ✅ Never expose raw error messages to client
1056
+
1057
+ **Rate Limiting:**
1058
+ - ⚠️ DZQL doesn't include rate limiting (v0.1.0)
1059
+ - ✅ Add rate limiting middleware for production
1060
+ - ✅ Limit login attempts
1061
+ - ✅ Limit operations per user/minute
1062
+
1063
+ **Error Handling:**
1064
+ - ✅ Catch all errors in client
1065
+ - ✅ Log errors server-side
1066
+ - ✅ Never expose stack traces in production
1067
+ - ✅ Return generic error messages to client
1068
+
1069
+ ---
1070
+
1071
+ ## Performance Guidelines
1072
+
1073
+ ### Index Recommendations
1074
+
1075
+ **Always Index:**
1076
+ - ✅ Primary keys (automatic)
1077
+ - ✅ Foreign keys used in paths
1078
+ - ✅ Fields in `searchable_fields`
1079
+ - ✅ Temporal fields (`valid_from`, `valid_to`)
1080
+ - ✅ Fields used in permission path filters
1081
+
1082
+ ```sql
1083
+ -- Example indexes for venues entity
1084
+ CREATE INDEX idx_venues_org_id ON venues(org_id);
1085
+ CREATE INDEX idx_venues_name ON venues(name); -- searchable field
1086
+ CREATE INDEX idx_sites_venue_id ON sites(venue_id); -- for FK includes
1087
+ ```
1088
+
1089
+ ### Searchable Fields Impact
1090
+
1091
+ **Performance Cost:**
1092
+ - Each searchable field adds to `_search` query cost
1093
+ - Text search uses `ILIKE` which can be slow without indexes
1094
+ - Limit to 3-5 truly searchable fields
1095
+
1096
+ ```sql
1097
+ -- Good: 3 searchable fields
1098
+ array['name', 'address', 'city']
1099
+
1100
+ -- Bad: Too many fields
1101
+ array['name', 'address', 'city', 'description', 'notes', 'tags', 'metadata']
1102
+ ```
1103
+
1104
+ ### FK Includes Cost
1105
+
1106
+ | Include Type | Cost | Query |
1107
+ |--------------|------|-------|
1108
+ | Single object | 1 JOIN | Fast |
1109
+ | Child array (small) | 1 subquery | Moderate |
1110
+ | Child array (large) | 1 subquery + many rows | Slow |
1111
+ | Multiple arrays | N subqueries | Very slow |
1112
+
1113
+ **Optimization:**
1114
+ ```sql
1115
+ -- Good: One FK dereference
1116
+ '{"org": "organisations"}'
1117
+
1118
+ -- Good: Small child array (<100 records)
1119
+ '{"sites": "sites"}'
1120
+
1121
+ -- Bad: Multiple large child arrays
1122
+ '{"sites": "sites", "events": "events", "contractors": "contractors"}'
1123
+ ```
1124
+
1125
+ ### Graph Rules Complexity
1126
+
1127
+ **Fast Graph Rules:**
1128
+ - ✅ Single action per rule
1129
+ - ✅ Simple match conditions
1130
+ - ✅ Direct variable references
1131
+
1132
+ **Slow Graph Rules:**
1133
+ - ❌ Multiple chained actions
1134
+ - ❌ Complex match conditions
1135
+ - ❌ Nested graph rules triggering other graph rules
1136
+
1137
+ ```jsonb
1138
+ // Good: Simple cascade
1139
+ "on_delete": {
1140
+ "cascade": {
1141
+ "actions": [{"type": "delete", "entity": "sites", "match": {"venue_id": "@id"}}]
1142
+ }
1143
+ }
1144
+
1145
+ // Bad: Complex chain
1146
+ "on_create": {
1147
+ "rule1": {"actions": [...]}, // Triggers more operations
1148
+ "rule2": {"actions": [...]}, // Which trigger more operations
1149
+ "rule3": {"actions": [...]} // Slows down creation significantly
1150
+ }
1151
+ ```
1152
+
1153
+ ### Query Optimization Tips
1154
+
1155
+ 1. **Limit searchable fields** to truly searchable content
1156
+ 2. **Index foreign keys** used in joins and paths
1157
+ 3. **Avoid large FK includes** in list operations
1158
+ 4. **Use pagination** (keep `limit` ≤ 100)
1159
+ 5. **Filter before including** FKs when possible
1160
+ 6. **Monitor slow queries** via PostgreSQL logs
1161
+
1162
+ ---
1163
+
1164
+ ## Additional Resources
1165
+
1166
+ - **API Reference**: See [packages/dzql/REFERENCE.md](packages/dzql/REFERENCE.md) for complete API documentation
1167
+ - **Tutorial**: See [packages/dzql/GETTING_STARTED.md](packages/dzql/GETTING_STARTED.md) for hands-on guide
1168
+ - **Examples**: See `packages/venues/` for complete working application
1169
+ - **Tests**: See `packages/venues/tests/` for comprehensive test patterns