dzql 0.6.18 → 0.6.20

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/for_ai.md CHANGED
@@ -1,699 +1,502 @@
1
- # DZQL Guide for AI Assistants
1
+ # DZQL Domain Modeling Guide
2
2
 
3
- This document defines the patterns and conventions for generating valid DZQL domain definitions. Use this guide when asked to "Create a DZQL app" or "Add an entity".
3
+ This guide defines patterns for generating valid DZQL domain definitions.
4
4
 
5
- ## Quick Start
5
+ ## Quick Reference
6
6
 
7
- The fastest way to create a new DZQL app:
8
-
9
- ```bash
10
- bun create dzql my-app
11
- cd my-app
12
- bun install
13
- bun run db:rebuild
14
- bun run dev
15
7
  ```
8
+ DOMAIN STRUCTURE
9
+ ================
10
+ export default {
11
+ entities: { ... },
12
+ subscribables: { ... },
13
+ customFunctions: [ ... ],
14
+ auth: { ... } // Optional: override auth types
15
+ } satisfies DomainConfig;
16
+
17
+ ENTITY PATTERN
18
+ ==============
19
+ entity_name: {
20
+ schema: { column: 'pg_type constraints' },
21
+ primaryKey: ['id'], // Default, override for composite
22
+ label: 'name', // For lookups/display
23
+ searchable: ['name'], // Text search fields
24
+ hidden: ['password_hash'], // Exclude from API responses
25
+ managed: true, // false = skip CRUD generation
26
+ softDelete: false, // true = use deleted_at
27
+ permissions: { view, create, update, delete },
28
+ includes: { rel: 'entity' }, // FK expansions
29
+ manyToMany: { ... },
30
+ graphRules: { on_create, on_update, on_delete },
31
+ notifications: { ... }
32
+ }
16
33
 
17
- ## Core Concept: The Domain Definition
18
-
19
- A DZQL application is defined by a single TypeScript/JavaScript module exporting `entities` and `subscribables`.
34
+ PERMISSION DSL
35
+ ==============
36
+ [] = Deny all
37
+ ['TRUE'] = Public access
38
+ ['@author_id'] = @user_id == @author_id
39
+ ['@author_id == @user_id'] = Explicit equality
40
+ ['@org_id->acts_for[org_id=$].user_id'] = Traversal
41
+ ['@org_id->acts_for[org_id=$]{active}.user_id'] = With temporal filter
42
+
43
+ SUBSCRIBABLE PATTERN
44
+ ====================
45
+ sub_name: {
46
+ params: { param: 'type' },
47
+ root: { entity: 'table', key: 'param' },
48
+ includes: { rel: { entity: 'table', includes: {...} } },
49
+ scopeTables: ['all', 'affected', 'tables'],
50
+ canSubscribe: ['permission_path']
51
+ }
52
+ ```
20
53
 
21
- ### 1. Entity Definition Pattern
54
+ ## Entity Definition
22
55
 
23
- Each key in `entities` maps to a database table.
56
+ Each key in `entities` maps to a PostgreSQL table.
24
57
 
25
- ```javascript
58
+ ```typescript
26
59
  export const entities = {
27
- // Key = Table Name (snake_case recommended)
28
- [entity_name]: {
29
-
30
- // 1. Schema: Standard PostgreSQL types
31
- // Format: 'type constraints'
60
+ posts: {
32
61
  schema: {
33
62
  id: 'serial PRIMARY KEY',
34
- name: 'text NOT NULL',
35
- org_id: 'int REFERENCES organisations(id) ON DELETE CASCADE', // Always define FK constraints
36
- created_at: 'timestamptz DEFAULT now()'
63
+ title: 'text NOT NULL',
64
+ content: 'text',
65
+ author_id: 'int NOT NULL REFERENCES users(id)',
66
+ org_id: 'int REFERENCES organisations(id) ON DELETE CASCADE',
67
+ created_at: 'timestamptz DEFAULT now()',
68
+ deleted_at: 'timestamptz' // For soft delete
37
69
  },
38
70
 
39
- // 2. Configuration
40
- label: 'name', // Field used for autocomplete/display
41
- searchable: ['name', 'description'], // Fields indexed for search
71
+ label: 'title',
72
+ searchable: ['title', 'content'],
73
+ softDelete: true,
42
74
 
43
- // 3. Permissions: Row-Level Security DSL
44
- // Rules are OR-ed together. If any rule passes, access is granted.
45
- // Empty array [] = Deny All (Default for strictness)
46
- // ['TRUE'] = Public Access
47
75
  permissions: {
48
- view: ['@org_id->acts_for[org_id=$]{active}.user_id'], // Complex traversal
49
- create: ['@author_id == @user_id'], // Simple check
50
- update: ['@id'], // Implies "User ID matches Record ID" (Owner)
51
- delete: []
76
+ view: ['@org_id->acts_for[org_id=$]{active}.user_id'],
77
+ create: ['@org_id->acts_for[org_id=$]{active}.user_id'],
78
+ update: ['@author_id'],
79
+ delete: ['@author_id']
80
+ },
81
+
82
+ // FK expansions - automatically included in responses
83
+ includes: {
84
+ author: 'users',
85
+ org: 'organisations'
52
86
  },
53
87
 
54
- // 4. Graph Rules: Side Effects & Cascades
55
88
  graphRules: {
56
- // Triggered AFTER successful INSERT
57
89
  on_create: {
58
- action_name: {
59
- actions: [
60
- // Database Side Effect
61
- {
62
- type: 'create',
63
- entity: 'notifications',
64
- data: { user_id: '@user_id', message: 'Welcome' }
65
- },
66
- // Async Reactor (External Side Effect via Runtime)
67
- {
68
- type: 'reactor',
69
- name: 'send_email',
70
- params: { email: '@email' }
71
- }
72
- ]
73
- }
74
- },
75
- // Triggered BEFORE DELETE
76
- on_delete: {
77
- cascade_cleanup: {
90
+ notify_org: {
78
91
  actions: [
79
- // Explicit Cascade (if not handled by DB FK)
80
- { type: 'delete', target: 'comments', params: { post_id: '@id' } }
92
+ { type: 'reactor', name: 'send_notification', params: { org_id: '@org_id' } }
81
93
  ]
82
94
  }
83
95
  }
96
+ },
97
+
98
+ notifications: {
99
+ org_members: ['@org_id->acts_for[org_id=$]{active}.user_id']
84
100
  }
85
101
  }
86
102
  };
87
103
  ```
