dzql 0.4.2 → 0.4.3
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/package.json +1 -1
- 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/package.json
CHANGED
|
@@ -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
|