dzql 0.4.2 โ 0.4.4
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/README.md +9 -6
- package/docs/README.md +5 -23
- package/docs/for-ai/claude-guide.md +32 -0
- package/docs/guides/interpreter-vs-compiler.md +237 -0
- package/docs/guides/many-to-many.md +17 -0
- package/package.json +2 -2
- package/src/compiler/codegen/graph-rules-codegen.js +159 -3
- package/src/compiler/codegen/operation-codegen.js +11 -3
- package/src/compiler/codegen/permission-codegen.js +5 -1
- package/src/server/db.js +7 -2
- package/src/server/index.js +4 -4
- package/src/server/ws.js +3 -2
- package/src/client/stores/README.md +0 -95
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ PostgreSQL-powered framework with automatic CRUD operations, live query subscrip
|
|
|
7
7
|
- **[Documentation Hub](docs/)** - Complete documentation index
|
|
8
8
|
- **[Getting Started Tutorial](docs/getting-started/tutorial.md)** - Complete tutorial with working todo app
|
|
9
9
|
- **[API Reference](docs/reference/api.md)** - Complete API documentation
|
|
10
|
-
- **[Live Query Subscriptions](docs/getting-started/subscriptions-quick-start.md)** - Real-time denormalized documents
|
|
10
|
+
- **[Live Query Subscriptions](docs/getting-started/subscriptions-quick-start.md)** - Real-time denormalized documents
|
|
11
11
|
- **[Compiler Documentation](docs/compiler/)** - Entity compilation guide and coding standards
|
|
12
12
|
- **[Claude Guide](docs/for-ai/claude-guide.md)** - Development guide for AI assistants
|
|
13
13
|
- **[Venues Example](../venues/)** - Full working application
|
|
@@ -32,7 +32,7 @@ await ws.connect();
|
|
|
32
32
|
const user = await ws.api.save.users({ name: 'Alice' });
|
|
33
33
|
const results = await ws.api.search.users({ filters: { name: 'alice' } });
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// Live query subscriptions
|
|
36
36
|
const { data, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
37
37
|
{ venue_id: 123 },
|
|
38
38
|
(updated) => console.log('Venue changed!', updated)
|
|
@@ -59,17 +59,20 @@ See **[Compiler Documentation](docs/compiler/)** for complete usage guide, codin
|
|
|
59
59
|
## Testing
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
#
|
|
63
|
-
|
|
62
|
+
# From repository root - start test database
|
|
63
|
+
docker compose up -d
|
|
64
|
+
|
|
65
|
+
# Initialize test database
|
|
66
|
+
bun run test:init
|
|
64
67
|
|
|
65
68
|
# Run tests
|
|
66
69
|
bun test
|
|
67
70
|
|
|
68
71
|
# Stop database
|
|
69
|
-
|
|
72
|
+
docker compose down
|
|
70
73
|
```
|
|
71
74
|
|
|
72
|
-
All tests use `bun:test` framework
|
|
75
|
+
All tests use `bun:test` framework. See **[tests/README.md](../../tests/README.md)** for details.
|
|
73
76
|
|
|
74
77
|
## License
|
|
75
78
|
|
package/docs/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Complete documentation for the DZQL PostgreSQL-powered framework.
|
|
|
7
7
|
New to DZQL? Start here:
|
|
8
8
|
|
|
9
9
|
- **[Tutorial](getting-started/tutorial.md)** - Complete step-by-step guide with a working todo app
|
|
10
|
+
- **[Interpreter vs Compiler](guides/interpreter-vs-compiler.md)** - Understand the two execution modes
|
|
10
11
|
- **[Subscriptions Quick Start](getting-started/subscriptions-quick-start.md)** - Get real-time subscriptions working in 5 minutes
|
|
11
12
|
|
|
12
13
|
## ๐ Guides
|
|
@@ -14,6 +15,9 @@ New to DZQL? Start here:
|
|
|
14
15
|
Feature-specific guides and how-tos:
|
|
15
16
|
|
|
16
17
|
- **[Live Query Subscriptions](guides/subscriptions.md)** - Real-time denormalized documents
|
|
18
|
+
- **[Many-to-Many Relationships](guides/many-to-many.md)** - Junction table management
|
|
19
|
+
- **[Field Defaults](guides/field-defaults.md)** - Auto-populate fields on create
|
|
20
|
+
- **[Custom Functions](guides/custom-functions.md)** - Extend with PostgreSQL or Bun functions
|
|
17
21
|
- **[Client Stores](guides/client-stores.md)** - Pinia store patterns for Vue.js
|
|
18
22
|
|
|
19
23
|
## ๐ Reference
|
|
@@ -29,7 +33,7 @@ Complete API documentation:
|
|
|
29
33
|
- [Quickstart](compiler/QUICKSTART.md) - Get started with the DZQL compiler
|
|
30
34
|
- [Advanced Filters](compiler/ADVANCED_FILTERS.md) - Complex search operators
|
|
31
35
|
- [Coding Standards](compiler/CODING_STANDARDS.md) - Best practices for DZQL code
|
|
32
|
-
- [Comparison](compiler/COMPARISON.md) -
|
|
36
|
+
- [Comparison](compiler/COMPARISON.md) - Runtime vs compiled side-by-side
|
|
33
37
|
|
|
34
38
|
## ๐ค For AI Assistants
|
|
35
39
|
|
|
@@ -40,28 +44,6 @@ Complete API documentation:
|
|
|
40
44
|
- [npm Package](https://www.npmjs.com/package/dzql)
|
|
41
45
|
- [GitHub Repository](https://github.com/blueshed/dzql)
|
|
42
46
|
- [Issue Tracker](https://github.com/blueshed/dzql/issues)
|
|
43
|
-
- [Changelog](../../../CHANGELOG.md)
|
|
44
|
-
- [Contributing](../../../CONTRIBUTING.md)
|
|
45
|
-
|
|
46
|
-
## ๐๏ธ Architecture
|
|
47
|
-
|
|
48
|
-
Looking for architecture and design docs? See the [repository docs](../../../docs/):
|
|
49
|
-
|
|
50
|
-
- [Permissions System](../../../docs/architecture/PERMISSIONS.md)
|
|
51
|
-
- [Project Roadmap](../../../docs/architecture/ROADMAP.md)
|
|
52
|
-
- [Subscription Architecture](../../../docs/architecture/SUBSCRIPTIONS_STRATEGY.md)
|
|
53
|
-
|
|
54
|
-
## ๐งช Development
|
|
55
|
-
|
|
56
|
-
Contributing to DZQL? See development documentation:
|
|
57
|
-
|
|
58
|
-
- [TDD Workflow](../../../docs/development/TDD_WORKFLOW.md)
|
|
59
|
-
- [WebSocket Testing](../../../docs/development/WEBSOCKET_TESTING.md)
|
|
60
|
-
- [Claude Web Setup](../../../docs/development/CLAUDE-WEB.md)
|
|
61
|
-
|
|
62
|
-
## ๐ฆ Package Contents
|
|
63
|
-
|
|
64
|
-
This documentation is published with the npm package. For repository-wide documentation (contributors, development workflow, architecture), see [`/docs/`](../../../docs/) in the repository root.
|
|
65
47
|
|
|
66
48
|
## Need Help?
|
|
67
49
|
|
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
4
|
|
|
5
|
+
## Quick Reference Card
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
DZQL QUICK REFERENCE
|
|
9
|
+
====================
|
|
10
|
+
|
|
11
|
+
5 Operations: get, save, delete, lookup, search
|
|
12
|
+
2 Modes: Interpreter (runtime) | Compiler (static SQL)
|
|
13
|
+
Client API: ws.api.{operation}.{entity}(params)
|
|
14
|
+
Server API: db.api.{operation}.{entity}(params, userId)
|
|
15
|
+
|
|
16
|
+
Entity Registration:
|
|
17
|
+
dzql.register_entity(
|
|
18
|
+
table_name, -- 'todos'
|
|
19
|
+
label_field, -- 'title' (for lookups)
|
|
20
|
+
searchable_fields, -- ARRAY['title', 'description']
|
|
21
|
+
fk_includes, -- '{"org": "organisations"}'
|
|
22
|
+
soft_delete, -- false
|
|
23
|
+
temporal_fields, -- '{}'
|
|
24
|
+
notification_paths, -- '{"ownership": ["@org_id->acts_for..."]}'
|
|
25
|
+
permission_paths, -- '{"view": [], "create": [...]}'
|
|
26
|
+
graph_rules, -- '{"on_create": {...}, "many_to_many": {...}}'
|
|
27
|
+
field_defaults -- '{"owner_id": "@user_id"}'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
M2M id_field naming: tag_ids (singular + _ids), NOT tags_ids
|
|
31
|
+
Permission [] = public, omitted = denied
|
|
32
|
+
Path syntax: @field->table[filter]{temporal}.target_field
|
|
33
|
+
|
|
34
|
+
Compile: dzql compile entities.sql -o compiled/
|
|
35
|
+
```
|
|
36
|
+
|
|
5
37
|
## Project Overview
|
|
6
38
|
|
|
7
39
|
DZQL is a PostgreSQL-powered framework that eliminates CRUD boilerplate by providing automatic database operations, real-time WebSocket synchronization, and graph-based relationship management. The core concept: register an entity in PostgreSQL and instantly get 5 standard operations (get, save, delete, lookup, search) plus real-time notifications with zero code.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# Interpreter vs Compiler Mode
|
|
2
|
+
|
|
3
|
+
DZQL offers two execution modes for your entities. Understanding when to use each is fundamental to getting the best out of the framework.
|
|
4
|
+
|
|
5
|
+
## Quick Summary
|
|
6
|
+
|
|
7
|
+
| Aspect | Interpreter | Compiler |
|
|
8
|
+
|--------|-------------|----------|
|
|
9
|
+
| **Setup** | Register entity, use immediately | Register entity, compile, deploy SQL |
|
|
10
|
+
| **Performance** | ~8-12ms per operation | ~2-4ms per operation |
|
|
11
|
+
| **Debugging** | Opaque (dynamic SQL) | Transparent (static SQL) |
|
|
12
|
+
| **Best For** | Development, prototyping | Production, performance-critical |
|
|
13
|
+
|
|
14
|
+
## How It Works
|
|
15
|
+
|
|
16
|
+
### Interpreter Mode (Runtime)
|
|
17
|
+
|
|
18
|
+
Entity configuration is stored as JSON in `dzql.entities` table and parsed at runtime:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Client Request โ generic_exec() โ Parse JSON config โ Build SQL โ Execute
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Characteristics:**
|
|
25
|
+
- Zero build step - changes take effect immediately
|
|
26
|
+
- JSON config parsed on every request
|
|
27
|
+
- Dynamic SQL generated at runtime
|
|
28
|
+
- Generic query plans (harder to optimize)
|
|
29
|
+
|
|
30
|
+
**Usage:**
|
|
31
|
+
```sql
|
|
32
|
+
-- Register entity
|
|
33
|
+
SELECT dzql.register_entity('todos', 'title', ARRAY['title'], ...);
|
|
34
|
+
|
|
35
|
+
-- Use immediately via generic executor
|
|
36
|
+
SELECT dzql.generic_exec('save', 'todos', '{"title": "Buy milk"}'::jsonb, 1);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Compiler Mode (Static)
|
|
40
|
+
|
|
41
|
+
Entity configuration is compiled into dedicated PostgreSQL functions:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Entity Definition โ dzql compile โ Static SQL Functions โ Deploy โ Execute
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Characteristics:**
|
|
48
|
+
- Build step required
|
|
49
|
+
- No JSON parsing at runtime
|
|
50
|
+
- Static SQL with specific query plans
|
|
51
|
+
- PostgreSQL can optimize and cache plans
|
|
52
|
+
|
|
53
|
+
**Usage:**
|
|
54
|
+
```bash
|
|
55
|
+
# Compile entities to SQL
|
|
56
|
+
dzql compile entities.sql -o compiled/
|
|
57
|
+
|
|
58
|
+
# Deploy to database
|
|
59
|
+
psql < compiled/entities.sql
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```sql
|
|
63
|
+
-- Use compiled functions directly
|
|
64
|
+
SELECT save_todos('{"title": "Buy milk"}'::jsonb, 1);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## The Server Automatically Chooses
|
|
68
|
+
|
|
69
|
+
The DZQL server (`db.js`) automatically tries compiled functions first:
|
|
70
|
+
|
|
71
|
+
```javascript
|
|
72
|
+
// In callDZQLOperation()
|
|
73
|
+
try {
|
|
74
|
+
// Try compiled function: save_todos()
|
|
75
|
+
const result = await sql.unsafe(`SELECT save_todos($1, $2)`, [data, userId]);
|
|
76
|
+
return result[0].result;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// If compiled function doesn't exist, fall back to interpreter
|
|
79
|
+
if (error.message.includes('save_todos') && error.code === '42883') {
|
|
80
|
+
return await sql`SELECT dzql.generic_exec('save', 'todos', ${data}, ${userId})`;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This means you can:
|
|
87
|
+
1. Start with interpreter mode during development
|
|
88
|
+
2. Compile and deploy when ready for production
|
|
89
|
+
3. Mix and match - some entities compiled, others interpreted
|
|
90
|
+
|
|
91
|
+
## Performance Comparison
|
|
92
|
+
|
|
93
|
+
### Interpreter (Runtime Parsing)
|
|
94
|
+
|
|
95
|
+
```sql
|
|
96
|
+
SELECT dzql.generic_exec('save', 'venues', '{"name": "MSG"}'::jsonb, 42);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Execution steps:**
|
|
100
|
+
1. Fetch entity config from `dzql.entities` (table lookup)
|
|
101
|
+
2. Parse `permission_paths` JSONB
|
|
102
|
+
3. Build permission query dynamically
|
|
103
|
+
4. Parse `graph_rules` JSONB
|
|
104
|
+
5. Execute rules via dynamic SQL
|
|
105
|
+
6. Parse `notification_paths` JSONB
|
|
106
|
+
7. Resolve paths dynamically
|
|
107
|
+
8. Execute the actual save
|
|
108
|
+
|
|
109
|
+
**Cost:** ~8-12ms, 3-5 JSONB parses, unpredictable query plans
|
|
110
|
+
|
|
111
|
+
### Compiler (Pre-built Functions)
|
|
112
|
+
|
|
113
|
+
```sql
|
|
114
|
+
SELECT save_venues('{"name": "MSG"}'::jsonb, 42);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Execution steps:**
|
|
118
|
+
1. Call `can_update_venues()` - pre-compiled permission check
|
|
119
|
+
2. Execute INSERT/UPDATE - direct SQL
|
|
120
|
+
3. Call `graph_venues_on_create()` - pre-compiled graph rules
|
|
121
|
+
4. Call `resolve_notification_paths_venues()` - pre-compiled
|
|
122
|
+
5. Done
|
|
123
|
+
|
|
124
|
+
**Cost:** ~2-4ms, 0 JSONB parses, optimized query plans
|
|
125
|
+
|
|
126
|
+
## When to Use Each
|
|
127
|
+
|
|
128
|
+
### Use Interpreter When:
|
|
129
|
+
- Rapid prototyping and development
|
|
130
|
+
- Schema changes frequently
|
|
131
|
+
- Learning DZQL concepts
|
|
132
|
+
- Small applications with low traffic
|
|
133
|
+
- Need maximum flexibility
|
|
134
|
+
|
|
135
|
+
### Use Compiler When:
|
|
136
|
+
- Production deployments
|
|
137
|
+
- Performance is critical
|
|
138
|
+
- Need predictable query performance
|
|
139
|
+
- Want reviewable/auditable SQL
|
|
140
|
+
- Large teams (generated SQL is easy to review)
|
|
141
|
+
- Complex permission or graph rules
|
|
142
|
+
|
|
143
|
+
### Recommended Workflow:
|
|
144
|
+
1. **Development:** Use interpreter for fast iteration
|
|
145
|
+
2. **Staging:** Compile and test performance
|
|
146
|
+
3. **Production:** Deploy compiled functions
|
|
147
|
+
|
|
148
|
+
## Compiling Entities
|
|
149
|
+
|
|
150
|
+
### Via CLI
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# Single file
|
|
154
|
+
dzql compile database/entities.sql -o compiled/
|
|
155
|
+
|
|
156
|
+
# Multiple files
|
|
157
|
+
dzql compile database/*.sql -o compiled/
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Programmatically
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
import { DZQLCompiler } from 'dzql/compiler';
|
|
164
|
+
|
|
165
|
+
const compiler = new DZQLCompiler();
|
|
166
|
+
const result = compiler.compileFromSQL(sqlContent);
|
|
167
|
+
|
|
168
|
+
console.log(result.sql); // Generated PostgreSQL functions
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### What Gets Generated
|
|
172
|
+
|
|
173
|
+
For each entity, the compiler generates:
|
|
174
|
+
|
|
175
|
+
| Function | Purpose |
|
|
176
|
+
|----------|---------|
|
|
177
|
+
| `get_{entity}(id, user_id)` | Retrieve single record |
|
|
178
|
+
| `save_{entity}(data, user_id)` | Create or update |
|
|
179
|
+
| `delete_{entity}(id, user_id)` | Delete record |
|
|
180
|
+
| `lookup_{entity}(term, user_id)` | Autocomplete search |
|
|
181
|
+
| `search_{entity}(filters, user_id)` | Paginated search |
|
|
182
|
+
| `can_view_{entity}(user_id, record)` | Permission check |
|
|
183
|
+
| `can_create_{entity}(user_id, record)` | Permission check |
|
|
184
|
+
| `can_update_{entity}(user_id, record)` | Permission check |
|
|
185
|
+
| `can_delete_{entity}(user_id, record)` | Permission check |
|
|
186
|
+
|
|
187
|
+
## Debugging
|
|
188
|
+
|
|
189
|
+
### Interpreter Mode
|
|
190
|
+
|
|
191
|
+
Debugging is harder because SQL is generated dynamically:
|
|
192
|
+
|
|
193
|
+
```sql
|
|
194
|
+
-- You see this
|
|
195
|
+
EXPLAIN ANALYZE SELECT dzql.generic_exec('save', 'venues', '...');
|
|
196
|
+
|
|
197
|
+
-- But the actual query is hidden inside
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Compiler Mode
|
|
201
|
+
|
|
202
|
+
Standard PostgreSQL tools work:
|
|
203
|
+
|
|
204
|
+
```sql
|
|
205
|
+
-- See the actual function
|
|
206
|
+
\sf save_venues
|
|
207
|
+
|
|
208
|
+
-- Analyze performance
|
|
209
|
+
EXPLAIN ANALYZE SELECT save_venues('{"name": "MSG"}'::jsonb, 42);
|
|
210
|
+
|
|
211
|
+
-- Check slow queries
|
|
212
|
+
SELECT * FROM pg_stat_statements WHERE query LIKE '%save_venues%';
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Feature Parity
|
|
216
|
+
|
|
217
|
+
Both modes support the same features:
|
|
218
|
+
|
|
219
|
+
| Feature | Interpreter | Compiler |
|
|
220
|
+
|---------|-------------|----------|
|
|
221
|
+
| CRUD operations | โ
| โ
|
|
|
222
|
+
| Permission paths | โ
| โ
|
|
|
223
|
+
| Graph rules | โ
| โ
|
|
|
224
|
+
| Notification paths | โ
| โ
|
|
|
225
|
+
| FK includes | โ
| โ
|
|
|
226
|
+
| Many-to-many | โ
| โ
|
|
|
227
|
+
| Field defaults | โ
| โ
|
|
|
228
|
+
| Soft delete | โ
| โ
|
|
|
229
|
+
| Temporal fields | โ
| โ
|
|
|
230
|
+
|
|
231
|
+
The difference is purely in execution speed and debuggability, not functionality.
|
|
232
|
+
|
|
233
|
+
## See Also
|
|
234
|
+
|
|
235
|
+
- [Compiler Quickstart](../compiler/QUICKSTART.md) - Get started with compilation
|
|
236
|
+
- [Compiler Comparison](../compiler/COMPARISON.md) - Detailed side-by-side analysis
|
|
237
|
+
- [API Reference](../reference/api.md) - The 5 operations
|
|
@@ -161,6 +161,23 @@ SELECT dzql.register_entity(
|
|
|
161
161
|
| `id_field` | Yes | Field name for ID array in API | `"tag_ids"` |
|
|
162
162
|
| `expand` | No | Include full objects (default: false) | `false` or `true` |
|
|
163
163
|
|
|
164
|
+
### Naming Convention for `id_field`
|
|
165
|
+
|
|
166
|
+
**Important:** The `id_field` should use the **singular** form of the target entity, not plural:
|
|
167
|
+
|
|
168
|
+
| Target Entity | Correct `id_field` | Wrong |
|
|
169
|
+
|---------------|-------------------|-------|
|
|
170
|
+
| `tags` | `tag_ids` | `tags_ids` |
|
|
171
|
+
| `roles` | `role_ids` | `roles_ids` |
|
|
172
|
+
| `categories` | `category_ids` | `categories_ids` |
|
|
173
|
+
| `users` | `user_ids` | `users_ids` |
|
|
174
|
+
|
|
175
|
+
This convention matches common ORM patterns and is more readable:
|
|
176
|
+
- `tag_ids: [1, 2, 3]` reads as "tag IDs"
|
|
177
|
+
- `tags_ids: [1, 2, 3]` reads awkwardly as "tags IDs"
|
|
178
|
+
|
|
179
|
+
Using the wrong naming will cause the M2M sync to fail because the generated code looks for a field name that doesn't match what clients send.
|
|
180
|
+
|
|
164
181
|
### The `expand` Flag
|
|
165
182
|
|
|
166
183
|
Controls whether full related objects are included in responses:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dzql",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "bun test ../../tests/core/*.test.js",
|
|
27
|
-
"prepublishOnly": "echo 'โ
Publishing DZQL
|
|
27
|
+
"prepublishOnly": "echo 'โ
Publishing DZQL...' "
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"jose": "^6.1.0",
|
|
@@ -39,24 +39,43 @@ export class GraphRulesCodegen {
|
|
|
39
39
|
const operation = trigger.replace('on_', ''); // on_create -> create
|
|
40
40
|
const functionName = `_graph_${this.tableName}_${trigger}`;
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
const ruleBlocks = [];
|
|
43
43
|
|
|
44
44
|
// Process each rule
|
|
45
45
|
for (const [ruleName, ruleConfig] of Object.entries(rules)) {
|
|
46
46
|
const description = ruleConfig.description || ruleName;
|
|
47
|
+
const condition = ruleConfig.condition;
|
|
47
48
|
const actions = Array.isArray(ruleConfig.actions)
|
|
48
49
|
? ruleConfig.actions
|
|
49
50
|
: (ruleConfig.actions ? [ruleConfig.actions] : []);
|
|
50
51
|
|
|
52
|
+
const actionBlocks = [];
|
|
51
53
|
for (const action of actions) {
|
|
52
54
|
const actionSQL = this._generateAction(action, ruleName, description);
|
|
53
55
|
if (actionSQL) {
|
|
54
56
|
actionBlocks.push(actionSQL);
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
|
|
60
|
+
if (actionBlocks.length === 0) {
|
|
61
|
+
continue; // Skip rules with no actions
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Wrap actions in condition IF block if condition is present
|
|
65
|
+
if (condition) {
|
|
66
|
+
const conditionSQL = this._generateCondition(condition, operation);
|
|
67
|
+
const ruleBlock = ` -- Rule: ${ruleName}
|
|
68
|
+
IF ${conditionSQL} THEN
|
|
69
|
+
${actionBlocks.join('\n\n')}
|
|
70
|
+
END IF;`;
|
|
71
|
+
ruleBlocks.push(ruleBlock);
|
|
72
|
+
} else {
|
|
73
|
+
// No condition - add actions directly
|
|
74
|
+
ruleBlocks.push(...actionBlocks);
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
77
|
|
|
59
|
-
if (
|
|
78
|
+
if (ruleBlocks.length === 0) {
|
|
60
79
|
return null; // No actions, no function
|
|
61
80
|
}
|
|
62
81
|
|
|
@@ -72,7 +91,7 @@ CREATE OR REPLACE FUNCTION ${functionName}(
|
|
|
72
91
|
${params}
|
|
73
92
|
) RETURNS VOID AS $$
|
|
74
93
|
BEGIN
|
|
75
|
-
${
|
|
94
|
+
${ruleBlocks.join('\n\n')}
|
|
76
95
|
END;
|
|
77
96
|
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
78
97
|
}
|
|
@@ -100,6 +119,9 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
100
119
|
case 'execute':
|
|
101
120
|
return this._generateExecuteAction(action, comment);
|
|
102
121
|
|
|
122
|
+
case 'notify':
|
|
123
|
+
return this._generateNotifyAction(action, comment);
|
|
124
|
+
|
|
103
125
|
default:
|
|
104
126
|
console.warn('Unknown action type:', action.type);
|
|
105
127
|
return null;
|
|
@@ -211,6 +233,140 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
211
233
|
PERFORM ${functionName}(${paramSQL});`;
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Generate NOTIFY action
|
|
238
|
+
* Creates an event that will be broadcast to specified users
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
_generateNotifyAction(action, comment) {
|
|
242
|
+
const users = action.users || [];
|
|
243
|
+
const message = action.message || '';
|
|
244
|
+
const data = action.data || {};
|
|
245
|
+
|
|
246
|
+
// Build user ID array resolution
|
|
247
|
+
let userIdSQL = 'ARRAY[]::INT[]';
|
|
248
|
+
|
|
249
|
+
if (users.length > 0) {
|
|
250
|
+
// Users can be paths like "@post_id->posts.author_id" or direct field refs like "@author_id"
|
|
251
|
+
const userPaths = [];
|
|
252
|
+
|
|
253
|
+
for (const userPath of users) {
|
|
254
|
+
if (userPath.startsWith('@') && !userPath.includes('->')) {
|
|
255
|
+
// Simple field reference: @author_id
|
|
256
|
+
const fieldName = userPath.substring(1);
|
|
257
|
+
userPaths.push(`(p_record->>'${fieldName}')::int`);
|
|
258
|
+
} else if (userPath.startsWith('@') && userPath.includes('->')) {
|
|
259
|
+
// Complex path: @post_id->posts.author_id - use runtime resolver
|
|
260
|
+
userPaths.push(`dzql.resolve_notification_path('${this.tableName}', p_record, '${userPath}')`);
|
|
261
|
+
} else {
|
|
262
|
+
// Literal user ID
|
|
263
|
+
userPaths.push(`${userPath}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (userPaths.length === 1 && !userPaths[0].includes('resolve_notification_path')) {
|
|
268
|
+
// Single simple field - wrap in array
|
|
269
|
+
userIdSQL = `ARRAY[${userPaths[0]}]`;
|
|
270
|
+
} else if (userPaths.length === 1) {
|
|
271
|
+
// Single path resolution (already returns array)
|
|
272
|
+
userIdSQL = userPaths[0];
|
|
273
|
+
} else {
|
|
274
|
+
// Multiple paths - need to combine arrays
|
|
275
|
+
userIdSQL = `(${userPaths.map(p =>
|
|
276
|
+
p.includes('resolve_notification_path')
|
|
277
|
+
? p
|
|
278
|
+
: `ARRAY[${p}]`
|
|
279
|
+
).join(' || ')})`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Build notification data object
|
|
284
|
+
const dataFields = [];
|
|
285
|
+
dataFields.push(`'type', 'graph_rule_notification'`);
|
|
286
|
+
dataFields.push(`'table', '${this.tableName}'`);
|
|
287
|
+
|
|
288
|
+
if (message) {
|
|
289
|
+
dataFields.push(`'message', ${this._resolveValue(message)}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Add custom data fields
|
|
293
|
+
for (const [key, value] of Object.entries(data)) {
|
|
294
|
+
dataFields.push(`'${key}', ${this._resolveValue(value)}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const dataSQL = dataFields.length > 0
|
|
298
|
+
? `jsonb_build_object(${dataFields.join(', ')})`
|
|
299
|
+
: "'{}'::jsonb";
|
|
300
|
+
|
|
301
|
+
return `${comment}
|
|
302
|
+
-- Create notification event
|
|
303
|
+
INSERT INTO dzql.events (
|
|
304
|
+
table_name,
|
|
305
|
+
op,
|
|
306
|
+
pk,
|
|
307
|
+
data,
|
|
308
|
+
user_id,
|
|
309
|
+
notify_users
|
|
310
|
+
) VALUES (
|
|
311
|
+
'${this.tableName}',
|
|
312
|
+
'notify',
|
|
313
|
+
jsonb_build_object('id', (p_record->>'id')::int),
|
|
314
|
+
${dataSQL},
|
|
315
|
+
p_user_id,
|
|
316
|
+
${userIdSQL}
|
|
317
|
+
);`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Generate condition SQL from condition string
|
|
322
|
+
* Supports @before.field, @after.field, @user_id, @id
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_generateCondition(condition, operation) {
|
|
326
|
+
let conditionSQL = condition;
|
|
327
|
+
|
|
328
|
+
// Replace @before.field references (for update/delete)
|
|
329
|
+
conditionSQL = conditionSQL.replace(/@before\.(\w+)/g, (match, field) => {
|
|
330
|
+
return `(p_old_record->>'${field}')`;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Replace @after.field references (for update)
|
|
334
|
+
conditionSQL = conditionSQL.replace(/@after\.(\w+)/g, (match, field) => {
|
|
335
|
+
if (operation === 'update') {
|
|
336
|
+
return `(p_new_record->>'${field}')`;
|
|
337
|
+
} else {
|
|
338
|
+
return `(p_record->>'${field}')`;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Replace @field references (current record)
|
|
343
|
+
conditionSQL = conditionSQL.replace(/@(\w+)(?!\w)/g, (match, field) => {
|
|
344
|
+
if (field === 'user_id') {
|
|
345
|
+
return 'p_user_id';
|
|
346
|
+
} else if (field === 'id') {
|
|
347
|
+
// Use appropriate record based on operation
|
|
348
|
+
if (operation === 'update') {
|
|
349
|
+
return `(p_new_record->>'id')`;
|
|
350
|
+
} else if (operation === 'delete') {
|
|
351
|
+
return `(p_old_record->>'id')`;
|
|
352
|
+
} else {
|
|
353
|
+
return `(p_record->>'id')`;
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
// Field from current record
|
|
357
|
+
if (operation === 'update') {
|
|
358
|
+
return `(p_new_record->>'${field}')`;
|
|
359
|
+
} else if (operation === 'delete') {
|
|
360
|
+
return `(p_old_record->>'${field}')`;
|
|
361
|
+
} else {
|
|
362
|
+
return `(p_record->>'${field}')`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return conditionSQL;
|
|
368
|
+
}
|
|
369
|
+
|
|
214
370
|
/**
|
|
215
371
|
* Resolve a value (variable reference or literal)
|
|
216
372
|
* @private
|
|
@@ -729,10 +729,11 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
/**
|
|
732
|
-
* Resolve a variable default (@user_id, @now, @today) to SQL expression
|
|
732
|
+
* Resolve a variable default (@user_id, @now, @today, @field_name) to SQL expression
|
|
733
733
|
* @private
|
|
734
734
|
*/
|
|
735
735
|
_resolveDefaultVariable(variable, fieldName) {
|
|
736
|
+
// Handle built-in variables
|
|
736
737
|
switch (variable) {
|
|
737
738
|
case '@user_id':
|
|
738
739
|
return 'p_user_id';
|
|
@@ -740,9 +741,16 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
740
741
|
return `to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
|
|
741
742
|
case '@today':
|
|
742
743
|
return `to_char(CURRENT_DATE, 'YYYY-MM-DD')`;
|
|
743
|
-
default:
|
|
744
|
-
throw new Error(`Unknown field default variable: ${variable} for field ${fieldName}`);
|
|
745
744
|
}
|
|
745
|
+
|
|
746
|
+
// Handle field references: @other_field
|
|
747
|
+
if (variable.startsWith('@')) {
|
|
748
|
+
const referencedField = variable.substring(1);
|
|
749
|
+
// Reference to another field in the data being inserted
|
|
750
|
+
return `(p_data->>'${referencedField}')`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
throw new Error(`Unknown field default variable: ${variable} for field ${fieldName}`);
|
|
746
754
|
}
|
|
747
755
|
|
|
748
756
|
/**
|
|
@@ -228,7 +228,11 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
|
228
228
|
|
|
229
229
|
// Add temporal condition
|
|
230
230
|
if (temporal) {
|
|
231
|
-
|
|
231
|
+
// Add temporal filtering for {active} marker
|
|
232
|
+
// Assumes standard field names: valid_from and valid_to
|
|
233
|
+
// This matches the interpreter's behavior in resolve_path_segment (002_functions.sql:316)
|
|
234
|
+
conditions.push(`${targetTable}.valid_from <= NOW()`);
|
|
235
|
+
conditions.push(`(${targetTable}.valid_to > NOW() OR ${targetTable}.valid_to IS NULL)`);
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
// Add user_id check (final target)
|
package/src/server/db.js
CHANGED
|
@@ -224,8 +224,13 @@ export async function callDZQLOperation(operation, entity, args, userId) {
|
|
|
224
224
|
throw new Error(`Unknown operation: ${operation}`);
|
|
225
225
|
}
|
|
226
226
|
} catch (error) {
|
|
227
|
-
//
|
|
228
|
-
|
|
227
|
+
// Only fall back if the COMPILED function itself doesn't exist
|
|
228
|
+
// Don't fall back for other "does not exist" errors (e.g., missing tables, downstream functions)
|
|
229
|
+
const isMissingCompiledFunction =
|
|
230
|
+
(error.message?.includes('does not exist') || error.code === '42883') &&
|
|
231
|
+
error.message?.includes(compiledFunctionName);
|
|
232
|
+
|
|
233
|
+
if (isMissingCompiledFunction) {
|
|
229
234
|
dbLogger.trace(`Compiled function ${compiledFunctionName} not found, trying generic_exec`);
|
|
230
235
|
const result = await sql`
|
|
231
236
|
SELECT dzql.generic_exec(${operation}, ${entity}, ${args}, ${userId}) as result
|
package/src/server/index.js
CHANGED
|
@@ -31,12 +31,12 @@ async function processSubscriptionUpdates(event, broadcast) {
|
|
|
31
31
|
for (const [subscribableName, subs] of subscriptionsByName.entries()) {
|
|
32
32
|
try {
|
|
33
33
|
// Ask PostgreSQL which subscription instances are affected
|
|
34
|
-
const result = await
|
|
34
|
+
const result = await sql.unsafe(
|
|
35
35
|
`SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
|
|
36
36
|
[table, op, before, after]
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
-
const affectedParamSets = result
|
|
39
|
+
const affectedParamSets = result[0]?.affected;
|
|
40
40
|
|
|
41
41
|
if (!affectedParamSets || affectedParamSets.length === 0) {
|
|
42
42
|
continue; // This subscribable not affected
|
|
@@ -51,12 +51,12 @@ async function processSubscriptionUpdates(event, broadcast) {
|
|
|
51
51
|
if (paramsMatch(sub.params, affectedParams)) {
|
|
52
52
|
try {
|
|
53
53
|
// Re-execute query to get updated data
|
|
54
|
-
const updated = await
|
|
54
|
+
const updated = await sql.unsafe(
|
|
55
55
|
`SELECT get_${subscribableName}($1, $2) as data`,
|
|
56
56
|
[sub.params, sub.user_id]
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
const data = updated
|
|
59
|
+
const data = updated[0]?.data;
|
|
60
60
|
|
|
61
61
|
// Send update to specific connection
|
|
62
62
|
const message = JSON.stringify({
|
package/src/server/ws.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
callUserFunction,
|
|
5
5
|
getUserProfile,
|
|
6
6
|
db,
|
|
7
|
+
sql,
|
|
7
8
|
} from "./db.js";
|
|
8
9
|
import { wsLogger, authLogger } from "./logger.js";
|
|
9
10
|
import {
|
|
@@ -317,12 +318,12 @@ export function createRPCHandler(customHandlers = {}) {
|
|
|
317
318
|
|
|
318
319
|
try {
|
|
319
320
|
// Execute initial query (this also checks permissions)
|
|
320
|
-
const queryResult = await
|
|
321
|
+
const queryResult = await sql.unsafe(
|
|
321
322
|
`SELECT get_${subscribableName}($1, $2) as data`,
|
|
322
323
|
[params, ws.data.user_id]
|
|
323
324
|
);
|
|
324
325
|
|
|
325
|
-
const data = queryResult
|
|
326
|
+
const data = queryResult[0]?.data;
|
|
326
327
|
|
|
327
328
|
// Register subscription in memory
|
|
328
329
|
const subscriptionId = registerSubscription(
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# DZQL Canonical Pinia Stores
|
|
2
|
-
|
|
3
|
-
**The official, AI-friendly Pinia stores for DZQL Vue.js applications.**
|
|
4
|
-
|
|
5
|
-
## Why These Stores Exist
|
|
6
|
-
|
|
7
|
-
When building DZQL apps, developers (and AI assistants) often struggle with:
|
|
8
|
-
|
|
9
|
-
1. **Three-phase lifecycle** - connecting โ login โ ready
|
|
10
|
-
2. **WebSocket connection management** - reconnection, error handling
|
|
11
|
-
3. **Authentication flow** - token storage, profile management
|
|
12
|
-
4. **Router integration** - navigation, state synchronization
|
|
13
|
-
5. **Inconsistent patterns** - every project does it differently
|
|
14
|
-
|
|
15
|
-
These canonical stores solve all of these problems with a **simple, consistent pattern** that AI can easily understand and replicate.
|
|
16
|
-
|
|
17
|
-
## The Stores
|
|
18
|
-
|
|
19
|
-
### `useWsStore` - WebSocket & Auth
|
|
20
|
-
|
|
21
|
-
Manages:
|
|
22
|
-
- WebSocket connection (with auto-reconnect)
|
|
23
|
-
- User authentication (login/register/logout)
|
|
24
|
-
- Connection state tracking
|
|
25
|
-
- Three-phase app lifecycle
|
|
26
|
-
|
|
27
|
-
### `useAppStore` - Application State
|
|
28
|
-
|
|
29
|
-
Manages:
|
|
30
|
-
- App initialization
|
|
31
|
-
- Router integration
|
|
32
|
-
- Entity metadata caching
|
|
33
|
-
- Navigation helpers
|
|
34
|
-
- UI state (sidebars, panels)
|
|
35
|
-
|
|
36
|
-
## Quick Example
|
|
37
|
-
|
|
38
|
-
```vue
|
|
39
|
-
<script setup>
|
|
40
|
-
import { computed } from 'vue'
|
|
41
|
-
import { useWsStore, useAppStore } from 'dzql/client/stores'
|
|
42
|
-
|
|
43
|
-
const wsStore = useWsStore()
|
|
44
|
-
const appStore = useAppStore()
|
|
45
|
-
|
|
46
|
-
const state = computed(() => wsStore.appState)
|
|
47
|
-
const ws = wsStore.getWs()
|
|
48
|
-
|
|
49
|
-
// Use DZQL API
|
|
50
|
-
const venues = await ws.api.search.venues({ limit: 50 })
|
|
51
|
-
</script>
|
|
52
|
-
|
|
53
|
-
<template>
|
|
54
|
-
<!-- Three-phase lifecycle -->
|
|
55
|
-
<div v-if="state === 'connecting'">Connecting...</div>
|
|
56
|
-
<LoginView v-else-if="state === 'login'" />
|
|
57
|
-
<MainApp v-else-if="state === 'ready'" />
|
|
58
|
-
</template>
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Documentation
|
|
62
|
-
|
|
63
|
-
- **[Quick Start](../../../docs/reference/client.md)** - Get running in 5 minutes
|
|
64
|
-
- **[Complete Guide](../../../docs/guides/client-stores.md)** - Full API reference and patterns
|
|
65
|
-
|
|
66
|
-
## Files
|
|
67
|
-
|
|
68
|
-
- `useWsStore.js` - WebSocket connection and authentication
|
|
69
|
-
- `useAppStore.js` - Application state and navigation
|
|
70
|
-
- `index.js` - Exports both stores
|
|
71
|
-
|
|
72
|
-
## For AI Assistants
|
|
73
|
-
|
|
74
|
-
When helping users with DZQL apps:
|
|
75
|
-
|
|
76
|
-
1. **Always use these stores** - Don't create custom connection logic
|
|
77
|
-
2. **Follow the three-phase lifecycle** - connecting โ login โ ready
|
|
78
|
-
3. **Use computed for reactive state** - `const profile = computed(() => wsStore.profile)`
|
|
79
|
-
4. **Get WS instance for API calls** - `const ws = wsStore.getWs()`
|
|
80
|
-
|
|
81
|
-
**Example prompt for AI:**
|
|
82
|
-
|
|
83
|
-
> "I'm using the canonical DZQL stores from `dzql/client/stores`. The pattern is:
|
|
84
|
-
> 1. useWsStore for WebSocket connection (three phases: connecting, login, ready)
|
|
85
|
-
> 2. useAppStore for app state and navigation
|
|
86
|
-
> 3. Access DZQL API via `wsStore.getWs().api.get.venues({ id: 1 })`
|
|
87
|
-
> Please follow this pattern."
|
|
88
|
-
|
|
89
|
-
## Version
|
|
90
|
-
|
|
91
|
-
These stores are available in DZQL v0.1.6+
|
|
92
|
-
|
|
93
|
-
## License
|
|
94
|
-
|
|
95
|
-
MIT
|