dzql 0.6.19 → 0.6.21
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/README.md +71 -321
- package/docs/for_ai.md +355 -552
- package/package.json +1 -1
- package/src/cli/codegen/sql.ts +26 -0
- package/src/cli/index.ts +75 -1
package/docs/for_ai.md
CHANGED
|
@@ -1,699 +1,502 @@
|
|
|
1
|
-
# DZQL
|
|
1
|
+
# DZQL Domain Modeling Guide
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This guide defines patterns for generating valid DZQL domain definitions.
|
|
4
4
|
|
|
5
|
-
## Quick
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
54
|
+
## Entity Definition
|
|
22
55
|
|
|
23
|
-
Each key in `entities` maps to a
|
|
56
|
+
Each key in `entities` maps to a PostgreSQL table.
|
|
24
57
|
|
|
25
|
-
```
|
|
58
|
+
```typescript
|
|
26
59
|
export const entities = {
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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'],
|
|
49
|
-
create: ['@
|
|
50
|
-
update: ['@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
## Permission Patterns
|
|
90
106
|
|
|
91
|
-
Permissions
|
|
107
|
+
Permissions compile to SQL `EXISTS` clauses. Rules are OR'd together.
|
|
92
108
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
```typescript
|
|
117
|
+
// Owner only
|
|
118
|
+
update: ['@author_id'] // Shorthand for @author_id == @user_id
|
|
108
119
|
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
147
|
+
## CRUD Operations
|
|
153
148
|
|
|
154
|
-
|
|
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
|
-
###
|
|
151
|
+
### Get - Rich Document
|
|
159
152
|
|
|
160
|
-
|
|
153
|
+
`get` returns a **rich document** with FK and M2M expansions:
|
|
161
154
|
|
|
162
155
|
```typescript
|
|
163
|
-
//
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
174
|
-
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
181
|
+
### Save - Atomic with Side Effects
|
|
175
182
|
|
|
176
|
-
|
|
183
|
+
`save` handles insert/update, M2M sync, and graph rules in one transaction:
|
|
177
184
|
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
```typescript
|
|
186
|
+
// Insert (no id)
|
|
187
|
+
await ws.api.save_posts({ title: 'New', author_id: 1, tag_ids: [1, 2] });
|
|
180
188
|
|
|
181
|
-
//
|
|
182
|
-
|
|
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
|
-
//
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
const store = useVenueDetailStore();
|
|
198
|
+
### Search - Filtered List
|
|
198
199
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
218
|
+
Uses the entity's `label` field for display.
|
|
244
219
|
|
|
245
|
-
|
|
246
|
-
const store = useVenueDetailStore();
|
|
220
|
+
### Delete
|
|
247
221
|
|
|
248
|
-
|
|
249
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
228
|
+
Subscribables define realtime data shapes for UI components.
|
|
268
229
|
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
236
|
+
root: {
|
|
237
|
+
entity: 'venues',
|
|
238
|
+
key: 'venue_id' // Maps param to entity PK
|
|
239
|
+
},
|
|
275
240
|
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
ws.registerStore(table_changed);
|
|
260
|
+
### Generated Types
|
|
290
261
|
|
|
291
|
-
|
|
292
|
-
});
|
|
293
|
-
```
|
|
262
|
+
The compiler generates TypeScript types for subscribables:
|
|
294
263
|
|
|
295
|
-
**User code - just works:**
|
|
296
264
|
```typescript
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
### Entity Notifications
|
|
265
|
+
// Generated in client/ws.ts
|
|
266
|
+
export interface VenueDetailParams {
|
|
267
|
+
venue_id: number;
|
|
268
|
+
}
|
|
303
269
|
|
|
304
|
-
|
|
270
|
+
export interface VenueDetailResult extends Venues {
|
|
271
|
+
org?: Organisations; // Many-to-one (singular)
|
|
272
|
+
sites?: Sites[]; // One-to-many (array)
|
|
273
|
+
}
|
|
305
274
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
282
|
+
## Many-to-Many Relationships
|
|
318
283
|
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
```javascript
|
|
328
|
-
view: ['@org_id->memberships[org_id=$].user_id']
|
|
329
|
-
```
|
|
312
|
+
## Graph Rules (Side Effects)
|
|
330
313
|
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
362
|
+
name: 'calculate_stats',
|
|
358
363
|
sql: `
|
|
359
|
-
CREATE OR REPLACE FUNCTION dzql_v2.
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
registerJsFunction('get_user_dashboard', async (ctx) => {
|
|
387
|
+
registerJsFunction('send_email', async (ctx) => {
|
|
432
388
|
const { userId, params, db } = ctx;
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
394
|
+
## Auth Configuration
|
|
475
395
|
|
|
476
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
635
|
-
import { DzqlNamespace } from 'dzql/namespace';
|
|
636
|
-
import postgres from 'postgres';
|
|
428
|
+
import { ws } from '@generated/client';
|
|
637
429
|
|
|
638
|
-
|
|
639
|
-
const manifest = await import('./dist/runtime/manifest.json');
|
|
430
|
+
await ws.connect('/ws');
|
|
640
431
|
|
|
641
|
-
|
|
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
|
-
###
|
|
438
|
+
### Subscribable Stores
|
|
645
439
|
|
|
646
440
|
```typescript
|
|
647
|
-
|
|
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
|
-
|
|
657
|
-
const
|
|
443
|
+
const store = useVenueDetailStore();
|
|
444
|
+
const { data } = await store.bind({ venue_id: 1 });
|
|
658
445
|
|
|
659
|
-
//
|
|
660
|
-
|
|
446
|
+
// data is reactive - updates automatically on changes
|
|
447
|
+
console.log(data.name, data.org.name, data.sites.length);
|
|
661
448
|
```
|
|
662
449
|
|
|
663
|
-
|
|
450
|
+
## Common Modeling Patterns
|
|
664
451
|
|
|
665
|
-
|
|
452
|
+
### Multi-tenant with Organizations
|
|
666
453
|
|
|
667
454
|
```typescript
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
###
|
|
478
|
+
### Ownership Pattern
|
|
676
479
|
|
|
677
480
|
```typescript
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
###
|
|
684
|
-
|
|
685
|
-
The namespace is designed for use with `invokej`, a CLI tool for invoking functions:
|
|
493
|
+
### Soft Delete
|
|
686
494
|
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|