dzql 0.6.37 → 0.7.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.
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +2 -2
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compiles
|
|
2
|
+
* Compiles permission rules into SQL.
|
|
3
3
|
*
|
|
4
4
|
* Supported formats:
|
|
5
|
-
* - Literal: TRUE, FALSE
|
|
5
|
+
* - Literal: TRUE (public), FALSE (nobody), AUTHENTICATED (any signed-in user)
|
|
6
6
|
* - Simple field check: @author_id (implies author_id == user_id)
|
|
7
7
|
* - Explicit comparison: @field == @user_id
|
|
8
8
|
* - Single-hop traversal: @org_id->acts_for[org_id=$]{active}.user_id
|
|
9
9
|
* - Multi-hop traversal: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
|
|
10
|
-
* -
|
|
10
|
+
* - Filtered middle hop: @occasion_id->occasion_promoters[occasion_id=$].promoter_id->user_orgs[org_id=$].user_id
|
|
11
|
+
* - Several filters: @org_id->user_orgs[org_id=$,role=owner].user_id
|
|
11
12
|
* - Table-first: contractor_rights[package_id=@package_id]{active}.contractor_org_id->...
|
|
12
13
|
*
|
|
13
|
-
* Uses the shared path parser for parity with notification compilation.
|
|
14
|
-
*
|
|
15
|
-
* @param entityName - The entity name
|
|
16
|
-
* @param rule - The permission rule string
|
|
17
|
-
* @param context - Optional context (unused)
|
|
18
|
-
* @param dataVar - Either a JSONB variable name (e.g., 'p_data') or a table name (e.g., 'packages')
|
|
19
|
-
* When it's a table name, we use table.column syntax instead of jsonb->>'column'
|
|
14
|
+
* Uses the shared path parser for parity with notification and subscribe compilation.
|
|
15
|
+
* A rule that can't be compiled throws PathCompileError; it never compiles to FALSE.
|
|
20
16
|
*/
|
|
21
17
|
|
|
22
|
-
import { parsePath, compilePathToExists } from "./paths.js";
|
|
23
|
-
|
|
24
|
-
export function compilePermission(entityName: string, rule: string, context: any, dataVar: string = 'p_data'): string {
|
|
25
|
-
// Literal TRUE/FALSE - public access or deny all
|
|
26
|
-
if (rule === 'TRUE' || rule === 'true') {
|
|
27
|
-
return 'TRUE';
|
|
28
|
-
}
|
|
29
|
-
if (rule === 'FALSE' || rule === 'false') {
|
|
30
|
-
return 'FALSE';
|
|
31
|
-
}
|
|
18
|
+
import { parsePath, compilePathToExists, PathCompileError, type RowSource } from "./paths.js";
|
|
32
19
|
|
|
33
|
-
|
|
20
|
+
/**
|
|
21
|
+
* @param entityName - The entity name (for error messages)
|
|
22
|
+
* @param rule - The permission rule string
|
|
23
|
+
* @param context - Optional context (unused)
|
|
24
|
+
* @param dataVar - A JSONB variable name (e.g., 'p_data', 'v_old_data'), a table name or
|
|
25
|
+
* alias in scope (e.g., 'packages'), or an explicit RowSource
|
|
26
|
+
*/
|
|
27
|
+
export function compilePermission(entityName: string, rule: string, context: any, dataVar: string | RowSource = 'p_data'): string {
|
|
34
28
|
const parsed = parsePath(rule);
|
|
35
|
-
|
|
36
29
|
if (!parsed.isValid) {
|
|
37
|
-
|
|
38
|
-
return 'FALSE';
|
|
30
|
+
throw new PathCompileError(`${entityName}: ${parsed.error}`, rule);
|
|
39
31
|
}
|
|
40
|
-
|
|
41
|
-
// Compile to EXISTS check
|
|
42
32
|
return compilePathToExists(parsed, dataVar, 'p_user_id');
|
|
43
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compile a list of rules: any one of them grants access. An empty or missing
|
|
37
|
+
* list denies everyone.
|
|
38
|
+
*/
|
|
39
|
+
export function compileRuleList(entityName: string, rules: string[] | undefined, dataVar: string | RowSource = 'p_data'): string {
|
|
40
|
+
if (!rules || rules.length === 0) return 'FALSE';
|
|
41
|
+
if (rules.length === 1) return compilePermission(entityName, rules[0], null, dataVar);
|
|
42
|
+
return rules.map(rule => `(${compilePermission(entityName, rule, null, dataVar)})`).join(' OR ');
|
|
43
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { loadDomain } from "./compiler/loader.js";
|
|
3
|
-
import {
|
|
4
|
-
import { generateIR } from "./compiler/ir.js";
|
|
5
|
-
import { generateCoreSQL, generateAuthSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
|
|
6
|
-
import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
|
|
3
|
+
import { compileDomain, DomainValidationError } from "./compile.js";
|
|
7
4
|
import { generateManifest } from "./codegen/manifest.js";
|
|
8
5
|
import { generateSubscribableStore } from "./codegen/subscribable_store.js";
|
|
9
6
|
import { generateClientSDK } from "./codegen/client.js";
|
|
@@ -60,62 +57,19 @@ async function main() {
|
|
|
60
57
|
const domain = await loadDomain(fullInputPath);
|
|
61
58
|
console.log("[Compiler] Domain loaded.");
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const ir = generateIR(domain);
|
|
72
|
-
console.log(`[Compiler] IR Generated. Subscribables: ${Object.keys(ir.subscribables).join(', ')}`);
|
|
73
|
-
|
|
74
|
-
// Phase 3: Generate SQL
|
|
75
|
-
const coreSQL = generateCoreSQL();
|
|
76
|
-
|
|
77
|
-
// Topologically sort entities by FK dependencies
|
|
78
|
-
// Entities must be created before entities that reference them
|
|
79
|
-
// Also detects circular FKs that need deferred constraint creation
|
|
80
|
-
const { sorted: sortedEntityNames, circularFKs } = topologicalSortEntities(ir.entities);
|
|
81
|
-
|
|
82
|
-
// Build set of circular FK columns for stripping during schema generation
|
|
83
|
-
const circularFKSet = new Set(circularFKs.map(fk => `${fk.table}.${fk.column}`));
|
|
84
|
-
|
|
85
|
-
const entitySQL: string[] = [];
|
|
86
|
-
for (const name of sortedEntityNames) {
|
|
87
|
-
const entityIR = ir.entities[name];
|
|
88
|
-
// Pass circular FK info so schema generation can strip those constraints
|
|
89
|
-
entitySQL.push(generateSchemaSQL(name, entityIR, circularFKSet));
|
|
90
|
-
// Skip CRUD generation for unmanaged entities (e.g., junction tables)
|
|
91
|
-
if (entityIR.managed !== false) {
|
|
92
|
-
entitySQL.push(generateEntitySQL(name, entityIR));
|
|
93
|
-
} else {
|
|
94
|
-
console.log(`[Compiler] Skipping CRUD for unmanaged entity: ${name}`);
|
|
60
|
+
let compiled;
|
|
61
|
+
try {
|
|
62
|
+
compiled = compileDomain(domain);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e instanceof DomainValidationError) {
|
|
65
|
+
console.error("[Compiler] Validation Failed:");
|
|
66
|
+
e.errors.forEach(err => console.error(` - ${err}`));
|
|
67
|
+
process.exit(1);
|
|
95
68
|
}
|
|
69
|
+
throw e;
|
|
96
70
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (circularFKs.length > 0) {
|
|
100
|
-
entitySQL.push(generateDeferredFKConstraints(circularFKs));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Generate subscribable SQL functions
|
|
104
|
-
const subscribableSQL: string[] = [];
|
|
105
|
-
const subscribableNames = Object.keys(ir.subscribables);
|
|
106
|
-
for (const [name, subIR] of Object.entries(ir.subscribables)) {
|
|
107
|
-
console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
|
|
108
|
-
subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
|
|
109
|
-
}
|
|
110
|
-
// Generate central compute_affected_keys function that aggregates all subscribables
|
|
111
|
-
subscribableSQL.push(generateComputeAffectedKeysFunction(subscribableNames));
|
|
112
|
-
|
|
113
|
-
// Collect custom functions SQL
|
|
114
|
-
const customFunctionSQL: string[] = [];
|
|
115
|
-
for (const fn of ir.customFunctions) {
|
|
116
|
-
console.log(`[Compiler] Adding custom function: ${fn.name}`);
|
|
117
|
-
customFunctionSQL.push(fn.sql);
|
|
118
|
-
}
|
|
71
|
+
const { ir, coreSQL, schemaSQL } = compiled;
|
|
72
|
+
console.log(`[Compiler] IR Generated. Subscribables: ${Object.keys(ir.subscribables).join(', ')}`);
|
|
119
73
|
|
|
120
74
|
// Phase 4: Generate Manifest
|
|
121
75
|
const manifest = generateManifest(ir);
|
|
@@ -128,26 +82,15 @@ async function main() {
|
|
|
128
82
|
|
|
129
83
|
writeFileSync(resolve(dbDir, `000_core.sql`), coreSQL);
|
|
130
84
|
const timestamp = new Date().toISOString().replace(/[:.-]/g, '');
|
|
85
|
+
writeFileSync(resolve(dbDir, `${timestamp}_schema.sql`), schemaSQL);
|
|
131
86
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const authSQL = generateAuthSQL();
|
|
135
|
-
let schemaContent = entitySQL.join('\n');
|
|
136
|
-
schemaContent += '\n\n-- Auth Functions\n' + authSQL;
|
|
137
|
-
if (customFunctionSQL.length > 0) {
|
|
138
|
-
schemaContent += '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n');
|
|
139
|
-
}
|
|
140
|
-
writeFileSync(resolve(dbDir, `${timestamp}_schema.sql`), schemaContent);
|
|
141
|
-
|
|
142
|
-
if (customFunctionSQL.length > 0) {
|
|
143
|
-
console.log(`[Generated] ${customFunctionSQL.length} Custom Functions`);
|
|
87
|
+
if (compiled.customFunctionCount > 0) {
|
|
88
|
+
console.log(`[Generated] ${compiled.customFunctionCount} Custom Functions`);
|
|
144
89
|
}
|
|
145
90
|
|
|
146
91
|
// Write subscribable SQL
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
console.log(`[Generated] ${subscribableSQL.length} Subscribable SQL functions`);
|
|
150
|
-
}
|
|
92
|
+
writeFileSync(resolve(dbDir, `${timestamp}_subscribables.sql`), compiled.subscribableSQL);
|
|
93
|
+
console.log(`[Generated] Subscribable SQL for ${Object.keys(ir.subscribables).length} subscribables`);
|
|
151
94
|
|
|
152
95
|
console.log(`[Generated] DB Migrations in ${dbDir}`);
|
|
153
96
|
|
|
@@ -227,148 +170,3 @@ async function main() {
|
|
|
227
170
|
}
|
|
228
171
|
|
|
229
172
|
main();
|
|
230
|
-
|
|
231
|
-
/** FK info for dependency analysis */
|
|
232
|
-
interface FKInfo {
|
|
233
|
-
table: string;
|
|
234
|
-
column: string;
|
|
235
|
-
referencedTable: string;
|
|
236
|
-
fullConstraint: string; // e.g., "int NOT NULL REFERENCES users(id)"
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Result of topological sort with cycle detection */
|
|
240
|
-
interface SortResult {
|
|
241
|
-
sorted: string[];
|
|
242
|
-
circularFKs: FKInfo[];
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Topologically sort entities based on FK dependencies.
|
|
247
|
-
* Entities that are referenced by others come first.
|
|
248
|
-
* Uses Kahn's algorithm for topological sorting.
|
|
249
|
-
* Returns both sorted order and any circular FK constraints that need deferred creation.
|
|
250
|
-
*/
|
|
251
|
-
function topologicalSortEntities(entities: Record<string, any>): SortResult {
|
|
252
|
-
const entityNames = Object.keys(entities);
|
|
253
|
-
|
|
254
|
-
// Build dependency graph: entity -> entities it depends on (references)
|
|
255
|
-
const dependencies: Record<string, Set<string>> = {};
|
|
256
|
-
const dependents: Record<string, Set<string>> = {};
|
|
257
|
-
|
|
258
|
-
// Track all FK info for cycle detection
|
|
259
|
-
const allFKs: FKInfo[] = [];
|
|
260
|
-
|
|
261
|
-
for (const name of entityNames) {
|
|
262
|
-
dependencies[name] = new Set();
|
|
263
|
-
dependents[name] = new Set();
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Parse REFERENCES from column types
|
|
267
|
-
for (const name of entityNames) {
|
|
268
|
-
const entity = entities[name];
|
|
269
|
-
for (const col of entity.columns || []) {
|
|
270
|
-
const match = col.type?.match(/REFERENCES\s+(\w+)/i);
|
|
271
|
-
if (match) {
|
|
272
|
-
const referencedEntity = match[1];
|
|
273
|
-
// Only track dependencies to entities we're managing
|
|
274
|
-
if (entityNames.includes(referencedEntity)) {
|
|
275
|
-
dependencies[name].add(referencedEntity);
|
|
276
|
-
dependents[referencedEntity].add(name);
|
|
277
|
-
allFKs.push({
|
|
278
|
-
table: name,
|
|
279
|
-
column: col.name,
|
|
280
|
-
referencedTable: referencedEntity,
|
|
281
|
-
fullConstraint: col.type
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Kahn's algorithm
|
|
289
|
-
const result: string[] = [];
|
|
290
|
-
const noIncoming: string[] = [];
|
|
291
|
-
const remainingDeps: Record<string, Set<string>> = {};
|
|
292
|
-
|
|
293
|
-
// Copy dependencies for mutation
|
|
294
|
-
for (const name of entityNames) {
|
|
295
|
-
remainingDeps[name] = new Set(dependencies[name]);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Find entities with no dependencies (no incoming edges)
|
|
299
|
-
for (const name of entityNames) {
|
|
300
|
-
if (remainingDeps[name].size === 0) {
|
|
301
|
-
noIncoming.push(name);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
while (noIncoming.length > 0) {
|
|
306
|
-
const node = noIncoming.shift()!;
|
|
307
|
-
result.push(node);
|
|
308
|
-
|
|
309
|
-
// Remove this node from the graph
|
|
310
|
-
for (const dependent of dependents[node]) {
|
|
311
|
-
remainingDeps[dependent].delete(node);
|
|
312
|
-
if (remainingDeps[dependent].size === 0) {
|
|
313
|
-
noIncoming.push(dependent);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Detect circular FKs
|
|
319
|
-
const circularFKs: FKInfo[] = [];
|
|
320
|
-
if (result.length !== entityNames.length) {
|
|
321
|
-
const cycleEntities = new Set(entityNames.filter(n => !result.includes(n)));
|
|
322
|
-
console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${[...cycleEntities].join(', ')}`);
|
|
323
|
-
|
|
324
|
-
// Find FKs that are part of the cycle - these need deferred creation
|
|
325
|
-
for (const fk of allFKs) {
|
|
326
|
-
if (cycleEntities.has(fk.table) && cycleEntities.has(fk.referencedTable)) {
|
|
327
|
-
circularFKs.push(fk);
|
|
328
|
-
console.log(`[Compiler] Deferring circular FK: ${fk.table}.${fk.column} -> ${fk.referencedTable}`);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Add remaining entities (cycle members) to result
|
|
333
|
-
result.push(...cycleEntities);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return { sorted: result, circularFKs };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Generate ALTER TABLE statements for deferred circular FK constraints.
|
|
341
|
-
*/
|
|
342
|
-
function generateDeferredFKConstraints(circularFKs: FKInfo[]): string {
|
|
343
|
-
if (circularFKs.length === 0) return '';
|
|
344
|
-
|
|
345
|
-
const statements: string[] = [
|
|
346
|
-
'\n-- === DEFERRED FK CONSTRAINTS (for circular dependencies) ===\n'
|
|
347
|
-
];
|
|
348
|
-
|
|
349
|
-
for (const fk of circularFKs) {
|
|
350
|
-
// Extract the REFERENCES part from the full constraint
|
|
351
|
-
const refMatch = fk.fullConstraint.match(/REFERENCES\s+(\w+)(\([^)]+\))?(\s+ON\s+.+)?/i);
|
|
352
|
-
if (refMatch) {
|
|
353
|
-
const targetTable = refMatch[1];
|
|
354
|
-
const targetCol = refMatch[2] || '(id)';
|
|
355
|
-
const cascadeClause = refMatch[3] || '';
|
|
356
|
-
const constraintName = `fk_${fk.table}_${fk.column}`;
|
|
357
|
-
|
|
358
|
-
statements.push(`ALTER TABLE ${fk.table} ADD CONSTRAINT ${constraintName} FOREIGN KEY (${fk.column}) REFERENCES ${targetTable}${targetCol}${cascadeClause};`);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return statements.join('\n');
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Remove REFERENCES clause from a column type for deferred FK creation.
|
|
367
|
-
* Preserves NOT NULL and other modifiers.
|
|
368
|
-
*/
|
|
369
|
-
function stripReferences(columnType: string): string {
|
|
370
|
-
// Remove REFERENCES clause and any ON DELETE/UPDATE clauses
|
|
371
|
-
return columnType
|
|
372
|
-
.replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*\s*/gi, ' ')
|
|
373
|
-
.trim();
|
|
374
|
-
}
|
package/src/client/ws.ts
CHANGED
|
@@ -51,6 +51,8 @@ export class WebSocketManager {
|
|
|
51
51
|
protected maxReconnectAttempts = 5;
|
|
52
52
|
protected tokenName = 'dzql_token';
|
|
53
53
|
protected isShuttingDown = false;
|
|
54
|
+
/** The URL last given to connect(): a reconnect goes back to it. */
|
|
55
|
+
protected url: string | null = null;
|
|
54
56
|
|
|
55
57
|
public user: any = null;
|
|
56
58
|
public ready: boolean = false;
|
|
@@ -101,15 +103,16 @@ export class WebSocketManager {
|
|
|
101
103
|
this.ready = false;
|
|
102
104
|
this.ws?.close();
|
|
103
105
|
this.ws = null;
|
|
104
|
-
this.isShuttingDown = false;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
connect(url: string | null = null, timeout = 5000): Promise<void> {
|
|
108
109
|
return new Promise((resolve, reject) => {
|
|
109
110
|
this.ready = false;
|
|
110
111
|
this.user = null;
|
|
112
|
+
this.isShuttingDown = false;
|
|
111
113
|
|
|
112
|
-
let wsUrl = url;
|
|
114
|
+
let wsUrl = url ?? this.url;
|
|
115
|
+
if (url) this.url = url;
|
|
113
116
|
if (!wsUrl) {
|
|
114
117
|
if (typeof globalThis !== "undefined" && "window" in globalThis) {
|
|
115
118
|
const win = globalThis as unknown as { location: { protocol: string; host: string } };
|
|
@@ -128,19 +131,20 @@ export class WebSocketManager {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
const connectionTimeout = setTimeout(() => {
|
|
131
|
-
|
|
134
|
+
socket.close();
|
|
132
135
|
reject(new Error('WebSocket connection timed out'));
|
|
133
136
|
}, timeout);
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
const socket = new WebSocket(wsUrl);
|
|
139
|
+
this.ws = socket;
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
socket.onopen = () => {
|
|
138
142
|
clearTimeout(connectionTimeout);
|
|
139
143
|
this.reconnectAttempts = 0;
|
|
140
144
|
resolve();
|
|
141
145
|
};
|
|
142
146
|
|
|
143
|
-
|
|
147
|
+
socket.onmessage = (event) => {
|
|
144
148
|
try {
|
|
145
149
|
this.handleMessage(JSON.parse(event.data));
|
|
146
150
|
} catch (error) {
|
|
@@ -148,13 +152,14 @@ export class WebSocketManager {
|
|
|
148
152
|
}
|
|
149
153
|
};
|
|
150
154
|
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
// only the current socket reconnects: one replaced by a later connect() or a disconnect() does not
|
|
156
|
+
socket.onclose = () => {
|
|
157
|
+
if (this.ws === socket && !this.isShuttingDown) {
|
|
153
158
|
this.attemptReconnect();
|
|
154
159
|
}
|
|
155
160
|
};
|
|
156
161
|
|
|
157
|
-
|
|
162
|
+
socket.onerror = (error) => {
|
|
158
163
|
clearTimeout(connectionTimeout);
|
|
159
164
|
reject(error);
|
|
160
165
|
};
|
|
@@ -164,7 +169,12 @@ export class WebSocketManager {
|
|
|
164
169
|
attemptReconnect() {
|
|
165
170
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
166
171
|
this.reconnectAttempts++;
|
|
167
|
-
setTimeout(() =>
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
// a disconnect() since, or a connect() already open or opening, needs no reconnect
|
|
174
|
+
if (this.isShuttingDown || (this.ws && this.ws.readyState <= WebSocket.OPEN)) return;
|
|
175
|
+
// a failed attempt closes, and its onclose tries again: nothing to reject to
|
|
176
|
+
this.connect().catch(() => {});
|
|
177
|
+
}, 1000 * this.reconnectAttempts);
|
|
168
178
|
}
|
|
169
179
|
}
|
|
170
180
|
|
package/src/runtime/auth.ts
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
import { jwtVerify, SignJWT } from "jose";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
export const DEV_JWT_SECRET = "default_dev_secret";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the JWT signing secret, failing closed in production.
|
|
7
|
+
*
|
|
8
|
+
* A hardcoded default secret is catastrophic: anyone can forge a token for any
|
|
9
|
+
* user_id and impersonate any account. So in production (NODE_ENV=production) we
|
|
10
|
+
* refuse to start unless JWT_SECRET is set to something other than the dev
|
|
11
|
+
* default. Outside production we fall back to the dev secret with a loud warning
|
|
12
|
+
* so local development and tests still work.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string {
|
|
15
|
+
const secret = env.JWT_SECRET;
|
|
16
|
+
const isProd = env.NODE_ENV === "production";
|
|
17
|
+
|
|
18
|
+
if (!secret || secret === DEV_JWT_SECRET) {
|
|
19
|
+
if (isProd) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"JWT_SECRET must be set to a strong, unique value in production; " +
|
|
22
|
+
"refusing to start with a missing or default development secret.",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
console.warn(
|
|
26
|
+
"[dzql] JWT_SECRET is not set; using an insecure development secret. " +
|
|
27
|
+
"Set JWT_SECRET to a strong value before deploying.",
|
|
28
|
+
);
|
|
29
|
+
return DEV_JWT_SECRET;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return secret;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SECRET_KEY = new TextEncoder().encode(resolveJwtSecret());
|
|
5
36
|
|
|
6
37
|
export interface UserSession {
|
|
7
38
|
userId: number;
|
package/src/runtime/index.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { serve } from "bun";
|
|
|
2
2
|
import { config } from "dotenv";
|
|
3
3
|
import { Database } from "./db.js";
|
|
4
4
|
import { WebSocketServer } from "./ws.js";
|
|
5
|
-
import { loadManifest } from "./manifest_loader.js";
|
|
5
|
+
import { loadManifest, getEntityHidden } from "./manifest_loader.js";
|
|
6
|
+
import { projectVisible } from "./projection.js";
|
|
6
7
|
import { readFileSync } from "fs";
|
|
7
8
|
import { resolve } from "path";
|
|
8
9
|
import { runtimeLogger, notifyLogger, serverLogger } from "./logger.js";
|
|
@@ -44,7 +45,7 @@ const wsServer = new WebSocketServer(db);
|
|
|
44
45
|
*
|
|
45
46
|
* Each connection receives the event at most once, even if matched by both.
|
|
46
47
|
*/
|
|
47
|
-
function processEventNotifications(event: {
|
|
48
|
+
async function processEventNotifications(event: {
|
|
48
49
|
table: string;
|
|
49
50
|
op: string;
|
|
50
51
|
pk: Record<string, unknown>;
|
|
@@ -52,7 +53,7 @@ function processEventNotifications(event: {
|
|
|
52
53
|
user_id: number | null;
|
|
53
54
|
affected_keys: string[];
|
|
54
55
|
notify_users: number[];
|
|
55
|
-
}) {
|
|
56
|
+
}): Promise<void> {
|
|
56
57
|
const { table, op, pk, data, affected_keys, notify_users } = event;
|
|
57
58
|
|
|
58
59
|
notifyLogger.debug(`processEventNotifications called for ${table}:${op}`);
|
|
@@ -62,11 +63,24 @@ function processEventNotifications(event: {
|
|
|
62
63
|
// Track which connections we've already notified to avoid duplicates
|
|
63
64
|
const notifiedConnections = new Set<string>();
|
|
64
65
|
|
|
66
|
+
// Project out the entity's hidden columns before broadcasting. The stored
|
|
67
|
+
// event row is full (routing needs it) but subscribers must only ever see
|
|
68
|
+
// what the getter would return — never e.g. users.password_hash on an update.
|
|
69
|
+
const visibleData = projectVisible(data, getEntityHidden(table));
|
|
70
|
+
|
|
65
71
|
// Build the event message for entity broadcasts
|
|
66
72
|
const entityEventMessage = JSON.stringify({
|
|
67
73
|
jsonrpc: "2.0",
|
|
68
74
|
method: `${table}:${op}`,
|
|
69
|
-
params: { table, op, pk, data }
|
|
75
|
+
params: { table, op, pk, data: visibleData }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// The same event carrying only its key, for a subscriber who may be told a
|
|
79
|
+
// row was deleted but not what it contained.
|
|
80
|
+
const keyOnlyEventMessage = JSON.stringify({
|
|
81
|
+
jsonrpc: "2.0",
|
|
82
|
+
method: `${table}:${op}`,
|
|
83
|
+
params: { table, op, pk, data: pk }
|
|
70
84
|
});
|
|
71
85
|
|
|
72
86
|
// 1. Send to users in notify_users list (entity notifications)
|
|
@@ -102,7 +116,34 @@ function processEventNotifications(event: {
|
|
|
102
116
|
if (affected_keys.includes(subKey)) {
|
|
103
117
|
// Send entity broadcast (not subscription:event) so global dispatcher can route it
|
|
104
118
|
if (!notifiedConnections.has(sub.connection_id)) {
|
|
105
|
-
|
|
119
|
+
// Re-authorize on push: a matching affected_key means the change is
|
|
120
|
+
// relevant to this subscription's params, not that the subscriber may
|
|
121
|
+
// see it. can_receive re-runs can_subscribe (so revoked access stops
|
|
122
|
+
// updates) and, for a list, checks this row against the subscriber's
|
|
123
|
+
// view rules and documents. It answers 'row', 'pk' (send the key only,
|
|
124
|
+
// for a delete), or NULL. Fail closed.
|
|
125
|
+
// Pass params and data as objects so postgres serializes them to jsonb;
|
|
126
|
+
// do NOT JSON.stringify + ::jsonb, which double-encodes into a json
|
|
127
|
+
// string scalar and makes every ->> lookup null (and the check fail).
|
|
128
|
+
let receive: string | null = null;
|
|
129
|
+
try {
|
|
130
|
+
const rows: any = await db.query(
|
|
131
|
+
`SELECT dzql_v2.${subscribableName}_can_receive($1, $2, $3, $4, $5) AS receive`,
|
|
132
|
+
[sub.user_id, sub.params ?? {}, table, op, data ?? {}]
|
|
133
|
+
);
|
|
134
|
+
receive = rows?.[0]?.receive ?? null;
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
notifyLogger.error(`can_receive check failed for ${subscribableName}: ${e.message}`);
|
|
137
|
+
receive = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (receive !== 'row' && receive !== 'pk') {
|
|
141
|
+
notifyLogger.debug(`Suppressed push to ${sub.connection_id.slice(0, 8)}... (may not see this ${table} row via ${subscribableName})`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const message = receive === 'row' ? entityEventMessage : keyOnlyEventMessage;
|
|
146
|
+
const sent = wsServer.toConnection(sub.connection_id, message);
|
|
106
147
|
if (sent) {
|
|
107
148
|
notifiedConnections.add(sub.connection_id);
|
|
108
149
|
notifyLogger.debug(`Sent entity broadcast to ${sub.connection_id.slice(0, 8)}... via ${subscribableName} (${table}:${op})`);
|
|
@@ -135,7 +176,7 @@ async function startListener() {
|
|
|
135
176
|
notifyLogger.debug(`Fetched ${events.length} events for commit ${commit_id}`);
|
|
136
177
|
for (const event of events) {
|
|
137
178
|
notifyLogger.debug(`Processing event: ${event.table_name}:${event.op}`);
|
|
138
|
-
processEventNotifications({
|
|
179
|
+
await processEventNotifications({
|
|
139
180
|
table: event.table_name,
|
|
140
181
|
op: event.op,
|
|
141
182
|
pk: event.pk,
|
|
@@ -16,6 +16,16 @@ export function getManifest(): Manifest {
|
|
|
16
16
|
return activeManifest;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Hidden columns for an entity, from the loaded manifest. Returns [] when the
|
|
21
|
+
* manifest or entity is unknown so callers can treat "no manifest" as "hide
|
|
22
|
+
* nothing extra" without throwing on the realtime hot path.
|
|
23
|
+
*/
|
|
24
|
+
export function getEntityHidden(table: string): string[] {
|
|
25
|
+
const entity = activeManifest?.entities?.[table] as { hidden?: string[] } | undefined;
|
|
26
|
+
return entity?.hidden ?? [];
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export function resolveFunction(name: string) {
|
|
20
30
|
const manifest = getManifest();
|
|
21
31
|
const fn = manifest.functions[name];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip an entity's hidden columns from a row before it is pushed to clients.
|
|
3
|
+
*
|
|
4
|
+
* The realtime path stores the full row in the events table (routing needs it),
|
|
5
|
+
* but what is broadcast to subscribers must match what the getter would return:
|
|
6
|
+
* a hidden column (e.g. users.password_hash) must never ride out on a live
|
|
7
|
+
* update even though it is present in the stored event data.
|
|
8
|
+
*/
|
|
9
|
+
export function projectVisible<T extends Record<string, unknown>>(
|
|
10
|
+
data: T | null | undefined,
|
|
11
|
+
hidden: string[] | undefined,
|
|
12
|
+
): T | null | undefined {
|
|
13
|
+
if (!data || !hidden || hidden.length === 0) return data;
|
|
14
|
+
const out: Record<string, unknown> = {};
|
|
15
|
+
for (const [key, value] of Object.entries(data)) {
|
|
16
|
+
if (!hidden.includes(key)) out[key] = value;
|
|
17
|
+
}
|
|
18
|
+
return out as T;
|
|
19
|
+
}
|