reflectdb 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1260 -0
  3. package/dist/cjs/client/index.cjs +1252 -0
  4. package/dist/cjs/client/index.d.cts +629 -0
  5. package/dist/cjs/client/storage/indexeddb.cjs +253 -0
  6. package/dist/cjs/client/storage/indexeddb.d.cts +85 -0
  7. package/dist/cjs/core/index.cjs +202 -0
  8. package/dist/cjs/core/index.d.cts +473 -0
  9. package/dist/cjs/react/index.cjs +1512 -0
  10. package/dist/cjs/react/index.d.cts +748 -0
  11. package/dist/cjs/server/drizzle.cjs +413 -0
  12. package/dist/cjs/server/drizzle.d.cts +233 -0
  13. package/dist/cjs/server/index.cjs +4486 -0
  14. package/dist/cjs/server/index.d.cts +1988 -0
  15. package/dist/cjs/svelte/index.cjs +1414 -0
  16. package/dist/cjs/svelte/index.d.cts +645 -0
  17. package/dist/cjs/transport/bun-ws.cjs +244 -0
  18. package/dist/cjs/transport/bun-ws.d.cts +215 -0
  19. package/dist/cjs/transport/polling.cjs +285 -0
  20. package/dist/cjs/transport/polling.d.cts +210 -0
  21. package/dist/cjs/transport/sse.cjs +312 -0
  22. package/dist/cjs/transport/sse.d.cts +205 -0
  23. package/dist/cjs/transport/ws.cjs +330 -0
  24. package/dist/cjs/transport/ws.d.cts +236 -0
  25. package/dist/cjs/vanilla/index.cjs +1430 -0
  26. package/dist/cjs/vanilla/index.d.cts +657 -0
  27. package/dist/client/index.d.ts +629 -0
  28. package/dist/client/index.js +106 -0
  29. package/dist/client/storage/indexeddb.d.ts +85 -0
  30. package/dist/client/storage/indexeddb.js +213 -0
  31. package/dist/core/index.d.ts +473 -0
  32. package/dist/core/index.js +56 -0
  33. package/dist/react/index.d.ts +748 -0
  34. package/dist/react/index.js +366 -0
  35. package/dist/server/drizzle.d.ts +233 -0
  36. package/dist/server/drizzle.js +9 -0
  37. package/dist/server/index.d.ts +1988 -0
  38. package/dist/server/index.js +3959 -0
  39. package/dist/shared/esm-3tkwvysa.js +54 -0
  40. package/dist/shared/esm-b7xs9cde.js +4 -0
  41. package/dist/shared/esm-rw7jjtrv.js +58 -0
  42. package/dist/shared/esm-wkwx6bd9.js +25 -0
  43. package/dist/shared/esm-ytrd3hbq.js +1007 -0
  44. package/dist/shared/esm-z1xse19c.js +369 -0
  45. package/dist/svelte/index.d.ts +645 -0
  46. package/dist/svelte/index.js +262 -0
  47. package/dist/transport/bun-ws.d.ts +215 -0
  48. package/dist/transport/bun-ws.js +140 -0
  49. package/dist/transport/polling.d.ts +210 -0
  50. package/dist/transport/polling.js +181 -0
  51. package/dist/transport/sse.d.ts +205 -0
  52. package/dist/transport/sse.js +208 -0
  53. package/dist/transport/ws.d.ts +236 -0
  54. package/dist/transport/ws.js +226 -0
  55. package/dist/vanilla/index.d.ts +657 -0
  56. package/dist/vanilla/index.js +278 -0
  57. package/package.json +253 -0
