dzql 0.6.12 → 0.6.14
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/docs/README.md +59 -16
- package/docs/for_ai.md +52 -2
- package/docs/project-setup.md +2 -0
- package/package.json +4 -2
- package/src/cli/codegen/notification.ts +219 -0
- package/src/cli/codegen/pinia.ts +28 -32
- package/src/cli/codegen/sql.ts +38 -6
- package/src/cli/codegen/subscribable_sql.ts +66 -9
- package/src/cli/codegen/subscribable_store.ts +101 -102
- package/src/cli/index.ts +4 -1
- package/src/client/ws.ts +177 -93
- package/src/runtime/index.ts +87 -18
- package/src/runtime/namespace.ts +2 -2
- package/src/runtime/subscriptions.ts +189 -0
- package/src/runtime/ws.ts +74 -55
|
@@ -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";
|
|
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
|
-
|
|
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;
|
|
17
|
+
id?: string;
|
|
12
18
|
userId?: number;
|
|
13
|
-
|
|
14
|
-
|
|
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,
|
|
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: (
|
|
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();
|
|
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
|
-
//
|
|
145
|
-
const
|
|
146
|
-
|
|
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:
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
231
|
+
return connectionIds;
|
|
209
232
|
}
|
|
210
233
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
}
|