nexabase-console 2.1.2 → 2.1.3

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.
Files changed (35) hide show
  1. package/dist/app/NexaApp.d.ts +3 -0
  2. package/dist/app/NexaApp.js +38 -11
  3. package/dist/firestore/CollectionReference.d.ts +2 -2
  4. package/dist/firestore/CollectionReference.js +41 -2
  5. package/dist/firestore/DocumentReference.d.ts +1 -1
  6. package/dist/firestore/DocumentReference.js +41 -11
  7. package/dist/firestore/FieldPath.d.ts +2 -1
  8. package/dist/firestore/FieldPath.js +31 -4
  9. package/dist/firestore/FieldValue.d.ts +8 -4
  10. package/dist/firestore/FieldValue.js +59 -7
  11. package/dist/firestore/Firestore.js +16 -4
  12. package/dist/firestore/GeoPoint.d.ts +1 -0
  13. package/dist/firestore/GeoPoint.js +13 -6
  14. package/dist/firestore/Query.d.ts +5 -0
  15. package/dist/firestore/Query.js +190 -56
  16. package/dist/firestore/QueryUtils.d.ts +19 -2
  17. package/dist/firestore/QueryUtils.js +296 -58
  18. package/dist/firestore/Snapshot.js +62 -17
  19. package/dist/firestore/SnapshotManager.js +27 -12
  20. package/dist/firestore/Timestamp.d.ts +1 -0
  21. package/dist/firestore/Timestamp.js +28 -2
  22. package/dist/firestore/batch.js +89 -36
  23. package/dist/firestore/pathValidation.d.ts +17 -0
  24. package/dist/firestore/pathValidation.js +61 -0
  25. package/dist/firestore/queryConstraints.d.ts +4 -4
  26. package/dist/firestore/queryConstraints.js +69 -8
  27. package/dist/firestore/transaction.js +72 -18
  28. package/dist/firestore/writes.d.ts +1 -1
  29. package/dist/firestore/writes.js +32 -14
  30. package/dist/transport/WebSocketClient.d.ts +341 -9
  31. package/dist/transport/WebSocketClient.js +1895 -131
  32. package/dist/transport/WebSocketClient.test.d.ts +1 -0
  33. package/dist/transport/WebSocketClient.test.js +454 -0
  34. package/dist/types/index.d.ts +1 -0
  35. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,454 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_test_1 = require("node:test");
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const WebSocketClient_1 = require("./WebSocketClient");
9
+ // Mock Socket.IO Socket implementation
10
+ class MockSocket {
11
+ constructor() {
12
+ this.id = `mock_socket_${Math.random().toString(36).substring(2, 7)}`;
13
+ this.connected = false;
14
+ this.auth = null;
15
+ this.eventHandlers = new Map();
16
+ this.emittedEvents = [];
17
+ }
18
+ on(event, handler) {
19
+ if (!this.eventHandlers.has(event)) {
20
+ this.eventHandlers.set(event, new Set());
21
+ }
22
+ this.eventHandlers.get(event).add(handler);
23
+ return this;
24
+ }
25
+ removeAllListeners() {
26
+ this.eventHandlers.clear();
27
+ return this;
28
+ }
29
+ emit(event, ...args) {
30
+ this.emittedEvents.push({ event, args });
31
+ // Check if last arg is an acknowledgement callback
32
+ const lastArg = args[args.length - 1];
33
+ if (typeof lastArg === 'function') {
34
+ // simulate default server ack
35
+ setTimeout(() => {
36
+ lastArg({ success: true, event });
37
+ }, 5);
38
+ }
39
+ return this;
40
+ }
41
+ trigger(event, ...args) {
42
+ const handlers = this.eventHandlers.get(event);
43
+ if (handlers) {
44
+ handlers.forEach((h) => h(...args));
45
+ }
46
+ }
47
+ connect() {
48
+ this.connected = true;
49
+ this.trigger('connect');
50
+ return this;
51
+ }
52
+ disconnect() {
53
+ this.connected = false;
54
+ this.trigger('disconnect', 'io client disconnect');
55
+ return this;
56
+ }
57
+ }
58
+ // Mock BroadcastChannel implementation
59
+ class MockBroadcastChannel {
60
+ constructor(name) {
61
+ this.closed = false;
62
+ this.onmessage = null;
63
+ this.postedMessages = [];
64
+ this.shouldFailPost = false;
65
+ this.name = name;
66
+ if (!MockBroadcastChannel.channels.has(name)) {
67
+ MockBroadcastChannel.channels.set(name, new Set());
68
+ }
69
+ MockBroadcastChannel.channels.get(name).add(this);
70
+ }
71
+ postMessage(message) {
72
+ if (this.closed)
73
+ throw new Error('BroadcastChannel is closed');
74
+ if (this.shouldFailPost)
75
+ throw new Error('Simulated BroadcastChannel failure');
76
+ this.postedMessages.push(message);
77
+ const peers = MockBroadcastChannel.channels.get(this.name);
78
+ if (peers) {
79
+ peers.forEach((peer) => {
80
+ if (peer !== this && !peer.closed && typeof peer.onmessage === 'function') {
81
+ queueMicrotask(() => {
82
+ if (!peer.closed && typeof peer.onmessage === 'function') {
83
+ peer.onmessage(new MessageEvent('message', { data: message }));
84
+ }
85
+ });
86
+ }
87
+ });
88
+ }
89
+ }
90
+ close() {
91
+ this.closed = true;
92
+ const peers = MockBroadcastChannel.channels.get(this.name);
93
+ if (peers) {
94
+ peers.delete(this);
95
+ }
96
+ }
97
+ static reset() {
98
+ MockBroadcastChannel.channels.clear();
99
+ }
100
+ }
101
+ MockBroadcastChannel.channels = new Map();
102
+ // Mock Web Lock Manager
103
+ class MockLockManager {
104
+ constructor() {
105
+ this.activeLocks = new Map();
106
+ this.queues = new Map();
107
+ }
108
+ request(name, optionsOrCallback, callback) {
109
+ const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
110
+ const signal = optionsOrCallback?.signal;
111
+ return new Promise((resolve, reject) => {
112
+ if (signal?.aborted) {
113
+ return reject(new DOMException('The request was aborted', 'AbortError'));
114
+ }
115
+ const executeLock = async () => {
116
+ if (signal?.aborted) {
117
+ const next = this.queues.get(name)?.shift();
118
+ if (next)
119
+ next();
120
+ return reject(new DOMException('The request was aborted', 'AbortError'));
121
+ }
122
+ let lockReleased = false;
123
+ const release = () => {
124
+ if (lockReleased)
125
+ return;
126
+ lockReleased = true;
127
+ this.activeLocks.delete(name);
128
+ const next = this.queues.get(name)?.shift();
129
+ if (next) {
130
+ next();
131
+ }
132
+ };
133
+ this.activeLocks.set(name, { release });
134
+ if (signal) {
135
+ signal.addEventListener('abort', () => {
136
+ release();
137
+ reject(new DOMException('The request was aborted', 'AbortError'));
138
+ });
139
+ }
140
+ try {
141
+ const result = await cb({ name });
142
+ release();
143
+ resolve(result);
144
+ }
145
+ catch (err) {
146
+ release();
147
+ reject(err);
148
+ }
149
+ };
150
+ if (!this.activeLocks.has(name)) {
151
+ executeLock();
152
+ }
153
+ else {
154
+ if (!this.queues.has(name)) {
155
+ this.queues.set(name, []);
156
+ }
157
+ this.queues.get(name).push(executeLock);
158
+ }
159
+ });
160
+ }
161
+ reset() {
162
+ this.activeLocks.clear();
163
+ this.queues.clear();
164
+ }
165
+ }
166
+ (0, node_test_1.describe)('WebSocketClient Production Hardening Test Suite', () => {
167
+ let mockLocks;
168
+ let activeSockets;
169
+ (0, node_test_1.beforeEach)(() => {
170
+ MockBroadcastChannel.reset();
171
+ mockLocks = new MockLockManager();
172
+ activeSockets = [];
173
+ // Polyfill window & global environment for Node.js test runner
174
+ globalThis.window = {
175
+ location: { origin: 'http://localhost:3000' },
176
+ addEventListener: () => { },
177
+ removeEventListener: () => { },
178
+ };
179
+ globalThis.document = {
180
+ visibilityState: 'visible',
181
+ addEventListener: () => { },
182
+ removeEventListener: () => { },
183
+ };
184
+ });
185
+ (0, node_test_1.afterEach)(() => {
186
+ MockBroadcastChannel.reset();
187
+ mockLocks.reset();
188
+ });
189
+ const createTestClient = (projectId = 'test-proj', serverUrl = 'http://localhost:3000', options = {}) => {
190
+ const socket = new MockSocket();
191
+ activeSockets.push(socket);
192
+ const client = new WebSocketClient_1.WebSocketClient(projectId, serverUrl, {
193
+ socketFactory: (url, opts) => {
194
+ socket.auth = opts?.auth || null;
195
+ return socket;
196
+ },
197
+ broadcastChannelFactory: (name) => new MockBroadcastChannel(name),
198
+ locks: mockLocks,
199
+ followerTtlMs: 1000,
200
+ heartbeatIntervalMs: 200,
201
+ pruneIntervalMs: 500,
202
+ leaderLeaseIntervalMs: 500,
203
+ ...options,
204
+ });
205
+ return { client, socket };
206
+ };
207
+ (0, node_test_1.describe)('1. Argument Validation & Construction', () => {
208
+ (0, node_test_1.it)('throws invalid-argument error for empty projectId', () => {
209
+ strict_1.default.throws(() => new WebSocketClient_1.WebSocketClient('', 'http://localhost:3000'), {
210
+ name: 'NexaWebSocketError',
211
+ code: 'invalid-argument',
212
+ });
213
+ });
214
+ (0, node_test_1.it)('throws invalid-argument error for empty serverUrl', () => {
215
+ strict_1.default.throws(() => new WebSocketClient_1.WebSocketClient('test-proj', ''), {
216
+ name: 'NexaWebSocketError',
217
+ code: 'invalid-argument',
218
+ });
219
+ });
220
+ (0, node_test_1.it)('derives stable coordination keys without exposing tokens', () => {
221
+ const key1 = WebSocketClient_1.WebSocketClient.deriveCoordinationKey('proj', 'http://localhost:3000', 'scopeA');
222
+ const key2 = WebSocketClient_1.WebSocketClient.deriveCoordinationKey('proj', 'http://localhost:3000', 'scopeA');
223
+ const key3 = WebSocketClient_1.WebSocketClient.deriveCoordinationKey('proj', 'http://localhost:3000', 'scopeB');
224
+ strict_1.default.equal(key1, key2);
225
+ strict_1.default.notEqual(key1, key3);
226
+ strict_1.default.equal(key1.length, 16);
227
+ });
228
+ });
229
+ (0, node_test_1.describe)('2. Tri-state Path Extraction & Strict Segment Matching', () => {
230
+ (0, node_test_1.it)('validates segment-aware path matching correctly', () => {
231
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/12', 'users/12'), true);
232
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/12/profile', 'users/12'), true);
233
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/12', 'users/12/profile'), true);
234
+ // Boundary check: users/12 MUST NOT match users/123 or users/1
235
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/123', 'users/12'), false);
236
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('users/1', 'users/12'), false);
237
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.isPathMatch('posts/10', 'posts/1'), false);
238
+ });
239
+ (0, node_test_1.it)('extracts tri-state status (none, valid, invalid)', () => {
240
+ strict_1.default.deepEqual(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({}), { status: 'none' });
241
+ strict_1.default.deepEqual(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ path: 'users/alice' }), {
242
+ status: 'valid',
243
+ path: 'users/alice',
244
+ });
245
+ strict_1.default.deepEqual(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ docPath: '/posts/123/' }), {
246
+ status: 'valid',
247
+ path: 'posts/123',
248
+ });
249
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ path: 'users//alice' }).status, 'invalid');
250
+ strict_1.default.equal(WebSocketClient_1.WebSocketClient.extractPayloadPathTriState({ path: '' }).status, 'invalid');
251
+ });
252
+ (0, node_test_1.it)('quarantines malformed paths from becoming global events in handleSocketDataEvent', async () => {
253
+ const { client, socket } = createTestClient();
254
+ client.ensureConnected();
255
+ await new Promise((r) => setTimeout(r, 20));
256
+ socket.connect();
257
+ let receivedCount = 0;
258
+ client.addListener('data_changed', () => {
259
+ receivedCount++;
260
+ });
261
+ // Emit malformed path
262
+ socket.trigger('data_changed', { path: 'users//invalid' });
263
+ strict_1.default.equal(receivedCount, 0);
264
+ // Emit valid untargeted without explicit isGlobal flag -> ignored
265
+ socket.trigger('data_changed', { value: 123 });
266
+ strict_1.default.equal(receivedCount, 0);
267
+ // Emit valid with proper path matching subscription
268
+ client.subscribe('users/alice');
269
+ socket.trigger('data_changed', { path: 'users/alice', value: 456 });
270
+ strict_1.default.equal(receivedCount, 1);
271
+ client.close();
272
+ });
273
+ });
274
+ (0, node_test_1.describe)('3. Request Counter Invariants in send()', () => {
275
+ (0, node_test_1.it)('increments and decrements counter safely on successful execution', async () => {
276
+ const { client, socket } = createTestClient();
277
+ client.ensureConnected();
278
+ await new Promise((r) => setTimeout(r, 20));
279
+ socket.connect();
280
+ const sendPromise = client.send('save_data', { foo: 'bar' });
281
+ strict_1.default.equal(client.activeLocalSendsCount, 1);
282
+ const res = await sendPromise;
283
+ strict_1.default.deepEqual(res, { success: true, event: 'save_data' });
284
+ strict_1.default.equal(client.activeLocalSendsCount, 0);
285
+ client.close();
286
+ });
287
+ (0, node_test_1.it)('decrements counter on timeout without leaking state', async () => {
288
+ const { client, socket } = createTestClient();
289
+ client.ensureConnected();
290
+ await new Promise((r) => setTimeout(r, 20));
291
+ // Socket connected but no ack sent
292
+ socket.emit = function (event, ...args) {
293
+ this.emittedEvents.push({ event, args });
294
+ return this; // no ack
295
+ };
296
+ socket.connect();
297
+ await strict_1.default.rejects(async () => {
298
+ await client.send('query_data', { q: 1 }, 50);
299
+ }, {
300
+ name: 'NexaWebSocketError',
301
+ code: 'timeout',
302
+ });
303
+ strict_1.default.equal(client.activeLocalSendsCount, 0);
304
+ client.close();
305
+ });
306
+ (0, node_test_1.it)('rejects reserved send events with invalid-argument', async () => {
307
+ const { client } = createTestClient();
308
+ await strict_1.default.rejects(async () => {
309
+ await client.send('connect', {});
310
+ }, {
311
+ name: 'NexaWebSocketError',
312
+ code: 'invalid-argument',
313
+ });
314
+ strict_1.default.equal(client.activeLocalSendsCount, 0);
315
+ client.close();
316
+ });
317
+ });
318
+ (0, node_test_1.describe)('4. Multi-tab Leader/Follower Coordination & Term Fencing', () => {
319
+ (0, node_test_1.it)('elects first tab as leader and second tab as follower', async () => {
320
+ const tabA = createTestClient('shared-proj');
321
+ const tabB = createTestClient('shared-proj');
322
+ tabA.client.ensureConnected();
323
+ tabB.client.ensureConnected();
324
+ await new Promise((r) => setTimeout(r, 50));
325
+ strict_1.default.equal(tabA.client.isLeader, true);
326
+ strict_1.default.equal(tabA.client.state, 'leader');
327
+ strict_1.default.equal(tabB.client.isLeader, false);
328
+ strict_1.default.equal(tabB.client.state, 'follower');
329
+ strict_1.default.equal(tabB.client.currentLeaderId, tabA.client.tabId);
330
+ strict_1.default.equal(tabB.client.currentElectionId, tabA.client.electionId);
331
+ tabA.client.close();
332
+ tabB.client.close();
333
+ });
334
+ (0, node_test_1.it)('forwards follower send to leader and receives response', async () => {
335
+ const tabA = createTestClient('shared-proj');
336
+ const tabB = createTestClient('shared-proj');
337
+ tabA.client.ensureConnected();
338
+ await new Promise((r) => setTimeout(r, 20));
339
+ tabA.socket.connect();
340
+ tabB.client.ensureConnected();
341
+ await new Promise((r) => setTimeout(r, 50));
342
+ const res = await tabB.client.send('fetch_doc', { id: 'doc123' }, 2000);
343
+ strict_1.default.deepEqual(res, { success: true, event: 'fetch_doc' });
344
+ tabA.client.close();
345
+ tabB.client.close();
346
+ });
347
+ (0, node_test_1.it)('deduplicates duplicate follower send requests at leader', async () => {
348
+ const tabA = createTestClient('shared-proj');
349
+ const tabB = createTestClient('shared-proj');
350
+ tabA.client.ensureConnected();
351
+ await new Promise((r) => setTimeout(r, 20));
352
+ tabA.socket.connect();
353
+ tabB.client.ensureConnected();
354
+ await new Promise((r) => setTimeout(r, 50));
355
+ let socketEmitCount = 0;
356
+ const originalEmit = tabA.socket.emit.bind(tabA.socket);
357
+ tabA.socket.emit = function (event, ...args) {
358
+ socketEmitCount++;
359
+ return originalEmit(event, ...args);
360
+ };
361
+ const res1 = await tabB.client.send('fetch_user', { uid: 'u1' });
362
+ strict_1.default.ok(res1);
363
+ strict_1.default.equal(socketEmitCount, 1);
364
+ tabA.client.close();
365
+ tabB.client.close();
366
+ });
367
+ (0, node_test_1.it)('returns ambiguous-outcome error when mutating send is disrupted during election change', async () => {
368
+ const tabA = createTestClient('shared-proj');
369
+ const tabB = createTestClient('shared-proj');
370
+ tabA.client.ensureConnected();
371
+ await new Promise((r) => setTimeout(r, 20));
372
+ tabA.socket.connect();
373
+ tabB.client.ensureConnected();
374
+ await new Promise((r) => setTimeout(r, 50));
375
+ // Override tabA.socket.emit so it does not ack immediately
376
+ tabA.socket.emit = function (event, ...args) {
377
+ this.emittedEvents.push({ event, args });
378
+ return this;
379
+ };
380
+ const mutatingPromise = tabB.client.send('write_document', { data: 'test' }, 2000);
381
+ // Wait for follower message to reach leader
382
+ await new Promise((r) => setTimeout(r, 30));
383
+ // Close leader abruptly
384
+ tabA.client.close();
385
+ await strict_1.default.rejects(mutatingPromise, (err) => {
386
+ strict_1.default.equal(err.name, 'NexaWebSocketError');
387
+ strict_1.default.equal(err.isAmbiguous, true);
388
+ return true;
389
+ });
390
+ tabB.client.close();
391
+ });
392
+ });
393
+ (0, node_test_1.describe)('5. Independent Coordination Mode Fallback', () => {
394
+ (0, node_test_1.it)('runs in independent mode when coordinationMode option is set to independent', async () => {
395
+ const { client, socket } = createTestClient('proj', 'http://localhost:3000', {
396
+ coordinationMode: 'independent',
397
+ });
398
+ strict_1.default.equal(client.isIndependent, true);
399
+ client.ensureConnected();
400
+ await new Promise((r) => setTimeout(r, 20));
401
+ strict_1.default.equal(client.state, 'leader');
402
+ socket.connect();
403
+ strict_1.default.equal(client.isConnected, true);
404
+ client.close();
405
+ });
406
+ });
407
+ (0, node_test_1.describe)('6. Dynamic Auth Refresh', () => {
408
+ (0, node_test_1.it)('retrieves fresh auth payload on socket startup and updateAuth', async () => {
409
+ let currentToken = 'token_v1';
410
+ const { client, socket } = createTestClient('proj', 'http://localhost:3000', {
411
+ getAuthPayload: async () => ({ token: currentToken }),
412
+ });
413
+ client.ensureConnected();
414
+ await new Promise((r) => setTimeout(r, 30));
415
+ strict_1.default.deepEqual(socket.auth, { token: 'token_v1' });
416
+ client.updateAuth({ token: 'token_v2' });
417
+ strict_1.default.deepEqual(socket.auth, { token: 'token_v2' });
418
+ client.close();
419
+ });
420
+ });
421
+ (0, node_test_1.describe)('7. Defensive Listener Dispatch & Reference Counting', () => {
422
+ (0, node_test_1.it)('allows listeners to unsubscribe safely during dispatch without skipping peers', async () => {
423
+ const { client, socket } = createTestClient();
424
+ client.ensureConnected();
425
+ await new Promise((r) => setTimeout(r, 20));
426
+ socket.connect();
427
+ let order = [];
428
+ let unsubA;
429
+ unsubA = client.addListener('custom_event', () => {
430
+ order.push('A');
431
+ unsubA(); // unregister self inside callback
432
+ });
433
+ client.addListener('custom_event', () => {
434
+ order.push('B');
435
+ });
436
+ client.dispatch('custom_event', { foo: 'bar' });
437
+ strict_1.default.deepEqual(order, ['A', 'B']);
438
+ // Second dispatch: only B should fire
439
+ order = [];
440
+ client.dispatch('custom_event', { foo: 'bar' });
441
+ strict_1.default.deepEqual(order, ['B']);
442
+ client.close();
443
+ });
444
+ });
445
+ (0, node_test_1.describe)('8. Safe and Idempotent close() Teardown', () => {
446
+ (0, node_test_1.it)('can be called multiple times safely without throwing', () => {
447
+ const { client } = createTestClient();
448
+ client.ensureConnected();
449
+ strict_1.default.doesNotThrow(() => client.close());
450
+ strict_1.default.doesNotThrow(() => client.close());
451
+ strict_1.default.equal(client.state, 'disposed');
452
+ });
453
+ });
454
+ });
@@ -22,6 +22,7 @@ export interface QueryConstraint {
22
22
  directionStr?: 'asc' | 'desc';
23
23
  limitValue?: number;
24
24
  docSnapshot?: any;
25
+ cursorValues?: any[];
25
26
  }
26
27
  export interface SnapshotMetadata {
27
28
  readonly hasPendingWrites: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexabase-console",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "SDK Client resmi untuk NexaBase: Platform Sinkronisasi NoSQL, Realtime, File Storage, & Autentikasi Offline-First.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",