dzql 0.6.13 → 0.6.15

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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Subscription Manager for Live Query Subscriptions
3
+ * Manages in-memory subscription registry and matching
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import { wsLogger } from './logger.js';
8
+
9
+ /** Subscription entry */
10
+ interface Subscription {
11
+ subscribable: string;
12
+ user_id: number | null;
13
+ connection_id: string;
14
+ params: Record<string, unknown>;
15
+ created_at: Date;
16
+ }
17
+
18
+ /** Subscribable metadata */
19
+ interface SubscribableMetadata {
20
+ scopeTables: string[];
21
+ pathMapping: Record<string, string>;
22
+ rootEntity: string | null;
23
+ }
24
+
25
+ /** In-memory subscription registry: subscription_id -> subscription */
26
+ const subscriptions = new Map<string, Subscription>();
27
+
28
+ /** Track subscriptions by connection for cleanup: connection_id -> Set<subscription_id> */
29
+ const connectionSubscriptions = new Map<string, Set<string>>();
30
+
31
+ /** Cache for subscribable metadata: subscribable_name -> metadata */
32
+ const subscribableMetadataCache = new Map<string, SubscribableMetadata>();
33
+
34
+ /**
35
+ * Register a new subscription
36
+ */
37
+ export function registerSubscription(
38
+ subscribableName: string,
39
+ userId: number | null,
40
+ connectionId: string,
41
+ params: Record<string, unknown>
42
+ ): string {
43
+ const subscriptionId = crypto.randomUUID();
44
+
45
+ subscriptions.set(subscriptionId, {
46
+ subscribable: subscribableName,
47
+ user_id: userId,
48
+ connection_id: connectionId,
49
+ params,
50
+ created_at: new Date()
51
+ });
52
+
53
+ if (!connectionSubscriptions.has(connectionId)) {
54
+ connectionSubscriptions.set(connectionId, new Set());
55
+ }
56
+ connectionSubscriptions.get(connectionId)!.add(subscriptionId);
57
+
58
+ wsLogger.debug(`Subscription registered: ${subscriptionId.slice(0, 8)}... (${subscribableName})`);
59
+
60
+ return subscriptionId;
61
+ }
62
+
63
+ /**
64
+ * Unregister a subscription
65
+ */
66
+ export function unregisterSubscription(subscriptionId: string): boolean {
67
+ const sub = subscriptions.get(subscriptionId);
68
+
69
+ if (!sub) {
70
+ return false;
71
+ }
72
+
73
+ const connSubs = connectionSubscriptions.get(sub.connection_id);
74
+ if (connSubs) {
75
+ connSubs.delete(subscriptionId);
76
+ if (connSubs.size === 0) {
77
+ connectionSubscriptions.delete(sub.connection_id);
78
+ }
79
+ }
80
+
81
+ subscriptions.delete(subscriptionId);
82
+ wsLogger.debug(`Subscription removed: ${subscriptionId.slice(0, 8)}...`);
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Unregister subscription by params
88
+ */
89
+ export function unregisterSubscriptionByParams(
90
+ subscribableName: string,
91
+ connectionId: string,
92
+ params: Record<string, unknown>
93
+ ): boolean {
94
+ const paramsStr = JSON.stringify(params);
95
+
96
+ for (const [subId, sub] of subscriptions.entries()) {
97
+ if (
98
+ sub.subscribable === subscribableName &&
99
+ sub.connection_id === connectionId &&
100
+ JSON.stringify(sub.params) === paramsStr
101
+ ) {
102
+ return unregisterSubscription(subId);
103
+ }
104
+ }
105
+
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * Remove all subscriptions for a connection (called on WebSocket close)
111
+ */
112
+ export function removeConnectionSubscriptions(connectionId: string): number {
113
+ const subIds = connectionSubscriptions.get(connectionId);
114
+
115
+ if (!subIds) {
116
+ return 0;
117
+ }
118
+
119
+ let count = 0;
120
+ for (const subId of subIds) {
121
+ if (subscriptions.delete(subId)) {
122
+ count++;
123
+ }
124
+ }
125
+
126
+ connectionSubscriptions.delete(connectionId);
127
+
128
+ if (count > 0) {
129
+ wsLogger.info(`Removed ${count} subscription(s) for connection ${connectionId.slice(0, 8)}...`);
130
+ }
131
+
132
+ return count;
133
+ }
134
+
135
+ /**
136
+ * Get all subscriptions grouped by subscribable name
137
+ */
138
+ export function getSubscriptionsBySubscribable(): Map<string, Array<Subscription & { subscriptionId: string }>> {
139
+ const grouped = new Map<string, Array<Subscription & { subscriptionId: string }>>();
140
+
141
+ for (const [subId, sub] of subscriptions.entries()) {
142
+ if (!grouped.has(sub.subscribable)) {
143
+ grouped.set(sub.subscribable, []);
144
+ }
145
+ grouped.get(sub.subscribable)!.push({ subscriptionId: subId, ...sub });
146
+ }
147
+
148
+ return grouped;
149
+ }
150
+
151
+ /**
152
+ * Check if params match (deep equality)
153
+ */
154
+ export function paramsMatch(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
155
+ return JSON.stringify(a) === JSON.stringify(b);
156
+ }
157
+
158
+ /**
159
+ * Cache subscribable metadata (from manifest)
160
+ */
161
+ export function cacheSubscribableMetadata(subscribableName: string, metadata: SubscribableMetadata): void {
162
+ subscribableMetadataCache.set(subscribableName, metadata);
163
+ }
164
+
165
+ /**
166
+ * Get subscribable metadata from cache
167
+ */
168
+ export function getSubscribableMetadata(subscribableName: string): SubscribableMetadata | undefined {
169
+ return subscribableMetadataCache.get(subscribableName);
170
+ }
171
+
172
+ /**
173
+ * Get scope tables for a subscribable
174
+ */
175
+ export function getSubscribableScopeTables(subscribableName: string): string[] {
176
+ const metadata = subscribableMetadataCache.get(subscribableName);
177
+ return metadata?.scopeTables || [];
178
+ }
179
+
180
+ /**
181
+ * Clear metadata cache
182
+ */
183
+ export function clearSubscribableMetadataCache(subscribableName?: string): void {
184
+ if (subscribableName) {
185
+ subscribableMetadataCache.delete(subscribableName);
186
+ } else {
187
+ subscribableMetadataCache.clear();
188
+ }
189
+ }
package/src/runtime/ws.ts CHANGED
@@ -1,18 +1,23 @@
1
1
  import { ServerWebSocket } from "bun";
2
- import { handleRequest } from "./server.js"; // The secure router
2
+ import { handleRequest } from "./server.js";
3
3
  import { verifyToken, signToken } from "./auth.js";
4
4
  import { Database } from "./db.js";
5
5
  import { wsLogger, authLogger, logWsAccess } from "./logger.js";
6
+ import { getManifest } from "./manifest_loader.js";
7
+ import {
8
+ registerSubscription,
9
+ removeConnectionSubscriptions,
10
+ unregisterSubscriptionByParams,
11
+ cacheSubscribableMetadata
12
+ } from "./subscriptions.js";
6
13
 
7
- // WebSocket configuration
8
- const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10); // 1MB default
14
+ const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10);
9
15
 
