dzql 0.1.6 → 0.2.0

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.
@@ -4,10 +4,12 @@
4
4
  */
5
5
 
6
6
  import { EntityParser } from './parser/entity-parser.js';
7
+ import { SubscribableParser } from './parser/subscribable-parser.js';
7
8
  import { generatePermissionFunctions } from './codegen/permission-codegen.js';
8
9
  import { generateOperations } from './codegen/operation-codegen.js';
9
10
  import { generateNotificationFunction } from './codegen/notification-codegen.js';
10
11
  import { generateGraphRuleFunctions } from './codegen/graph-rules-codegen.js';
12
+ import { generateSubscribable } from './codegen/subscribable-codegen.js';
11
13
  import crypto from 'crypto';
12
14
 
13
15
  export class DZQLCompiler {
@@ -18,6 +20,7 @@ export class DZQLCompiler {
18
20
  ...options
19
21
  };
20
22
  this.parser = new EntityParser();
23
+ this.subscribableParser = new SubscribableParser();
21
24
  }
22
25
 
23
26
  /**
@@ -79,6 +82,34 @@ export class DZQLCompiler {
79
82
  return result;
80
83
  }
81
84
 
85
+ /**
86
+ * Compile a subscribable definition to SQL
87
+ * @param {Object} subscribable - Subscribable configuration
88
+ * @returns {Object} Compilation result
89
+ */
90
+ compileSubscribable(subscribable) {
91
+ const startTime = Date.now();
92
+
93
+ // Normalize subscribable configuration
94
+ const normalized = typeof subscribable.name === 'string'
95
+ ? subscribable
96
+ : this.subscribableParser.parseFromObject(subscribable);
97
+
98
+ // Generate SQL
99
+ const sql = generateSubscribable(normalized);
100
+
101
+ // Calculate checksum
102
+ const checksum = this._calculateChecksum(sql);
103
+
104
+ return {
105
+ name: normalized.name,
106
+ sql,
107
+ checksum,
108
+ compilationTime: Date.now() - startTime,
109
+ generatedAt: new Date().toISOString()
110
+ };
111
+ }
112
+
82
113
  /**
83
114
  * Compile multiple entities
84
115
  * @param {Array} entities - Array of entity configurations
@@ -111,6 +142,38 @@ export class DZQLCompiler {
111
142
  };
112
143
  }
113
144
 
145
+ /**
146
+ * Compile multiple subscribables
147
+ * @param {Array} subscribables - Array of subscribable configurations
148
+ * @returns {Object} Compilation results
149
+ */
150
+ compileAllSubscribables(subscribables) {
151
+ const results = [];
152
+ const errors = [];
153
+
154
+ for (const subscribable of subscribables) {
155
+ try {
156
+ const result = this.compileSubscribable(subscribable);
157
+ results.push(result);
158
+ } catch (error) {
159
+ errors.push({
160
+ subscribable: subscribable.name || 'unknown',
161
+ error: error.message
162
+ });
163
+ }
164
+ }
165
+
166
+ return {
167
+ results,
168
+ errors,
169
+ summary: {
170
+ total: subscribables.length,
171
+ successful: results.length,
172
+ failed: errors.length
173
+ }
174
+ };
175
+ }
176
+
114
177
  /**
115
178
  * Compile from SQL file
116
179
  * @param {string} sqlContent - SQL file content
@@ -140,6 +203,25 @@ export class DZQLCompiler {
140
203
  return this.compileAll(entities);
141
204
  }
142
205
 
206
+ /**
207
+ * Compile subscribables from SQL file
208
+ * @param {string} sqlContent - SQL file content
209
+ * @returns {Object} Compilation results
210
+ */
211
+ compileSubscribablesFromSQL(sqlContent) {
212
+ const subscribables = this.subscribableParser.parseAllFromSQL(sqlContent);
213
+
214
+ if (subscribables.length === 0) {
215
+ return {
216
+ results: [],
217
+ errors: [],
218
+ summary: { total: 0, successful: 0, failed: 0 }
219
+ };
220
+ }
221
+
222
+ return this.compileAllSubscribables(subscribables);
223
+ }
224
+
143
225
  /**
144
226
  * Generate file header
145
227
  * @private
@@ -226,3 +308,36 @@ export function compileFromSQL(sqlContent, options = {}) {
226
308
  const compiler = new DZQLCompiler(options);
227
309
  return compiler.compileFromSQL(sqlContent);
228
310
  }
311
+
312
+ /**
313
+ * Compile a single subscribable
314
+ * @param {Object} subscribable - Subscribable configuration
315
+ * @param {Object} options - Compiler options
316
+ * @returns {Object} Compilation result
317
+ */
318
+ export function compileSubscribable(subscribable, options = {}) {
319
+ const compiler = new DZQLCompiler(options);
320
+ return compiler.compileSubscribable(subscribable);
321
+ }
322
+
323
+ /**
324
+ * Compile multiple subscribables
325
+ * @param {Array} subscribables - Array of subscribable configurations
326
+ * @param {Object} options - Compiler options
327
+ * @returns {Object} Compilation results
328
+ */
329
+ export function compileAllSubscribables(subscribables, options = {}) {
330
+ const compiler = new DZQLCompiler(options);
331
+ return compiler.compileAllSubscribables(subscribables);
332
+ }
333
+
334
+ /**
335
+ * Compile subscribables from SQL file content
336
+ * @param {string} sqlContent - SQL file content
337
+ * @param {Object} options - Compiler options
338
+ * @returns {Object} Compilation results
339
+ */
340
+ export function compileSubscribablesFromSQL(sqlContent, options = {}) {
341
+ const compiler = new DZQLCompiler(options);
342
+ return compiler.compileSubscribablesFromSQL(sqlContent);
343
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Subscribable Definition Parser
3
+ * Parses register_subscribable() calls and extracts configuration
4
+ */
5
+
6
+ export class SubscribableParser {
7
+ /**
8
+ * Parse a dzql.register_subscribable() call from SQL
9
+ * @param {string} sql - SQL containing register_subscribable call
10
+ * @returns {Object} Parsed subscribable configuration
11
+ */
12
+ parseFromSQL(sql) {
13
+ // Extract the register_subscribable call
14
+ const registerMatch = sql.match(/dzql\.register_subscribable\s*\(([\s\S]*?)\);/i);
15
+ if (!registerMatch) {
16
+ throw new Error('No register_subscribable call found in SQL');
17
+ }
18
+
19
+ const params = this._parseParameters(registerMatch[1]);
20
+
21
+ return this._buildSubscribableConfig(params);
22
+ }
23
+
24
+ /**
25
+ * Parse parameters from register_subscribable call
26
+ * @private
27
+ */
28
+ _parseParameters(paramsString) {
29
+ // Split by commas that are not inside quotes, parentheses, or brackets
30
+ const params = [];
31
+ let currentParam = '';
32
+ let depth = 0;
33
+ let inString = false;
34
+ let stringChar = null;
35
+
36
+ for (let i = 0; i < paramsString.length; i++) {
37
+ const char = paramsString[i];
38
+ const prevChar = i > 0 ? paramsString[i - 1] : '';
39
+
40
+ if ((char === "'" || char === '"') && prevChar !== '\\') {
41
+ if (!inString) {
42
+ inString = true;
43
+ stringChar = char;
44
+ } else if (char === stringChar) {
45
+ inString = false;
46
+ stringChar = null;
47
+ }
48
+ }
49
+
50
+ if (!inString) {
51
+ if (char === '(' || char === '{' || char === '[') depth++;
52
+ if (char === ')' || char === '}' || char === ']') depth--;
53
+
54
+ if (char === ',' && depth === 0) {
55
+ params.push(currentParam.trim());
56
+ currentParam = '';
57
+ continue;
58
+ }
59
+ }
60
+
61
+ currentParam += char;
62
+ }
63
+
64
+ if (currentParam.trim()) {
65
+ params.push(currentParam.trim());
66
+ }
67
+
68
+ return params;
69
+ }
70
+
71
+ /**
72
+ * Build subscribable configuration from parsed parameters
73
+ * register_subscribable(name, permission_paths, param_schema, root_entity, relations)
74
+ * @private
75
+ */
76
+ _buildSubscribableConfig(params) {
77
+ const config = {
78
+ name: this._cleanString(params[0]),
79
+ permissionPaths: params[1] ? this._parseJSON(params[1]) : {},
80
+ paramSchema: params[2] ? this._parseJSON(params[2]) : {},
81
+ rootEntity: this._cleanString(params[3]),
82
+ relations: params[4] ? this._parseJSON(params[4]) : {}
83
+ };
84
+
85
+ return config;
86
+ }
87
+
88
+ /**
89
+ * Parse from JavaScript object (for programmatic usage)
90
+ * @param {Object} obj - Subscribable configuration object
91
+ * @returns {Object} Normalized configuration
92
+ */
93
+ parseFromObject(obj) {
94
+ return {
95
+ name: obj.name,
96
+ permissionPaths: obj.permissionPaths || obj.permissions || {},
97
+ paramSchema: obj.paramSchema || obj.params || {},
98
+ rootEntity: obj.rootEntity || obj.root,
99
+ relations: obj.relations || {}
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Parse multiple subscribables from SQL file
105
+ * @param {string} sql - SQL file content
106
+ * @returns {Array} Array of subscribable configurations
107
+ */
108
+ parseAllFromSQL(sql) {
109
+ const subscribables = [];
110
+ const regex = /dzql\.register_subscribable\s*\(([\s\S]*?)\);/gi;
111
+ let match;
112
+
113
+ while ((match = regex.exec(sql)) !== null) {
114
+ try {
115
+ const params = this._parseParameters(match[1]);
116
+ const config = this._buildSubscribableConfig(params);
117
+ subscribables.push(config);
118
+ } catch (error) {
119
+ console.error('Failed to parse subscribable:', error.message);
120
+ }
121
+ }
122
+
123
+ return subscribables;
124
+ }
125
+
126
+ /**
127
+ * Clean a string parameter (remove quotes)
128
+ * @private
129
+ */
130
+ _cleanString(str) {
131
+ if (!str) return '';
132
+ // Remove outer quotes, SQL comments, then any remaining quotes and whitespace
133
+ let cleaned = str.replace(/^['"]|['"]$/g, ''); // Remove outer quotes
134
+ cleaned = cleaned.replace(/--[^\n]*/g, ''); // Remove SQL comments
135
+ cleaned = cleaned.replace(/['"\s]+$/g, ''); // Remove trailing quotes/whitespace
136
+ return cleaned.trim();
137
+ }
138
+
139
+ /**
140
+ * Parse JSONB object parameter
141
+ * @private
142
+ */
143
+ _parseJSON(str) {
144
+ if (!str || str === '{}' || str === "'{}'::jsonb") {
145
+ return {};
146
+ }
147
+
148
+ // Handle jsonb_build_object() syntax
149
+ if (str.includes('jsonb_build_object')) {
150
+ return this._parseJSONBuildObject(str);
151
+ }
152
+
153
+ // Handle plain JSON string
154
+ try {
155
+ // Remove ::jsonb cast
156
+ let cleaned = str.replace(/::jsonb$/i, '');
157
+ // Remove outer quotes if it's a string literal
158
+ cleaned = cleaned.replace(/^'(.*)'$/, '$1');
159
+ // Unescape internal quotes
160
+ cleaned = cleaned.replace(/''/g, "'");
161
+
162
+ return JSON.parse(cleaned);
163
+ } catch (error) {
164
+ console.error('Failed to parse JSON:', str, error);
165
+ return {};
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Parse jsonb_build_object() calls
171
+ * @private
172
+ */
173
+ _parseJSONBuildObject(str) {
174
+ const result = {};
175
+
176
+ // Extract content between jsonb_build_object( and )
177
+ const match = str.match(/jsonb_build_object\s*\(([\s\S]*)\)/i);
178
+ if (!match) return result;
179
+
180
+ // Parse key-value pairs
181
+ const params = this._parseParameters(match[1]);
182
+
183
+ for (let i = 0; i < params.length; i += 2) {
184
+ if (i + 1 < params.length) {
185
+ const key = this._cleanString(params[i]);
186
+ let value = params[i + 1];
187
+
188
+ // Check if value is nested jsonb_build_object or array
189
+ if (value.includes('jsonb_build_object')) {
190
+ value = this._parseJSONBuildObject(value);
191
+ } else if (value.includes('jsonb_build_array')) {
192
+ value = this._parseJSONBArray(value);
193
+ } else if (value.includes('ARRAY[')) {
194
+ value = this._parseArray(value);
195
+ } else {
196
+ value = this._cleanString(value);
197
+ }
198
+
199
+ result[key] = value;
200
+ }
201
+ }
202
+
203
+ return result;
204
+ }
205
+
206
+ /**
207
+ * Parse jsonb_build_array() calls
208
+ * @private
209
+ */
210
+ _parseJSONBArray(str) {
211
+ const match = str.match(/jsonb_build_array\s*\(([\s\S]*)\)/i);
212
+ if (!match) return [];
213
+
214
+ const params = this._parseParameters(match[1]);
215
+ return params.map(p => {
216
+ if (p.includes('jsonb_build_object')) {
217
+ return this._parseJSONBuildObject(p);
218
+ }
219
+ return this._cleanString(p);
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Parse ARRAY[...] syntax
225
+ * @private
226
+ */
227
+ _parseArray(str) {
228
+ if (!str || str === 'ARRAY[]::text[]') {
229
+ return [];
230
+ }
231
+
232
+ // Extract content between ARRAY[ and ]
233
+ const match = str.match(/ARRAY\[(.*?)\]/i);
234
+ if (!match) return [];
235
+
236
+ // Split by comma and clean each element
237
+ return match[1]
238
+ .split(',')
239
+ .map(s => this._cleanString(s))
240
+ .filter(s => s.length > 0);
241
+ }
242
+ }
@@ -0,0 +1,230 @@
1
+ -- Migration 009: Live Query Subscriptions Infrastructure
2
+ -- Adds support for subscribable documents (Pattern 1 from vision.md)
3
+
4
+ -- ============================================================================
5
+ -- Subscribables Registry (Metadata Only)
6
+ -- ============================================================================
7
+
8
+ -- Stores metadata for registered subscribables
9
+ -- Note: Active subscriptions are held in-memory on the server
10
+ CREATE TABLE IF NOT EXISTS dzql.subscribables (
11
+ name TEXT PRIMARY KEY,
12
+ permission_paths JSONB NOT NULL DEFAULT '{}'::jsonb,
13
+ param_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
14
+ root_entity TEXT NOT NULL,
15
+ relations JSONB NOT NULL DEFAULT '{}'::jsonb,
16
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
18
+ );
19
+
20
+ COMMENT ON TABLE dzql.subscribables IS
21
+ 'Registry of subscribable documents. Subscribables define denormalized views that clients can subscribe to for real-time updates.';
22
+
23
+ COMMENT ON COLUMN dzql.subscribables.name IS
24
+ 'Subscribable identifier (used in subscribe_<name> RPC calls)';
25
+
26
+ COMMENT ON COLUMN dzql.subscribables.permission_paths IS
27
+ 'Access control paths (e.g., {"subscribe": ["@org_id->acts_for[org_id=$]{active}.user_id"]})';
28
+
29
+ COMMENT ON COLUMN dzql.subscribables.param_schema IS
30
+ 'Parameter schema defining subscription key (e.g., {"venue_id": "int"})';
31
+
32
+ COMMENT ON COLUMN dzql.subscribables.root_entity IS
33
+ 'Root table for the subscribable document';
34
+
35
+ COMMENT ON COLUMN dzql.subscribables.relations IS
36
+ 'Related entities to include (e.g., {"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}})';
37
+
38
+ -- Index for quick lookups
39
+ CREATE INDEX IF NOT EXISTS idx_subscribables_root_entity
40
+ ON dzql.subscribables(root_entity);
41
+
42
+ -- ============================================================================
43
+ -- Register Subscribable Function
44
+ -- ============================================================================
45
+
46
+ CREATE OR REPLACE FUNCTION dzql.register_subscribable(
47
+ p_name TEXT,
48
+ p_permission_paths JSONB,
49
+ p_param_schema JSONB,
50
+ p_root_entity TEXT,
51
+ p_relations JSONB
52
+ ) RETURNS TEXT AS $$
53
+ DECLARE
54
+ v_result TEXT;
55
+ BEGIN
56
+ -- Validate inputs
57
+ IF p_name IS NULL OR p_name = '' THEN
58
+ RAISE EXCEPTION 'Subscribable name cannot be empty';
59
+ END IF;
60
+
61
+ IF p_root_entity IS NULL OR p_root_entity = '' THEN
62
+ RAISE EXCEPTION 'Root entity cannot be empty';
63
+ END IF;
64
+
65
+ -- Insert or update subscribable
66
+ INSERT INTO dzql.subscribables (
67
+ name,
68
+ permission_paths,
69
+ param_schema,
70
+ root_entity,
71
+ relations,
72
+ created_at,
73
+ updated_at
74
+ ) VALUES (
75
+ p_name,
76
+ COALESCE(p_permission_paths, '{}'::jsonb),
77
+ COALESCE(p_param_schema, '{}'::jsonb),
78
+ p_root_entity,
79
+ COALESCE(p_relations, '{}'::jsonb),
80
+ NOW(),
81
+ NOW()
82
+ )
83
+ ON CONFLICT (name) DO UPDATE SET
84
+ permission_paths = EXCLUDED.permission_paths,
85
+ param_schema = EXCLUDED.param_schema,
86
+ root_entity = EXCLUDED.root_entity,
87
+ relations = EXCLUDED.relations,
88
+ updated_at = NOW();
89
+
90
+ v_result := format('Subscribable "%s" registered successfully', p_name);
91
+
92
+ RAISE NOTICE '%', v_result;
93
+
94
+ RETURN v_result;
95
+ END;
96
+ $$ LANGUAGE plpgsql;
97
+
98
+ COMMENT ON FUNCTION dzql.register_subscribable IS
99
+ 'Register a subscribable document definition. Used by the compiler to store subscribable metadata.';
100
+
101
+ -- ============================================================================
102
+ -- Helper Functions
103
+ -- ============================================================================
104
+
105
+ -- Get all registered subscribables
106
+ CREATE OR REPLACE FUNCTION dzql.get_subscribables()
107
+ RETURNS TABLE (
108
+ name TEXT,
109
+ permission_paths JSONB,
110
+ param_schema JSONB,
111
+ root_entity TEXT,
112
+ relations JSONB,
113
+ created_at TIMESTAMPTZ,
114
+ updated_at TIMESTAMPTZ
115
+ ) AS $$
116
+ BEGIN
117
+ RETURN QUERY
118
+ SELECT
119
+ s.name,
120
+ s.permission_paths,
121
+ s.param_schema,
122
+ s.root_entity,
123
+ s.relations,
124
+ s.created_at,
125
+ s.updated_at
126
+ FROM dzql.subscribables s
127
+ ORDER BY s.name;
128
+ END;
129
+ $$ LANGUAGE plpgsql STABLE;
130
+
131
+ COMMENT ON FUNCTION dzql.get_subscribables IS
132
+ 'Retrieve all registered subscribables';
133
+
134
+ -- Get subscribable by name
135
+ CREATE OR REPLACE FUNCTION dzql.get_subscribable(p_name TEXT)
136
+ RETURNS TABLE (
137
+ name TEXT,
138
+ permission_paths JSONB,
139
+ param_schema JSONB,
140
+ root_entity TEXT,
141
+ relations JSONB,
142
+ created_at TIMESTAMPTZ,
143
+ updated_at TIMESTAMPTZ
144
+ ) AS $$
145
+ BEGIN
146
+ RETURN QUERY
147
+ SELECT
148
+ s.name,
149
+ s.permission_paths,
150
+ s.param_schema,
151
+ s.root_entity,
152
+ s.relations,
153
+ s.created_at,
154
+ s.updated_at
155
+ FROM dzql.subscribables s
156
+ WHERE s.name = p_name;
157
+ END;
158
+ $$ LANGUAGE plpgsql STABLE;
159
+
160
+ COMMENT ON FUNCTION dzql.get_subscribable IS
161
+ 'Retrieve a specific subscribable by name';
162
+
163
+ -- Get subscribables by root entity
164
+ CREATE OR REPLACE FUNCTION dzql.get_subscribables_by_entity(p_entity TEXT)
165
+ RETURNS TABLE (
166
+ name TEXT,
167
+ permission_paths JSONB,
168
+ param_schema JSONB,
169
+ root_entity TEXT,
170
+ relations JSONB,
171
+ created_at TIMESTAMPTZ,
172
+ updated_at TIMESTAMPTZ
173
+ ) AS $$
174
+ BEGIN
175
+ RETURN QUERY
176
+ SELECT
177
+ s.name,
178
+ s.permission_paths,
179
+ s.param_schema,
180
+ s.root_entity,
181
+ s.relations,
182
+ s.created_at,
183
+ s.updated_at
184
+ FROM dzql.subscribables s
185
+ WHERE s.root_entity = p_entity
186
+ ORDER BY s.name;
187
+ END;
188
+ $$ LANGUAGE plpgsql STABLE;
189
+
190
+ COMMENT ON FUNCTION dzql.get_subscribables_by_entity IS
191
+ 'Retrieve all subscribables for a given root entity';
192
+
193
+ -- Delete subscribable
194
+ CREATE OR REPLACE FUNCTION dzql.delete_subscribable(p_name TEXT)
195
+ RETURNS TEXT AS $$
196
+ DECLARE
197
+ v_result TEXT;
198
+ BEGIN
199
+ DELETE FROM dzql.subscribables
200
+ WHERE name = p_name;
201
+
202
+ IF FOUND THEN
203
+ v_result := format('Subscribable "%s" deleted successfully', p_name);
204
+ ELSE
205
+ v_result := format('Subscribable "%s" not found', p_name);
206
+ END IF;
207
+
208
+ RAISE NOTICE '%', v_result;
209
+
210
+ RETURN v_result;
211
+ END;
212
+ $$ LANGUAGE plpgsql;
213
+
214
+ COMMENT ON FUNCTION dzql.delete_subscribable IS
215
+ 'Delete a subscribable by name';
216
+
217
+ -- ============================================================================
218
+ -- Verification
219
+ -- ============================================================================
220
+
221
+ DO $$
222
+ BEGIN
223
+ RAISE NOTICE 'Migration 009: Live Query Subscriptions - Complete';
224
+ RAISE NOTICE 'Subscribables table created';
225
+ RAISE NOTICE 'Helper functions installed';
226
+ RAISE NOTICE '';
227
+ RAISE NOTICE 'Usage:';
228
+ RAISE NOTICE ' SELECT dzql.register_subscribable(''name'', permissions, params, root, relations);';
229
+ RAISE NOTICE ' SELECT * FROM dzql.get_subscribables();';
230
+ END $$;