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.
@@ -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';