88
104
 
89
- ### 2. Permission DSL Guide
105
+ ## Permission Patterns
90
106
 
91
- Permissions are compiled to SQL `EXISTS` clauses.
107
+ Permissions compile to SQL `EXISTS` clauses. Rules are OR'd together.
92
108
 
93
- * **Variables:**
94
- * `@user_id`: The authenticated user's ID.
95
- * `@id`: The Record's ID (or value of `id` column).
96
- * `@field`: The value of a field in the record (or input data).
109
+ ### Variables
110
+ - `@user_id` - Authenticated user's ID
111
+ - `@field` - Value of field in the record
112
+ - `@id` - Record's primary key value
97
113
 
98
- * **Patterns:**
99
- * **Self-Ownership:** `'@user_id == @owner_id'` (or just `'@owner_id'` shorthand).
100
- * **Traversal:** `'@org_id->acts_for[org_id=$]{active}.user_id'`
101
- * `@org_id`: Start from this field on the current entity.
102
- * `->acts_for`: Join to `acts_for` table.
103
- * `[org_id=$]`: Join condition (`acts_for.org_id = current.org_id`).
104
- * `{active}`: Filter condition (`acts_for.active = true` or temporal check).
105
- * `.user_id`: Final check (`acts_for.user_id = @user_id`).
114
+ ### Common Patterns
106
115
 
