dzql 0.6.36 → 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.
@@ -1,43 +1,43 @@
1
1
  /**
2
- * Compiles a permission rule into SQL.
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
- * - Deep traversal: @site_id->sites.venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
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
- // Parse the path
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
- console.warn(`[Compiler] Warning: ${parsed.error}`);
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 { analyzeDomain } from "./compiler/analyzer.js";
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
- const errors = analyzeDomain(domain);
64
- if (errors.length > 0) {
65
- console.error("[Compiler] Validation Failed:");
66
- errors.forEach(err => console.error(` - ${err}`));
67
- process.exit(1);
68
- }
69
-
70
- // Phase 2: Generate IR
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
- // Add deferred FK constraints at the end (for circular dependencies)
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
- // Combine entity SQL with auth functions and custom functions
133
- // Auth functions must come after schema (they depend on users table)
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
- if (subscribableSQL.length > 0) {
148
- writeFileSync(resolve(dbDir, `${timestamp}_subscribables.sql`), subscribableSQL.join('\n\n'));
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
- this.ws?.close();
134
+ socket.close();
132
135
  reject(new Error('WebSocket connection timed out'));
133
136
  }, timeout);
134
137
 
135
- this.ws = new WebSocket(wsUrl);
138
+ const socket = new WebSocket(wsUrl);
139
+ this.ws = socket;
136
140
 
137
- this.ws.onopen = () => {
141
+ socket.onopen = () => {
138
142
  clearTimeout(connectionTimeout);
139
143
  this.reconnectAttempts = 0;
140
144
  resolve();
141
145
  };
142
146
 
143
- this.ws.onmessage = (event) => {
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
- this.ws.onclose = () => {
152
- if (!this.isShuttingDown) {
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
- this.ws.onerror = (error) => {
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(() => this.connect(), 1000 * this.reconnectAttempts);
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
 
@@ -1,7 +1,38 @@
1
1
  import { jwtVerify, SignJWT } from "jose";
2
2
 
3
- const JWT_SECRET = process.env.JWT_SECRET || "default_dev_secret";
4
- const SECRET_KEY = new TextEncoder().encode(JWT_SECRET);
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;
@@ -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
- const sent = wsServer.toConnection(sub.connection_id, entityEventMessage);
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
+ }