dzql 0.5.5 → 0.5.7
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/bin/cli.js +7 -0
- package/docs/guides/atomic-updates.md +242 -0
- package/docs/guides/drop-semantics.md +554 -0
- package/docs/guides/subscriptions.md +3 -1
- package/package.json +1 -1
- package/src/client/ws.js +137 -7
- package/src/compiler/codegen/drop-semantics-codegen.js +553 -0
- package/src/compiler/codegen/subscribable-codegen.js +85 -0
- package/src/compiler/compiler.js +13 -3
- package/src/database/migrations/009_subscriptions.sql +10 -0
- package/src/database/migrations/010_atomic_updates.sql +150 -0
- package/src/server/index.js +25 -18
- package/src/server/subscriptions.js +125 -0
- package/src/server/ws.js +12 -2
|
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS dzql.subscribables (
|
|
|
13
13
|
param_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
14
14
|
root_entity TEXT NOT NULL,
|
|
15
15
|
relations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
16
|
+
scope_tables TEXT[] NOT NULL DEFAULT '{}',
|
|
16
17
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
17
18
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
18
19
|
);
|
|
@@ -35,6 +36,9 @@ COMMENT ON COLUMN dzql.subscribables.root_entity IS
|
|
|
35
36
|
COMMENT ON COLUMN dzql.subscribables.relations IS
|
|
36
37
|
'Related entities to include (e.g., {"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}})';
|
|
37
38
|
|
|
39
|
+
COMMENT ON COLUMN dzql.subscribables.scope_tables IS
|
|
40
|
+
'Array of table names that are in scope for this subscribable (root + all relations). Used for efficient event filtering.';
|
|
41
|
+
|
|
38
42
|
-- Index for quick lookups
|
|
39
43
|
CREATE INDEX IF NOT EXISTS idx_subscribables_root_entity
|
|
40
44
|
ON dzql.subscribables(root_entity);
|
|
@@ -110,6 +114,7 @@ RETURNS TABLE (
|
|
|
110
114
|
param_schema JSONB,
|
|
111
115
|
root_entity TEXT,
|
|
112
116
|
relations JSONB,
|
|
117
|
+
scope_tables TEXT[],
|
|
113
118
|
created_at TIMESTAMPTZ,
|
|
114
119
|
updated_at TIMESTAMPTZ
|
|
115
120
|
) AS $$
|
|
@@ -121,6 +126,7 @@ BEGIN
|
|
|
121
126
|
s.param_schema,
|
|
122
127
|
s.root_entity,
|
|
123
128
|
s.relations,
|
|
129
|
+
s.scope_tables,
|
|
124
130
|
s.created_at,
|
|
125
131
|
s.updated_at
|
|
126
132
|
FROM dzql.subscribables s
|
|
@@ -139,6 +145,7 @@ RETURNS TABLE (
|
|
|
139
145
|
param_schema JSONB,
|
|
140
146
|
root_entity TEXT,
|
|
141
147
|
relations JSONB,
|
|
148
|
+
scope_tables TEXT[],
|
|
142
149
|
created_at TIMESTAMPTZ,
|
|
143
150
|
updated_at TIMESTAMPTZ
|
|
144
151
|
) AS $$
|
|
@@ -150,6 +157,7 @@ BEGIN
|
|
|
150
157
|
s.param_schema,
|
|
151
158
|
s.root_entity,
|
|
152
159
|
s.relations,
|
|
160
|
+
s.scope_tables,
|
|
153
161
|
s.created_at,
|
|
154
162
|
s.updated_at
|
|
155
163
|
FROM dzql.subscribables s
|
|
@@ -168,6 +176,7 @@ RETURNS TABLE (
|
|
|
168
176
|
param_schema JSONB,
|
|
169
177
|
root_entity TEXT,
|
|
170
178
|
relations JSONB,
|
|
179
|
+
scope_tables TEXT[],
|
|
171
180
|
created_at TIMESTAMPTZ,
|
|
172
181
|
updated_at TIMESTAMPTZ
|
|
173
182
|
) AS $$
|
|
@@ -179,6 +188,7 @@ BEGIN
|
|
|
179
188
|
s.param_schema,
|
|
180
189
|
s.root_entity,
|
|
181
190
|
s.relations,
|
|
191
|
+
s.scope_tables,
|
|
182
192
|
s.created_at,
|
|
183
193
|
s.updated_at
|
|
184
194
|
FROM dzql.subscribables s
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
-- Migration 010: Atomic Updates for Subscribables
|
|
2
|
+
-- Adds extract_scope_tables function and updates register_subscribable to auto-populate scope_tables
|
|
3
|
+
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
-- Helper function to extract scope tables from relations
|
|
6
|
+
-- ============================================================================
|
|
7
|
+
|
|
8
|
+
CREATE OR REPLACE FUNCTION dzql.extract_scope_tables(
|
|
9
|
+
p_root_entity TEXT,
|
|
10
|
+
p_relations JSONB
|
|
11
|
+
) RETURNS TEXT[] AS $$
|
|
12
|
+
DECLARE
|
|
13
|
+
v_tables TEXT[];
|
|
14
|
+
v_key TEXT;
|
|
15
|
+
v_value JSONB;
|
|
16
|
+
v_entity TEXT;
|
|
17
|
+
v_nested JSONB;
|
|
18
|
+
BEGIN
|
|
19
|
+
-- Start with root entity
|
|
20
|
+
v_tables := ARRAY[p_root_entity];
|
|
21
|
+
|
|
22
|
+
-- Return early if no relations
|
|
23
|
+
IF p_relations IS NULL OR p_relations = '{}'::jsonb THEN
|
|
24
|
+
RETURN v_tables;
|
|
25
|
+
END IF;
|
|
26
|
+
|
|
27
|
+
-- Iterate through relations
|
|
28
|
+
FOR v_key, v_value IN SELECT * FROM jsonb_each(p_relations)
|
|
29
|
+
LOOP
|
|
30
|
+
-- Handle string relation (simple FK expansion): "org": "organisations"
|
|
31
|
+
IF jsonb_typeof(v_value) = 'string' THEN
|
|
32
|
+
v_entity := v_value #>> '{}';
|
|
33
|
+
IF v_entity IS NOT NULL AND v_entity != '' THEN
|
|
34
|
+
v_tables := array_append(v_tables, v_entity);
|
|
35
|
+
END IF;
|
|
36
|
+
-- Handle object relation: {"entity": "sites", "filter": "..."}
|
|
37
|
+
ELSIF jsonb_typeof(v_value) = 'object' THEN
|
|
38
|
+
v_entity := v_value ->> 'entity';
|
|
39
|
+
IF v_entity IS NOT NULL AND v_entity != '' THEN
|
|
40
|
+
v_tables := array_append(v_tables, v_entity);
|
|
41
|
+
END IF;
|
|
42
|
+
|
|
43
|
+
-- Recursively handle nested relations (include or relations)
|
|
44
|
+
v_nested := v_value -> 'include';
|
|
45
|
+
IF v_nested IS NOT NULL AND jsonb_typeof(v_nested) = 'object' THEN
|
|
46
|
+
v_tables := v_tables || dzql.extract_scope_tables(NULL, v_nested);
|
|
47
|
+
END IF;
|
|
48
|
+
|
|
49
|
+
v_nested := v_value -> 'relations';
|
|
50
|
+
IF v_nested IS NOT NULL AND jsonb_typeof(v_nested) = 'object' THEN
|
|
51
|
+
v_tables := v_tables || dzql.extract_scope_tables(NULL, v_nested);
|
|
52
|
+
END IF;
|
|
53
|
+
END IF;
|
|
54
|
+
END LOOP;
|
|
55
|
+
|
|
56
|
+
-- Remove duplicates and nulls
|
|
57
|
+
SELECT array_agg(DISTINCT t) INTO v_tables
|
|
58
|
+
FROM unnest(v_tables) t
|
|
59
|
+
WHERE t IS NOT NULL;
|
|
60
|
+
|
|
61
|
+
RETURN COALESCE(v_tables, ARRAY[]::TEXT[]);
|
|
62
|
+
END;
|
|
63
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
64
|
+
|
|
65
|
+
COMMENT ON FUNCTION dzql.extract_scope_tables IS
|
|
66
|
+
'Extract all table names from a subscribable definition (root entity + all relations recursively)';
|
|
67
|
+
|
|
68
|
+
-- ============================================================================
|
|
69
|
+
-- Update register_subscribable to auto-populate scope_tables
|
|
70
|
+
-- ============================================================================
|
|
71
|
+
|
|
72
|
+
CREATE OR REPLACE FUNCTION dzql.register_subscribable(
|
|
73
|
+
p_name TEXT,
|
|
74
|
+
p_permission_paths JSONB,
|
|
75
|
+
p_param_schema JSONB,
|
|
76
|
+
p_root_entity TEXT,
|
|
77
|
+
p_relations JSONB
|
|
78
|
+
) RETURNS TEXT AS $$
|
|
79
|
+
DECLARE
|
|
80
|
+
v_result TEXT;
|
|
81
|
+
v_scope_tables TEXT[];
|
|
82
|
+
BEGIN
|
|
83
|
+
-- Validate inputs
|
|
84
|
+
IF p_name IS NULL OR p_name = '' THEN
|
|
85
|
+
RAISE EXCEPTION 'Subscribable name cannot be empty';
|
|
86
|
+
END IF;
|
|
87
|
+
|
|
88
|
+
IF p_root_entity IS NULL OR p_root_entity = '' THEN
|
|
89
|
+
RAISE EXCEPTION 'Root entity cannot be empty';
|
|
90
|
+
END IF;
|
|
91
|
+
|
|
92
|
+
-- Extract scope tables from root entity and relations
|
|
93
|
+
v_scope_tables := dzql.extract_scope_tables(p_root_entity, p_relations);
|
|
94
|
+
|
|
95
|
+
-- Insert or update subscribable
|
|
96
|
+
INSERT INTO dzql.subscribables (
|
|
97
|
+
name,
|
|
98
|
+
permission_paths,
|
|
99
|
+
param_schema,
|
|
100
|
+
root_entity,
|
|
101
|
+
relations,
|
|
102
|
+
scope_tables,
|
|
103
|
+
created_at,
|
|
104
|
+
updated_at
|
|
105
|
+
) VALUES (
|
|
106
|
+
p_name,
|
|
107
|
+
COALESCE(p_permission_paths, '{}'::jsonb),
|
|
108
|
+
COALESCE(p_param_schema, '{}'::jsonb),
|
|
109
|
+
p_root_entity,
|
|
110
|
+
COALESCE(p_relations, '{}'::jsonb),
|
|
111
|
+
v_scope_tables,
|
|
112
|
+
NOW(),
|
|
113
|
+
NOW()
|
|
114
|
+
)
|
|
115
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
116
|
+
permission_paths = EXCLUDED.permission_paths,
|
|
117
|
+
param_schema = EXCLUDED.param_schema,
|
|
118
|
+
root_entity = EXCLUDED.root_entity,
|
|
119
|
+
relations = EXCLUDED.relations,
|
|
120
|
+
scope_tables = EXCLUDED.scope_tables,
|
|
121
|
+
updated_at = NOW();
|
|
122
|
+
|
|
123
|
+
v_result := format('Subscribable "%s" registered successfully with scope tables: %s',
|
|
124
|
+
p_name, array_to_string(v_scope_tables, ', '));
|
|
125
|
+
|
|
126
|
+
RAISE NOTICE '%', v_result;
|
|
127
|
+
|
|
128
|
+
RETURN v_result;
|
|
129
|
+
END;
|
|
130
|
+
$$ LANGUAGE plpgsql;
|
|
131
|
+
|
|
132
|
+
-- ============================================================================
|
|
133
|
+
-- Backfill existing subscribables with scope_tables
|
|
134
|
+
-- ============================================================================
|
|
135
|
+
|
|
136
|
+
UPDATE dzql.subscribables s
|
|
137
|
+
SET scope_tables = dzql.extract_scope_tables(s.root_entity, s.relations)
|
|
138
|
+
WHERE scope_tables = '{}' OR scope_tables IS NULL;
|
|
139
|
+
|
|
140
|
+
-- ============================================================================
|
|
141
|
+
-- Verification
|
|
142
|
+
-- ============================================================================
|
|
143
|
+
|
|
144
|
+
DO $$
|
|
145
|
+
BEGIN
|
|
146
|
+
RAISE NOTICE 'Migration 010: Atomic Updates - Complete';
|
|
147
|
+
RAISE NOTICE 'Created dzql.extract_scope_tables() function';
|
|
148
|
+
RAISE NOTICE 'Updated dzql.register_subscribable() to auto-populate scope_tables';
|
|
149
|
+
RAISE NOTICE 'Backfilled existing subscribables';
|
|
150
|
+
END $$;
|
package/src/server/index.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
+
import { getSubscriptionsBySubscribable, paramsMatch, getSubscribableScopeTables } from "./subscriptions.js";
|
|
6
6
|
|
|
7
7
|
// Re-export commonly used utilities
|
|
8
8
|
export { sql, db } from "./db.js";
|
|
@@ -11,12 +11,12 @@ export { createMCPRoute } from "./mcp.js";
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Process subscription updates when a database event occurs
|
|
14
|
-
*
|
|
14
|
+
* Forwards atomic events to affected subscriptions for client-side patching
|
|
15
15
|
* @param {Object} event - Database event {table, op, pk, before, after}
|
|
16
16
|
* @param {Function} broadcast - Broadcast function from WebSocket handlers
|
|
17
17
|
*/
|
|
18
18
|
async function processSubscriptionUpdates(event, broadcast) {
|
|
19
|
-
const { table, op, before, after } = event;
|
|
19
|
+
const { table, op, pk, before, after } = event;
|
|
20
20
|
|
|
21
21
|
// Get all active subscriptions grouped by subscribable
|
|
22
22
|
const subscriptionsByName = getSubscriptionsBySubscribable();
|
|
@@ -30,6 +30,14 @@ async function processSubscriptionUpdates(event, broadcast) {
|
|
|
30
30
|
// For each unique subscribable, check if this event affects any subscriptions
|
|
31
31
|
for (const [subscribableName, subs] of subscriptionsByName.entries()) {
|
|
32
32
|
try {
|
|
33
|
+
// Check if this table is in scope for this subscribable
|
|
34
|
+
// This is an optimization to avoid calling _affected_documents for unrelated tables
|
|
35
|
+
const scopeTables = await getSubscribableScopeTables(subscribableName, sql);
|
|
36
|
+
if (scopeTables.length > 0 && !scopeTables.includes(table)) {
|
|
37
|
+
notifyLogger.debug(`Table ${table} not in scope for ${subscribableName}, skipping`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
// Ask PostgreSQL which subscription instances are affected
|
|
34
42
|
const result = await sql.unsafe(
|
|
35
43
|
`SELECT ${subscribableName}_affected_documents($1, $2, $3, $4) as affected`,
|
|
@@ -42,7 +50,7 @@ async function processSubscriptionUpdates(event, broadcast) {
|
|
|
42
50
|
continue; // This subscribable not affected
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected`);
|
|
53
|
+
notifyLogger.debug(`${subscribableName}: ${affectedParamSets.length} param set(s) affected by ${table}:${op}`);
|
|
46
54
|
|
|
47
55
|
// Match affected params to active subscriptions
|
|
48
56
|
for (const affectedParams of affectedParamSets) {
|
|
@@ -50,33 +58,32 @@ async function processSubscriptionUpdates(event, broadcast) {
|
|
|
50
58
|
// Check if this subscription matches the affected params
|
|
51
59
|
if (paramsMatch(sub.params, affectedParams)) {
|
|
52
60
|
try {
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
`SELECT get_${subscribableName}($1, $2) as data`,
|
|
56
|
-
[sub.params, sub.user_id]
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
const data = updated[0]?.data;
|
|
60
|
-
|
|
61
|
-
// Send update to specific connection
|
|
61
|
+
// Forward atomic event instead of re-querying the full document
|
|
62
|
+
// Client will apply the patch to their local copy
|
|
62
63
|
const message = JSON.stringify({
|
|
63
64
|
jsonrpc: "2.0",
|
|
64
|
-
method: "subscription:
|
|
65
|
+
method: "subscription:event",
|
|
65
66
|
params: {
|
|
66
67
|
subscription_id: sub.subscriptionId,
|
|
67
68
|
subscribable: subscribableName,
|
|
68
|
-
|
|
69
|
+
event: {
|
|
70
|
+
table,
|
|
71
|
+
op,
|
|
72
|
+
pk,
|
|
73
|
+
data: after,
|
|
74
|
+
before
|
|
75
|
+
}
|
|
69
76
|
}
|
|
70
77
|
});
|
|
71
78
|
|
|
72
79
|
const sent = broadcast.toConnection(sub.connection_id, message);
|
|
73
80
|
if (sent) {
|
|
74
|
-
notifyLogger.debug(`Sent
|
|
81
|
+
notifyLogger.debug(`Sent atomic event to subscription ${sub.subscriptionId.slice(0, 8)}... (${table}:${op})`);
|
|
75
82
|
} else {
|
|
76
|
-
notifyLogger.warn(`Failed to send
|
|
83
|
+
notifyLogger.warn(`Failed to send event to connection ${sub.connection_id.slice(0, 8)}...`);
|
|
77
84
|
}
|
|
78
85
|
} catch (error) {
|
|
79
|
-
notifyLogger.error(`Failed to
|
|
86
|
+
notifyLogger.error(`Failed to send event to subscription ${sub.subscriptionId}:`, error.message);
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
}
|
|
@@ -18,6 +18,12 @@ const subscriptions = new Map();
|
|
|
18
18
|
*/
|
|
19
19
|
const connectionSubscriptions = new Map();
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Cache for subscribable metadata (scope tables, path mappings)
|
|
23
|
+
* Structure: subscribable_name -> { scopeTables, pathMapping, rootEntity, relations }
|
|
24
|
+
*/
|
|
25
|
+
const subscribableMetadataCache = new Map();
|
|
26
|
+
|
|
21
27
|
/**
|
|
22
28
|
* Register a new subscription
|
|
23
29
|
* @param {string} subscribableName - Name of the subscribable
|
|
@@ -207,3 +213,122 @@ export function getAllSubscriptions() {
|
|
|
207
213
|
...sub
|
|
208
214
|
}));
|
|
209
215
|
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get subscribable metadata (scope tables, path mapping) with caching
|
|
219
|
+
* @param {string} subscribableName - Name of the subscribable
|
|
220
|
+
* @param {function} sql - Database query function
|
|
221
|
+
* @returns {Promise<{scopeTables: string[], pathMapping: object, rootEntity: string, relations: object}>}
|
|
222
|
+
*/
|
|
223
|
+
export async function getSubscribableMetadata(subscribableName, sql) {
|
|
224
|
+
// Check cache first
|
|
225
|
+
if (subscribableMetadataCache.has(subscribableName)) {
|
|
226
|
+
return subscribableMetadataCache.get(subscribableName);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fetch from database
|
|
230
|
+
const result = await sql`
|
|
231
|
+
SELECT scope_tables, root_entity, relations
|
|
232
|
+
FROM dzql.subscribables
|
|
233
|
+
WHERE name = ${subscribableName}
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
if (!result || result.length === 0) {
|
|
237
|
+
// Return empty metadata if subscribable not found
|
|
238
|
+
const emptyMetadata = {
|
|
239
|
+
scopeTables: [],
|
|
240
|
+
pathMapping: {},
|
|
241
|
+
rootEntity: null,
|
|
242
|
+
relations: {}
|
|
243
|
+
};
|
|
244
|
+
return emptyMetadata;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { scope_tables, root_entity, relations } = result[0];
|
|
248
|
+
|
|
249
|
+
// Build path mapping from relations
|
|
250
|
+
const pathMapping = buildPathMappingFromRelations(root_entity, relations);
|
|
251
|
+
|
|
252
|
+
const metadata = {
|
|
253
|
+
scopeTables: scope_tables || [],
|
|
254
|
+
pathMapping,
|
|
255
|
+
rootEntity: root_entity,
|
|
256
|
+
relations: relations || {}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Cache for future use
|
|
260
|
+
subscribableMetadataCache.set(subscribableName, metadata);
|
|
261
|
+
|
|
262
|
+
wsLogger.debug(`Cached metadata for subscribable ${subscribableName}:`, {
|
|
263
|
+
scopeTables: metadata.scopeTables,
|
|
264
|
+
pathMapping: metadata.pathMapping
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return metadata;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build path mapping from relations configuration
|
|
272
|
+
* Maps table names to their document path for client-side patching
|
|
273
|
+
* @param {string} rootEntity - Root table name
|
|
274
|
+
* @param {object} relations - Relations configuration
|
|
275
|
+
* @returns {object} - Map of table name -> document path
|
|
276
|
+
*/
|
|
277
|
+
function buildPathMappingFromRelations(rootEntity, relations) {
|
|
278
|
+
const paths = {};
|
|
279
|
+
|
|
280
|
+
// Root entity maps to top level
|
|
281
|
+
if (rootEntity) {
|
|
282
|
+
paths[rootEntity] = '.';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const buildPaths = (rels, parentPath = '') => {
|
|
286
|
+
for (const [relName, relConfig] of Object.entries(rels || {})) {
|
|
287
|
+
const entity = typeof relConfig === 'string' ? relConfig : relConfig?.entity;
|
|
288
|
+
const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
|
|
289
|
+
|
|
290
|
+
if (entity) {
|
|
291
|
+
paths[entity] = currentPath;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Handle nested relations
|
|
295
|
+
if (typeof relConfig === 'object' && relConfig !== null) {
|
|
296
|
+
if (relConfig.include) {
|
|
297
|
+
buildPaths(relConfig.include, currentPath);
|
|
298
|
+
}
|
|
299
|
+
if (relConfig.relations) {
|
|
300
|
+
buildPaths(relConfig.relations, currentPath);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
buildPaths(relations);
|
|
307
|
+
return paths;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Clear the subscribable metadata cache
|
|
312
|
+
* Called when subscribables are reregistered or updated
|
|
313
|
+
* @param {string} [subscribableName] - Optional: clear specific subscribable, or all if not provided
|
|
314
|
+
*/
|
|
315
|
+
export function clearSubscribableMetadataCache(subscribableName = null) {
|
|
316
|
+
if (subscribableName) {
|
|
317
|
+
subscribableMetadataCache.delete(subscribableName);
|
|
318
|
+
wsLogger.debug(`Cleared metadata cache for ${subscribableName}`);
|
|
319
|
+
} else {
|
|
320
|
+
subscribableMetadataCache.clear();
|
|
321
|
+
wsLogger.debug('Cleared all subscribable metadata cache');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get scope tables for a subscribable (convenience function)
|
|
327
|
+
* @param {string} subscribableName - Name of the subscribable
|
|
328
|
+
* @param {function} sql - Database query function
|
|
329
|
+
* @returns {Promise<string[]>} - Array of table names in scope
|
|
330
|
+
*/
|
|
331
|
+
export async function getSubscribableScopeTables(subscribableName, sql) {
|
|
332
|
+
const metadata = await getSubscribableMetadata(subscribableName, sql);
|
|
333
|
+
return metadata.scopeTables;
|
|
334
|
+
}
|
package/src/server/ws.js
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
registerSubscription,
|
|
12
12
|
unregisterSubscription,
|
|
13
13
|
unregisterSubscriptionByParams,
|
|
14
|
-
removeConnectionSubscriptions
|
|
14
|
+
removeConnectionSubscriptions,
|
|
15
|
+
getSubscribableMetadata
|
|
15
16
|
} from "./subscriptions.js";
|
|
16
17
|
|
|
17
18
|
// Environment configuration
|
|
@@ -327,6 +328,9 @@ export function createRPCHandler(customHandlers = {}) {
|
|
|
327
328
|
|
|
328
329
|
const data = queryResult[0]?.data;
|
|
329
330
|
|
|
331
|
+
// Get subscribable metadata for schema (path mapping for atomic updates)
|
|
332
|
+
const metadata = await getSubscribableMetadata(subscribableName, sql);
|
|
333
|
+
|
|
330
334
|
// Register subscription in memory
|
|
331
335
|
const subscriptionId = registerSubscription(
|
|
332
336
|
subscribableName,
|
|
@@ -335,9 +339,15 @@ export function createRPCHandler(customHandlers = {}) {
|
|
|
335
339
|
params
|
|
336
340
|
);
|
|
337
341
|
|
|
342
|
+
// Build result with schema for client-side patching
|
|
338
343
|
const result = {
|
|
339
344
|
subscription_id: subscriptionId,
|
|
340
|
-
data
|
|
345
|
+
data,
|
|
346
|
+
// Include schema for atomic update support
|
|
347
|
+
schema: {
|
|
348
|
+
root: metadata.rootEntity,
|
|
349
|
+
paths: metadata.pathMapping
|
|
350
|
+
}
|
|
341
351
|
};
|
|
342
352
|
|
|
343
353
|
wsLogger.response(method, result, Date.now() - startTime);
|