dzql 0.5.33 → 0.6.0
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/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +293 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +641 -0
- package/docs/project-setup.md +432 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +164 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/create/.env.example +8 -0
- package/src/create/README.md +101 -0
- package/src/create/compose.yml +14 -0
- package/src/create/domain.ts +153 -0
- package/src/create/package.json +24 -0
- package/src/create/server.ts +18 -0
- package/src/create/setup.sh +11 -0
- package/src/create/tsconfig.json +15 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
package/docs/for_ai.md
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# TZQL Guide for AI Assistants
|
|
2
|
+
|
|
3
|
+
This document defines the patterns and conventions for generating valid TZQL domain definitions. Use this guide when asked to "Create a TZQL app" or "Add an entity".
|
|
4
|
+
|
|
5
|
+
## Core Concept: The Domain Definition
|
|
6
|
+
|
|
7
|
+
A TZQL application is defined by a single TypeScript/JavaScript module exporting `entities` and `subscribables`.
|
|
8
|
+
|
|
9
|
+
### 1. Entity Definition Pattern
|
|
10
|
+
|
|
11
|
+
Each key in `entities` maps to a database table.
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
export const entities = {
|
|
15
|
+
// Key = Table Name (snake_case recommended)
|
|
16
|
+
[entity_name]: {
|
|
17
|
+
|
|
18
|
+
// 1. Schema: Standard PostgreSQL types
|
|
19
|
+
// Format: 'type constraints'
|
|
20
|
+
schema: {
|
|
21
|
+
id: 'serial PRIMARY KEY',
|
|
22
|
+
name: 'text NOT NULL',
|
|
23
|
+
org_id: 'int REFERENCES organisations(id) ON DELETE CASCADE', // Always define FK constraints
|
|
24
|
+
created_at: 'timestamptz DEFAULT now()'
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
// 2. Configuration
|
|
28
|
+
label: 'name', // Field used for autocomplete/display
|
|
29
|
+
searchable: ['name', 'description'], // Fields indexed for search
|
|
30
|
+
|
|
31
|
+
// 3. Permissions: Row-Level Security DSL
|
|
32
|
+
// Rules are OR-ed together. If any rule passes, access is granted.
|
|
33
|
+
// Empty array [] = Deny All (Default for strictness)
|
|
34
|
+
// ['TRUE'] = Public Access
|
|
35
|
+
permissions: {
|
|
36
|
+
view: ['@org_id->acts_for[org_id=$]{active}.user_id'], // Complex traversal
|
|
37
|
+
create: ['@author_id == @user_id'], // Simple check
|
|
38
|
+
update: ['@id'], // Implies "User ID matches Record ID" (Owner)
|
|
39
|
+
delete: []
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// 4. Graph Rules: Side Effects & Cascades
|
|
43
|
+
graphRules: {
|
|
44
|
+
// Triggered AFTER successful INSERT
|
|
45
|
+
on_create: {
|
|
46
|
+
action_name: {
|
|
47
|
+
actions: [
|
|
48
|
+
// Database Side Effect
|
|
49
|
+
{
|
|
50
|
+
type: 'create',
|
|
51
|
+
entity: 'notifications',
|
|
52
|
+
data: { user_id: '@user_id', message: 'Welcome' }
|
|
53
|
+
},
|
|
54
|
+
// Async Reactor (External Side Effect via Runtime)
|
|
55
|
+
{
|
|
56
|
+
type: 'reactor',
|
|
57
|
+
name: 'send_email',
|
|
58
|
+
params: { email: '@email' }
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
// Triggered BEFORE DELETE
|
|
64
|
+
on_delete: {
|
|
65
|
+
cascade_cleanup: {
|
|
66
|
+
actions: [
|
|
67
|
+
// Explicit Cascade (if not handled by DB FK)
|
|
68
|
+
{ type: 'delete', target: 'comments', params: { post_id: '@id' } }
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 2. Permission DSL Guide
|
|
78
|
+
|
|
79
|
+
Permissions are compiled to SQL `EXISTS` clauses.
|
|
80
|
+
|
|
81
|
+
* **Variables:**
|
|
82
|
+
* `@user_id`: The authenticated user's ID.
|
|
83
|
+
* `@id`: The Record's ID (or value of `id` column).
|
|
84
|
+
* `@field`: The value of a field in the record (or input data).
|
|
85
|
+
|
|
86
|
+
* **Patterns:**
|
|
87
|
+
* **Self-Ownership:** `'@user_id == @owner_id'` (or just `'@owner_id'` shorthand).
|
|
88
|
+
* **Traversal:** `'@org_id->acts_for[org_id=$]{active}.user_id'`
|
|
89
|
+
* `@org_id`: Start from this field on the current entity.
|
|
90
|
+
* `->acts_for`: Join to `acts_for` table.
|
|
91
|
+
* `[org_id=$]`: Join condition (`acts_for.org_id = current.org_id`).
|
|
92
|
+
* `{active}`: Filter condition (`acts_for.active = true` or temporal check).
|
|
93
|
+
* `.user_id`: Final check (`acts_for.user_id = @user_id`).
|
|
94
|
+
|
|
95
|
+
### 3. Subscribable Definition Pattern (The "Smart Store")
|
|
96
|
+
|
|
97
|
+
Subscribables define the *shape* of the data the client needs. They are more than just queries; they define the **Graph Patching Strategy**.
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
export const subscribables = {
|
|
101
|
+
// Key = Subscription Name
|
|
102
|
+
venue_detail: {
|
|
103
|
+
// 1. Parameters (Inputs)
|
|
104
|
+
params: { venue_id: 'int' },
|
|
105
|
+
|
|
106
|
+
// 2. Root Entity
|
|
107
|
+
root: {
|
|
108
|
+
entity: 'venues',
|
|
109
|
+
key: 'venue_id' // Maps param 'venue_id' to entity PK
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// 3. Graph Structure (Nested Includes)
|
|
113
|
+
includes: {
|
|
114
|
+
// Simple relation
|
|
115
|
+
org: { relation: 'org', entity: 'organisations' },
|
|
116
|
+
|
|
117
|
+
// Nested relation
|
|
118
|
+
sites: {
|
|
119
|
+
relation: 'sites',
|
|
120
|
+
entity: 'sites',
|
|
121
|
+
// Recursive nesting
|
|
122
|
+
includes: {
|
|
123
|
+
allocations: { relation: 'allocations', entity: 'allocations' }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// 4. Scope Tables (Crucial for Realtime)
|
|
129
|
+
// List ALL tables that appear in this graph.
|
|
130
|
+
// The Runtime uses this to route events.
|
|
131
|
+
scopeTables: ['venues', 'organisations', 'sites', 'allocations'],
|
|
132
|
+
|
|
133
|
+
// 5. Subscription Permission
|
|
134
|
+
// Who is allowed to listen to this feed?
|
|
135
|
+
canSubscribe: ['@venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id']
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### When to use what?
|
|
141
|
+
|
|
142
|
+
* **Entities:** Always define entities for every physical table.
|
|
143
|
+
* **Subscribables:** Define a subscribable for every **Screen** or **Major Component** in the UI. (e.g., `dashboard`, `profile`, `item_detail`).
|
|
144
|
+
* *Do not* write manual API fetchers. Use subscribables to generate "Smart Stores" that self-update.
|
|
145
|
+
|
|
146
|
+
### Using Generated Subscribable Stores
|
|
147
|
+
|
|
148
|
+
Each subscribable in your domain generates a Pinia store with this structure:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
// Generated store API
|
|
152
|
+
const store = useVenueDetailStore();
|
|
153
|
+
|
|
154
|
+
store.bind(params) // Async - subscribe and get { data, loading, ready }
|
|
155
|
+
store.unbind(params) // Unsubscribe and remove document from store
|
|
156
|
+
store.documents // Ref - all bound documents keyed by JSON params
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Basic Usage:**
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
163
|
+
|
|
164
|
+
const store = useVenueDetailStore();
|
|
165
|
+
|
|
166
|
+
// bind() returns a Promise that resolves when first data arrives
|
|
167
|
+
const { data, loading, ready } = await store.bind({ venue_id: 1 });
|
|
168
|
+
|
|
169
|
+
// data is now populated - no need to check loading
|
|
170
|
+
console.log(data.name); // 'My Venue'
|
|
171
|
+
console.log(data.org.name); // 'My Org' (nested include)
|
|
172
|
+
console.log(data.sites); // [{...}, {...}] (array of related records)
|
|
173
|
+
|
|
174
|
+
// Subsequent calls with same params return cached subscription immediately
|
|
175
|
+
const cached = await store.bind({ venue_id: 1 }); // No new subscription created
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Vue Component Pattern (with Suspense):**
|
|
179
|
+
|
|
180
|
+
```vue
|
|
181
|
+
<script setup>
|
|
182
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
183
|
+
|
|
184
|
+
const props = defineProps(['venueId']);
|
|
185
|
+
const store = useVenueDetailStore();
|
|
186
|
+
|
|
187
|
+
// Top-level await - component suspends until data arrives
|
|
188
|
+
const { data } = await store.bind({ venue_id: props.venueId });
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<template>
|
|
192
|
+
<h1>{{ data.name }}</h1>
|
|
193
|
+
<p>Org: {{ data.org.name }}</p>
|
|
194
|
+
<ul>
|
|
195
|
+
<li v-for="site in data.sites" :key="site.id">
|
|
196
|
+
{{ site.name }}
|
|
197
|
+
</li>
|
|
198
|
+
</ul>
|
|
199
|
+
</template>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Vue Component Pattern (with loading state):**
|
|
203
|
+
|
|
204
|
+
```vue
|
|
205
|
+
<script setup>
|
|
206
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
207
|
+
import { ref, onMounted, watch } from 'vue';
|
|
208
|
+
|
|
209
|
+
const props = defineProps(['venueId']);
|
|
210
|
+
const store = useVenueDetailStore();
|
|
211
|
+
const doc = ref({ data: null, loading: true });
|
|
212
|
+
|
|
213
|
+
onMounted(async () => {
|
|
214
|
+
doc.value = await store.bind({ venue_id: props.venueId });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Re-bind when venueId changes
|
|
218
|
+
watch(() => props.venueId, async (newId) => {
|
|
219
|
+
doc.value = await store.bind({ venue_id: newId });
|
|
220
|
+
});
|
|
221
|
+
</script>
|
|
222
|
+
|
|
223
|
+
<template>
|
|
224
|
+
<div v-if="doc.loading">Loading...</div>
|
|
225
|
+
<template v-else>
|
|
226
|
+
<h1>{{ doc.data.name }}</h1>
|
|
227
|
+
</template>
|
|
228
|
+
</template>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Accessing Multiple Subscriptions:**
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
const store = useVenueDetailStore();
|
|
235
|
+
|
|
236
|
+
// Each unique params set creates a separate subscription
|
|
237
|
+
await store.bind({ venue_id: 1 });
|
|
238
|
+
await store.bind({ venue_id: 2 });
|
|
239
|
+
|
|
240
|
+
// Access all documents via store.documents
|
|
241
|
+
for (const [key, docState] of Object.entries(store.documents)) {
|
|
242
|
+
console.log(key, docState.data);
|
|
243
|
+
}
|
|
244
|
+
// '{"venue_id":1}' { id: 1, name: 'Venue 1', ... }
|
|
245
|
+
// '{"venue_id":2}' { id: 2, name: 'Venue 2', ... }
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Key Points:**
|
|
249
|
+
- `bind()` is async and awaits first data before returning
|
|
250
|
+
- Same params = same cached subscription (deduplication by JSON key)
|
|
251
|
+
- The `ready` Promise is stored for repeat callers to await
|
|
252
|
+
- **Stores own their data** - the WebSocket is just transport
|
|
253
|
+
- Realtime patches are applied by the store's `applyPatch()` function
|
|
254
|
+
- Data is reactive - changes trigger Vue reactivity automatically
|
|
255
|
+
- The store routes patch events by table name to the correct location in the document graph
|
|
256
|
+
|
|
257
|
+
### Common Patterns
|
|
258
|
+
|
|
259
|
+
**1. The "Owner" Pattern:**
|
|
260
|
+
```javascript
|
|
261
|
+
create: ['@author_id == @user_id']
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**2. The "Organization Member" Pattern:**
|
|
265
|
+
```javascript
|
|
266
|
+
view: ['@org_id->memberships[org_id=$].user_id']
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**3. The "Creator Side Effect" Pattern:**
|
|
270
|
+
Use `graphRules.on_create` to create related records automatically (e.g., creating a default 'Settings' record when a 'User' is created).
|
|
271
|
+
|
|
272
|
+
**4. The "Reactor" Pattern:**
|
|
273
|
+
Use `type: 'reactor'` for anything that requires Node.js (Email, Stripe, AI processing). Do not try to do complex logic in SQL.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Custom Functions
|
|
278
|
+
|
|
279
|
+
TZQL supports two types of custom functions that can be called via RPC:
|
|
280
|
+
|
|
281
|
+
### 1. SQL Custom Functions
|
|
282
|
+
|
|
283
|
+
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.
|
|
284
|
+
|
|
285
|
+
**Domain Definition:**
|
|
286
|
+
|
|
287
|
+
```javascript
|
|
288
|
+
// domain.js
|
|
289
|
+
export const entities = { /* ... */ };
|
|
290
|
+
export const subscribables = { /* ... */ };
|
|
291
|
+
|
|
292
|
+
// Add custom SQL functions
|
|
293
|
+
export const customFunctions = [
|
|
294
|
+
{
|
|
295
|
+
name: 'calculate_org_stats',
|
|
296
|
+
sql: `
|
|
297
|
+
CREATE OR REPLACE FUNCTION dzql_v2.calculate_org_stats(p_user_id int, p_params jsonb)
|
|
298
|
+
RETURNS jsonb LANGUAGE plpgsql AS $$
|
|
299
|
+
DECLARE
|
|
300
|
+
v_org_id int;
|
|
301
|
+
v_venue_count int;
|
|
302
|
+
v_total_revenue numeric;
|
|
303
|
+
BEGIN
|
|
304
|
+
v_org_id := (p_params->>'org_id')::int;
|
|
305
|
+
|
|
306
|
+
-- Permission check (optional but recommended)
|
|
307
|
+
IF NOT EXISTS (
|
|
308
|
+
SELECT 1 FROM acts_for
|
|
309
|
+
WHERE user_id = p_user_id AND org_id = v_org_id AND active = true
|
|
310
|
+
) THEN
|
|
311
|
+
RAISE EXCEPTION 'permission_denied' USING ERRCODE = 'P0001';
|
|
312
|
+
END IF;
|
|
313
|
+
|
|
314
|
+
SELECT COUNT(*) INTO v_venue_count
|
|
315
|
+
FROM venues WHERE org_id = v_org_id;
|
|
316
|
+
|
|
317
|
+
SELECT COALESCE(SUM(amount), 0) INTO v_total_revenue
|
|
318
|
+
FROM orders WHERE org_id = v_org_id;
|
|
319
|
+
|
|
320
|
+
RETURN jsonb_build_object(
|
|
321
|
+
'org_id', v_org_id,
|
|
322
|
+
'venue_count', v_venue_count,
|
|
323
|
+
'total_revenue', v_total_revenue
|
|
324
|
+
);
|
|
325
|
+
END;
|
|
326
|
+
$$;
|
|
327
|
+
`,
|
|
328
|
+
args: ['p_user_id', 'p_params'] // Optional, defaults to these
|
|
329
|
+
}
|
|
330
|
+
];
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**Calling from Client:**
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// The function is automatically added to the client SDK
|
|
337
|
+
const stats = await ws.api.calculate_org_stats({ org_id: 1 });
|
|
338
|
+
console.log(stats.venue_count, stats.total_revenue);
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Key Points:**
|
|
342
|
+
- Functions must be in the `dzql_v2` schema
|
|
343
|
+
- Standard signature: `(p_user_id int, p_params jsonb) RETURNS jsonb`
|
|
344
|
+
- Automatically added to the manifest allowlist (security)
|
|
345
|
+
- Compiled into the database migrations
|
|
346
|
+
|
|
347
|
+
### 2. JavaScript Custom Functions
|
|
348
|
+
|
|
349
|
+
JavaScript custom functions run in the Bun/Node runtime. They are ideal for:
|
|
350
|
+
- External API calls (Stripe, SendGrid, etc.)
|
|
351
|
+
- Complex business logic that's easier in JS than SQL
|
|
352
|
+
- Operations that need access to environment variables or external services
|
|
353
|
+
|
|
354
|
+
**Registration (in your server startup):**
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
// server.ts or wherever you start your runtime
|
|
358
|
+
import { registerJsFunction } from 'tzql/runtime';
|
|
359
|
+
|
|
360
|
+
// Simple function
|
|
361
|
+
registerJsFunction('hello_world', async (ctx) => {
|
|
362
|
+
return {
|
|
363
|
+
message: `Hello, User ${ctx.userId}!`,
|
|
364
|
+
timestamp: new Date().toISOString()
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Function with database access
|
|
369
|
+
registerJsFunction('get_user_dashboard', async (ctx) => {
|
|
370
|
+
const { userId, params, db } = ctx;
|
|
371
|
+
|
|
372
|
+
// Query the database
|
|
373
|
+
const orgs = await db.query(
|
|
374
|
+
'SELECT o.* FROM organisations o JOIN acts_for af ON o.id = af.org_id WHERE af.user_id = $1 AND af.active = true',
|
|
375
|
+
[userId]
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const venues = await db.query(
|
|
379
|
+
'SELECT * FROM venues WHERE org_id = ANY($1)',
|
|
380
|
+
[orgs.map(o => o.id)]
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
organizations: orgs,
|
|
385
|
+
venues: venues,
|
|
386
|
+
total_venues: venues.length
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Function calling external API
|
|
391
|
+
registerJsFunction('send_notification', async (ctx) => {
|
|
392
|
+
const { userId, params } = ctx;
|
|
393
|
+
|
|
394
|
+
// Call external service
|
|
395
|
+
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
headers: {
|
|
398
|
+
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
|
|
399
|
+
'Content-Type': 'application/json'
|
|
400
|
+
},
|
|
401
|
+
body: JSON.stringify({
|
|
402
|
+
to: params.email,
|
|
403
|
+
subject: params.subject,
|
|
404
|
+
content: params.message
|
|
405
|
+
})
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return { success: response.ok };
|
|
409
|
+
});
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Calling from Client:**
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
// JS functions are called the same way as SQL functions
|
|
416
|
+
const dashboard = await ws.api.get_user_dashboard({});
|
|
417
|
+
const result = await ws.api.send_notification({
|
|
418
|
+
email: 'user@example.com',
|
|
419
|
+
subject: 'Hello',
|
|
420
|
+
message: 'Welcome!'
|
|
421
|
+
});
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**Context Object:**
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
interface JsFunctionContext {
|
|
428
|
+
userId: number; // Authenticated user's ID
|
|
429
|
+
params: any; // Parameters passed from client
|
|
430
|
+
db: {
|
|
431
|
+
query(sql: string, params?: any[]): Promise<any[]>; // Database access
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Key Points:**
|
|
437
|
+
- JS functions take precedence over SQL functions with the same name
|
|
438
|
+
- No manifest entry needed - registration is enough
|
|
439
|
+
- Full access to Node.js/Bun APIs (fetch, fs, etc.)
|
|
440
|
+
- Can query the database via `ctx.db.query()`
|
|
441
|
+
- Errors thrown are propagated to the client
|
|
442
|
+
|
|
443
|
+
### When to Use Which?
|
|
444
|
+
|
|
445
|
+
| Use Case | SQL | JavaScript |
|
|
446
|
+
|----------|-----|------------|
|
|
447
|
+
| Complex aggregations | ✅ | |
|
|
448
|
+
| Data transformations | ✅ | |
|
|
449
|
+
| External API calls | | ✅ |
|
|
450
|
+
| Email/SMS notifications | | ✅ |
|
|
451
|
+
| File processing | | ✅ |
|
|
452
|
+
| Payment processing | | ✅ |
|
|
453
|
+
| Performance-critical queries | ✅ | |
|
|
454
|
+
| Access to env variables | | ✅ |
|
|
455
|
+
| Multi-step transactions | ✅ | |
|
|
456
|
+
| Real-time calculations | ✅ | |
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Unmanaged Entities (Junction Tables)
|
|
461
|
+
|
|
462
|
+
For junction tables used in many-to-many relationships, you typically don't want TZQL to generate CRUD functions. These tables are managed via the M2M relationship on the parent entity.
|
|
463
|
+
|
|
464
|
+
Use `managed: false` to skip CRUD generation:
|
|
465
|
+
|
|
466
|
+
```javascript
|
|
467
|
+
export const entities = {
|
|
468
|
+
brands: {
|
|
469
|
+
schema: {
|
|
470
|
+
id: 'serial PRIMARY KEY',
|
|
471
|
+
name: 'text NOT NULL'
|
|
472
|
+
},
|
|
473
|
+
// M2M relationship manages brand_tags automatically
|
|
474
|
+
manyToMany: {
|
|
475
|
+
tags: {
|
|
476
|
+
junctionTable: 'brand_tags',
|
|
477
|
+
localKey: 'brand_id',
|
|
478
|
+
foreignKey: 'tag_id',
|
|
479
|
+
targetEntity: 'tags',
|
|
480
|
+
idField: 'tag_ids'
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
tags: {
|
|
486
|
+
schema: {
|
|
487
|
+
id: 'serial PRIMARY KEY',
|
|
488
|
+
name: 'text NOT NULL'
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
// Junction table - no CRUD functions generated
|
|
493
|
+
brand_tags: {
|
|
494
|
+
schema: {
|
|
495
|
+
brand_id: 'int NOT NULL REFERENCES brands(id) ON DELETE CASCADE',
|
|
496
|
+
tag_id: 'int NOT NULL REFERENCES tags(id) ON DELETE CASCADE'
|
|
497
|
+
},
|
|
498
|
+
primaryKey: ['brand_id', 'tag_id'],
|
|
499
|
+
managed: false // Skip CRUD generation
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**What `managed: false` does:**
|
|
505
|
+
- The table schema is still created in the database
|
|
506
|
+
- No `get_brand_tags`, `save_brand_tags`, `delete_brand_tags`, etc. functions are generated
|
|
507
|
+
- No manifest entries for CRUD operations
|
|
508
|
+
- The junction table is managed via the parent entity's M2M operations
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Client Connection & Authentication
|
|
513
|
+
|
|
514
|
+
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).
|
|
515
|
+
|
|
516
|
+
### Connection Flow
|
|
517
|
+
|
|
518
|
+
1. Client connects with optional `?token=...` in URL
|
|
519
|
+
2. Server validates token (if present) and fetches user profile
|
|
520
|
+
3. Server sends: `{"method": "connection:ready", "params": {"user": {...} | null}}`
|
|
521
|
+
4. Client knows auth state immediately, can render accordingly
|
|
522
|
+
|
|
523
|
+
### WebSocketManager API
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
import { WebSocketManager } from 'tzql/client';
|
|
527
|
+
|
|
528
|
+
const ws = new WebSocketManager();
|
|
529
|
+
await ws.connect('ws://localhost:3000/ws');
|
|
530
|
+
|
|
531
|
+
// Connection state properties
|
|
532
|
+
ws.ready // boolean - true after connection:ready received
|
|
533
|
+
ws.user // user profile object or null if anonymous
|
|
534
|
+
|
|
535
|
+
// Register callback for ready state (called immediately if already ready)
|
|
536
|
+
const unsubscribe = ws.onReady((user) => {
|
|
537
|
+
if (user) {
|
|
538
|
+
console.log('Authenticated as:', user.email);
|
|
539
|
+
} else {
|
|
540
|
+
console.log('Anonymous connection');
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Authentication methods
|
|
545
|
+
await ws.login({ email: '...', password: '...' }); // Stores token in localStorage
|
|
546
|
+
await ws.register({ email: '...', password: '...' }); // Stores token in localStorage
|
|
547
|
+
await ws.logout(); // Clears token, user state, and reconnects
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Vue/Pinia Usage Pattern
|
|
551
|
+
|
|
552
|
+
```vue
|
|
553
|
+
<template>
|
|
554
|
+
<div v-if="!ws.ready">Loading...</div>
|
|
555
|
+
<LoginModal v-else-if="!ws.user" />
|
|
556
|
+
<RouterView v-else />
|
|
557
|
+
</template>
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### Why This Matters
|
|
561
|
+
|
|
562
|
+
- **Single source of truth:** WebSocket connection determines auth state, not localStorage
|
|
563
|
+
- **No race conditions:** UI waits for `connection:ready` before rendering
|
|
564
|
+
- **Simpler client code:** No need for separate auth check after connect
|
|
565
|
+
- **Better UX:** App shows loading state until connection ready, then immediately correct view
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## CLI Integration with Namespace
|
|
570
|
+
|
|
571
|
+
TZQL provides a namespace export for CLI tools like `invokej` to interact with the database directly without going through the WebSocket runtime.
|
|
572
|
+
|
|
573
|
+
### Setup
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
// cli.ts or server-side script
|
|
577
|
+
import { TzqlNamespace } from 'tzql/namespace';
|
|
578
|
+
import postgres from 'postgres';
|
|
579
|
+
|
|
580
|
+
const sql = postgres(process.env.DATABASE_URL);
|
|
581
|
+
const manifest = await import('./dist/runtime/manifest.json');
|
|
582
|
+
|
|
583
|
+
const tzql = new TzqlNamespace(sql, manifest);
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### CRUD Operations
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
// Get a record
|
|
590
|
+
const venue = await tzql.get('venues', { id: 1 }, userId);
|
|
591
|
+
|
|
592
|
+
// Save (create or update)
|
|
593
|
+
const newVenue = await tzql.save('venues', { name: 'New Venue', org_id: 1 }, userId);
|
|
594
|
+
|
|
595
|
+
// Delete
|
|
596
|
+
const deleted = await tzql.delete('venues', { id: 1 }, userId);
|
|
597
|
+
|
|
598
|
+
// Search with filters
|
|
599
|
+
const venues = await tzql.search('venues', { org_id: 1, limit: 10 }, userId);
|
|
600
|
+
|
|
601
|
+
// Lookup for autocomplete
|
|
602
|
+
const options = await tzql.lookup('venues', { q: 'test' }, userId);
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Ad-hoc Function Calls
|
|
606
|
+
|
|
607
|
+
Call any function in the manifest directly:
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
// Call a custom function
|
|
611
|
+
const result = await tzql.call('calculate_org_stats', { org_id: 1 }, userId);
|
|
612
|
+
|
|
613
|
+
// Call a subscribable getter
|
|
614
|
+
const detail = await tzql.call('get_venue_detail', { venue_id: 1 }, userId);
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### List Available Functions
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
// Get all functions from manifest
|
|
621
|
+
const functions = tzql.functions();
|
|
622
|
+
// Returns: ['login_user', 'register_user', 'get_venues', 'save_venues', ...]
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
### Use with invokej
|
|
626
|
+
|
|
627
|
+
The namespace is designed for use with `invokej`, a CLI tool for invoking functions:
|
|
628
|
+
|
|
629
|
+
```bash
|
|
630
|
+
# In your invokej configuration, register the TZQL namespace
|
|
631
|
+
invokej tzql:get venues '{"id": 1}'
|
|
632
|
+
invokej tzql:save venues '{"name": "Updated Venue", "id": 1}'
|
|
633
|
+
invokej tzql:call calculate_org_stats '{"org_id": 1}'
|
|
634
|
+
invokej tzql:functions
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
**Key Points:**
|
|
638
|
+
- All operations respect the same permissions as the WebSocket runtime
|
|
639
|
+
- The `userId` parameter is required for permission checks
|
|
640
|
+
- Operations are atomic (single transaction)
|
|
641
|
+
- Results are returned as JSON objects
|