107
- ### 3. Subscribable Definition Pattern (The "Smart Store")
116
+ ```typescript
117
+ // Owner only
118
+ update: ['@author_id'] // Shorthand for @author_id == @user_id
108
119
 
109
- Subscribables define the *shape* of the data the client needs. They are more than just queries; they define the **Graph Patching Strategy**.
120
+ // Organization member via junction table
121
+ view: ['@org_id->acts_for[org_id=$]{active}.user_id']
122
+ // Reads as: "user exists in acts_for where org_id matches and active=true"
110
123
 
111
- ```javascript
112
- export const subscribables = {
113
- // Key = Subscription Name
114
- venue_detail: {
115
- // 1. Parameters (Inputs)
116
- params: { venue_id: 'int' },
124
+ // Self (user can only access own record)
125
+ view: ['@id'] // For users table: @id == @user_id
117
126
 
118
- // 2. Root Entity
119
- root: {
120
- entity: 'venues',
121
- key: 'venue_id' // Maps param 'venue_id' to entity PK
122
- },
127
+ // Public read, authenticated write
128
+ view: ['TRUE'],
129
+ create: [] // Empty = logged-in users only
123
130
 
124
- // 3. Graph Structure (Nested Includes)
125
- includes: {
126
- // Simple relation
127
- org: { relation: 'org', entity: 'organisations' },
128
-
129
- // Nested relation
130
- sites: {
131
- relation: 'sites',
132
- entity: 'sites',
133
- // Recursive nesting
134
- includes: {
135
- allocations: { relation: 'allocations', entity: 'allocations' }
136
- }
137
- }
138
- },
131
+ // Multiple paths (OR'd)
132
+ view: ['@author_id', '@org_id->acts_for[org_id=$].user_id']
133
+ ```
139
134
 
140
- // 4. Scope Tables (Crucial for Realtime)
141
- // List ALL tables that appear in this graph.
142
- // The Runtime uses this to route events.
143
- scopeTables: ['venues', 'organisations', 'sites', 'allocations'],
135
+ ### Traversal Syntax
144
136
 
145
- // 5. Subscription Permission
146
- // Who is allowed to listen to this feed?
147
- canSubscribe: ['@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id']
148
- }
149
- };
137
+ ```
138
+ @field->table[join_condition]{filter}.target_field
139
+ │ │ │ │ │
140
+ │ │ │ │ └── Final check: table.target_field = @user_id
141
+ │ │ │ └── Optional: WHERE filter (e.g., active=true, temporal)
142
+ │ │ └── Join: table.join_field = current.@field
143
+ │ └── Target table
144
+ └── Starting field on current entity
150
145
  ```
151
146
 
152
- ### When to use what?
147
+ ## CRUD Operations
153
148
 
154
- * **Entities:** Always define entities for every physical table.
155
- * **Subscribables:** Define a subscribable for every **Screen** or **Major Component** in the UI. (e.g., `dashboard`, `profile`, `item_detail`).
156
- * *Do not* write manual API fetchers. Use subscribables to generate "Smart Stores" that self-update.
149
+ Every entity gets 5 operations: `get`, `save`, `delete`, `search`, `lookup`.
157
150
 
158
- ### Using Generated Subscribable Stores
151
+ ### Get - Rich Document
159
152
 
160
- Each subscribable in your domain generates a Pinia store with this structure:
153
+ `get` returns a **rich document** with FK and M2M expansions:
161
154
 
162
155
  ```typescript
163
- // Generated store API
164
- const store = useVenueDetailStore();
156
+ // Entity with includes and manyToMany
157
+ posts: {
158
+ schema: { id: 'serial PRIMARY KEY', author_id: 'int', org_id: 'int', title: 'text' },
159
+ includes: { author: 'users', org: 'organisations' },
160
+ manyToMany: { tags: { junctionTable: 'post_tags', ... } }
161
+ }
165
162
 
166
- store.bind(params) // Async - subscribe and get { data, loading, ready }
167
- store.unbind(params) // Unsubscribe and remove document from store
168
- store.documents // Ref - all bound documents keyed by JSON params
163
+ // get_posts({ id: 1 }) returns:
164
+ {
165
+ id: 1,
166
+ author_id: 5,
167
+ org_id: 2,
168
+ title: 'Hello',
169
+ author: { id: 5, name: 'Alice', email: '...' }, // Direct FK expanded
170
+ org: { id: 2, name: 'Acme Corp' }, // Direct FK expanded
171
+ tag_ids: [1, 3, 7], // M2M IDs
172
+ tags: [{ id: 1, name: 'tech' }, ...] // M2M expanded (if expand: true)
173
+ }
169
174
  ```
170
175
 
171
- **Basic Usage:**
176
+ **Key insight:** `get` is already a rich document. Use it as the starting point. Only move to subscribables when you need:
177
+ - One-to-many (reverse FK) expansion
178
+ - Complex nested includes
179
+ - Realtime updates across multiple tables
172
180
 
173
- ```typescript
174
- import { useVenueDetailStore } from '@/generated/client/stores';
181
+ ### Save - Atomic with Side Effects
175
182
 
176
- const store = useVenueDetailStore();
183
+ `save` handles insert/update, M2M sync, and graph rules in one transaction:
177
184
 
178
- // bind() returns a Promise that resolves when first data arrives
179
- const { data, loading, ready } = await store.bind({ venue_id: 1 });
185
+ ```typescript
186
+ // Insert (no id)
187
+ await ws.api.save_posts({ title: 'New', author_id: 1, tag_ids: [1, 2] });
180
188
 
181
- // data is now populated - no need to check loading
182
- console.log(data.name); // 'My Venue'
183
- console.log(data.org.name); // 'My Org' (nested include)
184
- console.log(data.sites); // [{...}, {...}] (array of related records)
189
+ // Update (has id) - partial update, only provided fields change
190
+ await ws.api.save_posts({ id: 1, title: 'Updated' });
185
191
 
186
- // Subsequent calls with same params return cached subscription immediately
187
- const cached = await store.bind({ venue_id: 1 }); // No new subscription created
192
+ // M2M sync happens automatically
193
+ await ws.api.save_posts({ id: 1, tag_ids: [3, 4] }); // Replaces old tags
188
194
  ```
189
195
 
190
- **Vue Component Pattern (with Suspense):**
191
-
192
- ```vue
193
- <script setup>
194
- import { useVenueDetailStore } from '@/generated/client/stores';
196
+ Returns the full document with FK/M2M expansions.
195
197
 
196
- const props = defineProps(['venueId']);
197
- const store = useVenueDetailStore();
198
+ ### Search - Filtered List
198
199
 
199
- // Top-level await - component suspends until data arrives
200
- const { data } = await store.bind({ venue_id: props.venueId });
201
- </script>
202
-
203
- <template>
204
- <h1>{{ data.name }}</h1>
205
- <p>Org: {{ data.org.name }}</p>
206
- <ul>
207
- <li v-for="site in data.sites" :key="site.id">
208
- {{ site.name }}
209
- </li>
210
- </ul>
211
- </template>
200
+ ```typescript
201
+ await ws.api.search_posts({
202
+ filters: { org_id: { eq: 1 }, title: { ilike: '%hello%' } },
203
+ sort_field: 'created_at',
204
+ sort_order: 'desc',
205
+ limit: 20,
206
+ offset: 0
207
+ });
212
208
  ```
213
209
 
214
- **Vue Component Pattern (with loading state):**
215
-
216
- ```vue
217
- <script setup>
218
- import { useVenueDetailStore } from '@/generated/client/stores';
219
- import { ref, onMounted, watch } from 'vue';
210
+ Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `ilike`, `is_null`.
220
211
 
221
- const props = defineProps(['venueId']);
222
- const store = useVenueDetailStore();
223
- const doc = ref({ data: null, loading: true });
224
-
225
- onMounted(async () => {
226
- doc.value = await store.bind({ venue_id: props.venueId });
227
- });
212
+ ### Lookup - Autocomplete
228
213
 
229
- // Re-bind when venueId changes
230
- watch(() => props.venueId, async (newId) => {
231
- doc.value = await store.bind({ venue_id: newId });
232
- });
233
- </script>
234
-
235
- <template>
236
- <div v-if="doc.loading">Loading...</div>
237
- <template v-else>
238
- <h1>{{ doc.data.name }}</h1>
239
- </template>
240
- </template>
214
+ ```typescript
215
+ await ws.api.lookup_posts({ q: 'hel' }); // Returns [{label: 'Hello World', value: 1}, ...]
241
216
  ```
242
217
 
243
- **Accessing Multiple Subscriptions:**
218
+ Uses the entity's `label` field for display.
244
219
 
245
- ```typescript
246
- const store = useVenueDetailStore();
220
+ ### Delete
247
221
 
248
- // Each unique params set creates a separate subscription
249
- await store.bind({ venue_id: 1 });
250
- await store.bind({ venue_id: 2 });
251
-
252
- // Access all documents via store.documents
253
- for (const [key, docState] of Object.entries(store.documents)) {
254
- console.log(key, docState.data);
255
- }
256
- // '{"venue_id":1}' { id: 1, name: 'Venue 1', ... }
257
- // '{"venue_id":2}' { id: 2, name: 'Venue 2', ... }
222
+ ```typescript
223
+ await ws.api.delete_posts({ id: 1 }); // Hard delete or soft delete based on entity config
258
224
  ```
259
225
 
260
- **Key Points:**
261
- - `bind()` is async and awaits first data before returning
262
- - Same params = same cached subscription (deduplication by JSON key)
263
- - The `ready` Promise is stored for repeat callers to await
264
- - **Stores own their data** - the WebSocket is just transport
265
- - Data is reactive - changes trigger Vue reactivity automatically
226
+ ## Subscribable Definition
266
227
 
267
- ### How Realtime Works
228
+ Subscribables define realtime data shapes for UI components.
268
229
 
269
- DZQL uses a unified `table_changed` pattern for all stores:
230
+ ```typescript
231
+ export const subscribables = {
232
+ // Name becomes: subscribe_venue_detail, get_venue_detail, useVenueDetailStore
233
+ venue_detail: {
234
+ params: { venue_id: 'int' },
270
235
 
271
- 1. **Database events:** PostgreSQL triggers emit events to `dzql_v2.events`
272
- 2. **Server broadcasts:** Runtime sends `{table}:{op}` messages (e.g., `venues:update`) to clients
273
- 3. **Auto-dispatch:** WebSocket client routes broadcasts to registered store handlers
274
- 4. **Store updates:** Each store's `table_changed` method applies updates to local data
236
+ root: {
237
+ entity: 'venues',
238
+ key: 'venue_id' // Maps param to entity PK
239
+ },
275
240
 
276
- **Stores self-register - no manual setup needed:**
241
+ includes: {
242
+ org: 'organisations', // Simple: FK expansion
243
+ sites: {
244
+ entity: 'sites',
245
+ includes: {
246
+ allocations: 'allocations' // Nested
247
+ }
248
+ }
249
+ },
277
250
 
278
- ```typescript
279
- // Generated store (simplified)
280
- export const useVenuesStore = defineStore('venues-store', () => {
281
- const records = ref([]);
251
+ // ALL tables that can trigger updates
252
+ scopeTables: ['venues', 'organisations', 'sites', 'allocations'],
282
253
 
283
- function table_changed(table: string, op: string, pk: Record<string, unknown>, data: unknown) {
284
- if (table !== 'venues') return;
285
- // Update records based on op (insert/update/delete)
254
+ // Who can subscribe
255
+ canSubscribe: ['@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id']
286
256
  }
257
+ };
258
+ ```
287
259
 
288
- // Self-register with WebSocket
289
- ws.registerStore(table_changed);
260
+ ### Generated Types
290
261
 
291
- return { records, get, save, search, table_changed };
292
- });
293
- ```
262
+ The compiler generates TypeScript types for subscribables:
294
263
 
295
- **User code - just works:**
296
264
  ```typescript
297
- const venuesStore = useVenuesStore(); // Auto-registers for broadcasts
298
- await venuesStore.search({ org_id: 1 });
299
- // records update automatically when broadcasts arrive!
300
- ```
301
-
302
- ### Entity Notifications
265
+ // Generated in client/ws.ts
266
+ export interface VenueDetailParams {
267
+ venue_id: number;
268
+ }
303
269
 
304
- Entities can define `notifications` paths to specify who receives broadcasts:
270
+ export interface VenueDetailResult extends Venues {
271
+ org?: Organisations; // Many-to-one (singular)
272
+ sites?: Sites[]; // One-to-many (array)
273
+ }
305
274
 
306
- ```javascript
307
- export const entities = {
308
- venues: {
309
- schema: { id: 'serial PRIMARY KEY', org_id: 'int', name: 'text' },
310
- notifications: {
311
- members: ['@org_id->acts_for[org_id=$]{active}.user_id']
312
- }
313
- }
314
- };
275
+ // In DzqlAPI interface
276
+ subscribe_venue_detail: (
277
+ params: VenueDetailParams,
278
+ callback: (data: VenueDetailResult) => void
279
+ ) => Promise<{ data: VenueDetailResult; unsubscribe: () => Promise<void> }>;
315
280
  ```
316
281
 
317
- When a venue is created/updated/deleted, all active members of that org receive the broadcast
282
+ ## Many-to-Many Relationships
318
283
 
319
- ### Common Patterns
284
+ ```typescript
285
+ brands: {
286
+ schema: {
287
+ id: 'serial PRIMARY KEY',
288
+ name: 'text NOT NULL'
289
+ },
290
+ manyToMany: {
291
+ tags: {
292
+ junctionTable: 'brand_tags',
293
+ localKey: 'brand_id',
294
+ foreignKey: 'tag_id',
295
+ targetEntity: 'tags',
296
+ idField: 'tag_ids' // Param name for save: { tag_ids: [1,2,3] }
297
+ }
298
+ }
299
+ },
320
300
 
321
- **1. The "Owner" Pattern:**
322
- ```javascript
323
- create: ['@author_id == @user_id']
301
+ // Junction table - skip CRUD generation
302
+ brand_tags: {
303
+ schema: {
304
+ brand_id: 'int NOT NULL REFERENCES brands(id) ON DELETE CASCADE',
305
+ tag_id: 'int NOT NULL REFERENCES tags(id) ON DELETE CASCADE'
306
+ },
307
+ primaryKey: ['brand_id', 'tag_id'],
308
+ managed: false // No get_brand_tags, save_brand_tags, etc.
309
+ }
324
310
  ```
325
311
 
326
- **2. The "Organization Member" Pattern:**
327
- ```javascript
328
- view: ['@org_id->memberships[org_id=$].user_id']
329
- ```
312
+ ## Graph Rules (Side Effects)
330
313
 
331
- **3. The "Creator Side Effect" Pattern:**
332
- Use `graphRules.on_create` to create related records automatically (e.g., creating a default 'Settings' record when a 'User' is created).
314
+ ```typescript
315
+ graphRules: {
316
+ on_create: {
317
+ rule_name: {
318
+ description: 'Create audit log on insert',
319
+ actions: [
320
+ // Create related record
321
+ {
322
+ type: 'create',
323
+ entity: 'audit_logs',
324
+ data: { entity: 'posts', entity_id: '@id', action: 'created' }
325
+ },
326
+ // Call external service via runtime
327
+ {
328
+ type: 'reactor',
329
+ name: 'send_email',
330
+ params: { user_id: '@author_id', template: 'post_created' }
331
+ }
332
+ ]
333
+ }
334
+ },
333
335
 
334
- **4. The "Reactor" Pattern:**
335
- Use `type: 'reactor'` for anything that requires Node.js (Email, Stripe, AI processing). Do not try to do complex logic in SQL.
336
+ on_update: {
337
+ status_change: {
338
+ condition: "@before.status = 'draft' AND @after.status = 'published'",
339
+ actions: [
340
+ { type: 'reactor', name: 'notify_subscribers', params: { post_id: '@id' } }
341
+ ]
342
+ }
343
+ },
336
344
 
337
- ---
345
+ on_delete: {
346
+ cleanup: {
347
+ actions: [
348
+ { type: 'delete', target: 'comments', match: { post_id: '@id' } }
349
+ ]
350
+ }
351
+ }
352
+ }
353
+ ```
338
354
 
339
355
  ## Custom Functions
340
356
 
341
- DZQL supports two types of custom functions that can be called via RPC:
342
-
343
- ### 1. SQL Custom Functions
344
-
345
- SQL custom functions are defined in your domain and compiled into the database migrations. They run inside PostgreSQL and are ideal for complex queries, aggregations, or operations that benefit from database-level performance.
357
+ ### SQL Functions
346
358
 
347
- **Domain Definition:**
348
-
349
- ```javascript
350
- // domain.js
351
- export const entities = { /* ... */ };
352
- export const subscribables = { /* ... */ };
353
-
354
- // Add custom SQL functions
359
+ ```typescript
355
360
  export const customFunctions = [
356
361
  {
357
- name: 'calculate_org_stats',
362
+ name: 'calculate_stats',
358
363
  sql: `
359
- CREATE OR REPLACE FUNCTION dzql_v2.calculate_org_stats(p_user_id int, p_params jsonb)
364
+ CREATE OR REPLACE FUNCTION dzql_v2.calculate_stats(p_user_id int, p_params jsonb)
360
365
  RETURNS jsonb LANGUAGE plpgsql AS $$
361
- DECLARE
362
- v_org_id int;
363
- v_venue_count int;
364
- v_total_revenue numeric;
365
366
  BEGIN
366
- v_org_id := (p_params->>'org_id')::int;
367
-
368
- -- Permission check (optional but recommended)
369
- IF NOT EXISTS (
370
- SELECT 1 FROM acts_for
371
- WHERE user_id = p_user_id AND org_id = v_org_id AND active = true
372
- ) THEN
373
- RAISE EXCEPTION 'permission_denied' USING ERRCODE = 'P0001';
374
- END IF;
375
-
376
- SELECT COUNT(*) INTO v_venue_count
377
- FROM venues WHERE org_id = v_org_id;
378
-
379
- SELECT COALESCE(SUM(amount), 0) INTO v_total_revenue
380
- FROM orders WHERE org_id = v_org_id;
381
-
382
367
  RETURN jsonb_build_object(
383
- 'org_id', v_org_id,
384
- 'venue_count', v_venue_count,
385
- 'total_revenue', v_total_revenue
368
+ 'total', (SELECT count(*) FROM posts WHERE org_id = (p_params->>'org_id')::int)
386
369
  );
387
370
  END;
388
- $$;
389
- `,
390
- args: ['p_user_id', 'p_params'] // Optional, defaults to these
371
+ $$;`,
372
+ args: ['p_user_id', 'p_params'],
373
+ // Type information for generated client
374
+ params: { org_id: 'number' },
375
+ returns: { total: 'number' }
391
376
  }
392
377
  ];
393
378
  ```
394
379
 
395
- **Calling from Client:**
396
-
397
- ```typescript
398
- // The function is automatically added to the client SDK
399
- const stats = await ws.api.calculate_org_stats({ org_id: 1 });
400
- console.log(stats.venue_count, stats.total_revenue);
401
- ```
402
-
403
- **Key Points:**
404
- - Functions must be in the `dzql_v2` schema
405
- - Standard signature: `(p_user_id int, p_params jsonb) RETURNS jsonb`
406
- - Automatically added to the manifest allowlist (security)
407
- - Compiled into the database migrations
408
-
409
- ### 2. JavaScript Custom Functions
380
+ ### JavaScript Functions
410
381
 
411
- JavaScript custom functions run in the Bun/Node runtime. They are ideal for:
412
- - External API calls (Stripe, SendGrid, etc.)
413
- - Complex business logic that's easier in JS than SQL
414
- - Operations that need access to environment variables or external services
415
-
416
- **Registration (in your server startup):**
382
+ Register in server startup for external APIs, complex logic, or env access:
417
383
 
418
384
  ```typescript
419
- // server.ts or wherever you start your runtime
420
- import { registerJsFunction } from 'dzql/runtime';
421
-
422
- // Simple function
423
- registerJsFunction('hello_world', async (ctx) => {
424
- return {
425
- message: `Hello, User ${ctx.userId}!`,
426
- timestamp: new Date().toISOString()
427
- };
428
- });
385
+ import { registerJsFunction } from 'dzql';
429
386
 
430
- // Function with database access
431
- registerJsFunction('get_user_dashboard', async (ctx) => {
387
+ registerJsFunction('send_email', async (ctx) => {
432
388
  const { userId, params, db } = ctx;
433
-
434
- // Query the database
435
- const orgs = await db.query(
436
- 'SELECT o.* FROM organisations o JOIN acts_for af ON o.id = af.org_id WHERE af.user_id = $1 AND af.active = true',
437
- [userId]
438
- );
439
-
440
- const venues = await db.query(
441
- 'SELECT * FROM venues WHERE org_id = ANY($1)',
442
- [orgs.map(o => o.id)]
443
- );
444
-
445
- return {
446
- organizations: orgs,
447
- venues: venues,
448
- total_venues: venues.length
449
- };
450
- });
451
-
452
- // Function calling external API
453
- registerJsFunction('send_notification', async (ctx) => {
454
- const { userId, params } = ctx;
455
-
456
- // Call external service
457
- const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
458
- method: 'POST',
459
- headers: {
460
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
461
- 'Content-Type': 'application/json'
462
- },
463
- body: JSON.stringify({
464
- to: params.email,
465
- subject: params.subject,
466
- content: params.message
467
- })
468
- });
469
-
470
- return { success: response.ok };
389
+ await fetch('https://api.sendgrid.com/...', { ... });
390
+ return { sent: true };
471
391
  });
472
392
  ```
473
393
 
474
- **Calling from Client:**
394
+ ## Auth Configuration
475
395
 
476
- ```typescript
477
- // JS functions are called the same way as SQL functions
478
- const dashboard = await ws.api.get_user_dashboard({});
479
- const result = await ws.api.send_notification({
480
- email: 'user@example.com',
481
- subject: 'Hello',
482
- message: 'Welcome!'
483
- });
484
- ```
485
-
486
- **Context Object:**
396
+ Override default auth types for client generation:
487
397
 
488
398
  ```typescript
489
- interface JsFunctionContext {
490
- userId: number; // Authenticated user's ID
491
- params: any; // Parameters passed from client
492
- db: {
493
- query(sql: string, params?: any[]): Promise<any[]>; // Database access
494
- };
495
- }
496
- ```
497
-
498
- **Key Points:**
499
- - JS functions take precedence over SQL functions with the same name
500
- - No manifest entry needed - registration is enough
501
- - Full access to Node.js/Bun APIs (fetch, fs, etc.)
502
- - Can query the database via `ctx.db.query()`
503
- - Errors thrown are propagated to the client
504
-
505
- ### When to Use Which?
506
-
507
- | Use Case | SQL | JavaScript |
508
- |----------|-----|------------|
509
- | Complex aggregations | ✅ | |
510
- | Data transformations | ✅ | |
511
- | External API calls | | ✅ |
512
- | Email/SMS notifications | | ✅ |
513
- | File processing | | ✅ |
514
- | Payment processing | | ✅ |
515
- | Performance-critical queries | ✅ | |
516
- | Access to env variables | | ✅ |
517
- | Multi-step transactions | ✅ | |
518
- | Real-time calculations | ✅ | |
519
-
520
- ---
521
-
522
- ## Unmanaged Entities (Junction Tables)
523
-
524
- For junction tables used in many-to-many relationships, you typically don't want DZQL to generate CRUD functions. These tables are managed via the M2M relationship on the parent entity.
525
-
526
- Use `managed: false` to skip CRUD generation:
527
-
528
- ```javascript
529
- export const entities = {
530
- brands: {
531
- schema: {
532
- id: 'serial PRIMARY KEY',
533
- name: 'text NOT NULL'
399
+ export default {
400
+ entities: { ... },
401
+
402
+ auth: {
403
+ userFields: {
404
+ user_id: 'number',
405
+ email: 'string',
406
+ name: 'string',
407
+ avatar_url: 'string'
534
408
  },
535
- // M2M relationship manages brand_tags automatically
536
- manyToMany: {
537
- tags: {
538
- junctionTable: 'brand_tags',
539
- localKey: 'brand_id',
540
- foreignKey: 'tag_id',
541
- targetEntity: 'tags',
542
- idField: 'tag_ids'
543
- }
544
- }
545
- },
546
-
547
- tags: {
548
- schema: {
549
- id: 'serial PRIMARY KEY',
550
- name: 'text NOT NULL'
551
- }
552
- },
553
-
554
- // Junction table - no CRUD functions generated
555
- brand_tags: {
556
- schema: {
557
- brand_id: 'int NOT NULL REFERENCES brands(id) ON DELETE CASCADE',
558
- tag_id: 'int NOT NULL REFERENCES tags(id) ON DELETE CASCADE'
559
- },
560
- primaryKey: ['brand_id', 'tag_id'],
561
- managed: false // Skip CRUD generation
409
+ loginParams: { email: 'string', password: 'string' },
410
+ registerParams: { email: 'string', password: 'string', name: 'string' }
562
411
  }
563
- };
412
+ } satisfies DomainConfig;
564
413
  ```
565
414
 
566
- **What `managed: false` does:**
567
- - The table schema is still created in the database
568
- - No `get_brand_tags`, `save_brand_tags`, `delete_brand_tags`, etc. functions are generated
569
- - No manifest entries for CRUD operations
570
- - The junction table is managed via the parent entity's M2M operations
571
-
572
- ---
573
-
574
- ## Client Connection & Authentication
575
-
576
- When a client connects to the WebSocket server, it immediately receives a `connection:ready` message containing the authenticated user profile (or `null` if not authenticated).
577
-
578
- ### Connection Flow
579
-
580
- 1. Client connects with optional `?token=...` in URL
581
- 2. Server validates token (if present) and fetches user profile
582
- 3. Server sends: `{"method": "connection:ready", "params": {"user": {...} | null}}`
583
- 4. Client knows auth state immediately, can render accordingly
584
-
585
- ### WebSocketManager API
586
-
415
+ Generated types:
587
416
  ```typescript
588
- import { ws } from '@generated/client/ws';
589
-
590
- await ws.connect('/ws');
591
-
592
- // Authentication via typed API
593
- const user = await ws.api.login_user({ email: '...', password: '...' });
594
- // Token is returned in response - store in localStorage
595
- if (user.token) {
596
- localStorage.setItem('dzql_token', user.token);
597
- }
598
-
599
- const newUser = await ws.api.register_user({ name: '...', email: '...', password: '...' });
600
- // Token is returned in response
601
-
602
- // Logout - clear token and disconnect
603
- localStorage.removeItem('dzql_token');
604
- ws.disconnect();
605
- await ws.connect('/ws'); // Reconnect without token
417
+ interface LoginParams { email: string; password: string; }
418
+ interface LoginResult extends AuthUser { token: string; }
419
+ interface RegisterParams { email: string; password: string; name: string; }
420
+ interface RegisterResult extends AuthUser { token: string; }
606
421
  ```
607
422
 
608
- ### Vue/Pinia Usage Pattern
609
-
610
- ```vue
611
- <template>
612
- <div v-if="!ws.ready">Loading...</div>
613
- <LoginModal v-else-if="!ws.user" />
614
- <RouterView v-else />
615
- </template>
616
- ```
423
+ ## Client Usage
617
424
 
618
- ### Why This Matters
619
-
620
- - **Single source of truth:** WebSocket connection determines auth state, not localStorage
621
- - **No race conditions:** UI waits for `connection:ready` before rendering
622
- - **Simpler client code:** No need for separate auth check after connect
623
- - **Better UX:** App shows loading state until connection ready, then immediately correct view
624
-
625
- ---
626
-
627
- ## CLI Integration with Namespace
628
-
629
- DZQL provides a namespace export for CLI tools like `invokej` to interact with the database directly without going through the WebSocket runtime.
630
-
631
- ### Setup
425
+ ### WebSocket Connection
632
426
 
633
427
  ```typescript
634
- // cli.ts or server-side script
635
- import { DzqlNamespace } from 'dzql/namespace';
636
- import postgres from 'postgres';
428
+ import { ws } from '@generated/client';
637
429
 
638
- const sql = postgres(process.env.DATABASE_URL);
639
- const manifest = await import('./dist/runtime/manifest.json');
430
+ await ws.connect('/ws');
640
431
 
641
- const dzql = new DzqlNamespace(sql, manifest);
432
+ // Typed API
433
+ const user = await ws.api.login_user({ email: '...', password: '...' });
434
+ const post = await ws.api.save_posts({ title: 'Hello', org_id: 1 });
435
+ const posts = await ws.api.search_posts({ filters: { org_id: { eq: 1 } } });
642
436
  ```
643
437
 
644
- ### CRUD Operations
438
+ ### Subscribable Stores
645
439
 
646
440
  ```typescript
647
- // Get a record
648
- const venue = await dzql.get('venues', { id: 1 }, userId);
649
-
650
- // Save (create or update)
651
- const newVenue = await dzql.save('venues', { name: 'New Venue', org_id: 1 }, userId);
652
-
653
- // Delete
654
- const deleted = await dzql.delete('venues', { id: 1 }, userId);
441
+ import { useVenueDetailStore } from '@generated/client/stores';
655
442
 
656
- // Search with filters
657
- const venues = await dzql.search('venues', { org_id: 1, limit: 10 }, userId);
443
+ const store = useVenueDetailStore();
444
+ const { data } = await store.bind({ venue_id: 1 });
658
445
 
659
- // Lookup for autocomplete
660
- const options = await dzql.lookup('venues', { q: 'test' }, userId);
446
+ // data is reactive - updates automatically on changes
447
+ console.log(data.name, data.org.name, data.sites.length);
661
448
  ```
662
449
 
663
- ### Ad-hoc Function Calls
450
+ ## Common Modeling Patterns
664
451
 
665
- Call any function in the manifest directly:
452
+ ### Multi-tenant with Organizations
666
453
 
667
454
  ```typescript
668
- // Call a custom function
669
- const result = await dzql.call('calculate_org_stats', { org_id: 1 }, userId);
670
-
671
- // Call a subscribable getter
672
- const detail = await dzql.call('get_venue_detail', { venue_id: 1 }, userId);
455
+ entities: {
456
+ users: { schema: { id: 'serial PRIMARY KEY', email: 'text UNIQUE NOT NULL' } },
457
+ organisations: { schema: { id: 'serial PRIMARY KEY', name: 'text NOT NULL' } },
458
+ acts_for: {
459
+ schema: {
460
+ user_id: 'int REFERENCES users(id)',
461
+ org_id: 'int REFERENCES organisations(id)',
462
+ valid_from: 'date DEFAULT CURRENT_DATE',
463
+ valid_to: 'date',
464
+ active: 'boolean GENERATED ALWAYS AS (valid_to IS NULL OR valid_to > CURRENT_DATE) STORED'
465
+ },
466
+ primaryKey: ['user_id', 'org_id', 'valid_from']
467
+ },
468
+ // All tenant data uses org_id and permission path
469
+ posts: {
470
+ schema: { ..., org_id: 'int REFERENCES organisations(id)' },
471
+ permissions: {
472
+ view: ['@org_id->acts_for[org_id=$]{active}.user_id']
473
+ }
474
+ }
475
+ }
673
476
  ```
674
477
 
675
- ### List Available Functions
478
+ ### Ownership Pattern
676
479
 
677
480
  ```typescript
678
- // Get all functions from manifest
679
- const functions = dzql.functions();
680
- // Returns: ['login_user', 'register_user', 'get_venues', 'save_venues', ...]
481
+ posts: {
482
+ schema: { ..., author_id: 'int REFERENCES users(id)' },
483
+ fieldDefaults: { author_id: '@user_id' }, // Auto-set on create
484
+ permissions: {
485
+ view: ['TRUE'],
486
+ create: [],
487
+ update: ['@author_id'],
488
+ delete: ['@author_id']
489
+ }
490
+ }
681
491
  ```
682
492
 
683
- ### Use with invokej
684
-
685
- The namespace is designed for use with `invokej`, a CLI tool for invoking functions:
493
+ ### Soft Delete
686
494
 
687
- ```bash
688
- # In your invokej configuration, register the DZQL namespace
689
- invokej dzql:get venues '{"id": 1}'
690
- invokej dzql:save venues '{"name": "Updated Venue", "id": 1}'
691
- invokej dzql:call calculate_org_stats '{"org_id": 1}'
692
- invokej dzql:functions
495
+ ```typescript
496
+ posts: {
497
+ schema: { ..., deleted_at: 'timestamptz' },
498
+ softDelete: true
499
+ // delete_posts sets deleted_at instead of removing row
500
+ // search_posts excludes deleted_at IS NOT NULL by default
501
+ }
693
502
  ```
694
-
695
- **Key Points:**
696
- - All operations respect the same permissions as the WebSocket runtime
697
- - The `userId` parameter is required for permission checks
698
- - Operations are atomic (single transaction)
699
- - Results are returned as JSON objects