dzql 0.1.0-alpha.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.
- package/package.json +65 -0
- package/src/client/ui-configs/sample-2.js +207 -0
- package/src/client/ui-loader.js +618 -0
- package/src/client/ui.js +990 -0
- package/src/client/ws.js +352 -0
- package/src/client.js +9 -0
- package/src/database/migrations/001_schema.sql +59 -0
- package/src/database/migrations/002_functions.sql +742 -0
- package/src/database/migrations/003_operations.sql +725 -0
- package/src/database/migrations/004_search.sql +505 -0
- package/src/database/migrations/005_entities.sql +511 -0
- package/src/database/migrations/006_auth.sql +83 -0
- package/src/database/migrations/007_events.sql +136 -0
- package/src/database/migrations/008_hello.sql +18 -0
- package/src/database/migrations/008a_meta.sql +165 -0
- package/src/index.js +19 -0
- package/src/server/api.js +9 -0
- package/src/server/db.js +261 -0
- package/src/server/index.js +141 -0
- package/src/server/logger.js +246 -0
- package/src/server/mcp.js +594 -0
- package/src/server/meta-route.js +251 -0
- package/src/server/ws.js +464 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { sql } from './db.js';
|
|
2
|
+
|
|
3
|
+
export function metaRoute() {
|
|
4
|
+
return async (req) => {
|
|
5
|
+
try {
|
|
6
|
+
// Get entity metadata from dzql.entities
|
|
7
|
+
const entities = await sql`
|
|
8
|
+
SELECT table_name, label_field, searchable_fields,
|
|
9
|
+
fk_includes, notification_paths, permission_paths
|
|
10
|
+
FROM dzql.entities
|
|
11
|
+
ORDER BY table_name
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
// Analyze foreign key relationships to determine relationship types
|
|
15
|
+
const foreignKeys = await sql`
|
|
16
|
+
SELECT
|
|
17
|
+
tc.table_name,
|
|
18
|
+
kcu.column_name,
|
|
19
|
+
ccu.table_name AS foreign_table_name,
|
|
20
|
+
ccu.column_name AS foreign_column_name
|
|
21
|
+
FROM
|
|
22
|
+
information_schema.table_constraints AS tc
|
|
23
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
24
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
25
|
+
AND tc.table_schema = kcu.table_schema
|
|
26
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
27
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
28
|
+
AND ccu.table_schema = tc.table_schema
|
|
29
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
30
|
+
AND tc.table_schema = 'public'
|
|
31
|
+
ORDER BY tc.table_name, kcu.column_name
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
// Get complete table schema information
|
|
35
|
+
const schemaInfo = await sql`
|
|
36
|
+
SELECT
|
|
37
|
+
table_name,
|
|
38
|
+
column_name,
|
|
39
|
+
data_type,
|
|
40
|
+
is_nullable,
|
|
41
|
+
column_default,
|
|
42
|
+
character_maximum_length,
|
|
43
|
+
numeric_precision,
|
|
44
|
+
numeric_scale,
|
|
45
|
+
ordinal_position
|
|
46
|
+
FROM information_schema.columns
|
|
47
|
+
WHERE table_schema = 'public'
|
|
48
|
+
AND table_name = ANY(${entities.map(e => e.table_name)})
|
|
49
|
+
ORDER BY table_name, ordinal_position
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
// Find junction tables for many-to-many relationships
|
|
53
|
+
const junctionTables = new Set();
|
|
54
|
+
const entityNames = new Set(entities.map(e => e.table_name));
|
|
55
|
+
|
|
56
|
+
// Group foreign keys by table to identify junction tables
|
|
57
|
+
const fksByTable = {};
|
|
58
|
+
foreignKeys.forEach(fk => {
|
|
59
|
+
if (!entityNames.has(fk.table_name)) return;
|
|
60
|
+
if (!fksByTable[fk.table_name]) fksByTable[fk.table_name] = [];
|
|
61
|
+
fksByTable[fk.table_name].push(fk);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Identify junction tables (2+ foreign keys, minimal other fields)
|
|
65
|
+
Object.keys(fksByTable).forEach(tableName => {
|
|
66
|
+
const fks = fksByTable[tableName];
|
|
67
|
+
if (fks.length >= 2) {
|
|
68
|
+
const entity = entities.find(e => e.table_name === tableName);
|
|
69
|
+
const searchableFields = entity?.searchable_fields || [];
|
|
70
|
+
const nonFkFields = searchableFields.filter(field =>
|
|
71
|
+
!fks.some(fk => fk.column_name === field)
|
|
72
|
+
);
|
|
73
|
+
if (nonFkFields.length <= 1) {
|
|
74
|
+
junctionTables.add(tableName);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Build relations array
|
|
80
|
+
const relations = [];
|
|
81
|
+
|
|
82
|
+
// Add foreign key relationships (both directions)
|
|
83
|
+
foreignKeys.forEach(fk => {
|
|
84
|
+
if (!entityNames.has(fk.table_name) || !entityNames.has(fk.foreign_table_name)) return;
|
|
85
|
+
|
|
86
|
+
// Skip junction tables - they'll be handled as many-to-many
|
|
87
|
+
if (junctionTables.has(fk.table_name)) return;
|
|
88
|
+
|
|
89
|
+
// Many-to-one: child.foreign_key → parent.primary_key
|
|
90
|
+
relations.push({
|
|
91
|
+
type: 'many_to_one',
|
|
92
|
+
from: `${fk.table_name}.${fk.column_name}`,
|
|
93
|
+
to: `${fk.foreign_table_name}.${fk.foreign_column_name}`
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// One-to-many: parent.primary_key ← child.foreign_key
|
|
97
|
+
relations.push({
|
|
98
|
+
type: 'one_to_many',
|
|
99
|
+
from: `${fk.foreign_table_name}.${fk.foreign_column_name}`,
|
|
100
|
+
to: `${fk.table_name}.${fk.column_name}`
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Add many-to-many relationships through junction tables
|
|
105
|
+
junctionTables.forEach(tableName => {
|
|
106
|
+
const fks = fksByTable[tableName];
|
|
107
|
+
// For each pair of foreign keys in the junction table
|
|
108
|
+
for (let i = 0; i < fks.length; i++) {
|
|
109
|
+
for (let j = i + 1; j < fks.length; j++) {
|
|
110
|
+
const fk1 = fks[i];
|
|
111
|
+
const fk2 = fks[j];
|
|
112
|
+
|
|
113
|
+
// Both directions of many-to-many
|
|
114
|
+
relations.push({
|
|
115
|
+
type: 'many_to_many',
|
|
116
|
+
from: `${fk1.foreign_table_name}.${fk1.foreign_column_name}`,
|
|
117
|
+
to: `${fk2.foreign_table_name}.${fk2.foreign_column_name}`,
|
|
118
|
+
via: `${tableName}.${fk1.column_name}.${fk2.column_name}`
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
relations.push({
|
|
122
|
+
type: 'many_to_many',
|
|
123
|
+
from: `${fk2.foreign_table_name}.${fk2.foreign_column_name}`,
|
|
124
|
+
to: `${fk1.foreign_table_name}.${fk1.foreign_column_name}`,
|
|
125
|
+
via: `${tableName}.${fk2.column_name}.${fk1.column_name}`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Build schema object grouped by table
|
|
132
|
+
const schema = {};
|
|
133
|
+
schemaInfo.forEach(col => {
|
|
134
|
+
if (!schema[col.table_name]) {
|
|
135
|
+
schema[col.table_name] = [];
|
|
136
|
+
}
|
|
137
|
+
schema[col.table_name].push({
|
|
138
|
+
column_name: col.column_name,
|
|
139
|
+
data_type: col.data_type,
|
|
140
|
+
is_nullable: col.is_nullable === 'YES',
|
|
141
|
+
column_default: col.column_default,
|
|
142
|
+
character_maximum_length: col.character_maximum_length,
|
|
143
|
+
numeric_precision: col.numeric_precision,
|
|
144
|
+
numeric_scale: col.numeric_scale,
|
|
145
|
+
ordinal_position: col.ordinal_position
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Build navigation graph from user starting point
|
|
150
|
+
const navigationGraph = buildNavigationGraph(entities, relations, schema);
|
|
151
|
+
|
|
152
|
+
const metadata = {
|
|
153
|
+
entities: entities,
|
|
154
|
+
relations: relations,
|
|
155
|
+
schema: schema,
|
|
156
|
+
navigationGraph: navigationGraph,
|
|
157
|
+
operations: ['get', 'save', 'delete', 'lookup', 'search'],
|
|
158
|
+
timestamp: new Date().toISOString()
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return new Response(JSON.stringify(metadata, null, 2), {
|
|
162
|
+
headers: { 'Content-Type': 'application/json' }
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return new Response(JSON.stringify({ error: error.message }), {
|
|
166
|
+
status: 500,
|
|
167
|
+
headers: { 'Content-Type': 'application/json' }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildNavigationGraph(entities, relations, schema) {
|
|
174
|
+
const graph = {};
|
|
175
|
+
|
|
176
|
+
// Helper to detect UI patterns from schema
|
|
177
|
+
function getUiHints(entityName) {
|
|
178
|
+
const entitySchema = schema[entityName] || [];
|
|
179
|
+
const hints = { primary_view: 'table', alternate_view: 'form', temporal_fields: [], geo_fields: [] };
|
|
180
|
+
|
|
181
|
+
entitySchema.forEach(col => {
|
|
182
|
+
if (col.data_type.includes('date') || col.data_type.includes('time')) {
|
|
183
|
+
hints.temporal_fields.push(col.column_name);
|
|
184
|
+
}
|
|
185
|
+
if (col.column_name.toLowerCase().includes('address') ||
|
|
186
|
+
col.column_name.toLowerCase().includes('location')) {
|
|
187
|
+
hints.geo_fields.push(col.column_name);
|
|
188
|
+
hints.primary_view = 'map';
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (hints.temporal_fields.length > 0) {
|
|
193
|
+
hints.alternate_view = 'calendar';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return hints;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build navigation paths starting from user
|
|
200
|
+
function buildPathsFrom(currentEntity, currentPath, visited, maxDepth) {
|
|
201
|
+
if (maxDepth <= 0 || visited.has(currentEntity)) return;
|
|
202
|
+
|
|
203
|
+
visited.add(currentEntity);
|
|
204
|
+
const pathKey = currentPath.join('→');
|
|
205
|
+
|
|
206
|
+
if (!graph[pathKey]) {
|
|
207
|
+
const entity = entities.find(e => e.table_name === currentEntity);
|
|
208
|
+
graph[pathKey] = {
|
|
209
|
+
path: pathKey,
|
|
210
|
+
current_entity: currentEntity,
|
|
211
|
+
available_actions: entity ? Object.keys(entity.permission_paths) : [],
|
|
212
|
+
navigation_options: [],
|
|
213
|
+
ui_hints: getUiHints(currentEntity),
|
|
214
|
+
breadcrumb: currentPath.map(p => p.split('.')[0])
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Find all outgoing relations from current entity
|
|
219
|
+
relations.forEach(rel => {
|
|
220
|
+
const fromEntity = rel.from.split('.')[0];
|
|
221
|
+
const toEntity = rel.to.split('.')[0];
|
|
222
|
+
|
|
223
|
+
if (fromEntity === currentEntity && !visited.has(toEntity)) {
|
|
224
|
+
graph[pathKey].navigation_options.push({
|
|
225
|
+
to: toEntity,
|
|
226
|
+
via: `${rel.from}→${rel.to}`,
|
|
227
|
+
relationship: rel.type
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Recursively build paths
|
|
231
|
+
const newPath = [...currentPath, rel.to];
|
|
232
|
+
buildPathsFrom(toEntity, newPath, new Set(visited), maxDepth - 1);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
visited.delete(currentEntity);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Start building from user
|
|
240
|
+
const userRelations = relations.filter(rel => rel.from.startsWith('users.') || rel.to.startsWith('users.'));
|
|
241
|
+
if (userRelations.length === 0) {
|
|
242
|
+
// If no direct user relations, start from all entities
|
|
243
|
+
entities.forEach(entity => {
|
|
244
|
+
buildPathsFrom(entity.table_name, [entity.table_name + '.id'], new Set(), 3);
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
buildPathsFrom('users', ['users.id'], new Set(), 4);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return graph;
|
|
251
|
+
}
|
package/src/server/ws.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
import {
|
|
3
|
+
callAuthFunction,
|
|
4
|
+
callUserFunction,
|
|
5
|
+
getUserProfile,
|
|
6
|
+
db,
|
|
7
|
+
} from "./db.js";
|
|
8
|
+
import { wsLogger, authLogger } from "./logger.js";
|
|
9
|
+
|
|
10
|
+
// Environment configuration
|
|
11
|
+
const JWT_SECRET_STRING = process.env.JWT_SECRET;
|
|
12
|
+
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
|
|
13
|
+
|
|
14
|
+
// WebSocket ping/pong configuration (important for Heroku and other platforms)
|
|
15
|
+
// Heroku terminates WebSocket connections after 55 seconds of inactivity (H15 error)
|
|
16
|
+
// Default 30s interval keeps connections alive well within that limit
|
|
17
|
+
const WS_PING_INTERVAL = parseInt(process.env.WS_PING_INTERVAL || "30000", 10); // 30 seconds default
|
|
18
|
+
const WS_PING_TIMEOUT = parseInt(process.env.WS_PING_TIMEOUT || "5000", 10); // 5 seconds default
|
|
19
|
+
const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10); // 1MB default
|
|
20
|
+
|
|
21
|
+
// Validate JWT_SECRET in production
|
|
22
|
+
if (process.env.NODE_ENV === "production" && !JWT_SECRET_STRING) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"JWT_SECRET environment variable is required in production. Generate one with: openssl rand -base64 32"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Warn if using default secret in development
|
|
29
|
+
if (!JWT_SECRET_STRING && process.env.NODE_ENV !== "test") {
|
|
30
|
+
console.warn(
|
|
31
|
+
"⚠️ WARNING: Using default JWT secret. Set JWT_SECRET environment variable for security."
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const JWT_SECRET = new TextEncoder().encode(
|
|
36
|
+
JWT_SECRET_STRING || "dev-secret-at-least-32-chars-long!!"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// JWT helpers
|
|
40
|
+
export async function create_jwt(payload) {
|
|
41
|
+
return await new SignJWT(payload)
|
|
42
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
43
|
+
.setIssuedAt()
|
|
44
|
+
.setExpirationTime(JWT_EXPIRES_IN)
|
|
45
|
+
.sign(JWT_SECRET);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function verify_jwt_token(token) {
|
|
49
|
+
try {
|
|
50
|
+
const { payload } = await jwtVerify(token, JWT_SECRET);
|
|
51
|
+
return payload;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// JSON-RPC helpers
|
|
58
|
+
export function create_rpc_response(id, result) {
|
|
59
|
+
return JSON.stringify({
|
|
60
|
+
jsonrpc: "2.0",
|
|
61
|
+
result,
|
|
62
|
+
id,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function create_rpc_error(id, code, message, data = null) {
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
jsonrpc: "2.0",
|
|
69
|
+
error: { code, message, data },
|
|
70
|
+
id,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// SID (Session ID) Promise Management for bidirectional client-server communication
|
|
75
|
+
// Used for async operations where server requests data from client
|
|
76
|
+
const sidPromises = new Map();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a promise with a unique SID that can be resolved/rejected later
|
|
80
|
+
* @param {number} timeout - Timeout in milliseconds (default: 30000)
|
|
81
|
+
* @returns {Object} - { sid, promise }
|
|
82
|
+
*/
|
|
83
|
+
export function createSIDPromise(timeout = 30000) {
|
|
84
|
+
const sid = crypto.randomUUID();
|
|
85
|
+
|
|
86
|
+
const promise = new Promise((resolve, reject) => {
|
|
87
|
+
// Store resolve/reject functions
|
|
88
|
+
sidPromises.set(sid, { resolve, reject });
|
|
89
|
+
|
|
90
|
+
// Set timeout
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
if (sidPromises.has(sid)) {
|
|
93
|
+
sidPromises.delete(sid);
|
|
94
|
+
reject(new Error(`SID request timeout after ${timeout}ms`));
|
|
95
|
+
}
|
|
96
|
+
}, timeout);
|
|
97
|
+
|
|
98
|
+
// Store timer so it can be cleared on resolution
|
|
99
|
+
const entry = sidPromises.get(sid);
|
|
100
|
+
if (entry) {
|
|
101
|
+
entry.timer = timer;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return { sid, promise };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a pending SID promise with a result
|
|
110
|
+
* @param {string} sid - The session ID
|
|
111
|
+
* @param {any} result - The result to resolve with
|
|
112
|
+
* @returns {boolean} - True if SID was found and resolved
|
|
113
|
+
*/
|
|
114
|
+
export function resolveSID(sid, result) {
|
|
115
|
+
const entry = sidPromises.get(sid);
|
|
116
|
+
if (!entry) {
|
|
117
|
+
wsLogger.warn(`Attempted to resolve unknown SID: ${sid}`);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clearTimeout(entry.timer);
|
|
122
|
+
sidPromises.delete(sid);
|
|
123
|
+
entry.resolve(result);
|
|
124
|
+
wsLogger.debug(`SID resolved: ${sid}`);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reject a pending SID promise with an error
|
|
130
|
+
* @param {string} sid - The session ID
|
|
131
|
+
* @param {Error|string} error - The error to reject with
|
|
132
|
+
* @returns {boolean} - True if SID was found and rejected
|
|
133
|
+
*/
|
|
134
|
+
export function rejectSID(sid, error) {
|
|
135
|
+
const entry = sidPromises.get(sid);
|
|
136
|
+
if (!entry) {
|
|
137
|
+
wsLogger.warn(`Attempted to reject unknown SID: ${sid}`);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
clearTimeout(entry.timer);
|
|
142
|
+
sidPromises.delete(sid);
|
|
143
|
+
entry.reject(typeof error === 'string' ? new Error(error) : error);
|
|
144
|
+
wsLogger.debug(`SID rejected: ${sid}`);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Create RPC handler function
|
|
149
|
+
export function createRPCHandler(customHandlers = {}) {
|
|
150
|
+
return async function handle_rpc(ws, message) {
|
|
151
|
+
let id = null;
|
|
152
|
+
let method = null;
|
|
153
|
+
const startTime = Date.now();
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(message);
|
|
157
|
+
method = parsed.method;
|
|
158
|
+
const params = parsed.params;
|
|
159
|
+
id = parsed.id;
|
|
160
|
+
|
|
161
|
+
// Log incoming request
|
|
162
|
+
wsLogger.request(method, params);
|
|
163
|
+
|
|
164
|
+
// Handle SID responses from client (special internal method)
|
|
165
|
+
if (method === "_sid_response") {
|
|
166
|
+
const { sid, result, error } = params || {};
|
|
167
|
+
if (!sid) {
|
|
168
|
+
return create_rpc_error(id, -32602, "Missing sid parameter");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (error) {
|
|
172
|
+
rejectSID(sid, error);
|
|
173
|
+
} else {
|
|
174
|
+
resolveSID(sid, result);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return create_rpc_response(id, { success: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Validate method doesn't start with underscore (private)
|
|
181
|
+
if (method.startsWith("_")) {
|
|
182
|
+
wsLogger.warn(`Blocked private function call: ${method}`);
|
|
183
|
+
return create_rpc_error(id, -32601, "Cannot call private functions");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle DZQL operations (require auth, identifiable by signature)
|
|
187
|
+
if (method.startsWith("dzql.")) {
|
|
188
|
+
if (!ws.data.user_id) {
|
|
189
|
+
return create_rpc_error(id, -32603, "Not authenticated");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const [, operation, entity] = method.split(".");
|
|
193
|
+
if (!operation || !entity) {
|
|
194
|
+
return create_rpc_error(
|
|
195
|
+
id,
|
|
196
|
+
-32602,
|
|
197
|
+
"Invalid DZQL method format. Use: dzql.operation.entity",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
!["get", "save", "delete", "lookup", "search"].includes(operation)
|
|
203
|
+
) {
|
|
204
|
+
return create_rpc_error(
|
|
205
|
+
id,
|
|
206
|
+
-32602,
|
|
207
|
+
`Unknown DZQL operation: ${operation}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
wsLogger.debug(`DZQL: Calling ${operation}.${entity} with params:`, JSON.stringify(params));
|
|
212
|
+
const result = await db.api[operation][entity](
|
|
213
|
+
params || {},
|
|
214
|
+
ws.data.user_id,
|
|
215
|
+
);
|
|
216
|
+
wsLogger.debug(`DZQL: ${operation}.${entity} returned successfully`);
|
|
217
|
+
return create_rpc_response(id, result);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Local API functions that don't require auth
|
|
221
|
+
if (method === "login_user") {
|
|
222
|
+
authLogger.debug(`Login attempt for: ${params.email}`);
|
|
223
|
+
const data = await callAuthFunction(
|
|
224
|
+
"login_user",
|
|
225
|
+
params.email,
|
|
226
|
+
params.password,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// On successful auth, set user_id on WebSocket connection
|
|
230
|
+
if (data && data.user_id) {
|
|
231
|
+
ws.data.user_id = data.user_id;
|
|
232
|
+
authLogger.info(`User logged in: ${params.email} (id: ${data.user_id})`);
|
|
233
|
+
|
|
234
|
+
// Create JWT token for client storage
|
|
235
|
+
const token = await create_jwt({
|
|
236
|
+
user_id: data.user_id,
|
|
237
|
+
email: data.email,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Get full profile
|
|
241
|
+
const profile = await getUserProfile(data.user_id);
|
|
242
|
+
|
|
243
|
+
const result = {
|
|
244
|
+
user_id: data.user_id,
|
|
245
|
+
email: data.email,
|
|
246
|
+
token,
|
|
247
|
+
profile,
|
|
248
|
+
};
|
|
249
|
+
wsLogger.response(method, result, Date.now() - startTime);
|
|
250
|
+
return create_rpc_response(id, result);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
authLogger.warn(`Login failed for: ${params.email}`);
|
|
254
|
+
wsLogger.response(method, data, Date.now() - startTime);
|
|
255
|
+
return create_rpc_response(id, data);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (method === "register_user") {
|
|
259
|
+
authLogger.debug(`Registration attempt for: ${params.email}`);
|
|
260
|
+
const data = await callAuthFunction(
|
|
261
|
+
"register_user",
|
|
262
|
+
params.email,
|
|
263
|
+
params.password,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// On successful registration, set user_id on WebSocket connection
|
|
267
|
+
if (data && data.user_id) {
|
|
268
|
+
ws.data.user_id = data.user_id;
|
|
269
|
+
authLogger.info(`User registered: ${params.email} (id: ${data.user_id})`);
|
|
270
|
+
|
|
271
|
+
// Create JWT token for client storage
|
|
272
|
+
const token = await create_jwt({
|
|
273
|
+
user_id: data.user_id,
|
|
274
|
+
email: data.email,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = {
|
|
278
|
+
user_id: data.user_id,
|
|
279
|
+
email: data.email,
|
|
280
|
+
token,
|
|
281
|
+
profile: data,
|
|
282
|
+
};
|
|
283
|
+
wsLogger.response(method, result, Date.now() - startTime);
|
|
284
|
+
return create_rpc_response(id, result);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
authLogger.warn(`Registration failed for: ${params.email}`);
|
|
288
|
+
wsLogger.response(method, data, Date.now() - startTime);
|
|
289
|
+
return create_rpc_response(id, data);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Everything else requires authentication
|
|
293
|
+
if (!ws.data.user_id) {
|
|
294
|
+
wsLogger.warn(`Unauthenticated request to: ${method}`);
|
|
295
|
+
return create_rpc_error(id, -32603, "Not authenticated");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Authenticated-only local functions
|
|
299
|
+
if (method === "logout") {
|
|
300
|
+
authLogger.info(`User logged out (id: ${ws.data.user_id})`);
|
|
301
|
+
ws.data.user_id = null;
|
|
302
|
+
const result = { success: true };
|
|
303
|
+
wsLogger.response(method, result, Date.now() - startTime);
|
|
304
|
+
return create_rpc_response(id, result);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check for custom handlers
|
|
308
|
+
if (customHandlers[method]) {
|
|
309
|
+
wsLogger.debug(`Calling custom handler: ${method}`);
|
|
310
|
+
const result = await customHandlers[method](ws.data.user_id, params);
|
|
311
|
+
wsLogger.response(method, result, Date.now() - startTime);
|
|
312
|
+
return create_rpc_response(id, result);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Call stored function with user_id as first parameter
|
|
316
|
+
wsLogger.debug(`Calling database function: ${method}`);
|
|
317
|
+
const result = await callUserFunction(method, ws.data.user_id, params);
|
|
318
|
+
wsLogger.response(method, result, Date.now() - startTime);
|
|
319
|
+
return create_rpc_response(id, result);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
wsLogger.error(`RPC error in ${method}:`, error.message);
|
|
322
|
+
wsLogger.debug(`RPC error stack:`, error.stack);
|
|
323
|
+
|
|
324
|
+
// PostgreSQL error codes
|
|
325
|
+
if (error.code) {
|
|
326
|
+
wsLogger.debug(`Returning PostgreSQL error for id=${id}`);
|
|
327
|
+
return create_rpc_error(id, -32603, String(error), {
|
|
328
|
+
code: error.code,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Generic error
|
|
333
|
+
wsLogger.debug(`Returning generic error for id=${id}: ${error.message}`);
|
|
334
|
+
return create_rpc_error(id, -32603, "Internal error", {
|
|
335
|
+
message: error.message,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Create WebSocket event handlers
|
|
342
|
+
export function createWebSocketHandlers(options = {}) {
|
|
343
|
+
const {
|
|
344
|
+
rpcHandler = null,
|
|
345
|
+
customHandlers = {},
|
|
346
|
+
onConnection = null,
|
|
347
|
+
onDisconnection = null,
|
|
348
|
+
} = options;
|
|
349
|
+
|
|
350
|
+
// Active WebSocket connections
|
|
351
|
+
const connections = new Map();
|
|
352
|
+
|
|
353
|
+
// Create RPC handler if not provided
|
|
354
|
+
const handler = rpcHandler || createRPCHandler(customHandlers);
|
|
355
|
+
|
|
356
|
+
// Create broadcaster function
|
|
357
|
+
const broadcast = createBroadcaster(connections);
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
connections,
|
|
361
|
+
broadcast,
|
|
362
|
+
|
|
363
|
+
// WebSocket configuration for Bun.serve
|
|
364
|
+
// These properties are required for proper ping/pong support (especially on Heroku)
|
|
365
|
+
perMessageDeflate: true,
|
|
366
|
+
maxPayloadLength: WS_MAX_MESSAGE_SIZE,
|
|
367
|
+
idleTimeout: WS_PING_INTERVAL / 1000, // Convert to seconds for Bun
|
|
368
|
+
closeOnBackpressureLimit: true, // Close connection if backpressure limit exceeded
|
|
369
|
+
|
|
370
|
+
// Connection opened
|
|
371
|
+
async open(ws) {
|
|
372
|
+
const id = crypto.randomUUID();
|
|
373
|
+
ws.data.connection_id = id;
|
|
374
|
+
connections.set(id, ws);
|
|
375
|
+
|
|
376
|
+
wsLogger.info(
|
|
377
|
+
`Connection opened: ${id.slice(0, 8)}...`,
|
|
378
|
+
ws.data.user_id ? `(user: ${ws.data.user_id})` : "(anonymous)",
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Get full profile if authenticated
|
|
382
|
+
let profile = null;
|
|
383
|
+
if (ws.data.user_id) {
|
|
384
|
+
try {
|
|
385
|
+
profile = await getUserProfile(ws.data.user_id);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
wsLogger.error("Failed to load profile:", error.message);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Send welcome message as JSON-RPC method call
|
|
392
|
+
ws.send(
|
|
393
|
+
JSON.stringify({
|
|
394
|
+
jsonrpc: "2.0",
|
|
395
|
+
method: "connected",
|
|
396
|
+
params: {
|
|
397
|
+
connection_id: id,
|
|
398
|
+
authenticated: !!ws.data.user_id,
|
|
399
|
+
profile,
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// Call custom connection handler
|
|
405
|
+
if (onConnection) {
|
|
406
|
+
onConnection(ws, id);
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
// Message received
|
|
411
|
+
async message(ws, message) {
|
|
412
|
+
const response = await handler(ws, message);
|
|
413
|
+
ws.send(response);
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
// Connection closed
|
|
417
|
+
close(ws) {
|
|
418
|
+
const id = ws.data.connection_id;
|
|
419
|
+
connections.delete(id);
|
|
420
|
+
wsLogger.info(`Connection closed: ${id?.slice(0, 8)}...`);
|
|
421
|
+
|
|
422
|
+
// Call custom disconnection handler
|
|
423
|
+
if (onDisconnection) {
|
|
424
|
+
onDisconnection(ws, id);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
// Error occurred
|
|
429
|
+
error(ws, error) {
|
|
430
|
+
wsLogger.error(`WebSocket error for ${ws.data.connection_id?.slice(0, 8)}...:`, error.message);
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Broadcast message to all authenticated connections or specific client_ids
|
|
436
|
+
export function createBroadcaster(connections) {
|
|
437
|
+
return function broadcastToConnections(message, client_ids = null) {
|
|
438
|
+
if (client_ids && Array.isArray(client_ids)) {
|
|
439
|
+
// Send to specific user_ids
|
|
440
|
+
for (const [id, ws] of connections) {
|
|
441
|
+
if (ws.data.user_id && client_ids.includes(ws.data.user_id)) {
|
|
442
|
+
ws.send(message);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
// Send to all authenticated connections
|
|
447
|
+
for (const [id, ws] of connections) {
|
|
448
|
+
if (ws.data.user_id) {
|
|
449
|
+
ws.send(message);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Legacy export for backward compatibility
|
|
457
|
+
export function broadcastToConnections(connections, message) {
|
|
458
|
+
// Send to all authenticated connections
|
|
459
|
+
for (const [id, ws] of connections) {
|
|
460
|
+
if (ws.data.user_id) {
|
|
461
|
+
ws.send(message);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|