dzql 0.5.33 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +309 -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 +653 -0
- package/docs/project-setup.md +456 -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 +166 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -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/README.md
CHANGED
|
@@ -1,54 +1,327 @@
|
|
|
1
|
-
# DZQL
|
|
1
|
+
# DZQL: The Compile-Only Realtime Database Framework
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DZQL ("Database Zero Query Language") is a PostgreSQL-native framework for building realtime, reactive applications without the runtime overhead or complexity of traditional ORMs or BaaS solutions.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick Start
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The fastest way to get started is with `bun create`:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
```
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
This creates a full-stack app with Vue/Vite frontend, DZQL server, and PostgreSQL database.
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
## The Problem
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **[Client Stores](guides/client-stores.md)** - Pinia store patterns for Vue.js
|
|
21
|
+
Building realtime apps is hard. You typically have to:
|
|
22
|
+
1. **Sync State:** Manually keep your frontend Pinia/Redux store in sync with your backend database.
|
|
23
|
+
2. **Manage Permissions:** Re-implement row-level security in your API layer (and hope it matches your DB).
|
|
24
|
+
3. **Handle Atomicity:** Ensure that complex operations (e.g., "Create Order + Reserve Inventory + Notify User") happen in a single transaction.
|
|
25
|
+
4. **Optimistic Updates:** Write complex client-side logic to "guess" the server's response, often leading to data divergence.
|
|
23
26
|
|
|
24
|
-
##
|
|
27
|
+
## The DZQL Solution
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
DZQL takes a radically different approach: **Compilation**.
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
- **[Client API](reference/client.md)** - WebSocket client and connection management
|
|
30
|
-
- **[Compiler](compiler/)** - Entity compilation, code generation, and coding standards
|
|
31
|
+
Instead of a heavy runtime framework, you define your **Domain Schema** (Entities, Relationships, Permissions) in a simple TypeScript configuration. DZQL compiles this definition into:
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
1. **Optimized SQL:** Specialized PostgreSQL functions (`save_order`, `get_product`) with *inlined* permission checks and *atomic* graph operations.
|
|
34
|
+
2. **Type-Safe Client SDK:** A generated TypeScript client that knows your exact API surface.
|
|
35
|
+
3. **Smart Pinia Stores:** Generated Vue stores that automatically handle realtime synchronization using atomic "Patch Events" from the database.
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
- [Advanced Filters](compiler/ADVANCED_FILTERS.md) - Complex search operators
|
|
36
|
-
- [Coding Standards](compiler/CODING_STANDARDS.md) - Best practices for DZQL code
|
|
37
|
-
- [Comparison](compiler/COMPARISON.md) - Runtime vs compiled side-by-side
|
|
37
|
+
### Key Features
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
* **Zero Runtime Interpretation:** No slow ORM query builders. Everything is compiled to native PL/pgSQL.
|
|
40
|
+
* **Security by Construction:** The runtime is a "dumb" gateway that routes requests by OID allowlist. It *cannot* execute arbitrary SQL.
|
|
41
|
+
* **Atomic Everything:** Complex graph operations (cascading creates/deletes) happen in a single database transaction.
|
|
42
|
+
* **Realtime by Default:** Every database write emits an atomic event batch. The client SDK automatically patches your local state. No "refetching" required.
|
|
43
|
+
* **JavaScript/TypeScript Native:** Define your schema in code you understand, get full type safety end-to-end.
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
## Manual Setup
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
If you prefer to set up manually instead of using `bun create dzql`:
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
- [GitHub Repository](https://github.com/blueshed/dzql)
|
|
47
|
-
- [Issue Tracker](https://github.com/blueshed/dzql/issues)
|
|
49
|
+
### 1. Define your Domain (`domain.ts`)
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
```typescript
|
|
52
|
+
export const entities = {
|
|
53
|
+
posts: {
|
|
54
|
+
schema: { id: 'serial PRIMARY KEY', title: 'text', author_id: 'int' },
|
|
55
|
+
permissions: { create: ['@author_id == @user_id'] } // Inlined SQL security
|
|
56
|
+
}
|
|
57
|
+
};
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
export const subscribables = {
|
|
60
|
+
post_feed: {
|
|
61
|
+
root: { entity: 'posts' },
|
|
62
|
+
scopeTables: ['posts']
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Compile
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bunx dzql domain.ts -o generated
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. Use in Client
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { usePostFeedStore } from '@generated/client/stores';
|
|
77
|
+
|
|
78
|
+
const feed = usePostFeedStore();
|
|
79
|
+
// Automatically fetches data AND subscribes to realtime updates
|
|
80
|
+
// bind() is async - awaits until first data arrives
|
|
81
|
+
const { data, loading } = await feed.bind({ user_id: 1 });
|
|
82
|
+
// data.value is now populated
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Architecture
|
|
86
|
+
|
|
87
|
+
* **Compiler:** CLI tool that analyzes your domain and generates artifacts.
|
|
88
|
+
* **Runtime:** A lightweight Bun/Node server that handles Auth (JWT) and WebSocket connection pooling.
|
|
89
|
+
* **Client:** A robust WebSocket SDK that manages reconnection and dispatches atomic patches to stores.
|
|
90
|
+
* **Namespace:** Direct database access for CLI tools like `invokej`.
|
|
91
|
+
|
|
92
|
+
## Package Exports
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { ... } from 'dzql'; // Runtime server
|
|
96
|
+
import { ... } from 'dzql/client'; // WebSocket client SDK
|
|
97
|
+
import { ... } from 'dzql/compiler'; // CLI compiler
|
|
98
|
+
import { DzqlNamespace } from 'dzql/namespace'; // CLI/invokej integration
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Client Connection & Authentication
|
|
102
|
+
|
|
103
|
+
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).
|
|
104
|
+
|
|
105
|
+
### Connection Flow
|
|
106
|
+
|
|
107
|
+
1. Client connects with optional `?token=...` in URL
|
|
108
|
+
2. Server validates token (if present) and fetches user profile
|
|
109
|
+
3. Server sends: `{"method": "connection:ready", "params": {"user": {...} | null}}`
|
|
110
|
+
4. Client knows auth state immediately
|
|
111
|
+
|
|
112
|
+
### Client API
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { WebSocketManager } from 'dzql/client';
|
|
116
|
+
|
|
117
|
+
const ws = new WebSocketManager();
|
|
118
|
+
await ws.connect('ws://localhost:3000/ws');
|
|
119
|
+
|
|
120
|
+
// Check connection state
|
|
121
|
+
console.log(ws.ready); // true after connection:ready received
|
|
122
|
+
console.log(ws.user); // user profile object or null
|
|
123
|
+
|
|
124
|
+
// Register callback for ready state
|
|
125
|
+
ws.onReady((user) => {
|
|
126
|
+
if (user) {
|
|
127
|
+
console.log('Authenticated as:', user.email);
|
|
128
|
+
} else {
|
|
129
|
+
console.log('Anonymous connection');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Authentication methods
|
|
134
|
+
await ws.login({ email: '...', password: '...' }); // Stores token in localStorage
|
|
135
|
+
await ws.logout(); // Clears token and user state
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Vue/Pinia Usage Pattern
|
|
139
|
+
|
|
140
|
+
```vue
|
|
141
|
+
<template>
|
|
142
|
+
<div v-if="!ws.ready">Loading...</div>
|
|
143
|
+
<LoginModal v-else-if="!ws.user" />
|
|
144
|
+
<RouterView v-else />
|
|
145
|
+
</template>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Generated Pinia Subscribable Stores
|
|
149
|
+
|
|
150
|
+
TZQL generates Pinia stores for each subscribable that handle:
|
|
151
|
+
- Initial data fetch via WebSocket subscription
|
|
152
|
+
- Automatic realtime patching when related data changes
|
|
153
|
+
- Deduplication of subscriptions by parameter key
|
|
154
|
+
|
|
155
|
+
### Store Structure
|
|
156
|
+
|
|
157
|
+
Each generated store exports:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
const store = useVenueDetailStore();
|
|
161
|
+
|
|
162
|
+
// Main API
|
|
163
|
+
store.bind(params) // Async - subscribes and returns { data, loading, ready }
|
|
164
|
+
store.unbind(params) // Unsubscribes and removes document from store
|
|
165
|
+
store.documents // Ref containing all bound documents keyed by JSON.stringify(params)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Basic Usage
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
172
|
+
|
|
173
|
+
const store = useVenueDetailStore();
|
|
174
|
+
|
|
175
|
+
// bind() is async - returns when first data arrives
|
|
176
|
+
const { data, loading, ready } = await store.bind({ venue_id: 1 });
|
|
177
|
+
|
|
178
|
+
// data is reactive and contains the document
|
|
179
|
+
console.log(data); // { id: 1, name: 'My Venue', sites: [...], org: {...} }
|
|
180
|
+
|
|
181
|
+
// loading is false after first data
|
|
182
|
+
console.log(loading); // false
|
|
183
|
+
|
|
184
|
+
// Subsequent calls with same params return cached subscription
|
|
185
|
+
const same = await store.bind({ venue_id: 1 }); // Returns immediately, no new subscription
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Vue Component Patterns
|
|
189
|
+
|
|
190
|
+
**Pattern 1: Top-level await (recommended with `<Suspense>`)**
|
|
191
|
+
|
|
192
|
+
```vue
|
|
193
|
+
<script setup>
|
|
194
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
195
|
+
|
|
196
|
+
const props = defineProps(['venueId']);
|
|
197
|
+
const store = useVenueDetailStore();
|
|
198
|
+
|
|
199
|
+
// Await at top level - component suspends until data arrives
|
|
200
|
+
const { data } = await store.bind({ venue_id: props.venueId });
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<!-- data is guaranteed to be populated -->
|
|
205
|
+
<h1>{{ data.name }}</h1>
|
|
206
|
+
<p>Organization: {{ data.org.name }}</p>
|
|
207
|
+
<ul>
|
|
208
|
+
<li v-for="site in data.sites" :key="site.id">
|
|
209
|
+
{{ site.name }} ({{ site.allocations.length }} allocations)
|
|
210
|
+
</li>
|
|
211
|
+
</ul>
|
|
212
|
+
</template>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Pattern 2: Reactive binding with loading state**
|
|
216
|
+
|
|
217
|
+
```vue
|
|
218
|
+
<script setup>
|
|
219
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
220
|
+
import { ref, onMounted, watch } from 'vue';
|
|
221
|
+
|
|
222
|
+
const props = defineProps(['venueId']);
|
|
223
|
+
const store = useVenueDetailStore();
|
|
224
|
+
const docState = ref({ data: null, loading: true });
|
|
225
|
+
|
|
226
|
+
onMounted(async () => {
|
|
227
|
+
docState.value = await store.bind({ venue_id: props.venueId });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Re-bind when venueId changes
|
|
231
|
+
watch(() => props.venueId, async (newId) => {
|
|
232
|
+
docState.value = await store.bind({ venue_id: newId });
|
|
233
|
+
});
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<template>
|
|
237
|
+
<div v-if="docState.loading">Loading...</div>
|
|
238
|
+
<div v-else>
|
|
239
|
+
<h1>{{ docState.data.name }}</h1>
|
|
240
|
+
</div>
|
|
241
|
+
</template>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Pattern 3: Multiple subscriptions**
|
|
245
|
+
|
|
246
|
+
```vue
|
|
247
|
+
<script setup>
|
|
248
|
+
import { useVenueDetailStore } from '@/generated/client/stores';
|
|
249
|
+
|
|
250
|
+
const store = useVenueDetailStore();
|
|
251
|
+
|
|
252
|
+
// Bind multiple venues - each gets its own cached subscription
|
|
253
|
+
const venues = await Promise.all([
|
|
254
|
+
store.bind({ venue_id: 1 }),
|
|
255
|
+
store.bind({ venue_id: 2 }),
|
|
256
|
+
store.bind({ venue_id: 3 }),
|
|
257
|
+
]);
|
|
258
|
+
</script>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Accessing All Documents
|
|
262
|
+
|
|
263
|
+
The store's `documents` ref contains all bound subscriptions:
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const store = useVenueDetailStore();
|
|
267
|
+
|
|
268
|
+
await store.bind({ venue_id: 1 });
|
|
269
|
+
await store.bind({ venue_id: 2 });
|
|
270
|
+
|
|
271
|
+
// Access all documents
|
|
272
|
+
console.log(store.documents);
|
|
273
|
+
// {
|
|
274
|
+
// '{"venue_id":1}': { data: {...}, loading: false, ready: Promise },
|
|
275
|
+
// '{"venue_id":2}': { data: {...}, loading: false, ready: Promise }
|
|
276
|
+
// }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### How Realtime Works
|
|
280
|
+
|
|
281
|
+
The WebSocket is a pure transport layer - it does not manage or cache data. Stores own their data.
|
|
282
|
+
|
|
283
|
+
When data changes in the database:
|
|
284
|
+
1. PostgreSQL triggers emit atomic events to the `dzql_v2.events` table
|
|
285
|
+
2. The runtime calls `*_affected_keys()` to find which subscriptions are affected
|
|
286
|
+
3. Events are broadcast to subscribed clients via WebSocket
|
|
287
|
+
4. The **store's** `subscription_event` function receives the event
|
|
288
|
+
5. The store's `applyPatch` function updates its local document in-place
|
|
289
|
+
6. Vue reactivity updates the UI automatically
|
|
290
|
+
|
|
291
|
+
**No refetching required** - changes are applied incrementally via patching.
|
|
292
|
+
|
|
293
|
+
### Patch Events
|
|
294
|
+
|
|
295
|
+
The store receives events with this structure:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
{
|
|
299
|
+
table: 'sites', // Which table changed
|
|
300
|
+
op: 'insert' | 'update' | 'delete',
|
|
301
|
+
pk: { id: 123 }, // Primary key of affected row
|
|
302
|
+
data: { ... } // Full row data
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The generated `applyPatch` function routes events to the correct location in the document graph based on the subscribable's `includes` structure.
|
|
307
|
+
|
|
308
|
+
## Why "Compile-Only"?
|
|
309
|
+
|
|
310
|
+
By moving complexity to build-time, we get:
|
|
311
|
+
* **Performance:** The database does the heavy lifting.
|
|
312
|
+
* **Correctness:** If it compiles, your permissions and relationships are valid.
|
|
313
|
+
* **Simplicity:** The runtime is tiny and easy to audit.
|
|
314
|
+
|
|
315
|
+
## Entity Options
|
|
316
|
+
|
|
317
|
+
| Option | Type | Description |
|
|
318
|
+
|--------|------|-------------|
|
|
319
|
+
| `schema` | object | Column definitions with PostgreSQL types |
|
|
320
|
+
| `primaryKey` | string[] | Composite primary key fields (default: `['id']`) |
|
|
321
|
+
| `label` | string | Field used for display/autocomplete |
|
|
322
|
+
| `softDelete` | boolean | Use `deleted_at` instead of hard delete |
|
|
323
|
+
| `managed` | boolean | Set to `false` to skip CRUD generation (for junction tables) |
|
|
324
|
+
| `permissions` | object | Row-level security rules |
|
|
325
|
+
| `graphRules` | object | Side effects on create/update/delete |
|
|
326
|
+
| `manyToMany` | object | M2M relationship definitions |
|
|
327
|
+
| `fieldDefaults` | object | Default values for fields on INSERT |
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# tzql Bug Report
|
|
2
|
+
|
|
3
|
+
## Generated Store applyPatch Doesn't Match Data Structure
|
|
4
|
+
|
|
5
|
+
**Severity:** High (Breaking)
|
|
6
|
+
|
|
7
|
+
**Status:** ✅ FIXED
|
|
8
|
+
|
|
9
|
+
**Description:**
|
|
10
|
+
The generated Pinia store's `applyPatch` function assumes a flat data structure, but the subscribable SQL was returning nested wrapper objects.
|
|
11
|
+
|
|
12
|
+
**SQL was returning:**
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"venues": {...},
|
|
16
|
+
"sites": [
|
|
17
|
+
{ "sites": { "id": 1, "name": "..." }, "allocations": [...] },
|
|
18
|
+
{ "sites": { "id": 2, "name": "..." }, "allocations": [...] }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Fix:**
|
|
24
|
+
Updated `subscribable_sql.ts` to use JSONB concatenation (`||`) to merge entity fields with nested includes into a flat structure:
|
|
25
|
+
|
|
26
|
+
```sql
|
|
27
|
+
SELECT jsonb_agg(
|
|
28
|
+
row_to_json(rel.*) || jsonb_build_object(
|
|
29
|
+
'allocations', COALESCE((
|
|
30
|
+
SELECT jsonb_agg(row_to_json(nested.*))
|
|
31
|
+
FROM allocations nested
|
|
32
|
+
WHERE nested.site_id = rel.id
|
|
33
|
+
), '[]'::jsonb))
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**SQL now returns:**
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"venues": {...},
|
|
41
|
+
"sites": [
|
|
42
|
+
{ "id": 1, "name": "...", "allocations": [...] },
|
|
43
|
+
{ "id": 2, "name": "...", "allocations": [...] }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This flat structure matches what `applyPatch` and `handleArrayPatch` expect, so realtime updates now work correctly for nested includes.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Bug 2: json/jsonb Type Mismatch in Subscribable SQL
|
|
53
|
+
|
|
54
|
+
**Severity:** Critical (Blocking)
|
|
55
|
+
|
|
56
|
+
**Status:** ✅ FIXED
|
|
57
|
+
|
|
58
|
+
**Description:**
|
|
59
|
+
The generated subscribable SQL used `row_to_json()` which returns `json` type, but concatenated it with `jsonb_build_object()` which returns `jsonb`. PostgreSQL cannot concatenate these types.
|
|
60
|
+
|
|
61
|
+
**Generated SQL (before):**
|
|
62
|
+
```sql
|
|
63
|
+
row_to_json(rel.*) || jsonb_build_object(...)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Error:**
|
|
67
|
+
```
|
|
68
|
+
operator does not exist: json || jsonb
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Fix:**
|
|
72
|
+
Changed to use `to_jsonb()` instead of `row_to_json()` when concatenating with nested includes:
|
|
73
|
+
|
|
74
|
+
```sql
|
|
75
|
+
to_jsonb(rel.*) || jsonb_build_object(...)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Environment
|
|
81
|
+
|
|
82
|
+
- tzql version: local development (linked)
|
|
83
|
+
- Database: PostgreSQL 17 (Docker)
|
|
84
|
+
- Client: Vue 3 + Pinia + TypeScript
|
|
85
|
+
- Runtime: Bun
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Feature Request: Send User Profile on WebSocket Connect
|
|
2
|
+
|
|
3
|
+
**Status: IMPLEMENTED**
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
When a client connects to the tzql WebSocket server, the server should immediately send a connection:ready message containing the authenticated user profile (or null if not authenticated).
|
|
8
|
+
|
|
9
|
+
## Current Behavior
|
|
10
|
+
|
|
11
|
+
1. Client connects with optional ?token=... in URL
|
|
12
|
+
2. Server opens connection but sends nothing
|
|
13
|
+
3. Client must call auth RPC to authenticate, then separately fetch profile
|
|
14
|
+
4. Client has no immediate knowledge of auth state
|
|
15
|
+
|
|
16
|
+
## Proposed Behavior
|
|
17
|
+
|
|
18
|
+
1. Client connects with optional ?token=... in URL
|
|
19
|
+
2. Server validates token (if present) and fetches user profile
|
|
20
|
+
3. Server sends first message:
|
|
21
|
+
|
|
22
|
+
{"method": "connection:ready", "params": {"user": {"id": 1, "name": "...", "email": "..."} | null}}
|
|
23
|
+
|
|
24
|
+
4. Client knows auth state immediately, can render accordingly
|
|
25
|
+
|
|
26
|
+
## Why This Matters
|
|
27
|
+
|
|
28
|
+
- Single source of truth: WebSocket connection determines auth state, not localStorage
|
|
29
|
+
- No race conditions: UI waits for connection:ready before rendering
|
|
30
|
+
- Simpler client code: No need for separate auth check after connect
|
|
31
|
+
- Better UX: App shows loading state until connection ready, then immediately correct view
|
|
32
|
+
|
|
33
|
+
## Client Usage Pattern
|
|
34
|
+
|
|
35
|
+
Template:
|
|
36
|
+
div v-if="!ready" - loading spinner
|
|
37
|
+
LoginModal v-else-if="!user"
|
|
38
|
+
RouterView v-else
|
|
39
|
+
|
|
40
|
+
## Suggested Server Changes
|
|
41
|
+
|
|
42
|
+
In src/runtime/ws.ts, modify handleOpen to:
|
|
43
|
+
1. Parse token from URL query params
|
|
44
|
+
2. If valid, verify token and fetch user profile
|
|
45
|
+
3. Send connection:ready message with user (or null)
|
|
46
|
+
|
|
47
|
+
## Suggested Client Changes
|
|
48
|
+
|
|
49
|
+
In src/client/ws.ts:
|
|
50
|
+
1. Add user and ready properties to class
|
|
51
|
+
2. Handle connection:ready message in handleMessage
|
|
52
|
+
3. Add onReady callback method
|
|
53
|
+
|
|
54
|
+
## Migration
|
|
55
|
+
|
|
56
|
+
- Existing clients that dont handle connection:ready will ignore it (no breaking change)
|
|
57
|
+
- New clients can opt-in to the pattern
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# tzql Bug Report
|
|
2
|
+
|
|
3
|
+
Bugs discovered while building the Venues application with tzql.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Bug 1: Hidden Fields Exposed in Subscribables
|
|
8
|
+
|
|
9
|
+
**Severity:** High (Security)
|
|
10
|
+
|
|
11
|
+
**Status:** ✅ FIXED
|
|
12
|
+
|
|
13
|
+
**Description:**
|
|
14
|
+
Fields marked as `hidden: true` in the domain schema are still exposed when using subscribables. The generated SQL uses `row_to_json(root.*)` which includes all columns regardless of the `hidden` property.
|
|
15
|
+
|
|
16
|
+
**Fix:**
|
|
17
|
+
Added `buildVisibleRowJson` helper in `subscribable_sql.ts` that generates `jsonb_build_object()` with only visible fields instead of `row_to_json(root.*)`. Also updated all other SQL generators (`sql.ts`) to exclude hidden fields from `get_*`, `search_*`, `save_*`, and `delete_*` functions.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Bug 2: Generated Pinia Stores Don't Await Async Subscribe
|
|
22
|
+
|
|
23
|
+
**Severity:** Medium
|
|
24
|
+
|
|
25
|
+
**Status:** ✅ FIXED
|
|
26
|
+
|
|
27
|
+
**Description:**
|
|
28
|
+
The generated Pinia stores call the async `subscribe` method but don't await it. This means `store.data` is undefined immediately after calling `store.subscribe()` even though the subscription has been initiated.
|
|
29
|
+
|
|
30
|
+
**Fix:**
|
|
31
|
+
Updated `subscribable_store.ts` to make the `bind` function async. It now:
|
|
32
|
+
1. Creates a `ready` Promise that resolves when first data arrives
|
|
33
|
+
2. Awaits the `ready` Promise before returning
|
|
34
|
+
3. Stores the `ready` Promise so repeat calls can also await it
|
|
35
|
+
|
|
36
|
+
**Generated Code (after fix):**
|
|
37
|
+
```typescript
|
|
38
|
+
async function bind(params) {
|
|
39
|
+
const key = JSON.stringify(params);
|
|
40
|
+
if (documents.value[key]) {
|
|
41
|
+
const existing = documents.value[key];
|
|
42
|
+
if (existing.loading.value) {
|
|
43
|
+
await existing.ready;
|
|
44
|
+
}
|
|
45
|
+
return existing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const docState = ref(null);
|
|
49
|
+
const loading = ref(true);
|
|
50
|
+
let resolveReady;
|
|
51
|
+
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
52
|
+
|
|
53
|
+
ws.api.subscribe_venue_detail(params, (initialData) => {
|
|
54
|
+
docState.value = initialData;
|
|
55
|
+
loading.value = false;
|
|
56
|
+
resolveReady();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
documents.value[key] = { data: docState, loading, ready };
|
|
60
|
+
await ready;
|
|
61
|
+
return documents.value[key];
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Usage:**
|
|
66
|
+
```typescript
|
|
67
|
+
const store = useMyProfileStore();
|
|
68
|
+
const { data, loading } = await store.bind({ user_id: 1 });
|
|
69
|
+
// data.value is now populated
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Bug 3: Subscribable Permission Check Uses Param Name Instead of Column Name
|
|
75
|
+
|
|
76
|
+
**Severity:** High (Breaking)
|
|
77
|
+
|
|
78
|
+
**Status:** ✅ FIXED
|
|
79
|
+
|
|
80
|
+
**Description:**
|
|
81
|
+
When generating SQL for subscribable permission checks, the compiler uses the parameter name instead of the actual database column name. This causes SQL errors when the param name differs from the column name.
|
|
82
|
+
|
|
83
|
+
**Fix:**
|
|
84
|
+
Updated `compileSubscribePermission` in `subscribable_sql.ts` to map param names to the root entity's `id` column when they match the rootKey. For example, `@org_id` now correctly resolves to `v_root.id` instead of `v_root.org_id`.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Summary
|
|
89
|
+
|
|
90
|
+
| Bug | Severity | Status | Notes |
|
|
91
|
+
|-----|----------|--------|-------|
|
|
92
|
+
| Hidden fields exposed | High | ✅ Fixed | Uses `jsonb_build_object` to exclude hidden fields |
|
|
93
|
+
| Stores don't await | Medium | ✅ Fixed | `bind()` is now async and awaits first data |
|
|
94
|
+
| Permission param/column mismatch | High | ✅ Fixed | Maps param name to `id` column |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Environment
|
|
99
|
+
|
|
100
|
+
- tzql version: (linked local development version)
|
|
101
|
+
- Database: PostgreSQL 17 (via Docker)
|
|
102
|
+
- Client: Vue 3 + Pinia + TypeScript
|
|
103
|
+
- Runtime: Bun
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Related Files
|
|
108
|
+
|
|
109
|
+
Detailed bug documents created in tzql docs:
|
|
110
|
+
- `/packages/tzql/docs/feature-requests/hidden-fields-in-subscribables.md`
|
|
111
|
+
- `/packages/tzql/docs/feature-requests/subscribable-param-key-bug.md`
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Bug: Hidden fields exposed in subscribables
|
|
2
|
+
|
|
3
|
+
**Status: IMPLEMENTED**
|
|
4
|
+
|
|
5
|
+
## Issue
|
|
6
|
+
|
|
7
|
+
Entity hidden fields are not excluded from subscribable queries.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
domain.js:
|
|
12
|
+
users: {
|
|
13
|
+
schema: { ... },
|
|
14
|
+
hidden: ["password_hash"], // Should be excluded from all queries
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
But get_my_profile returns:
|
|
18
|
+
{
|
|
19
|
+
"users": {
|
|
20
|
+
"password_hash": "$2a$06$..." // EXPOSED!
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
## Root Cause
|
|
25
|
+
|
|
26
|
+
The subscribable SQL generator uses row_to_json(root.*) which returns all columns.
|
|
27
|
+
|
|
28
|
+
## Fix
|
|
29
|
+
|
|
30
|
+
When generating subscribable SQL, exclude hidden fields by using explicit column list.
|
|
31
|
+
|
|
32
|
+
## Impact
|
|
33
|
+
|
|
34
|
+
Security vulnerability - sensitive data like password hashes exposed to clients.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Bug: Subscribable permission check uses param name instead of column name
|
|
2
|
+
|
|
3
|
+
## Issue
|
|
4
|
+
|
|
5
|
+
When generating subscribable SQL, the permission check incorrectly uses the param name instead of the actual column name on the root entity.
|
|
6
|
+
|
|
7
|
+
## Example
|
|
8
|
+
|
|
9
|
+
Domain:
|
|
10
|
+
```javascript
|
|
11
|
+
org_dashboard: {
|
|
12
|
+
params: { org_id: "int" },
|
|
13
|
+
root: { entity: "organisations", key: "org_id" },
|
|
14
|
+
canSubscribe: ["@org_id->acts_for[org_id=$]{active}.user_id"]
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Generated SQL:
|
|
19
|
+
```sql
|
|
20
|
+
-- WRONG: v_root.org_id doesn't exist on organisations table
|
|
21
|
+
WHERE acts_for.org_id = v_root.org_id
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Should be:
|
|
25
|
+
```sql
|
|
26
|
+
-- CORRECT: organisations.id is the actual column
|
|
27
|
+
WHERE acts_for.org_id = v_root.id
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Error
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
ERROR: record "v_root" has no field "org_id"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Fix
|
|
37
|
+
|
|
38
|
+
The compiler should resolve `key: "org_id"` to mean "param org_id maps to the root entity's primary key (id)", not "access v_root.org_id".
|