dzql 0.4.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -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 actionBlocks = [];
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 (actionBlocks.length === 0) {
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
- ${actionBlocks.join('\n\n')}
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
- conditions.push(`${targetTable}.valid_to IS NULL`);
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
@@ -173,50 +173,14 @@ export async function setupListeners(callback) {
173
173
  }
174
174
  }
175
175
 
176
- // Cache for mode detection (null = not checked, true = compiled, false = runtime)
177
- let isCompiledMode = null;
178
-
179
- // Auto-detect if we're in compiled or runtime mode
180
- async function detectMode() {
181
- if (isCompiledMode !== null) {
182
- return isCompiledMode;
183
- }
184
-
185
- try {
186
- // Check if dzql.generic_exec exists
187
- const result = await sql`
188
- SELECT 1 FROM pg_proc
189
- WHERE proname = 'generic_exec'
190
- AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'dzql')
191
- LIMIT 1
192
- `;
193
- isCompiledMode = result.length === 0; // If no results, it's compiled mode
194
- dbLogger.trace(isCompiledMode ? 'Detected compiled mode' : 'Detected runtime mode');
195
- } catch (error) {
196
- // If there's an error checking, assume runtime mode
197
- isCompiledMode = false;
198
- dbLogger.trace('Error detecting mode, assuming runtime mode');
199
- }
200
-
201
- return isCompiledMode;
202
- }
203
-
204
- // DZQL Generic Operations
176
+ // DZQL Generic Operations - Try compiled functions first, fall back to generic_exec
205
177
  export async function callDZQLOperation(operation, entity, args, userId) {
206
178
  dbLogger.trace(`DZQL ${operation}.${entity} for user ${userId}`);
207
179
 
208
- const compiled = await detectMode();
209
-
210
- if (!compiled) {
211
- // Runtime mode - use generic_exec
212
- const result = await sql`
213
- SELECT dzql.generic_exec(${operation}, ${entity}, ${args}, ${userId}) as result
214
- `;
215
- return result[0].result;
216
- } else {
217
- // Compiled mode - call compiled function directly
218
- const compiledFunctionName = `${operation}_${entity}`;
180
+ const compiledFunctionName = `${operation}_${entity}`;
219
181
 
182
+ try {
183
+ // Try compiled function first
220
184
  // Different operations have different signatures:
221
185
  // - search: search_entity(p_user_id, p_filters, p_search, p_sort, p_page, p_limit)
222
186
  // - get: get_entity(p_user_id, p_id, p_on_date)
@@ -259,6 +223,22 @@ export async function callDZQLOperation(operation, entity, args, userId) {
259
223
  } else {
260
224
  throw new Error(`Unknown operation: ${operation}`);
261
225
  }
226
+ } catch (error) {
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) {
234
+ dbLogger.trace(`Compiled function ${compiledFunctionName} not found, trying generic_exec`);
235
+ const result = await sql`
236
+ SELECT dzql.generic_exec(${operation}, ${entity}, ${args}, ${userId}) as result
237
+ `;
238
+ return result[0].result;
239
+ }
240
+ // Re-throw other errors
241
+ throw error;
262
242
  }
263
243
  }
264
244
 
@@ -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 db.query(
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.rows[0]?.affected;
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 db.query(
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.rows[0]?.data;
59
+ const data = updated[0]?.data;
60
60
 
61
61
  // Send update to specific connection
62
62
  const message = JSON.stringify({
@@ -9,9 +9,10 @@ import { sql, db } from "./db.js";
9
9
  const DEFAULT_USER_ID = 1;
10
10
 
11
11
  /**
12
- * Discover available entities from dzql.entities table
12
+ * Discover available entities from dzql.entities table or compiled functions
13
13
  */
14
14
  async function discoverEntities() {
15
+ // First try dzql.entities table (runtime mode)
15
16
  const result = await sql`
16
17
  SELECT table_name, label_field, searchable_fields
17
18
  FROM dzql.entities
@@ -19,13 +20,36 @@ async function discoverEntities() {
19
20
  `;
20
21
 
21
22
  const entities = {};
22
- for (const row of result) {
23
- const searchFields = row.searchable_fields?.join(", ") || "none";
24
- entities[row.table_name] = {
25
- label: row.label_field,
26
- searchable: row.searchable_fields || [],
27
- description: `Entity: ${row.table_name} (label: ${row.label_field}, searchable: ${searchFields})`,
28
- };
23
+
24
+ if (result.length > 0) {
25
+ // Runtime mode - use dzql.entities table
26
+ for (const row of result) {
27
+ const searchFields = row.searchable_fields?.join(", ") || "none";
28
+ entities[row.table_name] = {
29
+ label: row.label_field,
30
+ searchable: row.searchable_fields || [],
31
+ description: `Entity: ${row.table_name} (label: ${row.label_field}, searchable: ${searchFields})`,
32
+ };
33
+ }
34
+ } else {
35
+ // Compiled mode - discover from function names
36
+ const functions = await sql`
37
+ SELECT DISTINCT substring(proname from 'search_(.+)') as entity_name
38
+ FROM pg_proc
39
+ WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
40
+ AND proname LIKE 'search_%'
41
+ AND substring(proname from 'search_(.+)') IS NOT NULL
42
+ ORDER BY entity_name
43
+ `;
44
+
45
+ for (const row of functions) {
46
+ const entityName = row.entity_name;
47
+ entities[entityName] = {
48
+ label: 'id', // Default, since we can't know from functions alone
49
+ searchable: [],
50
+ description: `Entity: ${entityName} (compiled mode)`,
51
+ };
52
+ }
29
53
  }
30
54
 
31
55
  return entities;
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 db.query(
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.rows[0]?.data;
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