dzql 0.5.33 → 0.6.1

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.
Files changed (142) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +309 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +653 -0
  20. package/docs/project-setup.md +456 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. package/src/server/ws.js +0 -573
package/src/server/ws.js DELETED
@@ -1,573 +0,0 @@
1
- import { SignJWT, jwtVerify } from "jose";
2
- import {
3
- callAuthFunction,
4
- callUserFunction,
5
- getUserProfile,
6
- db,
7
- sql,
8
- } from "./db.js";
9
- import { wsLogger, authLogger } from "./logger.js";
10
- import {
11
- registerSubscription,
12
- unregisterSubscription,
13
- unregisterSubscriptionByParams,
14
- removeConnectionSubscriptions,
15
- getSubscribableMetadata,
16
- cacheSubscribableMetadata
17
- } from "./subscriptions.js";
18
-
19
- // Environment configuration
20
- const JWT_SECRET_STRING = process.env.JWT_SECRET;
21
- const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
22
-
23
- // WebSocket ping/pong configuration (important for Heroku and other platforms)
24
- // Heroku terminates WebSocket connections after 55 seconds of inactivity (H15 error)
25
- // Default 30s interval keeps connections alive well within that limit
26
- const WS_PING_INTERVAL = parseInt(process.env.WS_PING_INTERVAL || "30000", 10); // 30 seconds default
27
- const WS_PING_TIMEOUT = parseInt(process.env.WS_PING_TIMEOUT || "5000", 10); // 5 seconds default
28
- const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10); // 1MB default
29
-
30
- // Validate JWT_SECRET in production
31
- if (process.env.NODE_ENV === "production" && !JWT_SECRET_STRING) {
32
- throw new Error(
33
- "JWT_SECRET environment variable is required in production. Generate one with: openssl rand -base64 32"
34
- );
35
- }
36
-
37
- // Warn if using default secret in development
38
- if (!JWT_SECRET_STRING && process.env.NODE_ENV !== "test") {
39
- console.warn(
40
- "⚠️ WARNING: Using default JWT secret. Set JWT_SECRET environment variable for security."
41
- );
42
- }
43
-
44
- const JWT_SECRET = new TextEncoder().encode(
45
- JWT_SECRET_STRING || "dev-secret-at-least-32-chars-long!!"
46
- );
47
-
48
- // JWT helpers
49
- export async function create_jwt(payload) {
50
- return await new SignJWT(payload)
51
- .setProtectedHeader({ alg: "HS256" })
52
- .setIssuedAt()
53
- .setExpirationTime(JWT_EXPIRES_IN)
54
- .sign(JWT_SECRET);
55
- }
56
-
57
- export async function verify_jwt_token(token) {
58
- try {
59
- const { payload } = await jwtVerify(token, JWT_SECRET);
60
- return payload;
61
- } catch (error) {
62
- return null;
63
- }
64
- }
65
-
66
- // JSON-RPC helpers
67
- export function create_rpc_response(id, result) {
68
- return JSON.stringify({
69
- jsonrpc: "2.0",
70
- result,
71
- id,
72
- });
73
- }
74
-
75
- export function create_rpc_error(id, code, message, data = null) {
76
- return JSON.stringify({
77
- jsonrpc: "2.0",
78
- error: { code, message, data },
79
- id,
80
- });
81
- }
82
-
83
- // SID (Session ID) Promise Management for bidirectional client-server communication
84
- // Used for async operations where server requests data from client
85
- const sidPromises = new Map();
86
-
87
- /**
88
- * Create a promise with a unique SID that can be resolved/rejected later
89
- * @param {number} timeout - Timeout in milliseconds (default: 30000)
90
- * @returns {Object} - { sid, promise }
91
- */
92
- export function createSIDPromise(timeout = 30000) {
93
- const sid = crypto.randomUUID();
94
-
95
- const promise = new Promise((resolve, reject) => {
96
- // Store resolve/reject functions
97
- sidPromises.set(sid, { resolve, reject });
98
-
99
- // Set timeout
100
- const timer = setTimeout(() => {
101
- if (sidPromises.has(sid)) {
102
- sidPromises.delete(sid);
103
- reject(new Error(`SID request timeout after ${timeout}ms`));
104
- }
105
- }, timeout);
106
-
107
- // Store timer so it can be cleared on resolution
108
- const entry = sidPromises.get(sid);
109
- if (entry) {
110
- entry.timer = timer;
111
- }
112
- });
113
-
114
- return { sid, promise };
115
- }
116
-
117
- /**
118
- * Resolve a pending SID promise with a result
119
- * @param {string} sid - The session ID
120
- * @param {any} result - The result to resolve with
121
- * @returns {boolean} - True if SID was found and resolved
122
- */
123
- export function resolveSID(sid, result) {
124
- const entry = sidPromises.get(sid);
125
- if (!entry) {
126
- wsLogger.warn(`Attempted to resolve unknown SID: ${sid}`);
127
- return false;
128
- }
129
-
130
- clearTimeout(entry.timer);
131
- sidPromises.delete(sid);
132
- entry.resolve(result);
133
- wsLogger.debug(`SID resolved: ${sid}`);
134
- return true;
135
- }
136
-
137
- /**
138
- * Reject a pending SID promise with an error
139
- * @param {string} sid - The session ID
140
- * @param {Error|string} error - The error to reject with
141
- * @returns {boolean} - True if SID was found and rejected
142
- */
143
- export function rejectSID(sid, error) {
144
- const entry = sidPromises.get(sid);
145
- if (!entry) {
146
- wsLogger.warn(`Attempted to reject unknown SID: ${sid}`);
147
- return false;
148
- }
149
-
150
- clearTimeout(entry.timer);
151
- sidPromises.delete(sid);
152
- entry.reject(typeof error === 'string' ? new Error(error) : error);
153
- wsLogger.debug(`SID rejected: ${sid}`);
154
- return true;
155
- }
156
-
157
- // Create RPC handler function
158
- export function createRPCHandler(customHandlers = {}) {
159
- return async function handle_rpc(ws, message) {
160
- let id = null;
161
- let method = null;
162
- const startTime = Date.now();
163
-
164
- try {
165
- const parsed = JSON.parse(message);
166
- method = parsed.method;
167
- const params = parsed.params;
168
- id = parsed.id;
169
-
170
- // Log incoming request
171
- wsLogger.request(method, params);
172
-
173
- // Handle SID responses from client (special internal method)
174
- if (method === "_sid_response") {
175
- const { sid, result, error } = params || {};
176
- if (!sid) {
177
- return create_rpc_error(id, -32602, "Missing sid parameter");
178
- }
179
-
180
- if (error) {
181
- rejectSID(sid, error);
182
- } else {
183
- resolveSID(sid, result);
184
- }
185
-
186
- return create_rpc_response(id, { success: true });
187
- }
188
-
189
- // Validate method doesn't start with underscore (private)
190
- if (method.startsWith("_")) {
191
- wsLogger.warn(`Blocked private function call: ${method}`);
192
- return create_rpc_error(id, -32601, "Cannot call private functions");
193
- }
194
-
195
- // Handle DZQL operations (require auth, identifiable by signature)
196
- if (method.startsWith("dzql.")) {
197
- if (!ws.data.user_id) {
198
- return create_rpc_error(id, -32603, "Not authenticated");
199
- }
200
-
201
- const [, operation, entity] = method.split(".");
202
- if (!operation || !entity) {
203
- return create_rpc_error(
204
- id,
205
- -32602,
206
- "Invalid DZQL method format. Use: dzql.operation.entity",
207
- );
208
- }
209
-
210
- if (
211
- !["get", "save", "delete", "lookup", "search"].includes(operation)
212
- ) {
213
- return create_rpc_error(
214
- id,
215
- -32602,
216
- `Unknown DZQL operation: ${operation}`,
217
- );
218
- }
219
-
220
- wsLogger.debug(`DZQL: Calling ${operation}.${entity} with params:`, JSON.stringify(params));
221
- const result = await db.api[operation][entity](
222
- params || {},
223
- ws.data.user_id,
224
- );
225
- wsLogger.debug(`DZQL: ${operation}.${entity} returned successfully`);
226
- return create_rpc_response(id, result);
227
- }
228
-
229
- // Local API functions that don't require auth
230
- if (method === "login_user") {
231
- authLogger.debug(`Login attempt for: ${params.email}`);
232
- const data = await callAuthFunction(
233
- "login_user",
234
- params.email,
235
- params.password,
236
- params.options || null,
237
- );
238
-
239
- // On successful auth, set user_id on WebSocket connection
240
- if (data && data.user_id) {
241
- ws.data.user_id = data.user_id;
242
- authLogger.info(`User logged in: ${params.email} (id: ${data.user_id})`);
243
-
244
- // Create JWT token for client storage
245
- const token = await create_jwt({
246
- user_id: data.user_id,
247
- email: data.email,
248
- });
249
-
250
- // Get full profile
251
- const profile = await getUserProfile(data.user_id);
252
-
253
- const result = {
254
- user_id: data.user_id,
255
- email: data.email,
256
- token,
257
- profile,
258
- };
259
- wsLogger.response(method, result, Date.now() - startTime);
260
- return create_rpc_response(id, result);
261
- }
262
-
263
- authLogger.warn(`Login failed for: ${params.email}`);
264
- wsLogger.response(method, data, Date.now() - startTime);
265
- return create_rpc_response(id, data);
266
- }
267
-
268
- if (method === "register_user") {
269
- authLogger.debug(`Registration attempt for: ${params.email}`);
270
- const data = await callAuthFunction(
271
- "register_user",
272
- params.email,
273
- params.password,
274
- params.options || null,
275
- );
276
-
277
- // On successful registration, set user_id on WebSocket connection
278
- if (data && data.user_id) {
279
- ws.data.user_id = data.user_id;
280
- authLogger.info(`User registered: ${params.email} (id: ${data.user_id})`);
281
-
282
- // Create JWT token for client storage
283
- const token = await create_jwt({
284
- user_id: data.user_id,
285
- email: data.email,
286
- });
287
-
288
- const result = {
289
- user_id: data.user_id,
290
- email: data.email,
291
- token,
292
- profile: data,
293
- };
294
- wsLogger.response(method, result, Date.now() - startTime);
295
- return create_rpc_response(id, result);
296
- }
297
-
298
- authLogger.warn(`Registration failed for: ${params.email}`);
299
- wsLogger.response(method, data, Date.now() - startTime);
300
- return create_rpc_response(id, data);
301
- }
302
-
303
- // Everything else requires authentication
304
- if (!ws.data.user_id) {
305
- wsLogger.warn(`Unauthenticated request to: ${method}`);
306
- return create_rpc_error(id, -32603, "Not authenticated");
307
- }
308
-
309
- // Authenticated-only local functions
310
- if (method === "logout") {
311
- authLogger.info(`User logged out (id: ${ws.data.user_id})`);
312
- ws.data.user_id = null;
313
- const result = { success: true };
314
- wsLogger.response(method, result, Date.now() - startTime);
315
- return create_rpc_response(id, result);
316
- }
317
-
318
- // SUBSCRIPTION HANDLERS - Pattern match on method name
319
- if (method.startsWith("subscribe_")) {
320
- const subscribableName = method.replace("subscribe_", "");
321
- wsLogger.debug(`Subscribe request: ${subscribableName}`, params);
322
-
323
- try {
324
- // Execute initial query (this also checks permissions)
325
- const queryResult = await sql.unsafe(
326
- `SELECT get_${subscribableName}($1, $2) as result`,
327
- [params, ws.data.user_id]
328
- );
329
-
330
- const queryData = queryResult[0]?.result;
331
-
332
- // Check if compiled function returned embedded schema
333
- // Compiled: { data, schema } | Interpreted: just the data object
334
- let data, schema;
335
- if (queryData && queryData.schema && queryData.data !== undefined) {
336
- // Compiled mode - schema is embedded (includes scopeTables)
337
- data = queryData.data;
338
- schema = queryData.schema;
339
- // Cache scopeTables for event filtering
340
- if (schema.scopeTables) {
341
- cacheSubscribableMetadata(subscribableName, {
342
- scopeTables: schema.scopeTables,
343
- pathMapping: schema.paths,
344
- rootEntity: schema.root,
345
- relations: {}
346
- });
347
- }
348
- } else {
349
- // Interpreted mode - fetch schema from metadata table
350
- data = queryData;
351
- const metadata = await getSubscribableMetadata(subscribableName, sql);
352
- schema = {
353
- root: metadata.rootEntity,
354
- paths: metadata.pathMapping
355
- };
356
- }
357
-
358
- // Register subscription in memory
359
- const subscriptionId = registerSubscription(
360
- subscribableName,
361
- ws.data.user_id,
362
- ws.data.connection_id,
363
- params
364
- );
365
-
366
- // Build result with schema for client-side patching
367
- const result = {
368
- subscription_id: subscriptionId,
369
- data,
370
- schema
371
- };
372
-
373
- wsLogger.response(method, result, Date.now() - startTime);
374
- return create_rpc_response(id, result);
375
- } catch (error) {
376
- wsLogger.error(`Subscribe failed for ${subscribableName}:`, error.message);
377
- return create_rpc_error(id, -32603, error.message);
378
- }
379
- }
380
-
381
- if (method.startsWith("unsubscribe_")) {
382
- const subscribableName = method.replace("unsubscribe_", "");
383
- wsLogger.debug(`Unsubscribe request: ${subscribableName}`, params);
384
-
385
- // Remove subscription by params
386
- const removed = unregisterSubscriptionByParams(
387
- subscribableName,
388
- ws.data.connection_id,
389
- params
390
- );
391
-
392
- const result = { success: removed };
393
- wsLogger.response(method, result, Date.now() - startTime);
394
- return create_rpc_response(id, result);
395
- }
396
-
397
- // Check for custom handlers
398
- if (customHandlers[method]) {
399
- wsLogger.debug(`Calling custom handler: ${method}`);
400
- const result = await customHandlers[method](ws.data.user_id, params);
401
- wsLogger.response(method, result, Date.now() - startTime);
402
- return create_rpc_response(id, result);
403
- }
404
-
405
- // Call stored function with user_id as first parameter
406
- wsLogger.debug(`Calling database function: ${method}`);
407
- const result = await callUserFunction(method, ws.data.user_id, params);
408
- wsLogger.response(method, result, Date.now() - startTime);
409
- return create_rpc_response(id, result);
410
- } catch (error) {
411
- wsLogger.error(`RPC error in ${method}:`, error.message);
412
- wsLogger.debug(`RPC error stack:`, error.stack);
413
-
414
- // PostgreSQL error codes
415
- if (error.code) {
416
- wsLogger.debug(`Returning PostgreSQL error for id=${id}`);
417
- return create_rpc_error(id, -32603, String(error), {
418
- code: error.code,
419
- });
420
- }
421
-
422
- // Generic error
423
- wsLogger.debug(`Returning generic error for id=${id}: ${error.message}`);
424
- return create_rpc_error(id, -32603, "Internal error", {
425
- message: error.message,
426
- });
427
- }
428
- };
429
- }
430
-
431
- // Create WebSocket event handlers
432
- export function createWebSocketHandlers(options = {}) {
433
- const {
434
- rpcHandler = null,
435
- customHandlers = {},
436
- onConnection = null,
437
- onDisconnection = null,
438
- } = options;
439
-
440
- // Active WebSocket connections
441
- const connections = new Map();
442
-
443
- // Create RPC handler if not provided
444
- const handler = rpcHandler || createRPCHandler(customHandlers);
445
-
446
- // Create broadcaster function
447
- const broadcast = createBroadcaster(connections);
448
-
449
- return {
450
- connections,
451
- broadcast,
452
-
453
- // WebSocket configuration for Bun.serve
454
- // These properties are required for proper ping/pong support (especially on Heroku)
455
- perMessageDeflate: true,
456
- maxPayloadLength: WS_MAX_MESSAGE_SIZE,
457
- idleTimeout: WS_PING_INTERVAL / 1000, // Convert to seconds for Bun
458
- closeOnBackpressureLimit: true, // Close connection if backpressure limit exceeded
459
-
460
- // Connection opened
461
- async open(ws) {
462
- const id = crypto.randomUUID();
463
- ws.data.connection_id = id;
464
- connections.set(id, ws);
465
-
466
- wsLogger.info(
467
- `Connection opened: ${id.slice(0, 8)}...`,
468
- ws.data.user_id ? `(user: ${ws.data.user_id})` : "(anonymous)",
469
- );
470
-
471
- // Get full profile if authenticated
472
- let profile = null;
473
- if (ws.data.user_id) {
474
- try {
475
- profile = await getUserProfile(ws.data.user_id);
476
- } catch (error) {
477
- wsLogger.error("Failed to load profile:", error.message);
478
- }
479
- }
480
-
481
- // Send welcome message as JSON-RPC method call
482
- ws.send(
483
- JSON.stringify({
484
- jsonrpc: "2.0",
485
- method: "connected",
486
- params: {
487
- connection_id: id,
488
- authenticated: !!ws.data.user_id,
489
- profile,
490
- },
491
- }),
492
- );
493
-
494
- // Call custom connection handler
495
- if (onConnection) {
496
- onConnection(ws, id);
497
- }
498
- },
499
-
500
- // Message received
501
- async message(ws, message) {
502
- const response = await handler(ws, message);
503
- ws.send(response);
504
- },
505
-
506
- // Connection closed
507
- close(ws) {
508
- const id = ws.data.connection_id;
509
- connections.delete(id);
510
-
511
- // Clean up all subscriptions for this connection
512
- const removedCount = removeConnectionSubscriptions(id);
513
- if (removedCount > 0) {
514
- wsLogger.info(`Connection closed: ${id?.slice(0, 8)}... (${removedCount} subscriptions removed)`);
515
- } else {
516
- wsLogger.info(`Connection closed: ${id?.slice(0, 8)}...`);
517
- }
518
-
519
- // Call custom disconnection handler
520
- if (onDisconnection) {
521
- onDisconnection(ws, id);
522
- }
523
- },
524
-
525
- // Error occurred
526
- error(ws, error) {
527
- wsLogger.error(`WebSocket error for ${ws.data.connection_id?.slice(0, 8)}...:`, error.message);
528
- },
529
- };
530
- }
531
-
532
- // Broadcast message to all authenticated connections or specific client_ids
533
- export function createBroadcaster(connections) {
534
- const broadcastToConnections = function(message, client_ids = null) {
535
- if (client_ids && Array.isArray(client_ids)) {
536
- // Send to specific user_ids
537
- for (const [id, ws] of connections) {
538
- if (ws.data.user_id && client_ids.includes(ws.data.user_id)) {
539
- ws.send(message);
540
- }
541
- }
542
- } else {
543
- // Send to all authenticated connections
544
- for (const [id, ws] of connections) {
545
- if (ws.data.user_id) {
546
- ws.send(message);
547
- }
548
- }
549
- }
550
- };
551
-
552
- // Add helper function to send to a specific connection
553
- broadcastToConnections.toConnection = function(connectionId, message) {
554
- const ws = connections.get(connectionId);
555
- if (ws && ws.readyState === 1) { // 1 = OPEN
556
- ws.send(message);
557
- return true;
558
- }
559
- return false;
560
- };
561
-
562
- return broadcastToConnections;
563
- }
564
-
565
- // Legacy export for backward compatibility
566
- export function broadcastToConnections(connections, message) {
567
- // Send to all authenticated connections
568
- for (const [id, ws] of connections) {
569
- if (ws.data.user_id) {
570
- ws.send(message);
571
- }
572
- }
573
- }