dzql 0.1.6 → 0.2.1

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.
@@ -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 $$;
@@ -2,12 +2,96 @@ import { createWebSocketHandlers, verify_jwt_token } from "./ws.js";
2
2
  import { closeConnections, setupListeners, sql, db } from "./db.js";
3
3
  import * as defaultApi from "./api.js";
4
4
  import { serverLogger, notifyLogger } from "./logger.js";
5
+ import { getSubscriptionsBySubscribable, paramsMatch } from "./subscriptions.js";
5
6
 
6
7
  // Re-export commonly used utilities
7
8
  export { sql, db } from "./db.js";
8
9
  export { metaRoute } from "./meta-route.js";
9
10
  export { createMCPRoute } from "./mcp.js";
10
11
 
12
+ /**
13
+ * Process subscription updates when a database event occurs
14
+ * Checks if any active subscriptions are affected and sends updates
15
+ * @param {Object} event - Database event {table, op, pk, before, after}
16
+ * @param {Function} broadcast - Broadcast function from WebSocket handlers
17
+ */
18
+ async function processSubscriptionUpdates(event, broadcast) {
19
+ const { table, op, before, after } = event;
20
+
21
+ // Get all active subscriptions grouped by subscribable
22
+ const subscriptionsByName = getSubscriptionsBySubscribable();
23
+
24
+ if (subscriptionsByName.size === 0) {
25
+ return; // No active subscriptions
26
+ }
27
+
28
+ notifyLogger.debug(`Checking ${subscriptionsByName.size} subscribable(s) for affected subscriptions`);
29
+
30
+ // For each unique subscribable, check if this event affects any subscriptions
31
+ for (const [subscribableName, subs] of subscriptionsByName.entries()) {
32
+ try {
33
+ // Ask PostgreSQL which subscription instances are affected
34
+ const result = await db.query(
35
+ `SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
36
+ [table, op, before, after]
37
+ );
38
+
39
+ const affectedParamSets = result.rows[0]?.affected;
40
+
41
+ if (!affectedParamSets || affectedParamSets.length === 0) {
42
+ continue; // This subscribable not affected
43
+ }
44
+
45
+ notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected`);
46
+
47
+ // Match affected params to active subscriptions
48
+ for (const affectedParams of affectedParamSets) {
49
+ for (const sub of subs) {
50
+ // Check if this subscription matches the affected params
51
+ if (paramsMatch(sub.params, affectedParams)) {
52
+ try {
53
+ // Re-execute query to get updated data
54
+ const updated = await db.query(
55
+ `SELECT get_${subscribableName}($1, $2) as data`,
56
+ [sub.params, sub.user_id]
57
+ );
58
+
59
+ const data = updated.rows[0]?.data;
60
+
61
+ // Send update to specific connection
62
+ const message = JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ method: "subscription:update",
65
+ params: {
66
+ subscription_id: sub.subscriptionId,
67
+ subscribable: subscribableName,
68
+ data
69
+ }
70
+ });
71
+
72
+ const sent = broadcast.toConnection(sub.connection_id, message);
73
+ if (sent) {
74
+ notifyLogger.debug(`Sent update to subscription ${sub.subscriptionId.slice(0, 8)}...`);
75
+ } else {
76
+ notifyLogger.warn(`Failed to send update to connection ${sub.connection_id.slice(0, 8)}...`);
77
+ }
78
+ } catch (error) {
79
+ notifyLogger.error(`Failed to update subscription ${sub.subscriptionId}:`, error.message);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ } catch (error) {
85
+ // If the subscribable function doesn't exist, just skip
86
+ if (error.message && error.message.includes('does not exist')) {
87
+ notifyLogger.debug(`Subscribable ${subscribableName} functions not found, skipping`);
88
+ } else {
89
+ notifyLogger.error(`Error processing subscriptions for ${subscribableName}:`, error.message);
90
+ }
91
+ }
92
+ }
93
+ }
94
+
11
95
  /**
12
96
  * Create a DZQL server with WebSocket support, real-time updates, and automatic CRUD operations
13
97
  *
@@ -97,10 +181,11 @@ export function createServer(options = {}) {
97
181
  });
98
182
 
99
183
  // Setup NOTIFY listeners for real-time events
100
- setupListeners((event) => {
184
+ setupListeners(async (event) => {
101
185
  // Handle single dzql event with filtering
102
186
  const { notify_users, ...eventData } = event;
103
187
 
188
+ // PATTERN 2: Need to Know notifications (existing)
104
189
  // Create JSON-RPC notification
105
190
  const message = JSON.stringify({
106
191
  jsonrpc: "2.0",
@@ -118,6 +203,10 @@ export function createServer(options = {}) {
118
203
  notifyLogger.debug(`Broadcasting ${event.table}:${event.op} to all users`);
119
204
  broadcast(message);
120
205
  }
206
+
207
+ // PATTERN 1: Live Query subscriptions (new)
208
+ // Check if any subscriptions are affected by this event
209
+ await processSubscriptionUpdates(event, broadcast);
121
210
  });
122
211
 
123
212
  routes['/health'] = () => new Response("OK", { status: 200 });