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.
- package/README.md +23 -1
- package/docs/LIVE_QUERY_SUBSCRIPTIONS.md +535 -0
- package/docs/LIVE_QUERY_SUBSCRIPTIONS_STRATEGY.md +488 -0
- package/docs/REFERENCE.md +139 -0
- package/docs/SUBSCRIPTIONS_QUICK_START.md +203 -0
- package/package.json +2 -3
- package/src/client/ws.js +87 -2
- package/src/compiler/cli/compile-example.js +33 -0
- package/src/compiler/cli/compile-subscribable.js +43 -0
- package/src/compiler/cli/debug-compile.js +44 -0
- package/src/compiler/cli/debug-parse.js +26 -0
- package/src/compiler/cli/debug-path-parser.js +18 -0
- package/src/compiler/cli/debug-subscribable-parser.js +21 -0
- package/src/compiler/codegen/subscribable-codegen.js +446 -0
- package/src/compiler/compiler.js +115 -0
- package/src/compiler/parser/subscribable-parser.js +242 -0
- package/src/database/migrations/009_subscriptions.sql +230 -0
- package/src/server/index.js +90 -1
- package/src/server/subscriptions.js +209 -0
- package/src/server/ws.js +78 -2
- package/src/client/stores/README.md +0 -95
|
@@ -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 $$;
|
package/src/server/index.js
CHANGED
|
@@ -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 });
|