dzql 0.5.33 → 0.6.0
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/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +293 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +641 -0
- package/docs/project-setup.md +432 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +164 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/create/.env.example +8 -0
- package/src/create/README.md +101 -0
- package/src/create/compose.yml +14 -0
- package/src/create/domain.ts +153 -0
- package/src/create/package.json +24 -0
- package/src/create/server.ts +18 -0
- package/src/create/setup.sh +11 -0
- package/src/create/tsconfig.json +15 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
package/src/client/ws.js
DELETED
|
@@ -1,762 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WebSocket manager for DZQL client-side real-time communication
|
|
3
|
-
*
|
|
4
|
-
* Provides:
|
|
5
|
-
* - WebSocket connection management with auto-reconnect
|
|
6
|
-
* - JSON-RPC 2.0 protocol for API calls
|
|
7
|
-
* - Proxy-based API matching server-side db.api pattern
|
|
8
|
-
* - Real-time broadcast event handling
|
|
9
|
-
* - Automatic JWT authentication
|
|
10
|
-
*
|
|
11
|
-
* @class WebSocketManager
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* // Basic usage
|
|
15
|
-
* import { WebSocketManager } from 'dzql/client';
|
|
16
|
-
*
|
|
17
|
-
* const ws = new WebSocketManager();
|
|
18
|
-
* await ws.connect('ws://localhost:3000/ws');
|
|
19
|
-
*
|
|
20
|
-
* // Login
|
|
21
|
-
* const session = await ws.api.login_user({
|
|
22
|
-
* email: 'user@example.com',
|
|
23
|
-
* password: 'password123'
|
|
24
|
-
* });
|
|
25
|
-
*
|
|
26
|
-
* // Register with options (e.g., organisation name)
|
|
27
|
-
* const session = await ws.api.register_user({
|
|
28
|
-
* email: 'user@example.com',
|
|
29
|
-
* password: 'password123',
|
|
30
|
-
* options: { org_name: 'Acme Corp' }
|
|
31
|
-
* });
|
|
32
|
-
*
|
|
33
|
-
* // CRUD operations
|
|
34
|
-
* const venue = await ws.api.get.venues({ id: 1 });
|
|
35
|
-
* const created = await ws.api.save.venues({ name: 'New Venue' });
|
|
36
|
-
*
|
|
37
|
-
* // Listen to real-time updates
|
|
38
|
-
* ws.onBroadcast((method, params) => {
|
|
39
|
-
* console.log(`Event: ${method}`, params);
|
|
40
|
-
* });
|
|
41
|
-
*
|
|
42
|
-
* @example
|
|
43
|
-
* // Advanced search
|
|
44
|
-
* const results = await ws.api.search.venues({
|
|
45
|
-
* filters: {
|
|
46
|
-
* city: 'New York',
|
|
47
|
-
* capacity: { gte: 1000 },
|
|
48
|
-
* _search: 'garden'
|
|
49
|
-
* },
|
|
50
|
-
* sort: { field: 'name', order: 'asc' },
|
|
51
|
-
* page: 1,
|
|
52
|
-
* limit: 25
|
|
53
|
-
* });
|
|
54
|
-
*/
|
|
55
|
-
// Pure WebSocket manager class (no React dependencies)
|
|
56
|
-
class WebSocketManager {
|
|
57
|
-
/**
|
|
58
|
-
* Create a WebSocketManager instance
|
|
59
|
-
*
|
|
60
|
-
* @param {Object} [options={}] - Configuration options
|
|
61
|
-
* @param {number} [options.maxReconnectAttempts=5] - Maximum reconnection attempts before giving up
|
|
62
|
-
* @param {string} [options.tokenName='dzql_token'] - Name of the localStorage key for JWT token
|
|
63
|
-
*/
|
|
64
|
-
constructor(options = {}) {
|
|
65
|
-
this.ws = null;
|
|
66
|
-
this.messageId = 0;
|
|
67
|
-
this.pendingRequests = new Map();
|
|
68
|
-
this.broadcastCallbacks = new Set();
|
|
69
|
-
this.sidRequestHandlers = new Set();
|
|
70
|
-
this.subscriptions = new Map(); // subscription_id -> { callback, unsubscribe }
|
|
71
|
-
this.reconnectAttempts = 0;
|
|
72
|
-
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
73
|
-
this.tokenName = options.tokenName ?? 'dzql_token';
|
|
74
|
-
this.isShuttingDown = false;
|
|
75
|
-
|
|
76
|
-
// DZQL nested proxy API - matches server-side db.api pattern
|
|
77
|
-
// Proxy handles both DZQL operations and custom functions
|
|
78
|
-
const dzqlOps = {
|
|
79
|
-
get: this.createEntityProxy("get"),
|
|
80
|
-
save: this.createEntityProxy("save"),
|
|
81
|
-
delete: this.createEntityProxy("delete"),
|
|
82
|
-
lookup: this.createEntityProxy("lookup"),
|
|
83
|
-
search: this.createEntityProxy("search"),
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* API proxy for calling DZQL operations and custom functions
|
|
88
|
-
*
|
|
89
|
-
* @member {Object} api
|
|
90
|
-
* @memberof WebSocketManager
|
|
91
|
-
*
|
|
92
|
-
* @property {Object} get - Get single record by primary key
|
|
93
|
-
* @property {Object} save - Create or update record (upsert)
|
|
94
|
-
* @property {Object} delete - Delete record by primary key
|
|
95
|
-
* @property {Object} lookup - Autocomplete lookup by label field
|
|
96
|
-
* @property {Object} search - Advanced search with filters
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* // Get operation
|
|
100
|
-
* const venue = await ws.api.get.venues({ id: 1 });
|
|
101
|
-
*
|
|
102
|
-
* @example
|
|
103
|
-
* // Save operation (create)
|
|
104
|
-
* const created = await ws.api.save.venues({
|
|
105
|
-
* name: 'New Venue',
|
|
106
|
-
* org_id: 3
|
|
107
|
-
* });
|
|
108
|
-
*
|
|
109
|
-
* @example
|
|
110
|
-
* // Save operation (update)
|
|
111
|
-
* const updated = await ws.api.save.venues({
|
|
112
|
-
* id: 1,
|
|
113
|
-
* name: 'Updated Name'
|
|
114
|
-
* });
|
|
115
|
-
*
|
|
116
|
-
* @example
|
|
117
|
-
* // Delete operation
|
|
118
|
-
* await ws.api.delete.venues({ id: 1 });
|
|
119
|
-
*
|
|
120
|
-
* @example
|
|
121
|
-
* // Lookup for autocomplete
|
|
122
|
-
* const results = await ws.api.lookup.venues({ p_filter: 'garden' });
|
|
123
|
-
*
|
|
124
|
-
* @example
|
|
125
|
-
* // Search with filters
|
|
126
|
-
* const results = await ws.api.search.venues({
|
|
127
|
-
* filters: {
|
|
128
|
-
* city: 'New York',
|
|
129
|
-
* capacity: { gte: 1000, lt: 5000 },
|
|
130
|
-
* name: { ilike: '%garden%' },
|
|
131
|
-
* _search: 'madison'
|
|
132
|
-
* },
|
|
133
|
-
* sort: { field: 'name', order: 'asc' },
|
|
134
|
-
* page: 1,
|
|
135
|
-
* limit: 25
|
|
136
|
-
* });
|
|
137
|
-
*
|
|
138
|
-
* @example
|
|
139
|
-
* // Call custom function
|
|
140
|
-
* const result = await ws.api.myCustomFunction({ param: 'value' });
|
|
141
|
-
*/
|
|
142
|
-
this.api = new Proxy(dzqlOps, {
|
|
143
|
-
get: (target, prop) => {
|
|
144
|
-
// Return cached DZQL operation if it exists
|
|
145
|
-
if (prop in target) {
|
|
146
|
-
return target[prop];
|
|
147
|
-
}
|
|
148
|
-
// Handle subscribe_* methods specially
|
|
149
|
-
if (prop.startsWith('subscribe_')) {
|
|
150
|
-
return (params = {}, callback) => {
|
|
151
|
-
return this.subscribe(prop, params, callback);
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
// Handle unsubscribe_* methods
|
|
155
|
-
if (prop.startsWith('unsubscribe_')) {
|
|
156
|
-
return (params = {}) => {
|
|
157
|
-
return this.unsubscribe(prop, params);
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
// All other properties are treated as custom function calls
|
|
161
|
-
return (params = {}) => {
|
|
162
|
-
return this.call(prop, params);
|
|
163
|
-
};
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Create entity proxy for DZQL operations
|
|
170
|
-
*
|
|
171
|
-
* @param {string} operation - The operation type (get, save, delete, lookup, search)
|
|
172
|
-
* @returns {Proxy} A proxy that creates entity-specific methods
|
|
173
|
-
*
|
|
174
|
-
* @example
|
|
175
|
-
* // For search operations with advanced filtering:
|
|
176
|
-
* const venues = await ws.api.search.venues({
|
|
177
|
-
* filters: {
|
|
178
|
-
* city: "New York", // Exact match
|
|
179
|
-
* capacity: { gte: 1000, lt: 5000 }, // Range operators
|
|
180
|
-
* name: { ilike: "%garden%" }, // Pattern matching
|
|
181
|
-
* categories: ["sports", "concert"], // IN array
|
|
182
|
-
* description: { not_null: true }, // Not null check
|
|
183
|
-
* _search: "madison" // Text search across searchable fields
|
|
184
|
-
* },
|
|
185
|
-
* sort: { field: "name", order: "asc" },
|
|
186
|
-
* page: 1,
|
|
187
|
-
* limit: 25,
|
|
188
|
-
* on_date: "2024-01-15" // Optional temporal filter
|
|
189
|
-
* });
|
|
190
|
-
*
|
|
191
|
-
* Filter operators supported:
|
|
192
|
-
* - Exact match: {field: "value"}
|
|
193
|
-
* - Greater than: {field: {gt: 100}}
|
|
194
|
-
* - Greater or equal: {field: {gte: 100}}
|
|
195
|
-
* - Less than: {field: {lt: 100}}
|
|
196
|
-
* - Less or equal: {field: {lte: 100}}
|
|
197
|
-
* - Not equal: {field: {neq: "value"}}
|
|
198
|
-
* - Between: {field: {between: [10, 100]}}
|
|
199
|
-
* - Pattern match: {field: {like: "%pattern%"}}
|
|
200
|
-
* - Case-insensitive: {field: {ilike: "%pattern%"}}
|
|
201
|
-
* - IN array: {field: ["value1", "value2"]}
|
|
202
|
-
* - NOT IN: {field: {not_in: ["value1", "value2"]}}
|
|
203
|
-
* - IS NULL: {field: null}
|
|
204
|
-
* - IS NOT NULL: {field: {not_null: true}}
|
|
205
|
-
* - Text search: {_search: "search terms"}
|
|
206
|
-
*
|
|
207
|
-
* Response format:
|
|
208
|
-
* {
|
|
209
|
-
* data: [...], // Array of results
|
|
210
|
-
* total: 100, // Total count before pagination
|
|
211
|
-
* page: 1, // Current page number
|
|
212
|
-
* limit: 50 // Results per page
|
|
213
|
-
* }
|
|
214
|
-
*
|
|
215
|
-
* Error handling:
|
|
216
|
-
* Invalid column names will throw an error with message:
|
|
217
|
-
* "Column {column_name} does not exist in table {table_name}"
|
|
218
|
-
*
|
|
219
|
-
* Invalid operators are silently ignored (no error).
|
|
220
|
-
*/
|
|
221
|
-
createEntityProxy(operation) {
|
|
222
|
-
return new Proxy(
|
|
223
|
-
{},
|
|
224
|
-
{
|
|
225
|
-
get: (target, entityName) => {
|
|
226
|
-
return (params = {}) => {
|
|
227
|
-
return this.call(`dzql.${operation}.${entityName}`, params);
|
|
228
|
-
};
|
|
229
|
-
},
|
|
230
|
-
},
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Connect to DZQL WebSocket server
|
|
236
|
-
*
|
|
237
|
-
* Automatically detects environment (browser vs Node.js) and constructs WebSocket URL.
|
|
238
|
-
* If JWT token exists in localStorage, automatically includes it in connection.
|
|
239
|
-
*
|
|
240
|
-
* @param {string|null} [url=null] - WebSocket URL (auto-detected if not provided)
|
|
241
|
-
* Browser: ws://current-host/ws or wss://current-host/ws
|
|
242
|
-
* Node.js: ws://localhost:3000/ws
|
|
243
|
-
* @param {number} [timeout=5000] - Connection timeout in milliseconds
|
|
244
|
-
*
|
|
245
|
-
* @returns {Promise<void>} Resolves when connected, rejects on timeout or error
|
|
246
|
-
*
|
|
247
|
-
* @example
|
|
248
|
-
* // Auto-detect URL (browser)
|
|
249
|
-
* await ws.connect();
|
|
250
|
-
*
|
|
251
|
-
* @example
|
|
252
|
-
* // Explicit URL
|
|
253
|
-
* await ws.connect('ws://localhost:3000/ws');
|
|
254
|
-
*
|
|
255
|
-
* @example
|
|
256
|
-
* // Custom timeout
|
|
257
|
-
* await ws.connect(null, 10000); // 10 second timeout
|
|
258
|
-
*/
|
|
259
|
-
connect(url = null, timeout = 5000) {
|
|
260
|
-
return new Promise((resolve, reject) => {
|
|
261
|
-
let wsUrl;
|
|
262
|
-
|
|
263
|
-
if (url) {
|
|
264
|
-
// Direct URL provided (for testing)
|
|
265
|
-
wsUrl = url;
|
|
266
|
-
} else if (typeof window !== "undefined") {
|
|
267
|
-
// Browser environment
|
|
268
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
269
|
-
wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
270
|
-
} else {
|
|
271
|
-
// Node.js environment (default for testing)
|
|
272
|
-
wsUrl = "ws://localhost:3000/ws";
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Add JWT token as query parameter if available
|
|
276
|
-
if (typeof localStorage !== 'undefined'){
|
|
277
|
-
const storedToken = localStorage.getItem(this.tokenName);
|
|
278
|
-
if (storedToken) {
|
|
279
|
-
wsUrl += `?token=${encodeURIComponent(storedToken)}`;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const connectionTimeout = setTimeout(() => {
|
|
284
|
-
if (this.ws) {
|
|
285
|
-
this.ws.close();
|
|
286
|
-
}
|
|
287
|
-
reject(new Error(`WebSocket connection timed out after ${timeout}ms`));
|
|
288
|
-
}, timeout);
|
|
289
|
-
|
|
290
|
-
// When bundled by Bun, this will always use the browser's WebSocket
|
|
291
|
-
this.ws = new WebSocket(wsUrl);
|
|
292
|
-
|
|
293
|
-
this.ws.onopen = () => {
|
|
294
|
-
clearTimeout(connectionTimeout);
|
|
295
|
-
console.log(`WebSocket connected to ${wsUrl}`);
|
|
296
|
-
this.reconnectAttempts = 0;
|
|
297
|
-
resolve();
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
this.ws.onmessage = (event) => {
|
|
301
|
-
try {
|
|
302
|
-
const message = JSON.parse(event.data);
|
|
303
|
-
|
|
304
|
-
this.handleMessage(message);
|
|
305
|
-
} catch (error) {
|
|
306
|
-
console.error("Failed to parse WebSocket message:", error);
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
this.ws.onclose = () => {
|
|
311
|
-
console.log(`WebSocket disconnected from ${wsUrl}`);
|
|
312
|
-
if (!this.isShuttingDown) {
|
|
313
|
-
this.attemptReconnect();
|
|
314
|
-
}
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
this.ws.onerror = (error) => {
|
|
318
|
-
clearTimeout(connectionTimeout);
|
|
319
|
-
console.error(`WebSocket connection error to ${wsUrl}:`, error);
|
|
320
|
-
reject(error);
|
|
321
|
-
};
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
handleMessage(message) {
|
|
326
|
-
// Handle JSON-RPC responses
|
|
327
|
-
if (message.id && this.pendingRequests.has(message.id)) {
|
|
328
|
-
const { resolve, reject } = this.pendingRequests.get(message.id);
|
|
329
|
-
this.pendingRequests.delete(message.id);
|
|
330
|
-
|
|
331
|
-
if (message.error) {
|
|
332
|
-
reject(new Error(message.error.message || message.error.code || 'Unknown error'));
|
|
333
|
-
} else {
|
|
334
|
-
resolve(message.result);
|
|
335
|
-
}
|
|
336
|
-
} else {
|
|
337
|
-
// Handle subscription updates (legacy full document replacement)
|
|
338
|
-
if (message.method === "subscription:update") {
|
|
339
|
-
const { subscription_id, data } = message.params;
|
|
340
|
-
const sub = this.subscriptions.get(subscription_id);
|
|
341
|
-
if (sub && sub.callback) {
|
|
342
|
-
// Update local data and call callback
|
|
343
|
-
sub.localData = data;
|
|
344
|
-
sub.callback(data);
|
|
345
|
-
}
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Handle atomic subscription events (new efficient patching)
|
|
350
|
-
if (message.method === "subscription:event") {
|
|
351
|
-
const { subscription_id, event } = message.params;
|
|
352
|
-
const sub = this.subscriptions.get(subscription_id);
|
|
353
|
-
if (sub) {
|
|
354
|
-
this.applyAtomicUpdate(sub, event);
|
|
355
|
-
}
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Handle broadcasts and SID requests
|
|
360
|
-
|
|
361
|
-
// Check if this is a SID request from server
|
|
362
|
-
if (message.params && message.params.sid) {
|
|
363
|
-
// Call all registered SID handlers
|
|
364
|
-
this.sidRequestHandlers.forEach((handler) => {
|
|
365
|
-
handler(message.method, message.params);
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Call regular broadcast callbacks
|
|
370
|
-
this.broadcastCallbacks.forEach((callback) => {
|
|
371
|
-
callback(message.method, message.params);
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Apply an atomic update to a subscription's local data
|
|
378
|
-
* @private
|
|
379
|
-
* @param {Object} sub - Subscription object with localData, schema, callback
|
|
380
|
-
* @param {Object} event - Event with table, op, pk, data, before
|
|
381
|
-
*/
|
|
382
|
-
applyAtomicUpdate(sub, event) {
|
|
383
|
-
const { table, op, pk, data, before } = event;
|
|
384
|
-
const { schema, localData, callback } = sub;
|
|
385
|
-
|
|
386
|
-
// Fallback: if no schema or localData, we can't apply atomic updates
|
|
387
|
-
if (!schema || !localData) {
|
|
388
|
-
console.warn('Cannot apply atomic update: missing schema or localData');
|
|
389
|
-
// If we have data, just call callback with it as a fallback
|
|
390
|
-
if (data) {
|
|
391
|
-
callback(data);
|
|
392
|
-
}
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const path = schema.paths?.[table];
|
|
397
|
-
if (!path) {
|
|
398
|
-
console.warn(`Unknown table ${table} for subscribable, cannot apply patch`);
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Apply the update based on where the table lives in the document
|
|
403
|
-
if (path === '.' || table === schema.root) {
|
|
404
|
-
// Root entity changed
|
|
405
|
-
this.applyRootUpdate(localData, schema.root, op, data, before);
|
|
406
|
-
} else {
|
|
407
|
-
// Relation changed - find and update in nested structure
|
|
408
|
-
this.applyRelationUpdate(localData, path, op, pk, data);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Trigger callback with updated document
|
|
412
|
-
callback(localData);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Apply update to root entity
|
|
417
|
-
* @private
|
|
418
|
-
*/
|
|
419
|
-
applyRootUpdate(localData, rootKey, op, data, before) {
|
|
420
|
-
if (op === 'update' && data) {
|
|
421
|
-
// Merge update into root entity
|
|
422
|
-
if (localData[rootKey]) {
|
|
423
|
-
Object.assign(localData[rootKey], data);
|
|
424
|
-
}
|
|
425
|
-
} else if (op === 'delete') {
|
|
426
|
-
// Mark root as deleted (or set to null)
|
|
427
|
-
localData[rootKey] = null;
|
|
428
|
-
}
|
|
429
|
-
// insert at root level would be a new document, handled by initial subscribe
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Apply update to a relation (nested array)
|
|
434
|
-
* @private
|
|
435
|
-
*/
|
|
436
|
-
applyRelationUpdate(localData, path, op, pk, data) {
|
|
437
|
-
const arr = this.getArrayAtPath(localData, path);
|
|
438
|
-
if (!arr || !Array.isArray(arr)) {
|
|
439
|
-
console.warn(`Could not find array at path ${path}`);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (op === 'insert' && data) {
|
|
444
|
-
arr.push(data);
|
|
445
|
-
} else if (op === 'update' && data && pk) {
|
|
446
|
-
const idx = arr.findIndex(item => this.pkMatch(item, pk));
|
|
447
|
-
if (idx !== -1) {
|
|
448
|
-
Object.assign(arr[idx], data);
|
|
449
|
-
} else {
|
|
450
|
-
// Item not found, might be a new item that passes the filter - add it
|
|
451
|
-
arr.push(data);
|
|
452
|
-
}
|
|
453
|
-
} else if (op === 'delete' && pk) {
|
|
454
|
-
const idx = arr.findIndex(item => this.pkMatch(item, pk));
|
|
455
|
-
if (idx !== -1) {
|
|
456
|
-
arr.splice(idx, 1);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Get array at a dot-separated path in an object
|
|
463
|
-
* @private
|
|
464
|
-
*/
|
|
465
|
-
getArrayAtPath(obj, path) {
|
|
466
|
-
const parts = path.split('.');
|
|
467
|
-
let current = obj;
|
|
468
|
-
for (const part of parts) {
|
|
469
|
-
if (!current || typeof current !== 'object') return null;
|
|
470
|
-
current = current[part];
|
|
471
|
-
}
|
|
472
|
-
return current;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Check if an item matches a primary key
|
|
477
|
-
* @private
|
|
478
|
-
*/
|
|
479
|
-
pkMatch(item, pk) {
|
|
480
|
-
if (!item || !pk) return false;
|
|
481
|
-
for (const [key, value] of Object.entries(pk)) {
|
|
482
|
-
if (item[key] !== value) return false;
|
|
483
|
-
}
|
|
484
|
-
return true;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
attemptReconnect() {
|
|
488
|
-
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
489
|
-
this.reconnectAttempts++;
|
|
490
|
-
const delay = 1000 * this.reconnectAttempts;
|
|
491
|
-
setTimeout(() => {
|
|
492
|
-
console.log(
|
|
493
|
-
`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts}) to ws://localhost:3000/ws in ${delay}ms`,
|
|
494
|
-
);
|
|
495
|
-
this.connect();
|
|
496
|
-
}, delay);
|
|
497
|
-
} else {
|
|
498
|
-
console.error(
|
|
499
|
-
`WebSocket failed to connect after ${this.maxReconnectAttempts} attempts. Giving up.`,
|
|
500
|
-
);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Call a method via JSON-RPC over WebSocket
|
|
506
|
-
*
|
|
507
|
-
* @private
|
|
508
|
-
* @param {string} method - Method name (e.g., 'login_user' or 'dzql.get.venues')
|
|
509
|
-
* @param {Object} [params={}] - Method parameters
|
|
510
|
-
* @returns {Promise<*>} Resolves with method result, rejects on error
|
|
511
|
-
*/
|
|
512
|
-
call(method, params = {}) {
|
|
513
|
-
return new Promise((resolve, reject) => {
|
|
514
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
515
|
-
reject(new Error("WebSocket not connected"));
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const id = ++this.messageId;
|
|
520
|
-
const message = {
|
|
521
|
-
jsonrpc: "2.0",
|
|
522
|
-
method,
|
|
523
|
-
params,
|
|
524
|
-
id,
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
this.pendingRequests.set(id, { resolve, reject });
|
|
528
|
-
this.ws.send(JSON.stringify(message));
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Subscribe to a live query
|
|
534
|
-
*
|
|
535
|
-
* Subscribes to real-time updates for a document. The server returns the initial
|
|
536
|
-
* data along with a schema that enables efficient atomic updates (patching).
|
|
537
|
-
*
|
|
538
|
-
* @param {string} method - Method name (subscribe_<subscribable>)
|
|
539
|
-
* @param {object} params - Subscription parameters
|
|
540
|
-
* @param {function} callback - Callback function for updates
|
|
541
|
-
* @returns {Promise<{data, subscription_id, schema, unsubscribe}>} Initial data, schema, and unsubscribe function
|
|
542
|
-
*
|
|
543
|
-
* @example
|
|
544
|
-
* const { data, schema, unsubscribe } = await ws.api.subscribe_venue_detail(
|
|
545
|
-
* { venue_id: 1 },
|
|
546
|
-
* (updated) => console.log('Updated:', updated)
|
|
547
|
-
* );
|
|
548
|
-
*
|
|
549
|
-
* // Use initial data
|
|
550
|
-
* console.log('Initial:', data);
|
|
551
|
-
* console.log('Schema:', schema); // { root: 'venues', paths: { venues: '.', sites: 'sites', ... } }
|
|
552
|
-
*
|
|
553
|
-
* // Later: unsubscribe
|
|
554
|
-
* unsubscribe();
|
|
555
|
-
*/
|
|
556
|
-
async subscribe(method, params = {}, callback) {
|
|
557
|
-
if (!callback || typeof callback !== 'function') {
|
|
558
|
-
throw new Error('Subscribe requires a callback function');
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Call server to register subscription
|
|
562
|
-
const result = await this.call(method, params);
|
|
563
|
-
const { subscription_id, data, schema } = result;
|
|
564
|
-
|
|
565
|
-
// Create unsubscribe function
|
|
566
|
-
const unsubscribeFn = async () => {
|
|
567
|
-
const unsubMethod = method.replace('subscribe_', 'unsubscribe_');
|
|
568
|
-
await this.call(unsubMethod, params);
|
|
569
|
-
this.subscriptions.delete(subscription_id);
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
// Store callback, schema, and local data for atomic updates
|
|
573
|
-
this.subscriptions.set(subscription_id, {
|
|
574
|
-
callback,
|
|
575
|
-
unsubscribe: unsubscribeFn,
|
|
576
|
-
schema, // Schema for path mapping (enables atomic updates)
|
|
577
|
-
localData: data // Local copy for patching
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
// Return initial data, schema, and unsubscribe function
|
|
581
|
-
return {
|
|
582
|
-
data,
|
|
583
|
-
subscription_id,
|
|
584
|
-
schema,
|
|
585
|
-
unsubscribe: unsubscribeFn
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Unsubscribe from a live query
|
|
591
|
-
*
|
|
592
|
-
* @param {string} method - Method name (unsubscribe_<subscribable>)
|
|
593
|
-
* @param {object} params - Subscription parameters
|
|
594
|
-
* @returns {Promise<{success: boolean}>}
|
|
595
|
-
*
|
|
596
|
-
* @example
|
|
597
|
-
* await ws.api.unsubscribe_venue_detail({ venue_id: 1 });
|
|
598
|
-
*/
|
|
599
|
-
async unsubscribe(method, params = {}) {
|
|
600
|
-
return await this.call(method, params);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Register callback for real-time broadcast events
|
|
605
|
-
*
|
|
606
|
-
* Broadcasts are sent when data changes (insert/update/delete operations).
|
|
607
|
-
* Method format: "{table}:{operation}" (e.g., "venues:update")
|
|
608
|
-
*
|
|
609
|
-
* @param {Function} callback - Callback function (method, params) => void
|
|
610
|
-
* @returns {Function} Cleanup function to remove the callback
|
|
611
|
-
*
|
|
612
|
-
* @example
|
|
613
|
-
* // Listen to all broadcasts
|
|
614
|
-
* ws.onBroadcast((method, params) => {
|
|
615
|
-
* console.log(`Event: ${method}`, params);
|
|
616
|
-
* });
|
|
617
|
-
*
|
|
618
|
-
* @example
|
|
619
|
-
* // Listen to specific table events
|
|
620
|
-
* ws.onBroadcast((method, params) => {
|
|
621
|
-
* if (method === 'venues:update') {
|
|
622
|
-
* console.log('Venue updated:', params.after);
|
|
623
|
-
* }
|
|
624
|
-
* });
|
|
625
|
-
*
|
|
626
|
-
* @example
|
|
627
|
-
* // With cleanup
|
|
628
|
-
* const cleanup = ws.onBroadcast((method, params) => {
|
|
629
|
-
* console.log(method, params);
|
|
630
|
-
* });
|
|
631
|
-
*
|
|
632
|
-
* // Later: stop listening
|
|
633
|
-
* cleanup();
|
|
634
|
-
*
|
|
635
|
-
* @example
|
|
636
|
-
* // Event structure
|
|
637
|
-
* {
|
|
638
|
-
* table: 'venues',
|
|
639
|
-
* op: 'insert', // 'insert', 'update', or 'delete'
|
|
640
|
-
* pk: { id: 1 },
|
|
641
|
-
* before: null, // Old values (null for insert)
|
|
642
|
-
* after: { ... }, // New values (null for delete)
|
|
643
|
-
* user_id: 123,
|
|
644
|
-
* at: '2025-01-15T10:30:00Z'
|
|
645
|
-
* }
|
|
646
|
-
*/
|
|
647
|
-
onBroadcast(callback) {
|
|
648
|
-
this.broadcastCallbacks.add(callback);
|
|
649
|
-
return () => this.broadcastCallbacks.delete(callback);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
offBroadcast(callback) {
|
|
653
|
-
this.broadcastCallbacks.delete(callback);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* Register a handler for SID requests from server
|
|
658
|
-
* Handler receives (method, params) where params includes { sid, ...otherData }
|
|
659
|
-
* Handler should call respondToSID(sid, result) or respondToSID(sid, null, error)
|
|
660
|
-
*
|
|
661
|
-
* @param {Function} callback - Handler function
|
|
662
|
-
* @returns {Function} - Cleanup function to remove the handler
|
|
663
|
-
*/
|
|
664
|
-
onSIDRequest(callback) {
|
|
665
|
-
this.sidRequestHandlers.add(callback);
|
|
666
|
-
return () => this.sidRequestHandlers.delete(callback);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
offSIDRequest(callback) {
|
|
670
|
-
this.sidRequestHandlers.delete(callback);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* Respond to a SID request from server
|
|
675
|
-
*
|
|
676
|
-
* @param {string} sid - The session ID from the server request
|
|
677
|
-
* @param {any} result - The result to send back (use null if error)
|
|
678
|
-
* @param {string|Error} error - Optional error if the request failed
|
|
679
|
-
*/
|
|
680
|
-
async respondToSID(sid, result = null, error = null) {
|
|
681
|
-
const errorMessage = error ? (typeof error === 'string' ? error : error.message) : null;
|
|
682
|
-
|
|
683
|
-
return this.call('_sid_response', {
|
|
684
|
-
sid,
|
|
685
|
-
result,
|
|
686
|
-
error: errorMessage
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
isConnected() {
|
|
691
|
-
return this.ws?.readyState === WebSocket.OPEN;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
disconnect() {
|
|
695
|
-
this.isShuttingDown = true;
|
|
696
|
-
if (this.ws) {
|
|
697
|
-
this.ws.close();
|
|
698
|
-
this.ws = null;
|
|
699
|
-
}
|
|
700
|
-
this.pendingRequests.clear();
|
|
701
|
-
this.reconnectAttempts = 0;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Clean disconnect without reconnection attempts
|
|
706
|
-
* Perfect for test cleanup
|
|
707
|
-
*/
|
|
708
|
-
cleanDisconnect() {
|
|
709
|
-
this.isShuttingDown = true;
|
|
710
|
-
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent any reconnection
|
|
711
|
-
|
|
712
|
-
if (this.ws) {
|
|
713
|
-
this.ws.onclose = null; // Remove close handler to prevent reconnection
|
|
714
|
-
this.ws.close();
|
|
715
|
-
this.ws = null;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
this.pendingRequests.clear();
|
|
719
|
-
this.broadcastCallbacks.clear();
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* Reset the WebSocket manager to initial state
|
|
724
|
-
* Useful for test isolation
|
|
725
|
-
*/
|
|
726
|
-
reset() {
|
|
727
|
-
this.cleanDisconnect();
|
|
728
|
-
this.messageId = 0;
|
|
729
|
-
this.reconnectAttempts = 0;
|
|
730
|
-
this.isShuttingDown = false;
|
|
731
|
-
this.pendingRequests.clear();
|
|
732
|
-
this.broadcastCallbacks.clear();
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/**
|
|
736
|
-
* Check connection status
|
|
737
|
-
*/
|
|
738
|
-
getStatus() {
|
|
739
|
-
if (!this.ws) return "disconnected";
|
|
740
|
-
|
|
741
|
-
switch (this.ws.readyState) {
|
|
742
|
-
case WebSocket.CONNECTING:
|
|
743
|
-
return "connecting";
|
|
744
|
-
case WebSocket.OPEN:
|
|
745
|
-
return "connected";
|
|
746
|
-
case WebSocket.CLOSING:
|
|
747
|
-
return "closing";
|
|
748
|
-
case WebSocket.CLOSED:
|
|
749
|
-
return "disconnected";
|
|
750
|
-
default:
|
|
751
|
-
return "unknown";
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const ws = new WebSocketManager();
|
|
757
|
-
|
|
758
|
-
export const useWs = () => {
|
|
759
|
-
return ws;
|
|
760
|
-
};
|
|
761
|
-
|
|
762
|
-
export { WebSocketManager };
|