10
16
  interface WSContext {
11
- id?: string; // Set in handleOpen
17
+ id?: string;
12
18
  userId?: number;
13
- subscriptions?: Set<string>; // Set of subscription IDs, set in handleOpen
14
- lastPing?: number; // Set in handleOpen
15
- token?: string; // Token passed from URL query params during upgrade
19
+ lastPing?: number;
20
+ token?: string;
16
21
  }
17
22
 
18
23
  export class WebSocketServer {
@@ -23,23 +28,18 @@ export class WebSocketServer {
23
28
  this.db = db;
24
29
  }
25
30
 
26
- // Bun.serve websocket configuration object
27
31
  get websocket() {
28
32
  return {
29
- // WebSocket options for Bun
30
33
  perMessageDeflate: true,
31
34
  maxPayloadLength: WS_MAX_MESSAGE_SIZE,
32
- idleTimeout: 0, // No idle timeout - realtime connections stay open indefinitely
33
-
34
- // Handler hooks
35
+ idleTimeout: 0,
35
36
  open: (ws: ServerWebSocket<WSContext>) => this.handleOpen(ws),
36
37
  message: (ws: ServerWebSocket<WSContext>, message: string) => this.handleMessage(ws, message),
37
38
  close: (ws: ServerWebSocket<WSContext>) => this.handleClose(ws),
38
- drain: (ws: ServerWebSocket<WSContext>) => {}
39
+ drain: () => {}
39
40
  };
40
41
  }
41
42
 
42
- // Legacy alias for backwards compatibility
43
43
  get handlers() {
44
44
  return this.websocket;
45
45
  }
@@ -50,16 +50,11 @@ export class WebSocketServer {
50
50
 
51
51
  ws.data = {
52
52
  id,
53
- subscriptions: new Set(),
54
53
  lastPing: Date.now()
55
54
  };
56
55
  this.connections.set(id, ws);
57
56
  wsLogger.info(`Client ${id} connected`);
58
57
 
59
- // Subscribe to global broadcast channel initially
60
- ws.subscribe("broadcast");
61
-
62
- // If token was provided in URL, verify and authenticate
63
58
  let user: any = null;
64
59
  if (token) {
65
60
  try {
@@ -67,26 +62,21 @@ export class WebSocketServer {
67
62
  ws.data.userId = session.userId;
68
63
  authLogger.info(`Client ${id} authenticated via token as user ${session.userId}`);
69
64
 
70
- // Fetch user profile using get_users
71
65
  try {
72
66
  const profile = await handleRequest(this.db, 'get_users', { id: session.userId }, session.userId);
73
67
  if (profile) {
74
- // Remove sensitive data
75
68
  const { password_hash, ...safeProfile } = profile;
76
69
  user = safeProfile;
77
70
  }
78
71
  } catch (e: any) {
79
72
  authLogger.error(`Failed to fetch profile for user ${session.userId}:`, e.message);
80
- // Still authenticated, just no profile
81
73
  user = { id: session.userId };
82
74
  }
83
75
  } catch (e: any) {
84
76
  authLogger.debug(`Client ${id} token verification failed:`, e.message);
85
- // Token invalid, user remains null (anonymous connection)
86
77
  }
87
78
  }
88
79
 
89
- // Send connection:ready message
90
80
  ws.send(JSON.stringify({
91
81
  jsonrpc: "2.0",
92
82
  method: "connection:ready",
@@ -95,7 +85,7 @@ export class WebSocketServer {
95
85
  }
96
86
 
97
87
  private async handleMessage(ws: ServerWebSocket<WSContext>, message: string) {
98
- ws.data.lastPing = Date.now(); // Update activity
88
+ ws.data.lastPing = Date.now();
99
89
  const start = Date.now();
100
90
  let method = "unknown";
101
91
 
@@ -103,14 +93,11 @@ export class WebSocketServer {
103
93
  const req = JSON.parse(message);
104
94
  method = req.method || "unknown";
105
95
 
106
- // Handle Ping (Client-side heartbeat)
107
96
  if (req.method === 'ping') {
108
97
  ws.send(JSON.stringify({ id: req.id, result: 'pong' }));
109
- wsLogger.trace(`Client ${ws.data.id} ping`);
110
98
  return;
111
99
  }
112
100
 
113
- // Handle Auth Handshake
114
101
  if (req.method === 'auth') {
115
102
  try {
116
103
  const session = await verifyToken(req.params.token);
@@ -125,31 +112,40 @@ export class WebSocketServer {
125
112
  return;
126
113
  }
127
114
 
128
- // Require Auth for other methods?
129
- // V2 spec says `p_user_id` is passed to functions.
130
- // If not auth'd, we can pass null or throw.
131
115
  const userId = ws.data.userId || null;
116
+ const connectionId = ws.data.id!;
132
117
 
133
- // Handle RPC
134
118
  if (req.method) {
135
119
  if (req.method.startsWith("subscribe_")) {
136
- // Handle Subscription Registration
137
120
  const subscribableName = req.method.replace("subscribe_", "");
138
121
  const getFnName = `get_${subscribableName}`;
139
122
 
140
123
  try {
141
- // Call the get_ function to fetch initial data
142
124
  const snapshot = await handleRequest(this.db, getFnName, req.params, userId);
143
125
 
144
- // Register subscription
145
- const subId = `${subscribableName}:${JSON.stringify(req.params)}`;
146
- ws.data.subscriptions?.add(subId);
126
+ // Get metadata from manifest and cache it
127
+ const manifest = getManifest();
128
+ const subscribable = manifest.subscribables[subscribableName];
129
+ if (subscribable) {
130
+ cacheSubscribableMetadata(subscribableName, {
131
+ scopeTables: subscribable.scopeTables || [],
132
+ pathMapping: {}, // TODO: build from includes
133
+ rootEntity: subscribable.root?.entity || null
134
+ });
135
+ }
136
+
137
+ // Register subscription in central registry
138
+ const subscriptionId = registerSubscription(
139
+ subscribableName,
140
+ userId,
141
+ connectionId,
142
+ req.params || {}
143
+ );
147
144
 
148
- // Return snapshot with subscription_id and schema
149
145
  ws.send(JSON.stringify({
150
146
  id: req.id,
151
147
  result: {
152
- subscription_id: subId,
148
+ subscription_id: subscriptionId,
153
149
  data: snapshot.data,
154
150
  schema: snapshot.schema
155
151
  }
@@ -163,17 +159,18 @@ export class WebSocketServer {
163
159
  }));
164
160
  logWsAccess("SUB", req.method, Date.now() - start, userId, e.message);
165
161
  }
162
+ } else if (req.method.startsWith("unsubscribe_")) {
163
+ const subscribableName = req.method.replace("unsubscribe_", "");
164
+ const removed = unregisterSubscriptionByParams(subscribableName, connectionId, req.params || {});
165
+ ws.send(JSON.stringify({ id: req.id, result: { success: removed } }));
166
+ logWsAccess("UNSUB", req.method, Date.now() - start, userId);
166
167
  } else {
167
- // Normal Function Call
168
168
  try {
169
169
  const result = await handleRequest(this.db, req.method, req.params, userId);
170
170
 
171
- // Auto-generate token for auth methods
172
171
  if (req.method === 'login_user' || req.method === 'register_user') {
173
172
  const token = await signToken({ user_id: result.user_id, role: 'user' });
174
- // Update connection's userId for subsequent calls
175
173
  ws.data.userId = result.user_id;
176
- // Return profile + token
177
174
  ws.send(JSON.stringify({ id: req.id, result: { ...result, token } }));
178
175
  logWsAccess("CALL", req.method, Date.now() - start, result.user_id);
179
176
  } else {
@@ -193,7 +190,6 @@ export class WebSocketServer {
193
190
  } catch (e: any) {
194
191
  wsLogger.error(`Error processing message from ${ws.data.id}:`, e.message);
195
192
  logWsAccess("CALL", method, Date.now() - start, ws.data.userId, e.message);
196
- // Try to send error back if JSON parse didn't fail
197
193
  try {
198
194
  const reqId = JSON.parse(message).id;
199
195
  ws.send(JSON.stringify({ id: reqId, error: { code: 'INTERNAL_ERROR', message: e.message } }));
@@ -202,20 +198,43 @@ export class WebSocketServer {
202
198
  }
203
199
 
204
200
  private handleClose(ws: ServerWebSocket<WSContext>) {
205
- if (ws.data.id) {
206
- this.connections.delete(ws.data.id);
201
+ const connectionId = ws.data.id;
202
+ if (connectionId) {
203
+ this.connections.delete(connectionId);
204
+ removeConnectionSubscriptions(connectionId);
205
+ }
206
+ wsLogger.info(`Client ${connectionId} disconnected`);
207
+ }
208
+
209
+ /**
210
+ * Send message to a specific connection
211
+ */
212
+ public toConnection(connectionId: string, message: string): boolean {
213
+ const ws = this.connections.get(connectionId);
214
+ if (ws && ws.readyState === 1) { // WebSocket.OPEN
215
+ ws.send(message);
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ /**
222
+ * Get all connection IDs for a specific user
223
+ */
224
+ public getConnectionsByUserId(userId: number): string[] {
225
+ const connectionIds: string[] = [];
226
+ for (const [id, ws] of this.connections.entries()) {
227
+ if (ws.data?.userId === userId) {
228
+ connectionIds.push(id);
229
+ }
207
230
  }
208
- wsLogger.info(`Client ${ws.data.id} disconnected`);
231
+ return connectionIds;
209
232
  }
210
233
 
211
- public broadcast(message: string) {
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.
234
+ /**
235
+ * Broadcast to all connections (for backwards compatibility)
236
+ */
237
+ public broadcast(message: string): void {
219
238
  for (const ws of this.connections.values()) {
220
239
  ws.send(message);
221
240
  }