dzql 0.6.13 → 0.6.15
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/docs/README.md +59 -16
- package/docs/for_ai.md +52 -2
- package/docs/project-setup.md +2 -0
- package/package.json +4 -2
- package/src/cli/codegen/notification.ts +219 -0
- package/src/cli/codegen/pinia.ts +28 -32
- package/src/cli/codegen/sql.ts +38 -6
- package/src/cli/codegen/subscribable_sql.ts +89 -12
- package/src/cli/codegen/subscribable_store.ts +101 -102
- package/src/cli/index.ts +4 -1
- package/src/client/ws.ts +177 -93
- package/src/runtime/index.ts +91 -18
- package/src/runtime/subscriptions.ts +189 -0
- package/src/runtime/ws.ts +74 -55
package/docs/README.md
CHANGED
|
@@ -278,32 +278,75 @@ console.log(store.documents);
|
|
|
278
278
|
|
|
279
279
|
### How Realtime Works
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
DZQL uses a simple, unified broadcast pattern for all stores:
|
|
282
282
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
283
|
+
1. **Database events:** PostgreSQL triggers emit events to `dzql_v2.events`
|
|
284
|
+
2. **Server broadcasts:** Runtime sends `{table}:{op}` messages (e.g., `venues:update`) to clients based on:
|
|
285
|
+
- **Subscriptions:** Connections with matching `affected_keys`
|
|
286
|
+
- **Notifications:** Users in `notify_users` (from entity notification paths)
|
|
287
|
+
3. **Auto-dispatch:** The WebSocket client routes broadcasts to registered store handlers
|
|
288
|
+
4. **Store updates:** Each store's `table_changed` method applies updates to local data
|
|
289
|
+
5. **Vue reactivity:** UI updates automatically
|
|
290
290
|
|
|
291
|
-
**No refetching required** - changes are applied incrementally
|
|
291
|
+
**No refetching required** - changes are applied incrementally.
|
|
292
292
|
|
|
293
|
-
###
|
|
293
|
+
### The `table_changed` Pattern
|
|
294
294
|
|
|
295
|
-
|
|
295
|
+
Every generated store implements `table_changed` and self-registers with the WebSocket client:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// Generated store (simplified)
|
|
299
|
+
export const useVenuesStore = defineStore('venues-store', () => {
|
|
300
|
+
const records = ref([]);
|
|
301
|
+
|
|
302
|
+
function table_changed(table: string, op: string, pk: Record<string, unknown>, data: unknown) {
|
|
303
|
+
if (table !== 'venues') return;
|
|
304
|
+
// Update records based on op (insert/update/delete)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Self-register - no manual setup needed!
|
|
308
|
+
ws.registerStore(table_changed);
|
|
309
|
+
|
|
310
|
+
return { records, get, save, search, table_changed };
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**User code - just works:**
|
|
315
|
+
```typescript
|
|
316
|
+
const venuesStore = useVenuesStore(); // Auto-registers for broadcasts
|
|
317
|
+
await venuesStore.search({ org_id: 1 });
|
|
318
|
+
// records update automatically when broadcasts arrive - no setup needed!
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Broadcast Message Format
|
|
296
322
|
|
|
297
323
|
```typescript
|
|
298
324
|
{
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
325
|
+
"jsonrpc": "2.0",
|
|
326
|
+
"method": "venues:update", // {table}:{op}
|
|
327
|
+
"params": {
|
|
328
|
+
"pk": { "id": 123 },
|
|
329
|
+
"data": { "id": 123, "name": "Updated Venue", ... }
|
|
330
|
+
}
|
|
303
331
|
}
|
|
304
332
|
```
|
|
305
333
|
|
|
306
|
-
|
|
334
|
+
### Entity Notifications
|
|
335
|
+
|
|
336
|
+
Entities can define `notifications` paths to specify who receives broadcasts:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
export const entities = {
|
|
340
|
+
venues: {
|
|
341
|
+
schema: { id: 'serial PRIMARY KEY', org_id: 'int', name: 'text' },
|
|
342
|
+
notifications: {
|
|
343
|
+
members: ['@org_id->acts_for[org_id=$]{active}.user_id']
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
When a venue is created/updated/deleted, all active members of that org receive the broadcast.
|
|
307
350
|
|
|
308
351
|
## Why "Compile-Only"?
|
|
309
352
|
|
package/docs/for_ai.md
CHANGED
|
@@ -262,9 +262,59 @@ for (const [key, docState] of Object.entries(store.documents)) {
|
|
|
262
262
|
- Same params = same cached subscription (deduplication by JSON key)
|
|
263
263
|
- The `ready` Promise is stored for repeat callers to await
|
|
264
264
|
- **Stores own their data** - the WebSocket is just transport
|
|
265
|
-
- Realtime patches are applied by the store's `applyPatch()` function
|
|
266
265
|
- Data is reactive - changes trigger Vue reactivity automatically
|
|
267
|
-
|
|
266
|
+
|
|
267
|
+
### How Realtime Works
|
|
268
|
+
|
|
269
|
+
DZQL uses a unified `table_changed` pattern for all stores:
|
|
270
|
+
|
|
271
|
+
1. **Database events:** PostgreSQL triggers emit events to `dzql_v2.events`
|
|
272
|
+
2. **Server broadcasts:** Runtime sends `{table}:{op}` messages (e.g., `venues:update`) to clients
|
|
273
|
+
3. **Auto-dispatch:** WebSocket client routes broadcasts to registered store handlers
|
|
274
|
+
4. **Store updates:** Each store's `table_changed` method applies updates to local data
|
|
275
|
+
|
|
276
|
+
**Stores self-register - no manual setup needed:**
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
// Generated store (simplified)
|
|
280
|
+
export const useVenuesStore = defineStore('venues-store', () => {
|
|
281
|
+
const records = ref([]);
|
|
282
|
+
|
|
283
|
+
function table_changed(table: string, op: string, pk: Record<string, unknown>, data: unknown) {
|
|
284
|
+
if (table !== 'venues') return;
|
|
285
|
+
// Update records based on op (insert/update/delete)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Self-register with WebSocket
|
|
289
|
+
ws.registerStore(table_changed);
|
|
290
|
+
|
|
291
|
+
return { records, get, save, search, table_changed };
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**User code - just works:**
|
|
296
|
+
```typescript
|
|
297
|
+
const venuesStore = useVenuesStore(); // Auto-registers for broadcasts
|
|
298
|
+
await venuesStore.search({ org_id: 1 });
|
|
299
|
+
// records update automatically when broadcasts arrive!
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Entity Notifications
|
|
303
|
+
|
|
304
|
+
Entities can define `notifications` paths to specify who receives broadcasts:
|
|
305
|
+
|
|
306
|
+
```javascript
|
|
307
|
+
export const entities = {
|
|
308
|
+
venues: {
|
|
309
|
+
schema: { id: 'serial PRIMARY KEY', org_id: 'int', name: 'text' },
|
|
310
|
+
notifications: {
|
|
311
|
+
members: ['@org_id->acts_for[org_id=$]{active}.user_id']
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
When a venue is created/updated/deleted, all active members of that org receive the broadcast
|
|
268
318
|
|
|
269
319
|
### Common Patterns
|
|
270
320
|
|
package/docs/project-setup.md
CHANGED
|
@@ -387,6 +387,8 @@ async function deleteSite(id: number) {
|
|
|
387
387
|
</script>
|
|
388
388
|
```
|
|
389
389
|
|
|
390
|
+
**How realtime works:** Stores self-register with the WebSocket client on creation. When the server broadcasts `{table}:{op}` messages (e.g., `sites:insert`), each store's `table_changed` handler automatically applies the update to local data. No manual dispatcher setup required - just use the store and it works.
|
|
391
|
+
|
|
390
392
|
## 10. CLI Database Access with invj
|
|
391
393
|
|
|
392
394
|
Create `tasks.js` in the project root to enable CLI database operations:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dzql",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15",
|
|
4
4
|
"description": "Database-first real-time framework with TypeScript support",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test": "bun test"
|
|
27
|
+
"test": "bun run test:setup && bun test; bun run test:teardown",
|
|
28
|
+
"test:setup": "docker compose down -v && docker compose up -d --wait",
|
|
29
|
+
"test:teardown": "docker compose down -v"
|
|
28
30
|
},
|
|
29
31
|
"keywords": [
|
|
30
32
|
"postgresql",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Path Code Generator
|
|
3
|
+
* Generates PostgreSQL functions to resolve notification paths to user IDs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EntityIR } from "../../shared/ir.js";
|
|
7
|
+
import { compilePermission } from "../compiler/permissions.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate notification resolution function for an entity
|
|
11
|
+
* Returns a function that resolves notification paths to an array of user IDs
|
|
12
|
+
*/
|
|
13
|
+
export function generateNotificationFunction(name: string, entityIR: EntityIR): string {
|
|
14
|
+
const notifications = entityIR.notifications || {};
|
|
15
|
+
const pathNames = Object.keys(notifications);
|
|
16
|
+
|
|
17
|
+
if (pathNames.length === 0) {
|
|
18
|
+
// No notifications - return empty function
|
|
19
|
+
return `
|
|
20
|
+
-- Notification resolution for ${name} (no paths configured)
|
|
21
|
+
CREATE OR REPLACE FUNCTION dzql_v2.${name}_notify_users(
|
|
22
|
+
p_user_id INT,
|
|
23
|
+
p_data JSONB
|
|
24
|
+
) RETURNS INT[]
|
|
25
|
+
LANGUAGE plpgsql
|
|
26
|
+
STABLE
|
|
27
|
+
SECURITY DEFINER
|
|
28
|
+
SET search_path = dzql_v2, public
|
|
29
|
+
AS $$
|
|
30
|
+
BEGIN
|
|
31
|
+
RETURN ARRAY[]::INT[];
|
|
32
|
+
END;
|
|
33
|
+
$$;`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Generate SQL for each notification path
|
|
37
|
+
const pathQueries: string[] = [];
|
|
38
|
+
|
|
39
|
+
for (const [pathName, paths] of Object.entries(notifications)) {
|
|
40
|
+
if (!paths || !Array.isArray(paths)) continue;
|
|
41
|
+
|
|
42
|
+
for (const path of paths) {
|
|
43
|
+
// Use the same permission compiler - it generates EXISTS subqueries
|
|
44
|
+
// but we need SELECT user_id instead
|
|
45
|
+
const userQuery = compileNotificationPath(name, path);
|
|
46
|
+
if (userQuery) {
|
|
47
|
+
pathQueries.push(` -- ${pathName}: ${path}
|
|
48
|
+
v_users := v_users || ARRAY(${userQuery});`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pathSQL = pathQueries.length > 0
|
|
54
|
+
? pathQueries.join('\n\n')
|
|
55
|
+
: ' -- No valid notification paths';
|
|
56
|
+
|
|
57
|
+
return `
|
|
58
|
+
-- Notification resolution for ${name}
|
|
59
|
+
CREATE OR REPLACE FUNCTION dzql_v2.${name}_notify_users(
|
|
60
|
+
p_user_id INT,
|
|
61
|
+
p_data JSONB
|
|
62
|
+
) RETURNS INT[]
|
|
63
|
+
LANGUAGE plpgsql
|
|
64
|
+
STABLE
|
|
65
|
+
SECURITY DEFINER
|
|
66
|
+
SET search_path = dzql_v2, public
|
|
67
|
+
AS $$
|
|
68
|
+
DECLARE
|
|
69
|
+
v_users INT[] := ARRAY[]::INT[];
|
|
70
|
+
BEGIN
|
|
71
|
+
${pathSQL}
|
|
72
|
+
|
|
73
|
+
-- Return unique user IDs (excluding the acting user to avoid self-notification)
|
|
74
|
+
RETURN ARRAY(SELECT DISTINCT unnest(v_users) WHERE unnest != p_user_id);
|
|
75
|
+
END;
|
|
76
|
+
$$;`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compile a notification path to a SELECT query returning user IDs
|
|
81
|
+
*
|
|
82
|
+
* Path formats:
|
|
83
|
+
* - @field_id -> Direct field reference (must be user_id)
|
|
84
|
+
* - @field->table[filter]{temporal}.user_id -> Traverse to get user_id
|
|
85
|
+
*/
|
|
86
|
+
function compileNotificationPath(entityName: string, path: string): string | null {
|
|
87
|
+
path = path.trim();
|
|
88
|
+
|
|
89
|
+
// Direct field reference: @author_id (field must contain user_id)
|
|
90
|
+
if (path.match(/^@\w+$/) && !path.includes('->')) {
|
|
91
|
+
const field = path.slice(1);
|
|
92
|
+
return `SELECT (p_data->>'${field}')::int WHERE (p_data->>'${field}') IS NOT NULL`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Traversal path: @org_id->acts_for[org_id=$]{active}.user_id
|
|
96
|
+
const traversalMatch = path.match(/^@(\w+)->(.+)\.(\w+)$/);
|
|
97
|
+
if (traversalMatch) {
|
|
98
|
+
const [, startField, middle, endField] = traversalMatch;
|
|
99
|
+
return compileTraversalPath(startField, middle, endField);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Multi-hop: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
|
|
103
|
+
const multiHopMatch = path.match(/^@(\w+)->(.+)\.(\w+)->(.+)\.(\w+)$/);
|
|
104
|
+
if (multiHopMatch) {
|
|
105
|
+
const [, field1, table1, field2, rest, endField] = multiHopMatch;
|
|
106
|
+
return compileMultiHopPath(field1, table1, field2, rest, endField);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Table-first: table[filter]{temporal}.field->...
|
|
110
|
+
if (!path.startsWith('@')) {
|
|
111
|
+
return compileTableFirstPath(path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.warn(`[Notification] Unsupported path format: ${path}`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compile single-hop traversal: @org_id->acts_for[org_id=$]{active}.user_id
|
|
120
|
+
*/
|
|
121
|
+
function compileTraversalPath(startField: string, middle: string, endField: string): string {
|
|
122
|
+
// Parse: acts_for[org_id=$]{active}
|
|
123
|
+
const tableMatch = middle.match(/^(\w+)(?:\[([^\]]+)\])?(?:\{([^}]+)\})?$/);
|
|
124
|
+
if (!tableMatch) {
|
|
125
|
+
console.warn(`[Notification] Cannot parse traversal: ${middle}`);
|
|
126
|
+
return `SELECT NULL::int WHERE FALSE`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const [, table, filterStr, temporalField] = tableMatch;
|
|
130
|
+
const conditions: string[] = [];
|
|
131
|
+
|
|
132
|
+
// Parse filters: org_id=$
|
|
133
|
+
if (filterStr) {
|
|
134
|
+
const filters = filterStr.split(',').map(f => f.trim());
|
|
135
|
+
for (const filter of filters) {
|
|
136
|
+
const [field, value] = filter.split('=').map(s => s.trim());
|
|
137
|
+
if (value === '$') {
|
|
138
|
+
// $ means "use the start field value"
|
|
139
|
+
conditions.push(`${table}.${field} = (p_data->>'${startField}')::int`);
|
|
140
|
+
} else if (value.startsWith('@')) {
|
|
141
|
+
conditions.push(`${table}.${field} = (p_data->>'${value.slice(1)}')::int`);
|
|
142
|
+
} else {
|
|
143
|
+
conditions.push(`${table}.${field} = ${value}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// Default: join on start field
|
|
148
|
+
conditions.push(`${table}.id = (p_data->>'${startField}')::int`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Temporal filter: {active} means active = true AND valid_to IS NULL
|
|
152
|
+
if (temporalField) {
|
|
153
|
+
conditions.push(`${table}.${temporalField} = true`);
|
|
154
|
+
conditions.push(`${table}.valid_to IS NULL`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const whereClause = conditions.join(' AND ');
|
|
158
|
+
|
|
159
|
+
return `SELECT ${table}.${endField} FROM ${table} WHERE ${whereClause}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Compile multi-hop path: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
|
|
164
|
+
*/
|
|
165
|
+
function compileMultiHopPath(
|
|
166
|
+
field1: string,
|
|
167
|
+
table1: string,
|
|
168
|
+
field2: string,
|
|
169
|
+
rest: string,
|
|
170
|
+
endField: string
|
|
171
|
+
): string {
|
|
172
|
+
// First hop: get field2 from table1
|
|
173
|
+
const subquery1 = `(SELECT ${field2} FROM ${table1} WHERE id = (p_data->>'${field1}')::int)`;
|
|
174
|
+
|
|
175
|
+
// Parse second hop: acts_for[org_id=$]{active}
|
|
176
|
+
const tableMatch = rest.match(/^(\w+)(?:\[([^\]]+)\])?(?:\{([^}]+)\})?$/);
|
|
177
|
+
if (!tableMatch) {
|
|
178
|
+
console.warn(`[Notification] Cannot parse second hop: ${rest}`);
|
|
179
|
+
return `SELECT NULL::int WHERE FALSE`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const [, table2, filterStr, temporalField] = tableMatch;
|
|
183
|
+
const conditions: string[] = [];
|
|
184
|
+
|
|
185
|
+
// Parse filters
|
|
186
|
+
if (filterStr) {
|
|
187
|
+
const filters = filterStr.split(',').map(f => f.trim());
|
|
188
|
+
for (const filter of filters) {
|
|
189
|
+
const [field, value] = filter.split('=').map(s => s.trim());
|
|
190
|
+
if (value === '$') {
|
|
191
|
+
// $ in second hop means "use the result of first hop"
|
|
192
|
+
conditions.push(`${table2}.${field} = ${subquery1}`);
|
|
193
|
+
} else if (value.startsWith('@')) {
|
|
194
|
+
conditions.push(`${table2}.${field} = (p_data->>'${value.slice(1)}')::int`);
|
|
195
|
+
} else {
|
|
196
|
+
conditions.push(`${table2}.${field} = ${value}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Temporal filter
|
|
202
|
+
if (temporalField) {
|
|
203
|
+
conditions.push(`${table2}.${temporalField} = true`);
|
|
204
|
+
conditions.push(`${table2}.valid_to IS NULL`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const whereClause = conditions.join(' AND ');
|
|
208
|
+
|
|
209
|
+
return `SELECT ${table2}.${endField} FROM ${table2} WHERE ${whereClause}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Compile table-first path: contractor_rights[package_id=@package_id]{active}.contractor_org_id->...
|
|
214
|
+
*/
|
|
215
|
+
function compileTableFirstPath(path: string): string {
|
|
216
|
+
// This is complex - for now return null and log warning
|
|
217
|
+
console.warn(`[Notification] Table-first paths not yet supported: ${path}`);
|
|
218
|
+
return `SELECT NULL::int WHERE FALSE`;
|
|
219
|
+
}
|
package/src/cli/codegen/pinia.ts
CHANGED
|
@@ -60,14 +60,6 @@ export interface ${pascalName} {
|
|
|
60
60
|
${columnTypes}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Event payload for table changes */
|
|
64
|
-
export interface TableChangedPayload {
|
|
65
|
-
table: string;
|
|
66
|
-
operation: 'insert' | 'update' | 'delete';
|
|
67
|
-
pk: { ${pkField}: ${pkType} };
|
|
68
|
-
data: ${pascalName} | null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
63
|
export const use${pascalName}Store = defineStore('${entityName}-store', () => {
|
|
72
64
|
const records: Ref<${pascalName}[]> = ref([]);
|
|
73
65
|
const loading: Ref<boolean> = ref(false);
|
|
@@ -132,33 +124,37 @@ export const use${pascalName}Store = defineStore('${entityName}-store', () => {
|
|
|
132
124
|
}
|
|
133
125
|
}
|
|
134
126
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
127
|
+
// Broadcast handler - automatically registered with ws
|
|
128
|
+
function table_changed(table: string, op: string, pk: Record<string, unknown>, data: ${pascalName} | null): void {
|
|
129
|
+
if (table !== '${entityName}') return;
|
|
130
|
+
|
|
131
|
+
const pkValue = pk?.${pkField} as ${pkType};
|
|
132
|
+
const existingIndex = records.value.findIndex((r) => r.${pkField} === pkValue);
|
|
133
|
+
|
|
134
|
+
switch (op) {
|
|
135
|
+
case 'insert':
|
|
136
|
+
if (data && existingIndex === -1) {
|
|
137
|
+
records.value.push(data);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case 'update':
|
|
141
|
+
if (data && existingIndex !== -1) {
|
|
142
|
+
Object.assign(records.value[existingIndex], data);
|
|
143
|
+
} else if (data) {
|
|
144
|
+
records.value.push(data);
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
case 'delete':
|
|
148
|
+
if (existingIndex !== -1) {
|
|
149
|
+
records.value.splice(existingIndex, 1);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
159
152
|
}
|
|
160
153
|
}
|
|
161
154
|
|
|
155
|
+
// Self-register with WebSocket for broadcasts
|
|
156
|
+
ws.registerStore(table_changed);
|
|
157
|
+
|
|
162
158
|
return {
|
|
163
159
|
records,
|
|
164
160
|
loading,
|
package/src/cli/codegen/sql.ts
CHANGED
|
@@ -50,12 +50,28 @@ CREATE TABLE IF NOT EXISTS dzql_v2.events (
|
|
|
50
50
|
data jsonb,
|
|
51
51
|
old_data jsonb,
|
|
52
52
|
user_id int,
|
|
53
|
+
affected_keys text[] DEFAULT ARRAY[]::text[],
|
|
54
|
+
notify_users int[] DEFAULT ARRAY[]::int[],
|
|
53
55
|
created_at timestamptz DEFAULT now()
|
|
54
56
|
);
|
|
55
57
|
|
|
56
58
|
-- Commit Sequence
|
|
57
59
|
CREATE SEQUENCE IF NOT EXISTS dzql_v2.commit_seq;
|
|
58
60
|
|
|
61
|
+
-- Default compute_affected_keys (returns empty array, overwritten when subscribables exist)
|
|
62
|
+
CREATE OR REPLACE FUNCTION dzql_v2.compute_affected_keys(
|
|
63
|
+
p_table TEXT,
|
|
64
|
+
p_op TEXT,
|
|
65
|
+
p_data JSONB
|
|
66
|
+
) RETURNS TEXT[]
|
|
67
|
+
LANGUAGE plpgsql
|
|
68
|
+
IMMUTABLE
|
|
69
|
+
AS $$
|
|
70
|
+
BEGIN
|
|
71
|
+
RETURN ARRAY[]::text[];
|
|
72
|
+
END;
|
|
73
|
+
$$;
|
|
74
|
+
|
|
59
75
|
-- === AUTH FUNCTIONS ===
|
|
60
76
|
|
|
61
77
|
-- Register User
|
|
@@ -309,6 +325,7 @@ DECLARE
|
|
|
309
325
|
v_old_data jsonb;
|
|
310
326
|
v_commit_id bigint;
|
|
311
327
|
v_op text;
|
|
328
|
+
v_notify_users int[];
|
|
312
329
|
${m2mVarDeclarations}
|
|
313
330
|
BEGIN
|
|
314
331
|
v_commit_id := nextval('dzql_v2.commit_seq');
|
|
@@ -349,8 +366,11 @@ ${m2mExtraction}
|
|
|
349
366
|
${m2mSync}
|
|
350
367
|
${m2mExpansion}
|
|
351
368
|
|
|
352
|
-
--
|
|
353
|
-
|
|
369
|
+
-- Resolve notification recipients
|
|
370
|
+
v_notify_users := dzql_v2.${name}_notify_users(p_user_id, v_result);
|
|
371
|
+
|
|
372
|
+
-- Emit Event with pre-computed affected keys and notify users
|
|
373
|
+
INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id, affected_keys, notify_users)
|
|
354
374
|
VALUES (
|
|
355
375
|
v_commit_id,
|
|
356
376
|
'${name}',
|
|
@@ -358,7 +378,9 @@ ${m2mExpansion}
|
|
|
358
378
|
${pkJsonbExpr},
|
|
359
379
|
v_result,
|
|
360
380
|
v_old_data, -- NULL for insert
|
|
361
|
-
p_user_id
|
|
381
|
+
p_user_id,
|
|
382
|
+
dzql_v2.compute_affected_keys('${name}', v_op, v_result),
|
|
383
|
+
v_notify_users
|
|
362
384
|
);
|
|
363
385
|
|
|
364
386
|
-- Notify Runtime
|
|
@@ -419,6 +441,7 @@ AS $$
|
|
|
419
441
|
DECLARE
|
|
420
442
|
v_old_data jsonb;
|
|
421
443
|
v_commit_id bigint;
|
|
444
|
+
v_notify_users int[];
|
|
422
445
|
BEGIN
|
|
423
446
|
v_commit_id := nextval('dzql_v2.commit_seq');
|
|
424
447
|
|
|
@@ -440,8 +463,11 @@ BEGIN
|
|
|
440
463
|
-- Perform ${softDelete ? 'Soft ' : ''}Delete
|
|
441
464
|
${deleteOperation};
|
|
442
465
|
|
|
443
|
-
--
|
|
444
|
-
|
|
466
|
+
-- Resolve notification recipients
|
|
467
|
+
v_notify_users := dzql_v2.${name}_notify_users(p_user_id, v_old_data);
|
|
468
|
+
|
|
469
|
+
-- Emit Event with pre-computed affected keys and notify users (always 'delete' operation for client-side removal)
|
|
470
|
+
INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id, affected_keys, notify_users)
|
|
445
471
|
VALUES (
|
|
446
472
|
v_commit_id,
|
|
447
473
|
'${name}',
|
|
@@ -449,7 +475,9 @@ BEGIN
|
|
|
449
475
|
${pkJsonbExpr},
|
|
450
476
|
v_old_data, -- Include full data for subscription resolution
|
|
451
477
|
v_old_data,
|
|
452
|
-
p_user_id
|
|
478
|
+
p_user_id,
|
|
479
|
+
dzql_v2.compute_affected_keys('${name}', 'delete', v_old_data),
|
|
480
|
+
v_notify_users
|
|
453
481
|
);
|
|
454
482
|
|
|
455
483
|
-- Notify Runtime
|
|
@@ -689,7 +717,11 @@ $$;
|
|
|
689
717
|
|
|
690
718
|
// === AGGREGATE GENERATOR ===
|
|
691
719
|
export function generateEntitySQL(name: string, entityIR: EntityIR): string {
|
|
720
|
+
// Import here to avoid circular dependency
|
|
721
|
+
const { generateNotificationFunction } = require('./notification.js');
|
|
722
|
+
|
|
692
723
|
return [
|
|
724
|
+
generateNotificationFunction(name, entityIR),
|
|
693
725
|
generateSaveFunction(name, entityIR),
|
|
694
726
|
generateDeleteFunction(name, entityIR),
|
|
695
727
|
generateGetFunction(name, entityIR),
|