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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
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}`;
@@ -1,43 +1,42 @@
1
1
  /**
2
- * DZQL Namespace for invokej integration
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.js:
8
- * ```js
9
- * import { DzqlNamespace } from 'dzql/namespace';
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
- * constructor() {
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
- * invj dzql:entities # List all entities
22
- * invj dzql:subscribables # List all subscribables
23
- * invj dzql:functions # List all manifest functions
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
- * invj dzql:search venues '{"query": "test"}' # Search with filters
27
- * invj dzql:get venues '{"id": 1}' # Get by primary key
28
- * invj dzql:save venues '{"name": "New", "org_id": 1}' # Create (no id)
29
- * invj dzql:save venues '{"id": 1, "name": "Updated"}' # Update (with id)
30
- * invj dzql:delete venues '{"id": 1}' # Delete by primary key
31
- * invj dzql:lookup venues '{"query": "test"}' # Lookup for dropdowns
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
- * invj dzql:subscribe venue_detail '{"venue_id": 1}' # Get snapshot
33
+ * invt dzql:subscribe venue_detail '{"venue_id": 1}' # Get snapshot
35
34
  *
36
35
  * Ad-hoc Function Calls:
37
- * invj dzql:call login_user '{"email": "x", "password": "y"}'
38
- * invj dzql:call register_user '{"email": "x", "password": "y"}'
39
- * invj dzql:call get_venue_detail '{"venue_id": 1}'
40
- * invj dzql:call save_venues '{"name": "Test", "org_id": 1}'
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 invokej
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?: any): Promise<void> {
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?: any): Promise<void> {
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 invj dzql:search venues '{"query": "test"}'
259
+ * @example invt dzql:search venues '{"query": "test"}'
229
260
  */
230
- async search(_context: any, entity?: string, argsJson: string = "{}"): Promise<void> {
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
- args = JSON.parse(argsJson);
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 invj dzql:get venues '{"id": 1}'
275
+ * @example invt dzql:get venues '{"id": 1}'
262
276
  */
263
- async get(_context: any, entity?: string, argsJson: string = "{}"): Promise<void> {
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}`, args);
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 invj dzql:save venues '{"name": "New Venue", "org_id": 1}'
291
+ * @example invt dzql:save venues '{"name": "New Venue", "org_id": 1}'
295
292
  */
296
- async save(_context: any, entity?: string, argsJson: string = "{}"): Promise<void> {
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}`, args);
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 invj dzql:delete venues '{"id": 1}'
307
+ * @example invt dzql:delete venues '{"id": 1}'
328
308
  */
329
- async delete(_context: any, entity?: string, argsJson: string = "{}"): Promise<void> {
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}`, args);
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 invj dzql:lookup organisations '{"query": "acme"}'
323
+ * @example invt dzql:lookup organisations '{"query": "acme"}'
361
324
  */
362
- async lookup(_context: any, entity?: string, argsJson: string = "{}"): Promise<void> {
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}`, args);
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 invj dzql:subscribe venue_detail '{"venue_id": 1}'
339
+ * @example invt dzql:subscribe venue_detail '{"venue_id": 1}'
394
340
  */
395
- async subscribe(_context: any, name?: string, argsJson: string = "{}"): Promise<void> {
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}`, args);
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 invj dzql:call login_user '{"email": "test@example.com", "password": "secret"}'
427
- * @example invj dzql:call get_venue_detail '{"venue_id": 1}'
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: any, funcName?: string, argsJson: string = "{}"): Promise<void> {
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, args);
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 invj dzql:functions
372
+ * @example invt dzql:functions
462
373
  */
463
- async functions(_context?: any): Promise<void> {
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 }> = {};
@@ -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[];