dzql 0.6.7 → 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 +1 -1
- package/src/cli/compiler/graph_rules.ts +5 -1
- package/src/client/ws.ts +6 -5
- package/src/runtime/index.ts +13 -11
- package/src/runtime/js_functions.ts +1 -1
- package/src/runtime/logger.ts +308 -0
- package/src/runtime/manifest_loader.ts +5 -4
- package/src/runtime/server.ts +1 -1
- package/src/runtime/ws.ts +99 -76
- package/src/shared/ir.ts +4 -2
package/package.json
CHANGED
|
@@ -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
|
|
102
|
-
const
|
|
103
|
-
|
|
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
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
+
runtimeLogger.info(`Loaded Manifest v${manifest.version}`);
|
|
30
31
|
} catch (e) {
|
|
31
|
-
|
|
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
|
-
|
|
40
|
+
runtimeLogger.info("Setting up LISTEN on dzql_v2 channel...");
|
|
40
41
|
await db.listen("dzql_v2", async (payload) => {
|
|
41
|
-
|
|
42
|
+
notifyLogger.debug(`RAW NOTIFY received:`, payload);
|
|
42
43
|
try {
|
|
43
44
|
const { commit_id } = JSON.parse(payload);
|
|
44
|
-
|
|
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
|
-
|
|
73
|
+
} catch (e: any) {
|
|
74
|
+
notifyLogger.error("Listener Error:", e.message);
|
|
73
75
|
}
|
|
74
76
|
});
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
serverLogger.info(`Server listening on port ${PORT}`);
|
|
@@ -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
|
-
|
|
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("
|
|
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}`;
|
package/src/runtime/server.ts
CHANGED
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
|
|
11
|
+
id?: string; // Set in handleOpen
|
|
11
12
|
userId?: number;
|
|
12
|
-
subscriptions
|
|
13
|
-
lastPing
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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[];
|