@@ -0,0 +1,1007 @@
1
+ import {
2
+ compareHlc,
3
+ packHlc,
4
+ receiveHlc,
5
+ sendHlc,
6
+ unpackHlc
7
+ } from "./esm-rw7jjtrv.js";
8
+ import {
9
+ MAX_BATCH_SIZE,
10
+ PROTOCOL_VERSION,
11
+ SUPPORTED_PROTOCOL_VERSIONS
12
+ } from "./esm-3tkwvysa.js";
13
+
14
+ // src/client/ops.ts
15
+ function randomUUID() {
16
+ const c = globalThis.crypto;
17
+ if (c?.randomUUID)
18
+ return c.randomUUID();
19
+ const bytes = new Uint8Array(16);
20
+ if (c?.getRandomValues) {
21
+ c.getRandomValues(bytes);
22
+ } else {
23
+ for (let i = 0;i < 16; i++)
24
+ bytes[i] = Math.floor(Math.random() * 256);
25
+ }
26
+ bytes[6] = bytes[6] & 15 | 64;
27
+ bytes[8] = bytes[8] & 63 | 128;
28
+ const hex = [];
29
+ for (let i = 0;i < 16; i++)
30
+ hex.push(bytes[i].toString(16).padStart(2, "0"));
31
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
32
+ }
33
+ function createOpCreator(nodeId, initialHlc) {
34
+ let hlc = initialHlc ?? { ms: 0, counter: 0, nodeId };
35
+ function nextOp(table, op, rowId, payload) {
36
+ hlc = sendHlc(hlc);
37
+ return {
38
+ id: randomUUID(),
39
+ table,
40
+ op,
41
+ rowId,
42
+ payload,
43
+ hlc: packHlc(hlc)
44
+ };
45
+ }
46
+ return {
47
+ insert(table, rowId, payload) {
48
+ return nextOp(table, "insert", rowId, payload);
49
+ },
50
+ update(table, rowId, payload) {
51
+ return nextOp(table, "update", rowId, payload);
52
+ },
53
+ delete(table, rowId) {
54
+ return nextOp(table, "delete", rowId, null);
55
+ },
56
+ createBatch(ops) {
57
+ const batchId = randomUUID();
58
+ return ops.map((spec, i) => {
59
+ const op = nextOp(spec.table, spec.op, spec.rowId, spec.payload);
60
+ return {
61
+ ...op,
62
+ batchId,
63
+ batchSize: ops.length,
64
+ batchSeq: i
65
+ };
66
+ });
67
+ },
68
+ getHlc() {
69
+ return hlc;
70
+ }
71
+ };
72
+ }
73
+
74
+ // src/client/store.ts
75
+ class ClientStore {
76
+ pendingOps = [];
77
+ rows = new Map;
78
+ tableMeta = new Map;
79
+ storage;
80
+ pendingWrites = [];
81
+ writeChain = Promise.resolve();
82
+ constructor(storage) {
83
+ this.storage = storage ?? null;
84
+ }
85
+ async hydrate(options) {
86
+ if (!this.storage)
87
+ return;
88
+ const storage = this.storage;
89
+ const scoped = options?.tables;
90
+ const [allRows, pendingOps, tableMetaJson] = await Promise.all([
91
+ scoped ? Promise.all(scoped.map((table) => storage.getRows(table))).then((r) => r.flat()) : storage.getAllRows(),
92
+ storage.getPendingOps(),
93
+ storage.getMeta("tableMeta")
94
+ ]);
95
+ for (const row of allRows) {
96
+ let tableMap = this.rows.get(row.table);
97
+ if (!tableMap) {
98
+ tableMap = new Map;
99
+ this.rows.set(row.table, tableMap);
100
+ }
101
+ tableMap.set(row.rowId, row);
102
+ }
103
+ this.pendingOps = pendingOps;
104
+ if (tableMetaJson) {
105
+ const parsed = JSON.parse(tableMetaJson);
106
+ for (const [table, m] of Object.entries(parsed)) {
107
+ this.tableMeta.set(table, {
108
+ serverSet: m.serverSet,
109
+ readonly: m.readonly,
110
+ broadcast: m.broadcast ?? "consistent",
111
+ pk: m.pk ?? "id"
112
+ });
113
+ }
114
+ }
115
+ }
116
+ async flush() {
117
+ this.pendingWrites = [];
118
+ await this.writeChain;
119
+ }
120
+ enqueue(fn) {
121
+ if (!this.storage)
122
+ return;
123
+ this.writeChain = this.writeChain.then(fn).catch((err) => {
124
+ console.error("[reflectdb] storage write failed:", err);
125
+ });
126
+ this.pendingWrites.push(this.writeChain);
127
+ }
128
+ addPendingOp(op) {
129
+ const existing = this.getRow(op.table, op.rowId);
130
+ const pendingOp = {
131
+ op,
132
+ status: "pending",
133
+ rejectedReason: null,
134
+ createdAt: Date.now(),
135
+ preState: existing ? {
136
+ data: existing.data,
137
+ colClocks: { ...existing.colClocks },
138
+ serverHlc: existing.serverHlc
139
+ } : null
140
+ };
141
+ this.pendingOps.push(pendingOp);
142
+ this.enqueue(() => this.storage.appendPendingOps([pendingOp]));
143
+ }
144
+ getPendingOps() {
145
+ return this.pendingOps.filter((p) => p.status === "pending");
146
+ }
147
+ markSynced(opIds) {
148
+ const idSet = new Set(opIds);
149
+ this.pendingOps = this.pendingOps.filter((p) => !idSet.has(p.op.id));
150
+ this.enqueue(() => this.storage.removePendingOps(opIds));
151
+ }
152
+ markRejected(opId, reason) {
153
+ const pending = this.pendingOps.find((p) => p.op.id === opId);
154
+ if (pending) {
155
+ pending.status = "rejected";
156
+ pending.rejectedReason = reason;
157
+ this.enqueue(() => this.storage.updatePendingOps([pending]));
158
+ }
159
+ this.autoTrimRejected();
160
+ }
161
+ rejectBatch(batchId, reason) {
162
+ const updated = [];
163
+ for (const p of this.pendingOps) {
164
+ if (p.op.batchId === batchId) {
165
+ p.status = "rejected";
166
+ p.rejectedReason = reason;
167
+ updated.push(p);
168
+ }
169
+ }
170
+ if (updated.length > 0) {
171
+ this.enqueue(() => this.storage.updatePendingOps(updated));
172
+ }
173
+ this.autoTrimRejected();
174
+ }
175
+ autoTrimRejected() {
176
+ const cutoff = Date.now() - 60000;
177
+ const before = this.pendingOps.length;
178
+ this.pendingOps = this.pendingOps.filter((p) => p.status !== "rejected" || p.createdAt > cutoff);
179
+ if (this.pendingOps.length < before) {
180
+ this.enqueue(() => this.storage.putPendingOps([...this.pendingOps]));
181
+ }
182
+ }
183
+ clearRejected() {
184
+ this.pendingOps = this.pendingOps.filter((p) => p.status !== "rejected");
185
+ this.enqueue(() => this.storage.putPendingOps([...this.pendingOps]));
186
+ }
187
+ setRow(table, rowId, data, colClocks, serverHlc) {
188
+ if (data === null && serverHlc === null) {
189
+ this.rows.get(table)?.delete(rowId);
190
+ this.enqueue(() => this.storage.deleteRow(table, rowId));
191
+ return;
192
+ }
193
+ let tableMap = this.rows.get(table);
194
+ if (!tableMap) {
195
+ tableMap = new Map;
196
+ this.rows.set(table, tableMap);
197
+ }
198
+ const row = { table, rowId, data, colClocks, serverHlc };
199
+ tableMap.set(rowId, row);
200
+ this.enqueue(() => this.storage.putRow(table, rowId, row));
201
+ }
202
+ getRow(table, rowId) {
203
+ const row = this.rows.get(table)?.get(rowId);
204
+ if (!row || row.data === null)
205
+ return;
206
+ return row;
207
+ }
208
+ getRowEntry(table, rowId) {
209
+ return this.rows.get(table)?.get(rowId);
210
+ }
211
+ getRows(table, includeDeleted = false) {
212
+ const tableMap = this.rows.get(table);
213
+ if (!tableMap)
214
+ return [];
215
+ const result = [];
216
+ for (const row of tableMap.values()) {
217
+ if (row.data !== null) {
218
+ if (!includeDeleted && (row.data.deletedAt != null || row.data.deleted_at != null)) {
219
+ continue;
220
+ }
221
+ result.push(row);
222
+ }
223
+ }
224
+ return result;
225
+ }
226
+ clearTable(table) {
227
+ this.rows.delete(table);
228
+ this.enqueue(() => this.storage.clearTable(table));
229
+ }
230
+ applySnapshot(table, rows, colClocks, append = false, pk) {
231
+ if (!append) {
232
+ this.clearTable(table);
233
+ }
234
+ const effectivePk = pk ?? this.tableMeta.get(table)?.pk ?? "id";
235
+ for (const row of rows) {
236
+ const rowId = row[effectivePk] ?? "";
237
+ const clocks = colClocks[rowId] ?? {};
238
+ this.setRow(table, rowId, row, clocks, clocks._row ?? null);
239
+ }
240
+ for (const p of this.pendingOps) {
241
+ if (p.status === "pending" && p.op.table === table) {
242
+ this.applyOptimistic(p.op);
243
+ }
244
+ }
245
+ }
246
+ applyDelta(table, op, rowId, payload, hlc, colClocks) {
247
+ const existing = this.getRowEntry(table, rowId);
248
+ if (op === "delete") {
249
+ if (existing?.serverHlc && compareHlc(hlc, existing.serverHlc) <= 0) {
250
+ return;
251
+ }
252
+ this.setRow(table, rowId, null, {}, hlc);
253
+ return;
254
+ }
255
+ if (op === "insert" || !existing) {
256
+ if (existing?.serverHlc && compareHlc(hlc, existing.serverHlc) <= 0) {
257
+ return;
258
+ }
259
+ this.setRow(table, rowId, payload ?? {}, colClocks ?? { _row: hlc }, hlc);
260
+ return;
261
+ }
262
+ const incomingClocks = colClocks ?? {};
263
+ const mergedData = { ...existing.data };
264
+ const mergedClocks = { ...existing.colClocks };
265
+ let anyApplied = false;
266
+ for (const [col, value] of Object.entries(payload ?? {})) {
267
+ const incomingClock = incomingClocks[col] ?? hlc;
268
+ const existingClock = existing.colClocks[col];
269
+ if (!existingClock || compareHlc(incomingClock, existingClock) > 0) {
270
+ mergedData[col] = value;
271
+ mergedClocks[col] = incomingClock;
272
+ anyApplied = true;
273
+ }
274
+ }
275
+ if (!anyApplied) {
276
+ return;
277
+ }
278
+ const newRowHlc = existing.serverHlc && compareHlc(existing.serverHlc, hlc) >= 0 ? existing.serverHlc : hlc;
279
+ mergedClocks._row = newRowHlc;
280
+ this.setRow(table, rowId, mergedData, mergedClocks, newRowHlc);
281
+ }
282
+ revertOp(opId, serverRow) {
283
+ const pending = this.pendingOps.find((p) => p.op.id === opId);
284
+ if (!pending)
285
+ return;
286
+ const { table, rowId } = pending.op;
287
+ if (serverRow != null) {
288
+ const existing = this.getRow(table, rowId);
289
+ this.setRow(table, rowId, serverRow, existing?.colClocks ?? {}, existing?.serverHlc ?? null);
290
+ } else if (pending.preState) {
291
+ this.setRow(table, rowId, pending.preState.data, pending.preState.colClocks, pending.preState.serverHlc);
292
+ } else {
293
+ const tableMap = this.rows.get(table);
294
+ if (tableMap) {
295
+ tableMap.delete(rowId);
296
+ this.enqueue(() => this.storage.deleteRow(table, rowId));
297
+ }
298
+ }
299
+ this.pendingOps = this.pendingOps.filter((p) => p.op.id !== opId);
300
+ this.enqueue(() => this.storage.removePendingOps([opId]));
301
+ }
302
+ setTableMeta(meta) {
303
+ for (const [table, m] of Object.entries(meta)) {
304
+ this.tableMeta.set(table, {
305
+ serverSet: m.serverSet,
306
+ readonly: m.readonly,
307
+ broadcast: m.broadcast ?? "consistent",
308
+ pk: m.pk ?? "id"
309
+ });
310
+ }
311
+ this.enqueue(() => this.storage.setMeta("tableMeta", JSON.stringify(Object.fromEntries(this.tableMeta))));
312
+ }
313
+ getTableMeta(table) {
314
+ return this.tableMeta.get(table);
315
+ }
316
+ applyOptimistic(op) {
317
+ const meta = this.tableMeta.get(op.table);
318
+ let payload = op.payload;
319
+ if (payload && meta?.serverSet.length) {
320
+ payload = { ...payload };
321
+ for (const field of meta.serverSet) {
322
+ delete payload[field];
323
+ }
324
+ }
325
+ if (op.op === "delete") {
326
+ this.setRow(op.table, op.rowId, null, { _row: op.hlc }, op.hlc);
327
+ } else if (op.op === "insert") {
328
+ this.setRow(op.table, op.rowId, payload ?? {}, { _row: op.hlc }, op.hlc);
329
+ } else {
330
+ const existing = this.getRow(op.table, op.rowId);
331
+ if (existing?.data) {
332
+ const merged = { ...existing.data, ...payload };
333
+ this.setRow(op.table, op.rowId, merged, existing.colClocks, existing.serverHlc);
334
+ }
335
+ }
336
+ }
337
+ async clear() {
338
+ try {
339
+ await this.writeChain;
340
+ } catch {}
341
+ this.rows.clear();
342
+ this.pendingOps = [];
343
+ this.tableMeta.clear();
344
+ this.pendingWrites = [];
345
+ this.writeChain = Promise.resolve();
346
+ if (this.storage) {
347
+ await this.storage.clear();
348
+ }
349
+ }
350
+ }
351
+
352
+ // src/client/sync-client.ts
353
+ class SyncClient {
354
+ config;
355
+ transport;
356
+ opCreator;
357
+ store;
358
+ state = "disconnected";
359
+ negotiatedProtocolVersion = null;
360
+ syncedTables = new Set;
361
+ serverHlc = null;
362
+ listeners = new Set;
363
+ version = 0;
364
+ tableVersions = new Map;
365
+ tableListeners = new Map;
366
+ connectResolve = null;
367
+ connectReject = null;
368
+ connectInFlight = null;
369
+ ephemeralListeners = new Map;
370
+ totalCounts = new Map;
371
+ syncOptions = new Map;
372
+ bootstrapScheduled = false;
373
+ initialized = false;
374
+ syncParams = new Map;
375
+ reconnectTimer = null;
376
+ reconnectAttempts = 0;
377
+ closed = false;
378
+ inFlightOps = new Map;
379
+ static IN_FLIGHT_TTL_MS = 30000;
380
+ pushChain = Promise.resolve();
381
+ pushPending = null;
382
+ constructor(config) {
383
+ this.config = config;
384
+ this.transport = config.transport;
385
+ this.opCreator = createOpCreator(`client:${config.clientId}`);
386
+ this.store = new ClientStore(config.storage);
387
+ this.transport.subscribe((message) => {
388
+ this.handleMessage(message);
389
+ });
390
+ }
391
+ async init() {
392
+ if (!this.config.storage) {
393
+ this.initialized = true;
394
+ return;
395
+ }
396
+ this.state = "hydrating";
397
+ this.notify();
398
+ const subsJson = await this.config.storage.getMeta("syncSubscriptions");
399
+ if (subsJson) {
400
+ const subs = JSON.parse(subsJson);
401
+ for (const sub of subs) {
402
+ this.syncedTables.add(sub.table);
403
+ if (sub.params) {
404
+ this.syncParams.set(sub.table, sub.params);
405
+ }
406
+ if (sub.options) {
407
+ this.syncOptions.set(sub.table, sub.options);
408
+ }
409
+ }
410
+ }
411
+ await this.store.hydrate(this.config.hydrateAllTables || this.syncedTables.size === 0 ? undefined : { tables: [...this.syncedTables] });
412
+ const serverHlcStr = await this.config.storage.getMeta("serverHlc");
413
+ if (serverHlcStr) {
414
+ this.serverHlc = serverHlcStr;
415
+ }
416
+ const currentNodeId = `client:${this.config.clientId}`;
417
+ const hlcJson = await this.config.storage.getMeta("hlcState");
418
+ let restoredHlc = null;
419
+ if (hlcJson) {
420
+ const parsed = JSON.parse(hlcJson);
421
+ restoredHlc = { ...parsed, nodeId: currentNodeId };
422
+ this.opCreator = createOpCreator(currentNodeId, restoredHlc);
423
+ }
424
+ const pendingOps = this.store.getPendingOps();
425
+ if (pendingOps.length > 0) {
426
+ let maxMs = restoredHlc?.ms ?? 0;
427
+ let maxCounter = restoredHlc?.counter ?? 0;
428
+ for (const p of pendingOps) {
429
+ const opHlc = unpackHlc(p.op.hlc);
430
+ if (opHlc.ms > maxMs || opHlc.ms === maxMs && opHlc.counter > maxCounter) {
431
+ maxMs = opHlc.ms;
432
+ maxCounter = opHlc.counter;
433
+ }
434
+ }
435
+ this.opCreator = createOpCreator(currentNodeId, {
436
+ ms: maxMs,
437
+ counter: maxCounter,
438
+ nodeId: currentNodeId
439
+ });
440
+ }
441
+ this.state = "disconnected";
442
+ this.initialized = true;
443
+ this.notify();
444
+ }
445
+ async connect() {
446
+ if (!this.initialized && this.config.storage) {
447
+ await this.init();
448
+ }
449
+ if (this.connectInFlight)
450
+ return this.connectInFlight;
451
+ this.state = "connecting";
452
+ this.connectInFlight = new Promise((resolve, reject) => {
453
+ this.connectResolve = resolve;
454
+ this.connectReject = reject;
455
+ }).finally(() => {
456
+ this.connectInFlight = null;
457
+ });
458
+ await this.transport.send({
459
+ type: "hello",
460
+ protocolVersion: PROTOCOL_VERSION,
461
+ supportedVersions: SUPPORTED_PROTOCOL_VERSIONS,
462
+ clientId: this.config.clientId,
463
+ token: this.config.token
464
+ });
465
+ return this.connectInFlight;
466
+ }
467
+ async close() {
468
+ this.closed = true;
469
+ if (this.reconnectTimer) {
470
+ clearTimeout(this.reconnectTimer);
471
+ this.reconnectTimer = null;
472
+ }
473
+ this.state = "disconnected";
474
+ this.notify();
475
+ try {
476
+ await this.store.flush();
477
+ } catch (err) {
478
+ console.error("[reflectdb] store flush during close failed:", err);
479
+ }
480
+ await this.transport.close();
481
+ }
482
+ getState() {
483
+ return this.state;
484
+ }
485
+ getProtocolVersion() {
486
+ return this.negotiatedProtocolVersion;
487
+ }
488
+ async sync(table, params, options) {
489
+ this.syncedTables.add(table);
490
+ if (params) {
491
+ this.syncParams.set(table, params);
492
+ }
493
+ if (options) {
494
+ this.syncOptions.set(table, options);
495
+ }
496
+ this.persistSyncSubscriptions();
497
+ await this.transport.send({
498
+ type: "sync_declare",
499
+ table,
500
+ params,
501
+ window: options?.window
502
+ });
503
+ }
504
+ async loadMore(table, count) {
505
+ await this.transport.send({
506
+ type: "load_more",
507
+ table,
508
+ count
509
+ });
510
+ }
511
+ getTotalCount(table) {
512
+ return this.totalCounts.get(table) ?? null;
513
+ }
514
+ async unsync(table) {
515
+ this.syncedTables.delete(table);
516
+ this.syncOptions.delete(table);
517
+ this.syncParams.delete(table);
518
+ this.totalCounts.delete(table);
519
+ this.store.clearTable(table);
520
+ this.persistSyncSubscriptions();
521
+ this.notify([table]);
522
+ await this.transport.send({
523
+ type: "unsync",
524
+ table
525
+ });
526
+ }
527
+ async bootstrap() {
528
+ this.state = "bootstrapping";
529
+ await this.transport.send({
530
+ type: "bootstrap"
531
+ });
532
+ }
533
+ scheduleBootstrap() {
534
+ if (this.bootstrapScheduled)
535
+ return;
536
+ this.bootstrapScheduled = true;
537
+ queueMicrotask(() => {
538
+ this.bootstrapScheduled = false;
539
+ this.bootstrap();
540
+ });
541
+ }
542
+ async resume() {
543
+ if (!this.serverHlc) {
544
+ return this.bootstrap();
545
+ }
546
+ await this.transport.send({
547
+ type: "resume",
548
+ since: this.serverHlc
549
+ });
550
+ }
551
+ insert(table, rowId, payload) {
552
+ const op = this.opCreator.insert(table, rowId, payload);
553
+ this.store.addPendingOp(op);
554
+ this.store.applyOptimistic(op);
555
+ this.notify([table]);
556
+ return op;
557
+ }
558
+ update(table, rowId, payload) {
559
+ const op = this.opCreator.update(table, rowId, payload);
560
+ this.store.addPendingOp(op);
561
+ this.store.applyOptimistic(op);
562
+ this.notify([table]);
563
+ return op;
564
+ }
565
+ delete(table, rowId) {
566
+ const op = this.opCreator.delete(table, rowId);
567
+ this.store.addPendingOp(op);
568
+ this.store.applyOptimistic(op);
569
+ this.notify([table]);
570
+ return op;
571
+ }
572
+ batch(ops) {
573
+ const batchOps = this.opCreator.createBatch(ops);
574
+ for (const op of batchOps) {
575
+ this.store.addPendingOp(op);
576
+ this.store.applyOptimistic(op);
577
+ }
578
+ this.notify([...new Set(batchOps.map((o) => o.table))]);
579
+ return batchOps;
580
+ }
581
+ push() {
582
+ if (this.pushPending)
583
+ return this.pushPending;
584
+ const run = this.pushChain.catch(() => {}).then(() => {
585
+ this.pushPending = null;
586
+ return this.doPush();
587
+ });
588
+ this.pushPending = run;
589
+ this.pushChain = run.catch(() => {});
590
+ return run;
591
+ }
592
+ async doPush() {
593
+ await this.store.flush();
594
+ const cutoff = Date.now() - SyncClient.IN_FLIGHT_TTL_MS;
595
+ const pending = this.store.getPendingOps().filter((p) => {
596
+ const sentAt = this.inFlightOps.get(p.op.id);
597
+ return sentAt === undefined || sentAt <= cutoff;
598
+ });
599
+ if (pending.length === 0)
600
+ return;
601
+ const ops = pending.map((p) => p.op);
602
+ const chunks = [];
603
+ let current = [];
604
+ let i = 0;
605
+ while (i < ops.length) {
606
+ const op = ops[i];
607
+ if (op.batchId) {
608
+ let end = i + 1;
609
+ while (end < ops.length && ops[end].batchId === op.batchId)
610
+ end++;
611
+ const batchOps = ops.slice(i, end);
612
+ if (batchOps.length > MAX_BATCH_SIZE) {
613
+ if (current.length > 0) {
614
+ chunks.push(current);
615
+ current = [];
616
+ }
617
+ chunks.push(batchOps);
618
+ } else {
619
+ if (current.length + batchOps.length > MAX_BATCH_SIZE) {
620
+ chunks.push(current);
621
+ current = [];
622
+ }
623
+ current.push(...batchOps);
624
+ }
625
+ i = end;
626
+ } else {
627
+ if (current.length + 1 > MAX_BATCH_SIZE) {
628
+ chunks.push(current);
629
+ current = [];
630
+ }
631
+ current.push(op);
632
+ i++;
633
+ }
634
+ }
635
+ if (current.length > 0)
636
+ chunks.push(current);
637
+ for (const chunk of chunks) {
638
+ const chunkIds = chunk.map((op) => op.id);
639
+ const sentAt = Date.now();
640
+ for (const id of chunkIds)
641
+ this.inFlightOps.set(id, sentAt);
642
+ try {
643
+ await this.transport.send({
644
+ type: "ops",
645
+ ops: chunk,
646
+ token: this.config.token
647
+ });
648
+ } catch (err) {
649
+ for (const id of chunkIds)
650
+ this.inFlightOps.delete(id);
651
+ throw err;
652
+ }
653
+ }
654
+ }
655
+ clearInFlight() {
656
+ this.inFlightOps.clear();
657
+ }
658
+ async sendEphemeral(params) {
659
+ await this.transport.send({
660
+ type: "ephemeral",
661
+ ...params
662
+ });
663
+ }
664
+ subscribeEphemeral(key, listener) {
665
+ let listeners = this.ephemeralListeners.get(key);
666
+ if (!listeners) {
667
+ listeners = new Set;
668
+ this.ephemeralListeners.set(key, listeners);
669
+ }
670
+ listeners.add(listener);
671
+ return () => {
672
+ listeners.delete(listener);
673
+ if (listeners.size === 0) {
674
+ this.ephemeralListeners.delete(key);
675
+ }
676
+ };
677
+ }
678
+ getRows(table, options) {
679
+ return this.store.getRows(table, options?.includeDeleted).map((r) => r.data);
680
+ }
681
+ getRow(table, rowId) {
682
+ const row = this.store.getRow(table, rowId);
683
+ return row?.data ?? null;
684
+ }
685
+ getPendingCount() {
686
+ return this.store.getPendingOps().length;
687
+ }
688
+ getStore() {
689
+ return this.store;
690
+ }
691
+ subscribe(listener) {
692
+ this.listeners.add(listener);
693
+ return () => {
694
+ this.listeners.delete(listener);
695
+ };
696
+ }
697
+ subscribeTable(table, listener) {
698
+ let listeners = this.tableListeners.get(table);
699
+ if (!listeners) {
700
+ listeners = new Set;
701
+ this.tableListeners.set(table, listeners);
702
+ }
703
+ listeners.add(listener);
704
+ return () => {
705
+ listeners.delete(listener);
706
+ if (listeners.size === 0) {
707
+ this.tableListeners.delete(table);
708
+ }
709
+ };
710
+ }
711
+ getVersion() {
712
+ return this.version;
713
+ }
714
+ getTableVersion(table) {
715
+ return this.tableVersions.get(table) ?? 0;
716
+ }
717
+ notify(tables) {
718
+ this.version++;
719
+ if (tables) {
720
+ for (const table of tables) {
721
+ this.tableVersions.set(table, (this.tableVersions.get(table) ?? 0) + 1);
722
+ const tableListeners = this.tableListeners.get(table);
723
+ if (tableListeners) {
724
+ for (const listener of [...tableListeners]) {
725
+ try {
726
+ listener();
727
+ } catch (err) {
728
+ console.error("[reflectdb] table listener threw:", err);
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }
734
+ for (const listener of [...this.listeners]) {
735
+ try {
736
+ listener();
737
+ } catch (err) {
738
+ console.error("[reflectdb] listener threw:", err);
739
+ }
740
+ }
741
+ }
742
+ async clear(opts) {
743
+ if (opts?.tables && opts.tables.length > 0) {
744
+ for (const table of opts.tables) {
745
+ await this.store.clearTable(table);
746
+ this.syncedTables.delete(table);
747
+ this.syncOptions.delete(table);
748
+ this.syncParams.delete(table);
749
+ this.totalCounts.delete(table);
750
+ }
751
+ this.notify();
752
+ return;
753
+ }
754
+ await this.store.clear();
755
+ this.serverHlc = null;
756
+ this.syncedTables.clear();
757
+ this.syncOptions.clear();
758
+ this.syncParams.clear();
759
+ this.totalCounts.clear();
760
+ this.opCreator = createOpCreator(`client:${this.config.clientId}`);
761
+ this.clearInFlight();
762
+ this.state = "disconnected";
763
+ this.initialized = false;
764
+ this.notify();
765
+ }
766
+ handleMessage(message) {
767
+ switch (message.type) {
768
+ case "hello_ack":
769
+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(message.protocolVersion)) {
770
+ this.state = "disconnected";
771
+ this.connectReject?.(new Error(`Server negotiated unsupported version: ${message.protocolVersion}`));
772
+ this.connectResolve = null;
773
+ this.connectReject = null;
774
+ this.notify();
775
+ break;
776
+ }
777
+ this.negotiatedProtocolVersion = message.protocolVersion;
778
+ this.state = "connected";
779
+ this.connectResolve?.();
780
+ this.connectResolve = null;
781
+ this.connectReject = null;
782
+ this.notify();
783
+ break;
784
+ case "hello_reject":
785
+ this.state = "disconnected";
786
+ this.connectReject?.(new Error(message.reason));
787
+ this.connectResolve = null;
788
+ this.connectReject = null;
789
+ this.notify();
790
+ this.config.onError?.({ reason: "local:hello_rejected", message: message.reason });
791
+ break;
792
+ case "snapshot":
793
+ this.store.applySnapshot(message.table, message.rows, message.colClocks, message.append, message.pk);
794
+ if (message.totalCount != null) {
795
+ this.totalCounts.set(message.table, message.totalCount);
796
+ }
797
+ this.notify([message.table]);
798
+ this.config.onSync?.(message.table);
799
+ break;
800
+ case "bootstrap_complete":
801
+ this.serverHlc = message.serverHlc;
802
+ this.store.setTableMeta(message.tableMeta);
803
+ this.state = "synced";
804
+ this.mergeServerHlc(message.serverHlc);
805
+ this.persistMeta();
806
+ this.notify();
807
+ break;
808
+ case "resume_complete":
809
+ this.serverHlc = message.serverHlc;
810
+ this.state = "synced";
811
+ this.mergeServerHlc(message.serverHlc);
812
+ this.persistMeta();
813
+ this.notify();
814
+ break;
815
+ case "delta":
816
+ this.store.applyDelta(message.table, message.op, message.rowId, message.payload, message.hlc, message.colClocks);
817
+ this.notify([message.table]);
818
+ this.config.onSync?.(message.table);
819
+ break;
820
+ case "ack": {
821
+ const idSet = new Set(message.opIds);
822
+ const ackedTables = [
823
+ ...new Set(this.store.getPendingOps().filter((p) => idSet.has(p.op.id)).map((p) => p.op.table))
824
+ ];
825
+ for (const opId of message.opIds)
826
+ this.inFlightOps.delete(opId);
827
+ this.store.markSynced(message.opIds);
828
+ this.notify(ackedTables.length > 0 ? ackedTables : undefined);
829
+ break;
830
+ }
831
+ case "reject": {
832
+ const rejectedTables = [];
833
+ if (message.opId) {
834
+ this.inFlightOps.delete(message.opId);
835
+ const pending = this.store.getPendingOps().find((p) => p.op.id === message.opId);
836
+ if (pending)
837
+ rejectedTables.push(pending.op.table);
838
+ this.store.markRejected(message.opId, message.reason);
839
+ this.store.revertOp(message.opId, message.serverRow ?? null);
840
+ }
841
+ if (message.batchId) {
842
+ const batchOpIds = [];
843
+ for (const p of this.store.getPendingOps()) {
844
+ if (p.op.batchId === message.batchId) {
845
+ rejectedTables.push(p.op.table);
846
+ batchOpIds.push(p.op.id);
847
+ }
848
+ }
849
+ this.store.rejectBatch(message.batchId, message.reason);
850
+ for (const opId of batchOpIds) {
851
+ this.inFlightOps.delete(opId);
852
+ this.store.revertOp(opId, null);
853
+ }
854
+ }
855
+ const uniqueRejectedTables = [...new Set(rejectedTables)];
856
+ this.notify(uniqueRejectedTables.length > 0 ? uniqueRejectedTables : undefined);
857
+ this.config.onError?.({
858
+ opId: message.opId,
859
+ reason: message.reason
860
+ });
861
+ break;
862
+ }
863
+ case "resume_rejected":
864
+ this.serverHlc = null;
865
+ if (this.config.storage) {
866
+ this.config.storage.setMeta("serverHlc", "").catch((err) => {
867
+ console.error("[reflectdb] Failed to clear stale serverHlc:", err);
868
+ });
869
+ }
870
+ this.bootstrap();
871
+ break;
872
+ case "reauth":
873
+ this.handleReauth();
874
+ break;
875
+ case "disconnect":
876
+ this.state = "disconnected";
877
+ this.clearInFlight();
878
+ if (this.connectReject) {
879
+ this.connectReject(new Error(message.reason));
880
+ this.connectResolve = null;
881
+ this.connectReject = null;
882
+ }
883
+ this.notify();
884
+ this.config.onError?.({ reason: "local:disconnected", message: message.reason });
885
+ this.scheduleReconnect();
886
+ break;
887
+ case "shape_changed":
888
+ this.store.clearTable(message.table);
889
+ this.notify([message.table]);
890
+ this.config.onSync?.(message.table);
891
+ break;
892
+ case "count_changed":
893
+ this.totalCounts.set(message.table, message.totalCount);
894
+ this.notify([message.table]);
895
+ break;
896
+ case "ephemeral":
897
+ {
898
+ const listeners = this.ephemeralListeners.get(message.key);
899
+ if (listeners) {
900
+ for (const listener of listeners) {
901
+ listener(message);
902
+ }
903
+ }
904
+ }
905
+ break;
906
+ }
907
+ }
908
+ mergeServerHlc(packed) {
909
+ const remote = unpackHlc(packed);
910
+ const localHlc = this.opCreator.getHlc();
911
+ const merged = receiveHlc(localHlc, remote);
912
+ this.opCreator = createOpCreator(localHlc.nodeId, merged);
913
+ }
914
+ persistMeta() {
915
+ if (!this.config.storage)
916
+ return;
917
+ const storage = this.config.storage;
918
+ const hlcState = this.opCreator.getHlc();
919
+ storage.setMeta("serverHlc", this.serverHlc).catch((err) => {
920
+ console.error("[reflectdb] Failed to persist serverHlc:", err);
921
+ });
922
+ storage.setMeta("hlcState", JSON.stringify(hlcState)).catch((err) => {
923
+ console.error("[reflectdb] Failed to persist hlcState:", err);
924
+ });
925
+ }
926
+ persistSyncSubscriptions() {
927
+ if (!this.config.storage)
928
+ return;
929
+ const subs = Array.from(this.syncedTables).map((table) => ({
930
+ table,
931
+ params: this.syncParams.get(table),
932
+ options: this.syncOptions.get(table)
933
+ }));
934
+ this.config.storage.setMeta("syncSubscriptions", JSON.stringify(subs)).catch((err) => {
935
+ console.error("[reflectdb] Failed to persist sync subscriptions:", err);
936
+ });
937
+ }
938
+ handleReauth() {
939
+ if (!this.config.onReauth) {
940
+ this.config.onError?.({
941
+ reason: "local:reauth_failed",
942
+ message: "reauth requested but no onReauth handler configured"
943
+ });
944
+ return;
945
+ }
946
+ this.config.onReauth().then(async (token) => {
947
+ this.config.token = token;
948
+ try {
949
+ await this.transport.send({ type: "auth", token });
950
+ } catch (err) {
951
+ this.config.onError?.({
952
+ reason: "local:reauth_failed",
953
+ message: `auth send failed: ${err instanceof Error ? err.message : String(err)}`
954
+ });
955
+ }
956
+ }).catch((err) => {
957
+ this.config.onError?.({ reason: "local:reauth_failed", message: String(err) });
958
+ });
959
+ }
960
+ scheduleReconnect() {
961
+ if (this.closed || this.reconnectTimer)
962
+ return;
963
+ const cap = this.config.maxReconnectDelayMs ?? 30000;
964
+ const base = Math.min(1000 * 2 ** this.reconnectAttempts, cap);
965
+ const delay = Math.round(base * (0.5 + Math.random() * 0.5));
966
+ this.reconnectAttempts++;
967
+ this.reconnectTimer = setTimeout(async () => {
968
+ this.reconnectTimer = null;
969
+ try {
970
+ await this.connect();
971
+ if (this.closed)
972
+ return;
973
+ if (this.closed)
974
+ return;
975
+ const syncResults = await Promise.allSettled([...this.syncedTables].map((table) => this.sync(table, this.syncParams.get(table), this.syncOptions.get(table))));
976
+ for (const r of syncResults) {
977
+ if (r.status === "rejected") {
978
+ console.error("[reflectdb] reconnect re-sync failed:", r.reason);
979
+ }
980
+ }
981
+ if (this.closed)
982
+ return;
983
+ await this.resume();
984
+ this.clearInFlight();
985
+ if (this.closed)
986
+ return;
987
+ await this.push();
988
+ this.reconnectAttempts = 0;
989
+ } catch (err) {
990
+ try {
991
+ this.config.onError?.({
992
+ reason: "local:reconnect_failed",
993
+ message: `attempt ${this.reconnectAttempts}: ${err instanceof Error ? err.message : String(err)}`
994
+ });
995
+ } catch {}
996
+ this.scheduleReconnect();
997
+ }
998
+ }, delay);
999
+ }
1000
+ }
1001
+ function pushSafely(client) {
1002
+ return client.push().catch((err) => {
1003
+ console.error("[reflectdb] push failed:", err);
1004
+ });
1005
+ }
1006
+
1007
+ export { createOpCreator, ClientStore, SyncClient, pushSafely };