dzql 0.6.8 → 0.6.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,7 +7,11 @@ import type { GraphRuleIR } from "../../shared/ir.js";
7
7
  * @param trigger - The trigger context ('create', 'update', 'delete')
8
8
  * @param castToInt - Whether to cast the result to integer (for FK columns)
9
9
  */
10
- function resolveValue(value: string, trigger: string, castToInt: boolean = false): string {
10
+ function resolveValue(value: string | null, trigger: string, castToInt: boolean = false): string {
11
+ if (value === null) {
12
+ return 'NULL';
13
+ }
14
+
11
15
  if (typeof value !== 'string') {
12
16
  return String(value);
13
17
  }
package/src/client/ws.ts CHANGED
@@ -49,7 +49,7 @@ export class WebSocketManager {
49
49
 
50
50
  async login(credentials: any) {
51
51
  try {
52
- const result = await this.call('login_user', credentials);
52
+ const result = await this.call('login_user', credentials) as { token?: string };
53
53
  if (result && result.token) {
54
54
  if (typeof localStorage !== 'undefined') {
55
55
  localStorage.setItem(this.tokenName, result.token);
@@ -69,7 +69,7 @@ export class WebSocketManager {
69
69
  async register(credentials: any, options: any = {}) {
70
70
  try {
71
71
  const params = { ...credentials, options };
72
- const result = await this.call('register_user', params);
72
+ const result = await this.call('register_user', params) as { token?: string };
73
73
  if (result && result.token) {
74
74
  if (typeof localStorage !== 'undefined') {
75
75
  localStorage.setItem(this.tokenName, result.token);
@@ -98,9 +98,10 @@ export class WebSocketManager {
98
98
 
99
99
  let wsUrl = url;
100
100
  if (!wsUrl) {
101
- if (typeof window !== "undefined") {
102
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
103
- wsUrl = protocol + "//" + window.location.host + "/ws";
101
+ if (typeof globalThis !== "undefined" && "window" in globalThis) {
102
+ const win = globalThis as unknown as { location: { protocol: string; host: string } };
103
+ const protocol = win.location.protocol === "https:" ? "wss:" : "ws:";
104
+ wsUrl = protocol + "//" + win.location.host + "/ws";
104
105
  } else {
105
106
  wsUrl = "ws://localhost:3000/ws";
106
107
  }
@@ -5,6 +5,7 @@ import { WebSocketServer } from "./ws.js";
5
5
  import { loadManifest } from "./manifest_loader.js";
6
6
  import { readFileSync } from "fs";
7
7
  import { resolve } from "path";
8
+ import { runtimeLogger, notifyLogger, serverLogger } from "./logger.js";
8
9
 
9
10
  // Re-export JS function registration API for custom functions
10
11
  export { registerJsFunction, type JsFunctionHandler, type JsFunctionContext } from "./js_functions.js";
@@ -26,9 +27,9 @@ try {
26
27
  const manifestContent = readFileSync(manifestPath, "utf-8");
27
28
  const manifest = JSON.parse(manifestContent);
28
29
  loadManifest(manifest);
29
- console.log(`[Runtime] Loaded Manifest v${manifest.version}`);
30
+ runtimeLogger.info(`Loaded Manifest v${manifest.version}`);
30
31
  } catch (e) {
31
- console.warn(`[Runtime] Warning: Could not load manifest from ${MANIFEST_PATH}. Ensure you have compiled the project.`);
32
+ runtimeLogger.warn(`Could not load manifest from ${MANIFEST_PATH}. Ensure you have compiled the project.`);
32
33
  }
33
34
 
34
35
  // 3. Initialize WebSocket Server
@@ -36,12 +37,12 @@ const wsServer = new WebSocketServer(db);
36
37
 
37
38
  // 4. Start Commit Listener (Realtime)
38
39
  async function startListener() {
39
- console.log("[Runtime] Setting up LISTEN on dzql_v2 channel...");
40
+ runtimeLogger.info("Setting up LISTEN on dzql_v2 channel...");
40
41
  await db.listen("dzql_v2", async (payload) => {
41
- console.log(`[Runtime] RAW NOTIFY received:`, payload);
42
+ notifyLogger.debug(`RAW NOTIFY received:`, payload);
42
43
  try {
43
44
  const { commit_id } = JSON.parse(payload);
44
- console.log(`[Runtime] Received Commit: ${commit_id}`);
45
+ notifyLogger.debug(`Received Commit: ${commit_id}`);
45
46
 
46
47
  // Fetch events
47
48
  const events = await db.query(`
@@ -67,12 +68,13 @@ async function startListener() {
67
68
  }
68
69
  });
69
70
  wsServer.broadcast(message);
71
+ notifyLogger.trace(`Broadcast ${event.op} on ${event.table_name}`);
70
72
  }
71
- } catch (e) {
72
- console.error("[Runtime] Listener Error:", e);
73
+ } catch (e: any) {
74
+ notifyLogger.error("Listener Error:", e.message);
73
75
  }
74
76
  });
75
- console.log("[Runtime] Listening for DB Events...");
77
+ runtimeLogger.info("Listening for DB Events...");
76
78
  }
77
79
 
78
80
  // Wait for listener to be ready before starting server
@@ -85,14 +87,14 @@ const server = serve({
85
87
  const url = new URL(req.url);
86
88
 
87
89
  // Extract token from query params for WebSocket connections
88
- const token = url.searchParams.get("token");
90
+ const token = url.searchParams.get("token") ?? undefined;
89
91
 
90
92
  if (server.upgrade(req, { data: { token } })) {
91
- return;
93
+ return;
92
94
  }
93
95
  return new Response("DZQL Runtime Active", { status: 200 });
94
96
  },
95
97
  websocket: wsServer.handlers
96
98
  });
97
99
 
98
- console.log(`[Runtime] Server listening on port ${PORT}`);
100
+ serverLogger.info(`Server listening on port ${PORT}`);
@@ -2,7 +2,7 @@
2
2
  // Allows registering JS/Bun functions that can be called via RPC
3
3
 
4
4
  export interface JsFunctionContext {
5
- userId: number;
5
+ userId: number | null;
6
6
  params: any;
7
7
  db: {
8
8
  query(sql: string, params?: any[]): Promise<any[]>;
@@ -0,0 +1,308 @@
1
+ // logger.ts - Flexible logging system with categories and levels
2
+
3
+ // Log levels
4
+ const LOG_LEVELS: Record<string, number> = {
5
+ ERROR: 0,
6
+ WARN: 1,
7
+ INFO: 2,
8
+ DEBUG: 3,
9
+ TRACE: 4,
10
+ };
11
+
12
+ // Default log level from environment or INFO
13
+ const DEFAULT_LEVEL = process.env.LOG_LEVEL?.toUpperCase() || "INFO";
14
+
15
+ // Parse LOG_CATEGORIES from environment
16
+ // Format: "ws:debug,db:trace,auth:info" or "*:debug" for all
17
+ const parseCategories = (): Record<string, number> => {
18
+ const categories: Record<string, number> = {};
19
+ const envCategories = process.env.LOG_CATEGORIES || "";
20
+
21
+ if (!envCategories) {
22
+ // Default: INFO level unless in production/test
23
+ if (process.env.NODE_ENV === "production") {
24
+ categories["*"] = LOG_LEVELS.ERROR;
25
+ } else if (process.env.NODE_ENV === "test") {
26
+ categories["*"] = LOG_LEVELS.ERROR;
27
+ } else {
28
+ categories["*"] = LOG_LEVELS[DEFAULT_LEVEL] ?? LOG_LEVELS.INFO;
29
+ }
30
+ return categories;
31
+ }
32
+
33
+ // Parse category:level pairs
34
+ envCategories.split(",").forEach((pair) => {
35
+ const [category, level] = pair.trim().split(":");
36
+ if (category && level) {
37
+ const levelValue = LOG_LEVELS[level.toUpperCase()];
38
+ if (levelValue !== undefined) {
39
+ categories[category] = levelValue;
40
+ }
41
+ }
42
+ });
43
+
44
+ // Set default for non-specified categories
45
+ if (!categories["*"]) {
46
+ categories["*"] = LOG_LEVELS[DEFAULT_LEVEL] ?? LOG_LEVELS.INFO;
47
+ }
48
+
49
+ return categories;
50
+ };
51
+
52
+ // Initialize categories
53
+ let logCategories = parseCategories();
54
+
55
+ // Colors for terminal output
56
+ const colors = {
57
+ reset: "\x1b[0m",
58
+ bright: "\x1b[1m",
59
+ dim: "\x1b[2m",
60
+ red: "\x1b[31m",
61
+ green: "\x1b[32m",
62
+ yellow: "\x1b[33m",
63
+ blue: "\x1b[34m",
64
+ magenta: "\x1b[35m",
65
+ cyan: "\x1b[36m",
66
+ white: "\x1b[37m",
67
+ gray: "\x1b[90m",
68
+ };
69
+
70
+ // Level colors
71
+ const levelColors: Record<string, string> = {
72
+ ERROR: colors.red,
73
+ WARN: colors.yellow,
74
+ INFO: colors.blue,
75
+ DEBUG: colors.cyan,
76
+ TRACE: colors.gray,
77
+ };
78
+
79
+ // Category colors
80
+ const categoryColors: Record<string, string> = {
81
+ ws: colors.green,
82
+ db: colors.magenta,
83
+ auth: colors.yellow,
84
+ api: colors.cyan,
85
+ server: colors.blue,
86
+ notify: colors.magenta,
87
+ access: colors.green,
88
+ runtime: colors.blue,
89
+ };
90
+
91
+ // Format timestamp
92
+ const timestamp = (): string => {
93
+ const now = new Date();
94
+ return now.toISOString().replace("T", " ").slice(0, -5);
95
+ };
96
+
97
+ // Check if logging is enabled for category and level
98
+ const shouldLog = (category: string, level: string): boolean => {
99
+ const categoryLevel = logCategories[category] ?? logCategories["*"] ?? LOG_LEVELS.INFO;
100
+ return LOG_LEVELS[level] <= categoryLevel;
101
+ };
102
+
103
+ // Format log message with colors
104
+ const formatMessage = (category: string, level: string, message: string, ...args: any[]): any[] => {
105
+ const useColors = process.env.NO_COLOR !== "1" && process.env.NODE_ENV !== "test";
106
+
107
+ if (useColors) {
108
+ const catColor = categoryColors[category] || colors.white;
109
+ const lvlColor = levelColors[level];
110
+
111
+ return [
112
+ `${colors.gray}${timestamp()}${colors.reset}`,
113
+ `${lvlColor}[${level}]${colors.reset}`,
114
+ `${catColor}[${category}]${colors.reset}`,
115
+ message,
116
+ ...args,
117
+ ];
118
+ } else {
119
+ return [timestamp(), `[${level}]`, `[${category}]`, message, ...args];
120
+ }
121
+ };
122
+
123
+ export interface Logger {
124
+ error: (message: string, ...args: any[]) => void;
125
+ warn: (message: string, ...args: any[]) => void;
126
+ info: (message: string, ...args: any[]) => void;
127
+ debug: (message: string, ...args: any[]) => void;
128
+ trace: (message: string, ...args: any[]) => void;
129
+ request: (method: string, params?: any) => void;
130
+ response: (method: string, result: any, duration: number) => void;
131
+ }
132
+
133
+ // Create logger for a specific category
134
+ export const createLogger = (category: string): Logger => {
135
+ return {
136
+ error: (message: string, ...args: any[]) => {
137
+ if (shouldLog(category, "ERROR")) {
138
+ console.error(...formatMessage(category, "ERROR", message, ...args));
139
+ }
140
+ },
141
+ warn: (message: string, ...args: any[]) => {
142
+ if (shouldLog(category, "WARN")) {
143
+ console.warn(...formatMessage(category, "WARN", message, ...args));
144
+ }
145
+ },
146
+ info: (message: string, ...args: any[]) => {
147
+ if (shouldLog(category, "INFO")) {
148
+ console.log(...formatMessage(category, "INFO", message, ...args));
149
+ }
150
+ },
151
+ debug: (message: string, ...args: any[]) => {
152
+ if (shouldLog(category, "DEBUG")) {
153
+ console.log(...formatMessage(category, "DEBUG", message, ...args));
154
+ }
155
+ },
156
+ trace: (message: string, ...args: any[]) => {
157
+ if (shouldLog(category, "TRACE")) {
158
+ console.log(...formatMessage(category, "TRACE", message, ...args));
159
+ }
160
+ },
161
+ // For request/response logging
162
+ request: (method: string, params?: any) => {
163
+ if (shouldLog(category, "DEBUG")) {
164
+ console.log(
165
+ ...formatMessage(category, "DEBUG", `→ ${method}`, params ? JSON.stringify(params) : "")
166
+ );
167
+ }
168
+ },
169
+ response: (method: string, result: any, duration: number) => {
170
+ if (shouldLog(category, "DEBUG")) {
171
+ const resultStr =
172
+ result === undefined
173
+ ? "void"
174
+ : typeof result === "object"
175
+ ? `${JSON.stringify(result).slice(0, 100)}...`
176
+ : result;
177
+ console.log(...formatMessage(category, "DEBUG", `← ${method} (${duration}ms)`, resultStr));
178
+ }
179
+ },
180
+ };
181
+ };
182
+
183
+ // Pre-configured loggers for common categories
184
+ export const wsLogger = createLogger("ws");
185
+ export const dbLogger = createLogger("db");
186
+ export const authLogger = createLogger("auth");
187
+ export const serverLogger = createLogger("server");
188
+ export const notifyLogger = createLogger("notify");
189
+ export const accessLogger = createLogger("access");
190
+ export const runtimeLogger = createLogger("runtime");
191
+
192
+ // Reload configuration (useful for runtime changes)
193
+ export const reloadConfig = (): void => {
194
+ logCategories = parseCategories();
195
+ };
196
+
197
+ // Get current configuration
198
+ export const getConfig = (): { categories: Record<string, number>; levels: Record<string, number> } => {
199
+ return {
200
+ categories: logCategories,
201
+ levels: LOG_LEVELS,
202
+ };
203
+ };
204
+
205
+ // Middleware for timing operations
206
+ export const timed = async <T>(category: string, operation: string, fn: () => Promise<T>): Promise<T> => {
207
+ const logger = createLogger(category);
208
+ const start = Date.now();
209
+
210
+ try {
211
+ logger.trace(`Starting ${operation}`);
212
+ const result = await fn();
213
+ const duration = Date.now() - start;
214
+ logger.debug(`Completed ${operation} in ${duration}ms`);
215
+ return result;
216
+ } catch (error: any) {
217
+ const duration = Date.now() - start;
218
+ logger.error(`Failed ${operation} after ${duration}ms:`, error.message);
219
+ throw error;
220
+ }
221
+ };
222
+
223
+ // Access log format: "method path status duration"
224
+ export const logAccess = (
225
+ method: string,
226
+ path: string,
227
+ status: number,
228
+ duration: number,
229
+ userId?: number | null
230
+ ): void => {
231
+ if (shouldLog("access", "INFO")) {
232
+ const userStr = userId ? `user:${userId}` : "anon";
233
+ const statusColor = status >= 400 ? colors.red : status >= 300 ? colors.yellow : colors.green;
234
+ const useColors = process.env.NO_COLOR !== "1" && process.env.NODE_ENV !== "test";
235
+
236
+ if (useColors) {
237
+ console.log(
238
+ `${colors.gray}${timestamp()}${colors.reset}`,
239
+ `${colors.blue}[INFO]${colors.reset}`,
240
+ `${colors.green}[access]${colors.reset}`,
241
+ `${method} ${path}`,
242
+ `${statusColor}${status}${colors.reset}`,
243
+ `${duration}ms`,
244
+ `${colors.gray}${userStr}${colors.reset}`
245
+ );
246
+ } else {
247
+ console.log(timestamp(), "[INFO]", "[access]", `${method} ${path}`, status, `${duration}ms`, userStr);
248
+ }
249
+ }
250
+ };
251
+
252
+ // WebSocket access log
253
+ export const logWsAccess = (
254
+ action: string,
255
+ method: string,
256
+ duration: number,
257
+ userId?: number | null,
258
+ error?: string
259
+ ): void => {
260
+ if (shouldLog("access", "INFO")) {
261
+ const userStr = userId ? `user:${userId}` : "anon";
262
+ const useColors = process.env.NO_COLOR !== "1" && process.env.NODE_ENV !== "test";
263
+ const statusColor = error ? colors.red : colors.green;
264
+ const status = error ? "ERR" : "OK";
265
+
266
+ if (useColors) {
267
+ console.log(
268
+ `${colors.gray}${timestamp()}${colors.reset}`,
269
+ `${colors.blue}[INFO]${colors.reset}`,
270
+ `${colors.green}[access]${colors.reset}`,
271
+ `WS ${action}`,
272
+ method,
273
+ `${statusColor}${status}${colors.reset}`,
274
+ `${duration}ms`,
275
+ `${colors.gray}${userStr}${colors.reset}`,
276
+ error ? `${colors.red}${error}${colors.reset}` : ""
277
+ );
278
+ } else {
279
+ console.log(
280
+ timestamp(),
281
+ "[INFO]",
282
+ "[access]",
283
+ `WS ${action}`,
284
+ method,
285
+ status,
286
+ `${duration}ms`,
287
+ userStr,
288
+ error || ""
289
+ );
290
+ }
291
+ }
292
+ };
293
+
294
+ export default {
295
+ createLogger,
296
+ wsLogger,
297
+ dbLogger,
298
+ authLogger,
299
+ serverLogger,
300
+ notifyLogger,
301
+ accessLogger,
302
+ runtimeLogger,
303
+ reloadConfig,
304
+ getConfig,
305
+ timed,
306
+ logAccess,
307
+ logWsAccess,
308
+ };
@@ -1,16 +1,17 @@
1
1
  import { Manifest } from "../cli/codegen/manifest.js";
2
+ import { runtimeLogger } from "./logger.js";
2
3
 
3
4
  // Global cache for the loaded manifest
4
5
  let activeManifest: Manifest | null = null;
5
6
 
6
7
  export function loadManifest(manifest: Manifest) {
7
- console.log(`[Runtime] Loading manifest v${manifest.version}`);
8
+ runtimeLogger.debug(`Loading manifest v${manifest.version}`);
8
9
  activeManifest = manifest;
9
10
  }
10
11
 
11
12
  export function getManifest(): Manifest {
12
13
  if (!activeManifest) {
13
- throw new Error("[Runtime] Manifest not loaded.");
14
+ throw new Error("Manifest not loaded.");
14
15
  }
15
16
  return activeManifest;
16
17
  }
@@ -18,11 +19,11 @@ export function getManifest(): Manifest {
18
19
  export function resolveFunction(name: string) {
19
20
  const manifest = getManifest();
20
21
  const fn = manifest.functions[name];
21
-
22
+
22
23
  if (!fn) {
23
24
  return null;
24
25
  }
25
-
26
+
26
27
  // In a real DB-connected runtime, we would resolve OID here.
27
28
  // For now, we return the schema-qualified name.
28
29
  return `${fn.schema}.${fn.name}`;
@@ -11,7 +11,7 @@ export async function handleRequest(
11
11
  db: DBClient,
12
12
  method: string,
13
13
  params: any,
14
- userId: number
14
+ userId: number | null
15
15
  ) {
16
16
  // 1. Check for JS function handler first (takes precedence)
17
17
  if (hasJsFunction(method)) {
package/src/runtime/ws.ts CHANGED
@@ -2,15 +2,16 @@ import { ServerWebSocket } from "bun";
2
2
  import { handleRequest } from "./server.js"; // The secure router
3
3
  import { verifyToken, signToken } from "./auth.js";
4
4
  import { Database } from "./db.js";
5
+ import { wsLogger, authLogger, logWsAccess } from "./logger.js";
5
6
 
6
7
  // WebSocket configuration
7
8
  const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10); // 1MB default
8
9
 
9
10
  interface WSContext {
10
- id: string;
11
+ id?: string; // Set in handleOpen
11
12
  userId?: number;
12
- subscriptions: Set<string>; // Set of subscription IDs
13
- lastPing: number;
13
+ subscriptions?: Set<string>; // Set of subscription IDs, set in handleOpen
14
+ lastPing?: number; // Set in handleOpen
14
15
  token?: string; // Token passed from URL query params during upgrade
15
16
  }
16
17
 
@@ -53,7 +54,7 @@ export class WebSocketServer {
53
54
  lastPing: Date.now()
54
55
  };
55
56
  this.connections.set(id, ws);
56
- console.log(`[WS] Client ${id} connected`);
57
+ wsLogger.info(`Client ${id} connected`);
57
58
 
58
59
  // Subscribe to global broadcast channel initially
59
60
  ws.subscribe("broadcast");
@@ -64,7 +65,7 @@ export class WebSocketServer {
64
65
  try {
65
66
  const session = await verifyToken(token);
66
67
  ws.data.userId = session.userId;
67
- console.log(`[WS] Client ${id} authenticated via token as user ${session.userId}`);
68
+ authLogger.info(`Client ${id} authenticated via token as user ${session.userId}`);
68
69
 
69
70
  // Fetch user profile using get_users
70
71
  try {
@@ -74,13 +75,13 @@ export class WebSocketServer {
74
75
  const { password_hash, ...safeProfile } = profile;
75
76
  user = safeProfile;
76
77
  }
77
- } catch (e) {
78
- console.error(`[WS] Failed to fetch profile for user ${session.userId}:`, e);
78
+ } catch (e: any) {
79
+ authLogger.error(`Failed to fetch profile for user ${session.userId}:`, e.message);
79
80
  // Still authenticated, just no profile
80
81
  user = { id: session.userId };
81
82
  }
82
83
  } catch (e: any) {
83
- console.log(`[WS] Client ${id} token verification failed:`, e.message);
84
+ authLogger.debug(`Client ${id} token verification failed:`, e.message);
84
85
  // Token invalid, user remains null (anonymous connection)
85
86
  }
86
87
  }
@@ -95,27 +96,33 @@ export class WebSocketServer {
95
96
 
96
97
  private async handleMessage(ws: ServerWebSocket<WSContext>, message: string) {
97
98
  ws.data.lastPing = Date.now(); // Update activity
99
+ const start = Date.now();
100
+ let method = "unknown";
98
101
 
99
102
  try {
100
103
  const req = JSON.parse(message);
104
+ method = req.method || "unknown";
101
105
 
102
106
  // Handle Ping (Client-side heartbeat)
103
107
  if (req.method === 'ping') {
104
- ws.send(JSON.stringify({ id: req.id, result: 'pong' }));
105
- return;
108
+ ws.send(JSON.stringify({ id: req.id, result: 'pong' }));
109
+ wsLogger.trace(`Client ${ws.data.id} ping`);
110
+ return;
106
111
  }
107
112
 
108
113
  // Handle Auth Handshake
109
114
  if (req.method === 'auth') {
110
- try {
111
- const session = await verifyToken(req.params.token);
112
- ws.data.userId = session.userId;
113
- ws.send(JSON.stringify({ id: req.id, result: { success: true, userId: session.userId } }));
114
- console.log(`[WS] Client ${ws.data.id} authenticated as user ${session.userId}`);
115
- } catch (e: any) {
116
- ws.send(JSON.stringify({ id: req.id, error: { code: 'UNAUTHORIZED', message: e.message } }));
117
- }
118
- return;
115
+ try {
116
+ const session = await verifyToken(req.params.token);
117
+ ws.data.userId = session.userId;
118
+ ws.send(JSON.stringify({ id: req.id, result: { success: true, userId: session.userId } }));
119
+ authLogger.info(`Client ${ws.data.id} authenticated as user ${session.userId}`);
120
+ logWsAccess("CALL", "auth", Date.now() - start, session.userId);
121
+ } catch (e: any) {
122
+ ws.send(JSON.stringify({ id: req.id, error: { code: 'UNAUTHORIZED', message: e.message } }));
123
+ logWsAccess("CALL", "auth", Date.now() - start, null, e.message);
124
+ }
125
+ return;
119
126
  }
120
127
 
121
128
  // Require Auth for other methods?
@@ -125,76 +132,92 @@ export class WebSocketServer {
125
132
 
126
133
  // Handle RPC
127
134
  if (req.method) {
128
- if (req.method.startsWith("subscribe_")) {
129
- // Handle Subscription Registration
130
- const subscribableName = req.method.replace("subscribe_", "");
131
- const getFnName = `get_${subscribableName}`;
132
-
133
- try {
134
- // Call the get_ function to fetch initial data
135
- const snapshot = await handleRequest(this.db, getFnName, req.params, userId);
136
-
137
- // Register subscription
138
- const subId = `${subscribableName}:${JSON.stringify(req.params)}`;
139
- ws.data.subscriptions.add(subId);
140
-
141
- // Return snapshot with subscription_id and schema
142
- ws.send(JSON.stringify({
143
- id: req.id,
144
- result: {
145
- subscription_id: subId,
146
- data: snapshot.data,
147
- schema: snapshot.schema
148
- }
149
- }));
150
- } catch (e: any) {
151
- ws.send(JSON.stringify({
152
- id: req.id,
153
- error: { code: e.code || 'INTERNAL_ERROR', message: e.message }
154
- }));
155
- }
156
- } else {
157
- // Normal Function Call
158
- const result = await handleRequest(this.db, req.method, req.params, userId);
159
-
160
- // Auto-generate token for auth methods
161
- if (req.method === 'login_user' || req.method === 'register_user') {
162
- const token = await signToken({ user_id: result.user_id, role: 'user' });
163
- // Update connection's userId for subsequent calls
164
- ws.data.userId = result.user_id;
165
- // Return profile + token
166
- ws.send(JSON.stringify({ id: req.id, result: { ...result, token } }));
167
- } else {
168
- ws.send(JSON.stringify({ id: req.id, result }));
169
- }
135
+ if (req.method.startsWith("subscribe_")) {
136
+ // Handle Subscription Registration
137
+ const subscribableName = req.method.replace("subscribe_", "");
138
+ const getFnName = `get_${subscribableName}`;
139
+
140
+ try {
141
+ // Call the get_ function to fetch initial data
142
+ const snapshot = await handleRequest(this.db, getFnName, req.params, userId);
143
+
144
+ // Register subscription
145
+ const subId = `${subscribableName}:${JSON.stringify(req.params)}`;
146
+ ws.data.subscriptions?.add(subId);
147
+
148
+ // Return snapshot with subscription_id and schema
149
+ ws.send(JSON.stringify({
150
+ id: req.id,
151
+ result: {
152
+ subscription_id: subId,
153
+ data: snapshot.data,
154
+ schema: snapshot.schema
155
+ }
156
+ }));
157
+
158
+ logWsAccess("SUB", req.method, Date.now() - start, userId);
159
+ } catch (e: any) {
160
+ ws.send(JSON.stringify({
161
+ id: req.id,
162
+ error: { code: e.code || 'INTERNAL_ERROR', message: e.message }
163
+ }));
164
+ logWsAccess("SUB", req.method, Date.now() - start, userId, e.message);
170
165
  }
166
+ } else {
167
+ // Normal Function Call
168
+ try {
169
+ const result = await handleRequest(this.db, req.method, req.params, userId);
170
+
171
+ // Auto-generate token for auth methods
172
+ if (req.method === 'login_user' || req.method === 'register_user') {
173
+ const token = await signToken({ user_id: result.user_id, role: 'user' });
174
+ // Update connection's userId for subsequent calls
175
+ ws.data.userId = result.user_id;
176
+ // Return profile + token
177
+ ws.send(JSON.stringify({ id: req.id, result: { ...result, token } }));
178
+ logWsAccess("CALL", req.method, Date.now() - start, result.user_id);
179
+ } else {
180
+ ws.send(JSON.stringify({ id: req.id, result }));
181
+ logWsAccess("CALL", req.method, Date.now() - start, userId);
182
+ }
183
+ } catch (e: any) {
184
+ ws.send(JSON.stringify({
185
+ id: req.id,
186
+ error: { code: e.code || 'INTERNAL_ERROR', message: e.message }
187
+ }));
188
+ logWsAccess("CALL", req.method, Date.now() - start, userId, e.message);
189
+ }
190
+ }
171
191
  }
172
192
 
173
193
  } catch (e: any) {
174
- console.error(`[WS] Error processing message from ${ws.data.id}:`, e);
194
+ wsLogger.error(`Error processing message from ${ws.data.id}:`, e.message);
195
+ logWsAccess("CALL", method, Date.now() - start, ws.data.userId, e.message);
175
196
  // Try to send error back if JSON parse didn't fail
176
197
  try {
177
- const reqId = JSON.parse(message).id;
178
- ws.send(JSON.stringify({ id: reqId, error: { code: 'INTERNAL_ERROR', message: e.message } }));
198
+ const reqId = JSON.parse(message).id;
199
+ ws.send(JSON.stringify({ id: reqId, error: { code: 'INTERNAL_ERROR', message: e.message } }));
179
200
  } catch (ignore) {}
180
201
  }
181
202
  }
182
203
 
183
204
  private handleClose(ws: ServerWebSocket<WSContext>) {
184
- this.connections.delete(ws.data.id);
185
- console.log(`[WS] Client ${ws.data.id} disconnected`);
205
+ if (ws.data.id) {
206
+ this.connections.delete(ws.data.id);
207
+ }
208
+ wsLogger.info(`Client ${ws.data.id} disconnected`);
186
209
  }
187
210
 
188
211
  public broadcast(message: string) {
189
- // Use Bun's native publish for efficiency
190
- // 'broadcast' topic is subscribed by all on connect
191
- // In V2, we might have specific topics per subscription key
192
- // server.publish("broadcast", message);
193
- // Since this class doesn't hold the 'server' instance directly,
194
- // we iterate or need to pass server in.
195
- // For now, iteration is fine for prototype.
196
- for (const ws of this.connections.values()) {
197
- ws.send(message);
198
- }
212
+ // Use Bun's native publish for efficiency
213
+ // 'broadcast' topic is subscribed by all on connect
214
+ // In V2, we might have specific topics per subscription key
215
+ // server.publish("broadcast", message);
216
+ // Since this class doesn't hold the 'server' instance directly,
217
+ // we iterate or need to pass server in.
218
+ // For now, iteration is fine for prototype.
219
+ for (const ws of this.connections.values()) {
220
+ ws.send(message);
221
+ }
199
222
  }
200
223
  }
package/src/shared/ir.ts CHANGED
@@ -12,7 +12,7 @@ export interface GraphRuleActionConfig {
12
12
  target?: string; // Target entity for update/delete actions
13
13
  name?: string; // Reactor name for reactor type
14
14
  function?: string; // Function name for validate/execute
15
- data?: Record<string, string>; // Data for create/update (field -> @variable)
15
+ data?: Record<string, string | null>; // Data for create/update (field -> @variable or null)
16
16
  match?: Record<string, string>; // Match condition for update/delete
17
17
  params?: Record<string, string>; // Parameters for reactor/validate/execute
18
18
  error_message?: string; // Error message for validate
@@ -30,6 +30,7 @@ export interface IncludeConfig {
30
30
  entity: string;
31
31
  filter?: Record<string, string>;
32
32
  includes?: Record<string, string | IncludeConfig>;
33
+ temporal?: boolean; // Only include active temporal records
33
34
  }
34
35
 
35
36
  /** Many-to-many relationship configuration */
@@ -160,7 +161,7 @@ export interface GraphRuleIR {
160
161
  ruleName?: string; // The name of the rule (for comments)
161
162
  description?: string; // Human-readable description
162
163
  condition?: string; // e.g., "@before.status = 'draft' AND @after.status = 'posted'"
163
- params: Record<string, string>; // Data for create, or params for reactor/validate/execute
164
+ params: Record<string, string | null>; // Data for create, or params for reactor/validate/execute
164
165
  match?: Record<string, string>; // WHERE clause for update/delete actions
165
166
  error_message?: string; // Error message for validate action
166
167
  }
@@ -171,6 +172,7 @@ export interface SubscribableIR {
171
172
  root: {
172
173
  entity: string;
173
174
  key: string;
175
+ filter?: Record<string, string | boolean | number>;
174
176
  };
175
177
  includes: Record<string, IncludeIR>;
176
178
  scopeTables: string[];