dzql 0.6.8 → 0.6.10
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/namespace.ts +79 -168
- 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/namespace.ts
CHANGED
|
@@ -1,43 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DZQL Namespace for
|
|
2
|
+
* DZQL Namespace for invoket integration
|
|
3
3
|
*
|
|
4
4
|
* Provides CLI-style access to DZQL operations via the compiled manifest.
|
|
5
5
|
* Each method outputs JSON to console and closes the connection before returning.
|
|
6
6
|
*
|
|
7
|
-
* Setup - add to your tasks.
|
|
8
|
-
* ```
|
|
9
|
-
* import {
|
|
7
|
+
* Setup - add to your tasks.ts:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { Context } from "invoket/context";
|
|
10
|
+
* import { DzqlNamespace } from "dzql/namespace";
|
|
10
11
|
*
|
|
11
12
|
* export class Tasks {
|
|
12
|
-
*
|
|
13
|
-
* this.dzql = new DzqlNamespace();
|
|
14
|
-
* }
|
|
13
|
+
* dzql = new DzqlNamespace();
|
|
15
14
|
* }
|
|
16
15
|
* ```
|
|
17
16
|
*
|
|
18
17
|
* Available Commands:
|
|
19
18
|
*
|
|
20
19
|
* Discovery:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* invt dzql:entities # List all entities
|
|
21
|
+
* invt dzql:subscribables # List all subscribables
|
|
22
|
+
* invt dzql:functions # List all manifest functions
|
|
24
23
|
*
|
|
25
24
|
* Entity CRUD:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
25
|
+
* invt dzql:search venues '{"query": "test"}' # Search with filters
|
|
26
|
+
* invt dzql:get venues '{"id": 1}' # Get by primary key
|
|
27
|
+
* invt dzql:save venues '{"name": "New", "org_id": 1}' # Create (no id)
|
|
28
|
+
* invt dzql:save venues '{"id": 1, "name": "Updated"}' # Update (with id)
|
|
29
|
+
* invt dzql:delete venues '{"id": 1}' # Delete by primary key
|
|
30
|
+
* invt dzql:lookup venues '{"query": "test"}' # Lookup for dropdowns
|
|
32
31
|
*
|
|
33
32
|
* Subscribables:
|
|
34
|
-
*
|
|
33
|
+
* invt dzql:subscribe venue_detail '{"venue_id": 1}' # Get snapshot
|
|
35
34
|
*
|
|
36
35
|
* Ad-hoc Function Calls:
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* invt dzql:call login_user '{"email": "x", "password": "y"}'
|
|
37
|
+
* invt dzql:call register_user '{"email": "x", "password": "y"}'
|
|
38
|
+
* invt dzql:call get_venue_detail '{"venue_id": 1}'
|
|
39
|
+
* invt dzql:call save_venues '{"name": "Test", "org_id": 1}'
|
|
41
40
|
*
|
|
42
41
|
* Environment:
|
|
43
42
|
* DATABASE_URL - PostgreSQL connection string (default: postgres://localhost:5432/dzql)
|
|
@@ -54,6 +53,27 @@ import type { Manifest, FunctionDef } from "../cli/codegen/manifest.js";
|
|
|
54
53
|
// Default user for CLI operations
|
|
55
54
|
const DEFAULT_USER_ID = 1;
|
|
56
55
|
|
|
56
|
+
/** Query parameters for search operations */
|
|
57
|
+
export interface SearchParams {
|
|
58
|
+
query?: string;
|
|
59
|
+
filters?: Record<string, unknown>;
|
|
60
|
+
sort_field?: string;
|
|
61
|
+
sort_order?: "asc" | "desc";
|
|
62
|
+
limit?: number;
|
|
63
|
+
offset?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Primary key for get/delete operations */
|
|
67
|
+
export interface PkParams {
|
|
68
|
+
id?: number;
|
|
69
|
+
[key: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Generic params for any operation */
|
|
73
|
+
export interface CallParams {
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
/**
|
|
58
78
|
* Load manifest from MANIFEST_PATH env var or default locations
|
|
59
79
|
*/
|
|
@@ -122,7 +142,16 @@ function discoverSubscribables(manifest: Manifest): Record<string, { params: Rec
|
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
/**
|
|
125
|
-
* DZQL operations namespace for
|
|
145
|
+
* DZQL operations namespace for invoket
|
|
146
|
+
*
|
|
147
|
+
* Use with invoket task runner:
|
|
148
|
+
* ```ts
|
|
149
|
+
* import { DzqlNamespace } from "dzql/namespace";
|
|
150
|
+
*
|
|
151
|
+
* export class Tasks {
|
|
152
|
+
* dzql = new DzqlNamespace();
|
|
153
|
+
* }
|
|
154
|
+
* ```
|
|
126
155
|
*/
|
|
127
156
|
export class DzqlNamespace {
|
|
128
157
|
private userId: number;
|
|
@@ -193,8 +222,9 @@ export class DzqlNamespace {
|
|
|
193
222
|
|
|
194
223
|
/**
|
|
195
224
|
* List all available entities
|
|
225
|
+
* @example invt dzql:entities
|
|
196
226
|
*/
|
|
197
|
-
async entities(_context
|
|
227
|
+
async entities(_context: unknown): Promise<void> {
|
|
198
228
|
try {
|
|
199
229
|
const { manifest } = await this.init();
|
|
200
230
|
const entities = discoverEntities(manifest);
|
|
@@ -209,8 +239,9 @@ export class DzqlNamespace {
|
|
|
209
239
|
|
|
210
240
|
/**
|
|
211
241
|
* List all available subscribables
|
|
242
|
+
* @example invt dzql:subscribables
|
|
212
243
|
*/
|
|
213
|
-
async subscribables(_context
|
|
244
|
+
async subscribables(_context: unknown): Promise<void> {
|
|
214
245
|
try {
|
|
215
246
|
const { manifest } = await this.init();
|
|
216
247
|
const subscribables = discoverSubscribables(manifest);
|
|
@@ -225,28 +256,11 @@ export class DzqlNamespace {
|
|
|
225
256
|
|
|
226
257
|
/**
|
|
227
258
|
* Search an entity
|
|
228
|
-
* @example
|
|
259
|
+
* @example invt dzql:search venues '{"query": "test"}'
|
|
229
260
|
*/
|
|
230
|
-
async search(_context:
|
|
231
|
-
if (!entity) {
|
|
232
|
-
console.error("Error: entity name required");
|
|
233
|
-
console.error("Usage: invj dzql:search <entity> '<json_args>'");
|
|
234
|
-
console.error('Example: invj dzql:search venues \'{"query": "test"}\'');
|
|
235
|
-
await this.cleanup();
|
|
236
|
-
process.exit(1);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
let args: any;
|
|
261
|
+
async search(_context: unknown, entity: string, params: SearchParams = {}): Promise<void> {
|
|
240
262
|
try {
|
|
241
|
-
|
|
242
|
-
} catch {
|
|
243
|
-
console.error("Error: arguments must be valid JSON");
|
|
244
|
-
await this.cleanup();
|
|
245
|
-
process.exit(1);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
const result = await this.executeFunction(`search_${entity}`, args);
|
|
263
|
+
const result = await this.executeFunction(`search_${entity}`, params);
|
|
250
264
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
251
265
|
await this.cleanup();
|
|
252
266
|
} catch (error: any) {
|
|
@@ -258,28 +272,11 @@ export class DzqlNamespace {
|
|
|
258
272
|
|
|
259
273
|
/**
|
|
260
274
|
* Get entity by ID
|
|
261
|
-
* @example
|
|
275
|
+
* @example invt dzql:get venues '{"id": 1}'
|
|
262
276
|
*/
|
|
263
|
-
async get(_context:
|
|
264
|
-
if (!entity) {
|
|
265
|
-
console.error("Error: entity name required");
|
|
266
|
-
console.error("Usage: invj dzql:get <entity> '<json_args>'");
|
|
267
|
-
console.error('Example: invj dzql:get venues \'{"id": 1}\'');
|
|
268
|
-
await this.cleanup();
|
|
269
|
-
process.exit(1);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
let args: any;
|
|
273
|
-
try {
|
|
274
|
-
args = JSON.parse(argsJson);
|
|
275
|
-
} catch {
|
|
276
|
-
console.error("Error: arguments must be valid JSON");
|
|
277
|
-
await this.cleanup();
|
|
278
|
-
process.exit(1);
|
|
279
|
-
}
|
|
280
|
-
|
|
277
|
+
async get(_context: unknown, entity: string, pk: PkParams): Promise<void> {
|
|
281
278
|
try {
|
|
282
|
-
const result = await this.executeFunction(`get_${entity}`,
|
|
279
|
+
const result = await this.executeFunction(`get_${entity}`, pk);
|
|
283
280
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
284
281
|
await this.cleanup();
|
|
285
282
|
} catch (error: any) {
|
|
@@ -291,28 +288,11 @@ export class DzqlNamespace {
|
|
|
291
288
|
|
|
292
289
|
/**
|
|
293
290
|
* Save (create or update) entity
|
|
294
|
-
* @example
|
|
291
|
+
* @example invt dzql:save venues '{"name": "New Venue", "org_id": 1}'
|
|
295
292
|
*/
|
|
296
|
-
async save(_context:
|
|
297
|
-
if (!entity) {
|
|
298
|
-
console.error("Error: entity name required");
|
|
299
|
-
console.error("Usage: invj dzql:save <entity> '<json_args>'");
|
|
300
|
-
console.error('Example: invj dzql:save venues \'{"name": "Test Venue", "org_id": 1}\'');
|
|
301
|
-
await this.cleanup();
|
|
302
|
-
process.exit(1);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
let args: any;
|
|
306
|
-
try {
|
|
307
|
-
args = JSON.parse(argsJson);
|
|
308
|
-
} catch {
|
|
309
|
-
console.error("Error: arguments must be valid JSON");
|
|
310
|
-
await this.cleanup();
|
|
311
|
-
process.exit(1);
|
|
312
|
-
}
|
|
313
|
-
|
|
293
|
+
async save(_context: unknown, entity: string, data: CallParams): Promise<void> {
|
|
314
294
|
try {
|
|
315
|
-
const result = await this.executeFunction(`save_${entity}`,
|
|
295
|
+
const result = await this.executeFunction(`save_${entity}`, data);
|
|
316
296
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
317
297
|
await this.cleanup();
|
|
318
298
|
} catch (error: any) {
|
|
@@ -324,28 +304,11 @@ export class DzqlNamespace {
|
|
|
324
304
|
|
|
325
305
|
/**
|
|
326
306
|
* Delete entity by ID
|
|
327
|
-
* @example
|
|
307
|
+
* @example invt dzql:delete venues '{"id": 1}'
|
|
328
308
|
*/
|
|
329
|
-
async delete(_context:
|
|
330
|
-
if (!entity) {
|
|
331
|
-
console.error("Error: entity name required");
|
|
332
|
-
console.error("Usage: invj dzql:delete <entity> '<json_args>'");
|
|
333
|
-
console.error('Example: invj dzql:delete venues \'{"id": 1}\'');
|
|
334
|
-
await this.cleanup();
|
|
335
|
-
process.exit(1);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
let args: any;
|
|
339
|
-
try {
|
|
340
|
-
args = JSON.parse(argsJson);
|
|
341
|
-
} catch {
|
|
342
|
-
console.error("Error: arguments must be valid JSON");
|
|
343
|
-
await this.cleanup();
|
|
344
|
-
process.exit(1);
|
|
345
|
-
}
|
|
346
|
-
|
|
309
|
+
async delete(_context: unknown, entity: string, pk: PkParams): Promise<void> {
|
|
347
310
|
try {
|
|
348
|
-
const result = await this.executeFunction(`delete_${entity}`,
|
|
311
|
+
const result = await this.executeFunction(`delete_${entity}`, pk);
|
|
349
312
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
350
313
|
await this.cleanup();
|
|
351
314
|
} catch (error: any) {
|
|
@@ -357,28 +320,11 @@ export class DzqlNamespace {
|
|
|
357
320
|
|
|
358
321
|
/**
|
|
359
322
|
* Lookup entity (for dropdowns/autocomplete)
|
|
360
|
-
* @example
|
|
323
|
+
* @example invt dzql:lookup organisations '{"query": "acme"}'
|
|
361
324
|
*/
|
|
362
|
-
async lookup(_context:
|
|
363
|
-
if (!entity) {
|
|
364
|
-
console.error("Error: entity name required");
|
|
365
|
-
console.error("Usage: invj dzql:lookup <entity> '<json_args>'");
|
|
366
|
-
console.error('Example: invj dzql:lookup organisations \'{"query": "acme"}\'');
|
|
367
|
-
await this.cleanup();
|
|
368
|
-
process.exit(1);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
let args: any;
|
|
372
|
-
try {
|
|
373
|
-
args = JSON.parse(argsJson);
|
|
374
|
-
} catch {
|
|
375
|
-
console.error("Error: arguments must be valid JSON");
|
|
376
|
-
await this.cleanup();
|
|
377
|
-
process.exit(1);
|
|
378
|
-
}
|
|
379
|
-
|
|
325
|
+
async lookup(_context: unknown, entity: string, params: SearchParams = {}): Promise<void> {
|
|
380
326
|
try {
|
|
381
|
-
const result = await this.executeFunction(`lookup_${entity}`,
|
|
327
|
+
const result = await this.executeFunction(`lookup_${entity}`, params);
|
|
382
328
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
383
329
|
await this.cleanup();
|
|
384
330
|
} catch (error: any) {
|
|
@@ -390,28 +336,11 @@ export class DzqlNamespace {
|
|
|
390
336
|
|
|
391
337
|
/**
|
|
392
338
|
* Get subscribable snapshot
|
|
393
|
-
* @example
|
|
339
|
+
* @example invt dzql:subscribe venue_detail '{"venue_id": 1}'
|
|
394
340
|
*/
|
|
395
|
-
async subscribe(_context:
|
|
396
|
-
if (!name) {
|
|
397
|
-
console.error("Error: subscribable name required");
|
|
398
|
-
console.error("Usage: invj dzql:subscribe <name> '<json_args>'");
|
|
399
|
-
console.error('Example: invj dzql:subscribe venue_detail \'{"venue_id": 1}\'');
|
|
400
|
-
await this.cleanup();
|
|
401
|
-
process.exit(1);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
let args: any;
|
|
405
|
-
try {
|
|
406
|
-
args = JSON.parse(argsJson);
|
|
407
|
-
} catch {
|
|
408
|
-
console.error("Error: arguments must be valid JSON");
|
|
409
|
-
await this.cleanup();
|
|
410
|
-
process.exit(1);
|
|
411
|
-
}
|
|
412
|
-
|
|
341
|
+
async subscribe(_context: unknown, name: string, params: CallParams = {}): Promise<void> {
|
|
413
342
|
try {
|
|
414
|
-
const result = await this.executeFunction(`get_${name}`,
|
|
343
|
+
const result = await this.executeFunction(`get_${name}`, params);
|
|
415
344
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
416
345
|
await this.cleanup();
|
|
417
346
|
} catch (error: any) {
|
|
@@ -423,30 +352,12 @@ export class DzqlNamespace {
|
|
|
423
352
|
|
|
424
353
|
/**
|
|
425
354
|
* Call any function in the manifest by name
|
|
426
|
-
* @example
|
|
427
|
-
* @example
|
|
355
|
+
* @example invt dzql:call login_user '{"email": "test@example.com", "password": "secret"}'
|
|
356
|
+
* @example invt dzql:call get_venue_detail '{"venue_id": 1}'
|
|
428
357
|
*/
|
|
429
|
-
async call(_context:
|
|
430
|
-
if (!funcName) {
|
|
431
|
-
console.error("Error: function name required");
|
|
432
|
-
console.error("Usage: invj dzql:call <function_name> '<json_args>'");
|
|
433
|
-
console.error('Example: invj dzql:call login_user \'{"email": "test@example.com", "password": "secret"}\'');
|
|
434
|
-
console.error('Example: invj dzql:call get_venue_detail \'{"venue_id": 1}\'');
|
|
435
|
-
await this.cleanup();
|
|
436
|
-
process.exit(1);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
let args: any;
|
|
440
|
-
try {
|
|
441
|
-
args = JSON.parse(argsJson);
|
|
442
|
-
} catch {
|
|
443
|
-
console.error("Error: arguments must be valid JSON");
|
|
444
|
-
await this.cleanup();
|
|
445
|
-
process.exit(1);
|
|
446
|
-
}
|
|
447
|
-
|
|
358
|
+
async call(_context: unknown, funcName: string, params: CallParams = {}): Promise<void> {
|
|
448
359
|
try {
|
|
449
|
-
const result = await this.executeFunction(funcName,
|
|
360
|
+
const result = await this.executeFunction(funcName, params);
|
|
450
361
|
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
451
362
|
await this.cleanup();
|
|
452
363
|
} catch (error: any) {
|
|
@@ -458,9 +369,9 @@ export class DzqlNamespace {
|
|
|
458
369
|
|
|
459
370
|
/**
|
|
460
371
|
* List all available functions in the manifest
|
|
461
|
-
* @example
|
|
372
|
+
* @example invt dzql:functions
|
|
462
373
|
*/
|
|
463
|
-
async functions(_context
|
|
374
|
+
async functions(_context: unknown): Promise<void> {
|
|
464
375
|
try {
|
|
465
376
|
const { manifest } = await this.init();
|
|
466
377
|
const functions: Record<string, { args: string[]; returnType: string }> = {};
|
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[];
|