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
package/src/client/ws.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Pure WebSocket manager class (no React dependencies)
|
|
2
|
+
class WebSocketManager {
|
|
3
|
+
constructor(options = {}) {
|
|
4
|
+
this.ws = null;
|
|
5
|
+
this.messageId = 0;
|
|
6
|
+
this.pendingRequests = new Map();
|
|
7
|
+
this.broadcastCallbacks = new Set();
|
|
8
|
+
this.sidRequestHandlers = new Set();
|
|
9
|
+
this.reconnectAttempts = 0;
|
|
10
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
11
|
+
this.isShuttingDown = false;
|
|
12
|
+
|
|
13
|
+
// Ad
|
|
14
|
+
|
|
15
|
+
// DZQL nested proxy API - matches server-side db.api pattern
|
|
16
|
+
// Proxy handles both DZQL operations and custom functions
|
|
17
|
+
const dzqlOps = {
|
|
18
|
+
get: this.createEntityProxy("get"),
|
|
19
|
+
save: this.createEntityProxy("save"),
|
|
20
|
+
delete: this.createEntityProxy("delete"),
|
|
21
|
+
lookup: this.createEntityProxy("lookup"),
|
|
22
|
+
search: this.createEntityProxy("search"),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
this.api = new Proxy(dzqlOps, {
|
|
26
|
+
get: (target, prop) => {
|
|
27
|
+
// Return cached DZQL operation if it exists
|
|
28
|
+
if (prop in target) {
|
|
29
|
+
return target[prop];
|
|
30
|
+
}
|
|
31
|
+
// All other properties are treated as custom function calls
|
|
32
|
+
return (params = {}) => {
|
|
33
|
+
return this.call(prop, params);
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create entity proxy for DZQL operations
|
|
41
|
+
*
|
|
42
|
+
* @param {string} operation - The operation type (get, save, delete, lookup, search)
|
|
43
|
+
* @returns {Proxy} A proxy that creates entity-specific methods
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // For search operations with advanced filtering:
|
|
47
|
+
* const venues = await ws.api.search.venues({
|
|
48
|
+
* filters: {
|
|
49
|
+
* city: "New York", // Exact match
|
|
50
|
+
* capacity: { gte: 1000, lt: 5000 }, // Range operators
|
|
51
|
+
* name: { ilike: "%garden%" }, // Pattern matching
|
|
52
|
+
* categories: ["sports", "concert"], // IN array
|
|
53
|
+
* description: { not_null: true }, // Not null check
|
|
54
|
+
* _search: "madison" // Text search across searchable fields
|
|
55
|
+
* },
|
|
56
|
+
* sort: { field: "name", order: "asc" },
|
|
57
|
+
* page: 1,
|
|
58
|
+
* limit: 25,
|
|
59
|
+
* on_date: "2024-01-15" // Optional temporal filter
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* Filter operators supported:
|
|
63
|
+
* - Exact match: {field: "value"}
|
|
64
|
+
* - Greater than: {field: {gt: 100}}
|
|
65
|
+
* - Greater or equal: {field: {gte: 100}}
|
|
66
|
+
* - Less than: {field: {lt: 100}}
|
|
67
|
+
* - Less or equal: {field: {lte: 100}}
|
|
68
|
+
* - Not equal: {field: {neq: "value"}}
|
|
69
|
+
* - Between: {field: {between: [10, 100]}}
|
|
70
|
+
* - Pattern match: {field: {like: "%pattern%"}}
|
|
71
|
+
* - Case-insensitive: {field: {ilike: "%pattern%"}}
|
|
72
|
+
* - IN array: {field: ["value1", "value2"]}
|
|
73
|
+
* - NOT IN: {field: {not_in: ["value1", "value2"]}}
|
|
74
|
+
* - IS NULL: {field: null}
|
|
75
|
+
* - IS NOT NULL: {field: {not_null: true}}
|
|
76
|
+
* - Text search: {_search: "search terms"}
|
|
77
|
+
*
|
|
78
|
+
* Response format:
|
|
79
|
+
* {
|
|
80
|
+
* data: [...], // Array of results
|
|
81
|
+
* total: 100, // Total count before pagination
|
|
82
|
+
* page: 1, // Current page number
|
|
83
|
+
* limit: 50 // Results per page
|
|
84
|
+
* }
|
|
85
|
+
*
|
|
86
|
+
* Error handling:
|
|
87
|
+
* Invalid column names will throw an error with message:
|
|
88
|
+
* "Column {column_name} does not exist in table {table_name}"
|
|
89
|
+
*
|
|
90
|
+
* Invalid operators are silently ignored (no error).
|
|
91
|
+
*/
|
|
92
|
+
createEntityProxy(operation) {
|
|
93
|
+
return new Proxy(
|
|
94
|
+
{},
|
|
95
|
+
{
|
|
96
|
+
get: (target, entityName) => {
|
|
97
|
+
return (params = {}) => {
|
|
98
|
+
return this.call(`dzql.${operation}.${entityName}`, params);
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
connect(url = null, timeout = 5000) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
let wsUrl;
|
|
108
|
+
|
|
109
|
+
if (url) {
|
|
110
|
+
// Direct URL provided (for testing)
|
|
111
|
+
wsUrl = url;
|
|
112
|
+
} else if (typeof window !== "undefined") {
|
|
113
|
+
// Browser environment
|
|
114
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
115
|
+
wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
116
|
+
} else {
|
|
117
|
+
// Node.js environment (default for testing)
|
|
118
|
+
wsUrl = "ws://localhost:3000/ws";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add JWT token as query parameter if available
|
|
122
|
+
if (typeof localStorage !== 'undefined'){
|
|
123
|
+
const storedToken = localStorage.getItem("dzql_token");
|
|
124
|
+
if (storedToken) {
|
|
125
|
+
wsUrl += `?token=${encodeURIComponent(storedToken)}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const connectionTimeout = setTimeout(() => {
|
|
130
|
+
if (this.ws) {
|
|
131
|
+
this.ws.close();
|
|
132
|
+
}
|
|
133
|
+
reject(new Error(`WebSocket connection timed out after ${timeout}ms`));
|
|
134
|
+
}, timeout);
|
|
135
|
+
|
|
136
|
+
// When bundled by Bun, this will always use the browser's WebSocket
|
|
137
|
+
this.ws = new WebSocket(wsUrl);
|
|
138
|
+
|
|
139
|
+
this.ws.onopen = () => {
|
|
140
|
+
clearTimeout(connectionTimeout);
|
|
141
|
+
console.log(`WebSocket connected to ${wsUrl}`);
|
|
142
|
+
this.reconnectAttempts = 0;
|
|
143
|
+
resolve();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
this.ws.onmessage = (event) => {
|
|
147
|
+
try {
|
|
148
|
+
const message = JSON.parse(event.data);
|
|
149
|
+
|
|
150
|
+
this.handleMessage(message);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error("Failed to parse WebSocket message:", error);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
this.ws.onclose = () => {
|
|
157
|
+
console.log(`WebSocket disconnected from ${wsUrl}`);
|
|
158
|
+
if (!this.isShuttingDown) {
|
|
159
|
+
this.attemptReconnect();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
this.ws.onerror = (error) => {
|
|
164
|
+
clearTimeout(connectionTimeout);
|
|
165
|
+
console.error(`WebSocket connection error to ${wsUrl}:`, error);
|
|
166
|
+
reject(error);
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
handleMessage(message) {
|
|
172
|
+
// Handle JSON-RPC responses
|
|
173
|
+
if (message.id && this.pendingRequests.has(message.id)) {
|
|
174
|
+
const { resolve, reject } = this.pendingRequests.get(message.id);
|
|
175
|
+
this.pendingRequests.delete(message.id);
|
|
176
|
+
|
|
177
|
+
if (message.error) {
|
|
178
|
+
reject(new Error(message.error.message || message.error.code || 'Unknown error'));
|
|
179
|
+
} else {
|
|
180
|
+
resolve(message.result);
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Handle broadcasts and SID requests
|
|
184
|
+
|
|
185
|
+
// Check if this is a SID request from server
|
|
186
|
+
if (message.params && message.params.sid) {
|
|
187
|
+
// Call all registered SID handlers
|
|
188
|
+
this.sidRequestHandlers.forEach((handler) => {
|
|
189
|
+
handler(message.method, message.params);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Call regular broadcast callbacks
|
|
194
|
+
this.broadcastCallbacks.forEach((callback) => {
|
|
195
|
+
callback(message.method, message.params);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
attemptReconnect() {
|
|
201
|
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
202
|
+
this.reconnectAttempts++;
|
|
203
|
+
const delay = 1000 * this.reconnectAttempts;
|
|
204
|
+
setTimeout(() => {
|
|
205
|
+
console.log(
|
|
206
|
+
`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts}) to ws://localhost:3000/ws in ${delay}ms`,
|
|
207
|
+
);
|
|
208
|
+
this.connect();
|
|
209
|
+
}, delay);
|
|
210
|
+
} else {
|
|
211
|
+
console.error(
|
|
212
|
+
`WebSocket failed to connect after ${this.maxReconnectAttempts} attempts. Giving up.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
call(method, params = {}) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
220
|
+
reject(new Error("WebSocket not connected"));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const id = ++this.messageId;
|
|
225
|
+
const message = {
|
|
226
|
+
jsonrpc: "2.0",
|
|
227
|
+
method,
|
|
228
|
+
params,
|
|
229
|
+
id,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
233
|
+
this.ws.send(JSON.stringify(message));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
onBroadcast(callback) {
|
|
238
|
+
this.broadcastCallbacks.add(callback);
|
|
239
|
+
return () => this.broadcastCallbacks.delete(callback);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
offBroadcast(callback) {
|
|
243
|
+
this.broadcastCallbacks.delete(callback);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Register a handler for SID requests from server
|
|
248
|
+
* Handler receives (method, params) where params includes { sid, ...otherData }
|
|
249
|
+
* Handler should call respondToSID(sid, result) or respondToSID(sid, null, error)
|
|
250
|
+
*
|
|
251
|
+
* @param {Function} callback - Handler function
|
|
252
|
+
* @returns {Function} - Cleanup function to remove the handler
|
|
253
|
+
*/
|
|
254
|
+
onSIDRequest(callback) {
|
|
255
|
+
this.sidRequestHandlers.add(callback);
|
|
256
|
+
return () => this.sidRequestHandlers.delete(callback);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
offSIDRequest(callback) {
|
|
260
|
+
this.sidRequestHandlers.delete(callback);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Respond to a SID request from server
|
|
265
|
+
*
|
|
266
|
+
* @param {string} sid - The session ID from the server request
|
|
267
|
+
* @param {any} result - The result to send back (use null if error)
|
|
268
|
+
* @param {string|Error} error - Optional error if the request failed
|
|
269
|
+
*/
|
|
270
|
+
async respondToSID(sid, result = null, error = null) {
|
|
271
|
+
const errorMessage = error ? (typeof error === 'string' ? error : error.message) : null;
|
|
272
|
+
|
|
273
|
+
return this.call('_sid_response', {
|
|
274
|
+
sid,
|
|
275
|
+
result,
|
|
276
|
+
error: errorMessage
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
isConnected() {
|
|
281
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
disconnect() {
|
|
285
|
+
this.isShuttingDown = true;
|
|
286
|
+
if (this.ws) {
|
|
287
|
+
this.ws.close();
|
|
288
|
+
this.ws = null;
|
|
289
|
+
}
|
|
290
|
+
this.pendingRequests.clear();
|
|
291
|
+
this.reconnectAttempts = 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Clean disconnect without reconnection attempts
|
|
296
|
+
* Perfect for test cleanup
|
|
297
|
+
*/
|
|
298
|
+
cleanDisconnect() {
|
|
299
|
+
this.isShuttingDown = true;
|
|
300
|
+
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent any reconnection
|
|
301
|
+
|
|
302
|
+
if (this.ws) {
|
|
303
|
+
this.ws.onclose = null; // Remove close handler to prevent reconnection
|
|
304
|
+
this.ws.close();
|
|
305
|
+
this.ws = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.pendingRequests.clear();
|
|
309
|
+
this.broadcastCallbacks.clear();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Reset the WebSocket manager to initial state
|
|
314
|
+
* Useful for test isolation
|
|
315
|
+
*/
|
|
316
|
+
reset() {
|
|
317
|
+
this.cleanDisconnect();
|
|
318
|
+
this.messageId = 0;
|
|
319
|
+
this.reconnectAttempts = 0;
|
|
320
|
+
this.isShuttingDown = false;
|
|
321
|
+
this.pendingRequests.clear();
|
|
322
|
+
this.broadcastCallbacks.clear();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check connection status
|
|
327
|
+
*/
|
|
328
|
+
getStatus() {
|
|
329
|
+
if (!this.ws) return "disconnected";
|
|
330
|
+
|
|
331
|
+
switch (this.ws.readyState) {
|
|
332
|
+
case WebSocket.CONNECTING:
|
|
333
|
+
return "connecting";
|
|
334
|
+
case WebSocket.OPEN:
|
|
335
|
+
return "connected";
|
|
336
|
+
case WebSocket.CLOSING:
|
|
337
|
+
return "closing";
|
|
338
|
+
case WebSocket.CLOSED:
|
|
339
|
+
return "disconnected";
|
|
340
|
+
default:
|
|
341
|
+
return "unknown";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const ws = new WebSocketManager();
|
|
347
|
+
|
|
348
|
+
export const useWs = () => {
|
|
349
|
+
return ws;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export { WebSocketManager };
|
package/src/client.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// DZQL Framework - Client Entry Point
|
|
2
|
+
// This file exports only client-side code for browser use
|
|
3
|
+
|
|
4
|
+
// Re-export client utilities
|
|
5
|
+
export { WebSocketManager, useWs } from './client/ws.js';
|
|
6
|
+
|
|
7
|
+
// Re-export UI framework
|
|
8
|
+
export { mount, state, Component } from './client/ui.js';
|
|
9
|
+
export { loadUI, loadEntityUI } from './client/ui-loader.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
-- DZQL Core Schema - Version 3.0.0
|
|
2
|
+
-- Basic schema, tables, and meta information
|
|
3
|
+
|
|
4
|
+
-- === Schema Creation ===
|
|
5
|
+
CREATE SCHEMA IF NOT EXISTS dzql;
|
|
6
|
+
|
|
7
|
+
-- === Meta Table ===
|
|
8
|
+
CREATE TABLE IF NOT EXISTS dzql.meta (
|
|
9
|
+
installed_at timestamptz DEFAULT now(),
|
|
10
|
+
version text NOT NULL
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
INSERT INTO dzql.meta (version) VALUES ('3.0.0')
|
|
14
|
+
ON CONFLICT DO NOTHING;
|
|
15
|
+
|
|
16
|
+
-- === Entity Configuration Table ===
|
|
17
|
+
CREATE TABLE IF NOT EXISTS dzql.entities (
|
|
18
|
+
table_name text PRIMARY KEY,
|
|
19
|
+
label_field text NOT NULL, -- field to use for lookup labels
|
|
20
|
+
searchable_fields text[] NOT NULL, -- fields to search in search operations
|
|
21
|
+
fk_includes jsonb DEFAULT '{}', -- foreign keys to dereference in get operations
|
|
22
|
+
soft_delete boolean DEFAULT false, -- use deleted_at instead of hard delete
|
|
23
|
+
temporal_fields jsonb DEFAULT '{}', -- valid_from/valid_to field names for temporal filtering
|
|
24
|
+
notification_paths jsonb DEFAULT '{}', -- paths to determine who gets notified
|
|
25
|
+
permission_paths jsonb DEFAULT '{}', -- paths to determine who has permission for operations
|
|
26
|
+
graph_rules jsonb DEFAULT '{}' -- graph evolution rules for automatic relationship management
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- === Registry (allowlist of callable functions) ===
|
|
30
|
+
CREATE TABLE IF NOT EXISTS dzql.registry (
|
|
31
|
+
fn_regproc regproc PRIMARY KEY,
|
|
32
|
+
description text
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- === Event Audit Table ===
|
|
36
|
+
CREATE TABLE IF NOT EXISTS dzql.events (
|
|
37
|
+
event_id bigserial PRIMARY KEY,
|
|
38
|
+
table_name text NOT NULL,
|
|
39
|
+
op text NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
|
|
40
|
+
pk jsonb NOT NULL, -- primary key of affected record
|
|
41
|
+
before jsonb, -- old values (NULL for INSERT)
|
|
42
|
+
after jsonb, -- new values (NULL for DELETE)
|
|
43
|
+
user_id int, -- who made the change
|
|
44
|
+
notify_users int[], -- who should be notified (NULL = everyone)
|
|
45
|
+
at timestamptz DEFAULT now() -- when the change occurred
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- Index for efficient event queries
|
|
49
|
+
CREATE INDEX IF NOT EXISTS dzql_events_table_pk_idx ON dzql.events (table_name, pk, at);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS dzql_events_user_idx ON dzql.events (user_id, at);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS dzql_events_event_id_idx ON dzql.events (event_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_dzql_events_at ON dzql.events(at);
|
|
53
|
+
|
|
54
|
+
-- === Comments ===
|
|
55
|
+
COMMENT ON SCHEMA dzql IS 'DZQL framework core schema';
|
|
56
|
+
COMMENT ON TABLE dzql.entities IS 'Configuration for entities with automatic CRUD operations';
|
|
57
|
+
COMMENT ON TABLE dzql.registry IS 'Registry of callable PostgreSQL functions';
|
|
58
|
+
COMMENT ON TABLE dzql.events IS 'Audit trail of all entity changes';
|
|
59
|
+
COMMENT ON COLUMN dzql.entities.graph_rules IS 'Graph evolution rules that define how relationships change during operations';
|