dzql 0.1.0-alpha.4 → 0.1.1

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/CLAUDE.md ADDED
@@ -0,0 +1,931 @@
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
+ ### 5. Real-Time Event Flow
233
+
234
+ 1. Database trigger fires on INSERT/UPDATE/DELETE
235
+ 2. Notification paths resolve affected user_ids
236
+ 3. Event written to `dzql.events` with `notify_users` array
237
+ 4. PostgreSQL NOTIFY on 'dzql' channel
238
+ 5. Bun server filters by `notify_users` (null = all authenticated users)
239
+ 6. WebSocket sends event as JSON-RPC method: `{table}:{op}`
240
+
241
+ ## Writing Tests
242
+
243
+ Tests use Bun's built-in test runner with these patterns:
244
+
245
+ ### Test Structure
246
+ ```javascript
247
+ import { test, expect, beforeAll, afterAll } from "bun:test";
248
+ import { sql, db } from "dzql";
249
+
250
+ const PREFIX = `TEST_${Date.now()}`; // Unique prefix for test isolation
251
+ let testUserId;
252
+
253
+ beforeAll(async () => {
254
+ // Create test user
255
+ const result = await sql`SELECT register_user(...)`;
256
+ testUserId = result[0].user_data.user_id;
257
+ });
258
+
259
+ afterAll(async () => {
260
+ // Clean up test data in dependency order (children first)
261
+ await sql`DELETE FROM child_table WHERE parent_id IN (...)`;
262
+ await sql`DELETE FROM parent_table WHERE name LIKE ${PREFIX + '%'}`;
263
+ await sql`DELETE FROM users WHERE id = ${testUserId}`;
264
+ });
265
+ ```
266
+
267
+ ### Key Testing Patterns
268
+ - Use unique PREFIX with timestamp to avoid conflicts
269
+ - Clean up in correct FK dependency order (children before parents)
270
+ - Use `db.api` for testing DZQL operations (not WebSocket)
271
+ - Direct SQL via `sql` tagged template for setup/cleanup
272
+ - Server-side API requires explicit `userId` as second parameter
273
+
274
+ ## Adding New Entities
275
+
276
+ 1. **Create table in domain SQL file** (`packages/venues/database/init_db/009_venues_domain.sql`)
277
+ 2. **Register entity** with `dzql.register_entity()` in same file
278
+ 3. **Configure permissions** using path syntax
279
+ 4. **Add graph rules** if needed for relationship management
280
+ 5. **Write tests** following existing patterns in `packages/venues/tests/`
281
+
282
+ No TypeScript types, API routes, or resolvers needed - everything is handled by generic operations.
283
+
284
+ ## Adding Custom Functions
285
+
286
+ ### PostgreSQL Functions (Stored Procedures)
287
+ ```sql
288
+ CREATE OR REPLACE FUNCTION my_function(p_user_id INT, p_param TEXT DEFAULT 'default')
289
+ RETURNS JSONB
290
+ LANGUAGE plpgsql
291
+ SECURITY DEFINER
292
+ AS $$
293
+ BEGIN
294
+ -- Function logic
295
+ RETURN jsonb_build_object('result', p_param);
296
+ END;
297
+ $$;
298
+ ```
299
+
300
+ Call from client: `await ws.api.my_function({param: 'value'})`
301
+
302
+ ### Bun Functions (JavaScript)
303
+ ```javascript
304
+ // packages/venues/server/api.js
305
+ export async function myBunFunction(userId, params = {}) {
306
+ const { param = 'default' } = params;
307
+ // Can use db.api for database access
308
+ return { result: param };
309
+ }
310
+ ```
311
+
312
+ Call from client: `await ws.api.myBunFunction({param: 'value'})`
313
+
314
+ **Both types:**
315
+ - First parameter is always `user_id` (auto-injected on client)
316
+ - Require authentication
317
+ - Use same proxy API syntax
318
+ - Return JSON-serializable data
319
+
320
+ ## CLI Access (invokej)
321
+
322
+ DZQL operations are available via CLI using `invokej` for testing and scripting:
323
+
324
+ ```bash
325
+ # List all entities
326
+ invokej dzql.entities
327
+
328
+ # Search entities
329
+ invokej dzql.search organisations '{"query": "test"}'
330
+
331
+ # Get entity by ID (use primary key field, usually "id")
332
+ invokej dzql.get venues '{"id": 1}'
333
+
334
+ # Create/update entity
335
+ invokej dzql.save venues '{"name": "New Venue", "org_id": 1, "address": "123 Main St"}'
336
+
337
+ # Delete entity
338
+ invokej dzql.delete venues '{"id": 1}'
339
+
340
+ # Lookup (autocomplete)
341
+ invokej dzql.lookup organisations '{"query": "acme"}'
342
+ ```
343
+
344
+ **CLI Notes:**
345
+ - All commands use default `user_id=1` for permissions
346
+ - Arguments must be valid JSON strings
347
+ - Mirrors MCP server functionality exactly
348
+ - Defined in `tasks.js` at project root
349
+
350
+ ## Important Conventions
351
+
352
+ ### Database
353
+ - Core DZQL tables use `dzql` schema
354
+ - Application tables use `public` schema
355
+ - Migration files are numbered sequentially (001, 002, etc.)
356
+ - Domain-specific SQL files start at 009
357
+ - **No `created_at`/`updated_at` columns** - use `dzql.events` table for complete audit trail
358
+
359
+ ### Code Style
360
+ - ES modules (type: "module" in package.json)
361
+ - Async/await for all database operations
362
+ - Tagged templates for SQL queries (`sql` from postgres package)
363
+ - Proxy patterns for API routing
364
+
365
+ ### Real-time Events
366
+ - Listen using `ws.onBroadcast((method, params) => {})`
367
+ - Method format: `{table}:{operation}` (e.g., "venues:update")
368
+ - Params include: `{table, op, pk, before, after, user_id, at}`
369
+ - Target users via notification paths or broadcast to all
370
+
371
+ ### Permissions
372
+ - Empty view permission array `[]` = public read access
373
+ - Non-empty arrays = restricted to resolved user_ids
374
+ - Permissions checked before operations execute
375
+ - Use path syntax to traverse relationships
376
+
377
+ ## Common Gotchas
378
+
379
+ 1. **Server vs Client API**: Server `db.api` requires explicit `userId` as second parameter; client `ws.api` auto-injects from JWT
380
+ 2. **Test Cleanup Order**: Always delete FK children before parents to avoid constraint violations
381
+ 3. **Temporal Filtering**: Use `{active}` in paths for current relationships; omit for all time
382
+ 4. **Graph Rule Variables**: Use `@` prefix for all variables (`@user_id`, `@id`, `@field_name`)
383
+ 5. **Permission Paths**: Empty array means "allow all", missing permission type means "deny all"
384
+ 6. **NOTIFY Filtering**: `notify_users: null` broadcasts to all authenticated users; array targets specific user_ids
385
+
386
+ ---
387
+
388
+ ## Database Schema Reference
389
+
390
+ ### Core DZQL Tables
391
+
392
+ #### `dzql.entities`
393
+ Stores entity configuration metadata:
394
+ ```sql
395
+ TABLE dzql.entities {
396
+ table_name TEXT PRIMARY KEY,
397
+ label_field TEXT NOT NULL,
398
+ searchable_fields TEXT[] NOT NULL,
399
+ fk_includes JSONB DEFAULT '{}'::jsonb,
400
+ soft_delete BOOLEAN DEFAULT false,
401
+ temporal_fields JSONB DEFAULT '{}'::jsonb,
402
+ notification_paths JSONB DEFAULT '{}'::jsonb,
403
+ permission_paths JSONB DEFAULT '{}'::jsonb,
404
+ graph_rules JSONB DEFAULT '{}'::jsonb
405
+ }
406
+ ```
407
+
408
+ #### `dzql.events`
409
+ Complete audit trail with real-time notification data:
410
+ ```sql
411
+ TABLE dzql.events {
412
+ event_id BIGSERIAL PRIMARY KEY,
413
+ context_id TEXT, -- For catchup queries
414
+ table_name TEXT NOT NULL,
415
+ op TEXT NOT NULL, -- 'insert' | 'update' | 'delete'
416
+ pk JSONB NOT NULL, -- Primary key: {id: 1}
417
+ before JSONB, -- Old values (null for insert)
418
+ after JSONB, -- New values (null for delete)
419
+ user_id INT, -- Who made the change
420
+ notify_users INT[], -- Who to notify (null = all)
421
+ at TIMESTAMPTZ DEFAULT NOW()
422
+ }
423
+ ```
424
+
425
+ #### `dzql.registry`
426
+ Allowed custom functions (optional):
427
+ ```sql
428
+ TABLE dzql.registry {
429
+ function_name TEXT PRIMARY KEY,
430
+ description TEXT
431
+ }
432
+ ```
433
+
434
+ ---
435
+
436
+ ## Common Error Messages
437
+
438
+ ### Error Dictionary
439
+
440
+ | Error Message | Cause | Solution |
441
+ |---------------|-------|----------|
442
+ | `"record not found"` | GET operation on non-existent ID | Check ID exists, handle 404 case |
443
+ | `"Permission denied: view on users"` | User not in permission path result | Verify user has access, check permission paths |
444
+ | `"Permission denied: create on venues"` | User can't create records | Add user to create permission path |
445
+ | `"entity users not configured"` | Table not registered with DZQL | Call `dzql.register_entity()` |
446
+ | `"Column foo does not exist in table users"` | Invalid filter field in SEARCH | Check `searchable_fields` configuration |
447
+ | `"Invalid function name: foo"` | Custom function doesn't exist | Create function or check spelling |
448
+ | `"Function not found"` | Function not exported or not in DB | Export from api.js or CREATE FUNCTION |
449
+ | `"Authentication required"` | Not logged in | Call `login_user()` first |
450
+ | `"Invalid token"` | Expired or malformed JWT | Re-authenticate with `login_user()` |
451
+ | `"Duplicate key violates unique constraint"` | Inserting duplicate unique value | Check for existing record first |
452
+ | `"Foreign key violation"` | Referenced record doesn't exist | Create parent record before child |
453
+ | `"Invalid JSON"` | Malformed JSON in request | Validate JSON syntax |
454
+
455
+ ### Error Handling Pattern
456
+
457
+ ```javascript
458
+ try {
459
+ const user = await ws.api.get.users({id: userId});
460
+ } catch (error) {
461
+ if (error.message === 'record not found') {
462
+ // Handle missing record
463
+ } else if (error.message.includes('Permission denied')) {
464
+ // Handle unauthorized access
465
+ } else {
466
+ // Handle unexpected errors
467
+ }
468
+ }
469
+ ```
470
+
471
+ ---
472
+
473
+ ## Event Structure Reference
474
+
475
+ ### WebSocket Event Format
476
+
477
+ **Method:** `"{table}:{operation}"`
478
+ - Examples: `"venues:insert"`, `"users:update"`, `"sites:delete"`
479
+
480
+ **Params Structure:**
481
+ ```javascript
482
+ {
483
+ table: 'venues', // Table name
484
+ op: 'insert', // Operation: 'insert' | 'update' | 'delete'
485
+ pk: {id: 1}, // Primary key object
486
+ before: { // Old values (null for insert)
487
+ id: 1,
488
+ name: 'Old Name',
489
+ address: 'Old Address'
490
+ },
491
+ after: { // New values (null for delete)
492
+ id: 1,
493
+ name: 'New Name',
494
+ address: 'New Address'
495
+ },
496
+ user_id: 123, // User who made the change
497
+ at: '2025-01-01T12:00:00Z', // Timestamp
498
+ notify_users: [1, 2, 3] // Targeted users (null = all authenticated)
499
+ }
500
+ ```
501
+
502
+ ### Using Event Data
503
+
504
+ ```javascript
505
+ ws.onBroadcast((method, params) => {
506
+ // For insert
507
+ if (method === 'venues:insert') {
508
+ const newRecord = params.after;
509
+ // params.before is null
510
+ }
511
+
512
+ // For update
513
+ if (method === 'venues:update') {
514
+ const oldRecord = params.before;
515
+ const newRecord = params.after;
516
+ // Compare to detect what changed
517
+ }
518
+
519
+ // For delete
520
+ if (method === 'venues:delete') {
521
+ const deletedRecord = params.before;
522
+ // params.after is null
523
+ }
524
+ });
525
+ ```
526
+
527
+ ---
528
+
529
+ ## Decision Trees
530
+
531
+ ### When to Use Graph Rules vs Manual Operations
532
+
533
+ **Use Graph Rules When:**
534
+ - ✅ Pattern repeats consistently (always create X when Y is created)
535
+ - ✅ Relationship is declarative (cascade delete, ownership transfer)
536
+ - ✅ Action is atomic with parent operation
537
+ - ✅ Business rule applies system-wide
538
+ - ✅ Need automatic execution within same transaction
539
+
540
+ **Use Manual Operations When:**
541
+ - ❌ Logic is complex with multiple conditions
542
+ - ❌ Requires external API calls
543
+ - ❌ Need user confirmation before action
544
+ - ❌ Action is optional or contextual
545
+ - ❌ Involves asynchronous processing
546
+
547
+ **Examples:**
548
+
549
+ ✅ **Good for Graph Rules:**
550
+ ```jsonb
551
+ // Creator becomes owner
552
+ "on_create": {
553
+ "establish_ownership": {
554
+ "actions": [{
555
+ "type": "create",
556
+ "entity": "acts_for",
557
+ "data": {"user_id": "@user_id", "org_id": "@id"}
558
+ }]
559
+ }
560
+ }
561
+ ```
562
+
563
+ ❌ **Bad for Graph Rules:**
564
+ ```javascript
565
+ // Send welcome email, create Stripe customer, notify Slack
566
+ // Too many external dependencies - do manually
567
+ export async function createOrganisation(userId, params) {
568
+ const org = await db.api.save.organisations(params, userId);
569
+ await sendWelcomeEmail(org);
570
+ await createStripeCustomer(org);
571
+ await notifySlack(org);
572
+ return org;
573
+ }
574
+ ```
575
+
576
+ ### PostgreSQL Functions vs Bun Functions
577
+
578
+ **Use PostgreSQL Functions When:**
579
+ - ✅ Data-heavy operations (aggregations, complex queries)
580
+ - ✅ Need transactional guarantees
581
+ - ✅ Performance critical (no network overhead)
582
+ - ✅ Pure database logic
583
+ - ✅ Reusable across multiple applications
584
+
585
+ **Use Bun Functions When:**
586
+ - ✅ Complex business logic
587
+ - ✅ Need external API calls
588
+ - ✅ Require npm packages
589
+ - ✅ Easier to test/debug in JavaScript
590
+ - ✅ Rapid prototyping
591
+
592
+ **Example Decision:**
593
+
594
+ ✅ **PostgreSQL - Data aggregation:**
595
+ ```sql
596
+ CREATE FUNCTION get_venue_stats(p_user_id INT, p_venue_id INT)
597
+ RETURNS JSONB AS $$
598
+ SELECT jsonb_build_object(
599
+ 'total_sites', COUNT(s.id),
600
+ 'total_events', COUNT(e.id),
601
+ 'revenue', SUM(e.revenue)
602
+ )
603
+ FROM sites s
604
+ LEFT JOIN events e ON e.site_id = s.id
605
+ WHERE s.venue_id = p_venue_id;
606
+ $$ LANGUAGE sql;
607
+ ```
608
+
609
+ ✅ **Bun - External API integration:**
610
+ ```javascript
611
+ export async function sendInvitation(userId, params) {
612
+ const { email, org_id } = params;
613
+
614
+ // Send via SendGrid
615
+ await sendgrid.send({...});
616
+
617
+ // Create invitation record
618
+ await db.api.save.invitations({
619
+ email, org_id, sent_by: userId
620
+ }, userId);
621
+
622
+ return { success: true };
623
+ }
624
+ ```
625
+
626
+ ### Notification Paths vs Broadcast All
627
+
628
+ **Use Targeted Notification Paths When:**
629
+ - ✅ Only specific users should see the change
630
+ - ✅ Data is sensitive (private org data)
631
+ - ✅ Need to reduce notification noise
632
+ - ✅ Clear ownership/membership model
633
+
634
+ **Use Broadcast All (null) When:**
635
+ - ✅ Public data everyone should see
636
+ - ✅ No ownership model
637
+ - ✅ Simplicity is priority
638
+ - ✅ Small user base
639
+
640
+ **Example:**
641
+
642
+ ✅ **Targeted (private venues):**
643
+ ```sql
644
+ SELECT dzql.register_entity(
645
+ 'venues', 'name', array['name'],
646
+ '{}', false, '{}',
647
+ '{
648
+ "ownership": ["@org_id->acts_for[org_id=$]{active}.user_id"]
649
+ }' -- Only org members notified
650
+ );
651
+ ```
652
+
653
+ ✅ **Broadcast (public events):**
654
+ ```sql
655
+ SELECT dzql.register_entity(
656
+ 'public_events', 'name', array['name'],
657
+ '{}', false, '{}',
658
+ '{}' -- Empty = notify all authenticated users
659
+ );
660
+ ```
661
+
662
+ ---
663
+
664
+ ## Transaction Boundaries
665
+
666
+ ### What Runs Atomically
667
+
668
+ **Single Transaction Includes:**
669
+ 1. Primary operation (save/delete)
670
+ 2. All graph rule actions for that operation
671
+ 3. Event log writing
672
+ 4. Trigger execution
673
+
674
+ **Example Flow:**
675
+ ```javascript
676
+ // User creates organisation
677
+ await ws.api.save.organisations({name: 'Acme Corp'});
678
+
679
+ // PostgreSQL executes in ONE transaction:
680
+ // 1. INSERT INTO organisations
681
+ // 2. Graph rule: INSERT INTO acts_for (creator becomes owner)
682
+ // 3. INSERT INTO dzql.events
683
+ // 4. NOTIFY 'dzql'
684
+ // Either all succeed or all rollback
685
+ ```
686
+
687
+ ### Transaction Rollback
688
+
689
+ If any step fails, **entire transaction rolls back**:
690
+
691
+ ```sql
692
+ -- This graph rule will rollback the org creation if site creation fails
693
+ "on_create": {
694
+ "create_default_site": {
695
+ "actions": [{
696
+ "type": "create",
697
+ "entity": "sites",
698
+ "data": {
699
+ "venue_id": "@id",
700
+ "invalid_field": "@foo" -- ERROR: invalid_field doesn't exist
701
+ }
702
+ }]
703
+ }
704
+ }
705
+ -- Result: Organisation is NOT created, error is thrown
706
+ ```
707
+
708
+ ### Multiple Operations Are Separate Transactions
709
+
710
+ ```javascript
711
+ // These are TWO separate transactions
712
+ const org = await ws.api.save.organisations({name: 'Acme'}); // Transaction 1
713
+ const venue = await ws.api.save.venues({org_id: org.id}); // Transaction 2
714
+
715
+ // If venue creation fails, org still exists
716
+ ```
717
+
718
+ To make multiple operations atomic, use a PostgreSQL function:
719
+
720
+ ```sql
721
+ CREATE FUNCTION create_org_with_venue(p_user_id INT, p_org_name TEXT, p_venue_name TEXT)
722
+ RETURNS JSONB AS $$
723
+ DECLARE
724
+ v_org RECORD;
725
+ v_venue RECORD;
726
+ BEGIN
727
+ INSERT INTO organisations (name) VALUES (p_org_name) RETURNING * INTO v_org;
728
+ INSERT INTO venues (name, org_id) VALUES (p_venue_name, v_org.id) RETURNING * INTO v_venue;
729
+
730
+ RETURN jsonb_build_object('org', to_jsonb(v_org), 'venue', to_jsonb(v_venue));
731
+ END;
732
+ $$ LANGUAGE plpgsql;
733
+ ```
734
+
735
+ ---
736
+
737
+ ## FK Includes Edge Cases
738
+
739
+ ### Circular References
740
+
741
+ **Problem:** A→B and B→A causes infinite recursion
742
+
743
+ ```sql
744
+ -- organisations.parent_org_id -> organisations
745
+ -- organisations.child_orgs <- organisations
746
+ SELECT dzql.register_entity(
747
+ 'organisations', 'name', array['name'],
748
+ '{"parent": "organisations", "children": "organisations"}' -- CIRCULAR!
749
+ );
750
+ ```
751
+
752
+ **Solution:** Only include one direction
753
+ ```sql
754
+ '{"parent": "organisations"}' -- Parent only, not children
755
+ ```
756
+
757
+ ### Deeply Nested Includes
758
+
759
+ **Problem:** Performance degrades with deep nesting
760
+
761
+ ```sql
762
+ -- venue -> org -> parent_org -> parent_org -> ...
763
+ '{"org": "organisations"}' -- This only derefs 1 level
764
+ ```
765
+
766
+ **Behavior:**
767
+ - FK includes dereference **1 level only**
768
+ - Nested includes (org.parent_org) are NOT automatically included
769
+ - To include nested, configure in each entity separately
770
+
771
+ ### Performance Implications
772
+
773
+ | FK Includes | Query Complexity | Recommendation |
774
+ |-------------|------------------|----------------|
775
+ | None | SELECT * FROM table | Fastest |
776
+ | 1-2 single objects | 1-2 JOINs | Good |
777
+ | 3+ single objects | 3+ JOINs | Consider splitting |
778
+ | 1 child array | 1 subquery | Good |
779
+ | 2+ child arrays | 2+ subqueries | Slow - avoid |
780
+
781
+ **Optimization Strategy:**
782
+ - Only include FKs you actually need
783
+ - Avoid including large child arrays unless necessary
784
+ - Consider separate queries for child arrays
785
+ - Use pagination for child arrays
786
+
787
+ ---
788
+
789
+ ## Security Checklist
790
+
791
+ ### When Building DZQL Applications
792
+
793
+ **Authentication:**
794
+ - ✅ Always require `login_user()` before operations
795
+ - ✅ Store JWT in secure storage (not localStorage for production)
796
+ - ✅ Set `JWT_EXPIRES_IN` to reasonable duration (7d default)
797
+ - ✅ Regenerate `JWT_SECRET` for production (never use default)
798
+ - ✅ Validate token on every operation (automatic in DZQL)
799
+
800
+ **Permissions:**
801
+ - ✅ Configure `permission_paths` for all non-public entities
802
+ - ✅ Use empty array `[]` only for truly public data
803
+ - ✅ Test permission paths with different user roles
804
+ - ✅ Never trust client-side filtering - always enforce server-side
805
+ - ✅ Use `{active}` temporal filtering in permission paths
806
+
807
+ **Graph Rules:**
808
+ - ✅ Validate graph rule variables exist
809
+ - ✅ Test rollback behavior (ensure atomicity)
810
+ - ✅ Avoid complex logic in graph rules (use functions instead)
811
+ - ✅ Document cascade delete behavior
812
+
813
+ **Input Validation:**
814
+ - ✅ DZQL validates entity schema automatically
815
+ - ✅ Add custom validation in PostgreSQL functions if needed
816
+ - ✅ Use CHECK constraints for business rules
817
+ - ✅ Never expose raw error messages to client
818
+
819
+ **Rate Limiting:**
820
+ - ⚠️ DZQL doesn't include rate limiting (v0.1.0)
821
+ - ✅ Add rate limiting middleware for production
822
+ - ✅ Limit login attempts
823
+ - ✅ Limit operations per user/minute
824
+
825
+ **Error Handling:**
826
+ - ✅ Catch all errors in client
827
+ - ✅ Log errors server-side
828
+ - ✅ Never expose stack traces in production
829
+ - ✅ Return generic error messages to client
830
+
831
+ ---
832
+
833
+ ## Performance Guidelines
834
+
835
+ ### Index Recommendations
836
+
837
+ **Always Index:**
838
+ - ✅ Primary keys (automatic)
839
+ - ✅ Foreign keys used in paths
840
+ - ✅ Fields in `searchable_fields`
841
+ - ✅ Temporal fields (`valid_from`, `valid_to`)
842
+ - ✅ Fields used in permission path filters
843
+
844
+ ```sql
845
+ -- Example indexes for venues entity
846
+ CREATE INDEX idx_venues_org_id ON venues(org_id);
847
+ CREATE INDEX idx_venues_name ON venues(name); -- searchable field
848
+ CREATE INDEX idx_sites_venue_id ON sites(venue_id); -- for FK includes
849
+ ```
850
+
851
+ ### Searchable Fields Impact
852
+
853
+ **Performance Cost:**
854
+ - Each searchable field adds to `_search` query cost
855
+ - Text search uses `ILIKE` which can be slow without indexes
856
+ - Limit to 3-5 truly searchable fields
857
+
858
+ ```sql
859
+ -- Good: 3 searchable fields
860
+ array['name', 'address', 'city']
861
+
862
+ -- Bad: Too many fields
863
+ array['name', 'address', 'city', 'description', 'notes', 'tags', 'metadata']
864
+ ```
865
+
866
+ ### FK Includes Cost
867
+
868
+ | Include Type | Cost | Query |
869
+ |--------------|------|-------|
870
+ | Single object | 1 JOIN | Fast |
871
+ | Child array (small) | 1 subquery | Moderate |
872
+ | Child array (large) | 1 subquery + many rows | Slow |
873
+ | Multiple arrays | N subqueries | Very slow |
874
+
875
+ **Optimization:**
876
+ ```sql
877
+ -- Good: One FK dereference
878
+ '{"org": "organisations"}'
879
+
880
+ -- Good: Small child array (<100 records)
881
+ '{"sites": "sites"}'
882
+
883
+ -- Bad: Multiple large child arrays
884
+ '{"sites": "sites", "events": "events", "contractors": "contractors"}'
885
+ ```
886
+
887
+ ### Graph Rules Complexity
888
+
889
+ **Fast Graph Rules:**
890
+ - ✅ Single action per rule
891
+ - ✅ Simple match conditions
892
+ - ✅ Direct variable references
893
+
894
+ **Slow Graph Rules:**
895
+ - ❌ Multiple chained actions
896
+ - ❌ Complex match conditions
897
+ - ❌ Nested graph rules triggering other graph rules
898
+
899
+ ```jsonb
900
+ // Good: Simple cascade
901
+ "on_delete": {
902
+ "cascade": {
903
+ "actions": [{"type": "delete", "entity": "sites", "match": {"venue_id": "@id"}}]
904
+ }
905
+ }
906
+
907
+ // Bad: Complex chain
908
+ "on_create": {
909
+ "rule1": {"actions": [...]}, // Triggers more operations
910
+ "rule2": {"actions": [...]}, // Which trigger more operations
911
+ "rule3": {"actions": [...]} // Slows down creation significantly
912
+ }
913
+ ```
914
+
915
+ ### Query Optimization Tips
916
+
917
+ 1. **Limit searchable fields** to truly searchable content
918
+ 2. **Index foreign keys** used in joins and paths
919
+ 3. **Avoid large FK includes** in list operations
920
+ 4. **Use pagination** (keep `limit` ≤ 100)
921
+ 5. **Filter before including** FKs when possible
922
+ 6. **Monitor slow queries** via PostgreSQL logs
923
+
924
+ ---
925
+
926
+ ## Additional Resources
927
+
928
+ - **API Reference**: See [packages/dzql/REFERENCE.md](packages/dzql/REFERENCE.md) for complete API documentation
929
+ - **Tutorial**: See [packages/dzql/GETTING_STARTED.md](packages/dzql/GETTING_STARTED.md) for hands-on guide
930
+ - **Examples**: See `packages/venues/` for complete working application
931
+ - **Tests**: See `packages/venues/tests/` for comprehensive test patterns