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 +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 +20 -40
- package/src/server/index.js +4 -4
- package/src/server/namespace.js +32 -8
- 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
|
@@ -173,50 +173,14 @@ export async function setupListeners(callback) {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
//
|
|
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
|
|
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
|
|
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/namespace.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
entities
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|