@lsync/client 0.0.1 → 0.0.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.
package/dist/index.mjs CHANGED
@@ -1,6 +1,4 @@
1
- import { createTRPCProxyClient } from "@trpc/client";
2
- import { parseServerMessage, sendClientRpcRequest } from "@lsync/transport";
3
- import { observable } from "@trpc/server/observable";
1
+ import { ProtocolError, parseServerMessage, sendClientMessage } from "@lsync/transport";
4
2
  import { createCollection } from "@tanstack/db";
5
3
  //#region src/batch.ts
6
4
  function createBatch(collection, clientId, transaction) {
@@ -45,6 +43,13 @@ function toChangeMessage(update, existing) {
45
43
  }
46
44
  //#endregion
47
45
  //#region src/client-rpc.ts
46
+ function rejectPending(pending, error) {
47
+ const requests = [...pending.values()];
48
+ pending.clear();
49
+ for (const request of requests) try {
50
+ request.reject(error);
51
+ } catch {}
52
+ }
48
53
  function takePending(pending, id) {
49
54
  if (id === null || id === void 0) return;
50
55
  const key = String(id);
@@ -52,41 +57,6 @@ function takePending(pending, id) {
52
57
  pending.delete(key);
53
58
  return request;
54
59
  }
55
- function requestForOperation(id, op) {
56
- if (op.type === "mutation" && op.path === "push") return {
57
- id,
58
- method: "mutation",
59
- params: {
60
- path: "push",
61
- input: { json: op.input }
62
- }
63
- };
64
- if (op.type === "query" && op.path === "read") return {
65
- id,
66
- method: "query",
67
- params: {
68
- path: "read",
69
- input: { json: op.input }
70
- }
71
- };
72
- if (op.type === "query" && op.path === "changes") return {
73
- id,
74
- method: "query",
75
- params: {
76
- path: "changes",
77
- input: { json: op.input }
78
- }
79
- };
80
- if (op.type === "mutation" && op.path === "api") return {
81
- id,
82
- method: "mutation",
83
- params: {
84
- path: "api",
85
- input: { json: op.input }
86
- }
87
- };
88
- throw new Error(`Unsupported operation: ${op.type}.${op.path}`);
89
- }
90
60
  function collectionScope$1(collection) {
91
61
  return `/${collection.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean).map(normalizeSegment$1).join("/")}/`;
92
62
  }
@@ -108,7 +78,9 @@ var ClientSubscriptions = class {
108
78
  if (!entry) {
109
79
  entry = {
110
80
  collection: key,
81
+ disconnectListeners: /* @__PURE__ */ new Set(),
111
82
  listeners: /* @__PURE__ */ new Set(),
83
+ reconnectListeners: /* @__PURE__ */ new Set(),
112
84
  ready: Promise.resolve()
113
85
  };
114
86
  this.subscriptions.set(key, entry);
@@ -117,9 +89,20 @@ var ClientSubscriptions = class {
117
89
  if (shouldSubscribe) entry.ready = this.start(entry);
118
90
  return {
119
91
  ready: entry.ready,
92
+ onDisconnect: (next) => addLifecycleListener(entry.disconnectListeners, next),
93
+ onReconnect: (next) => addLifecycleListener(entry.reconnectListeners, next),
120
94
  unsubscribe: () => this.unsubscribe(key, listener)
121
95
  };
122
96
  }
97
+ get hasSubscriptions() {
98
+ return this.subscriptions.size > 0;
99
+ }
100
+ disconnected() {
101
+ for (const entry of this.subscriptions.values()) for (const listener of entry.disconnectListeners) listener();
102
+ }
103
+ reconnected() {
104
+ for (const entry of this.subscriptions.values()) for (const listener of entry.reconnectListeners) listener();
105
+ }
123
106
  async replay(ws) {
124
107
  await Promise.all([...this.subscriptions.values()].map((entry) => this.subscribeOnSocket(ws, entry)));
125
108
  }
@@ -165,164 +148,472 @@ var ClientSubscriptions = class {
165
148
  if (entry.listeners.size > 0) return;
166
149
  this.subscriptions.delete(key);
167
150
  const socket = this.controls.currentSocket();
168
- if (socket?.readyState === WebSocket.OPEN) this.controls.send(socket, "unsubscribe", entry.collection);
151
+ if (socket?.readyState === WebSocket.OPEN) this.controls.send(socket, "unsubscribe", entry.collection).catch(() => void 0);
169
152
  }
170
153
  };
154
+ function addLifecycleListener(listeners, listener) {
155
+ listeners.add(listener);
156
+ return () => listeners.delete(listener);
157
+ }
171
158
  //#endregion
172
- //#region src/client.ts
159
+ //#region src/client-reconnect.ts
160
+ var ConnectionClosedError = class extends Error {
161
+ constructor(message = "Sync WebSocket closed before the request completed") {
162
+ super(message);
163
+ this.name = "ConnectionClosedError";
164
+ }
165
+ };
166
+ var RequestTimeoutError = class extends Error {
167
+ constructor() {
168
+ super("Sync RPC request timed out");
169
+ this.name = "RequestTimeoutError";
170
+ }
171
+ };
172
+ function reconnectPolicy(option) {
173
+ const input = typeof option === "object" ? option : {};
174
+ return {
175
+ enabled: option !== false,
176
+ initialDelayMs: input.initialDelayMs ?? 100,
177
+ maxDelayMs: input.maxDelayMs ?? 5e3,
178
+ multiplier: input.multiplier ?? 2,
179
+ maxAttempts: input.maxAttempts ?? 5
180
+ };
181
+ }
182
+ function reconnectDelay(policy, attempt) {
183
+ return Math.min(policy.initialDelayMs * policy.multiplier ** attempt, policy.maxDelayMs);
184
+ }
185
+ function wait(delayMs) {
186
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
187
+ }
188
+ function retryableMutationError(error) {
189
+ if (error instanceof ConnectionClosedError || error instanceof RequestTimeoutError) return true;
190
+ if (typeof error !== "object" || error === null || !("cause" in error)) return false;
191
+ return retryableMutationError(error.cause);
192
+ }
193
+ //#endregion
194
+ //#region src/client-shared.ts
173
195
  const sharedClients = /* @__PURE__ */ new Map();
196
+ function acquireSharedClient(options) {
197
+ const key = sharedClientKey(options);
198
+ let entry = sharedClients.get(key);
199
+ if (!entry) {
200
+ entry = {
201
+ client: createClient(options),
202
+ refs: 0
203
+ };
204
+ sharedClients.set(key, entry);
205
+ }
206
+ entry.refs += 1;
207
+ let released = false;
208
+ const release = () => {
209
+ if (released) return;
210
+ released = true;
211
+ entry.refs -= 1;
212
+ if (entry.refs === 0 && sharedClients.get(key) === entry) {
213
+ sharedClients.delete(key);
214
+ entry.client.close();
215
+ }
216
+ };
217
+ return {
218
+ client: entry.client,
219
+ release,
220
+ [Symbol.dispose]: release
221
+ };
222
+ }
223
+ function sharedClientKey(options) {
224
+ const url = new URL(options.url);
225
+ url.searchParams.delete("clientId");
226
+ const reconnect = typeof options.reconnect === "object" ? JSON.stringify(options.reconnect) : options.reconnect;
227
+ return [
228
+ url,
229
+ options.clientId,
230
+ reconnect,
231
+ options.connectionTimeoutMs,
232
+ options.requestTimeoutMs
233
+ ].join("\0");
234
+ }
235
+ //#endregion
236
+ //#region src/client.ts
174
237
  function createClient(options) {
175
238
  const clientId = options.clientId ?? crypto.randomUUID();
176
239
  const pending = /* @__PURE__ */ new Map();
240
+ const reconnect = reconnectPolicy(options.reconnect);
241
+ const connectionTimeoutMs = options.connectionTimeoutMs ?? 1e4;
242
+ const requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
177
243
  let nextId = 1;
178
244
  let socket;
179
245
  let connectPromise;
246
+ let reconnectTimer;
247
+ let closed = false;
248
+ let hasOpened = false;
180
249
  let subscriptions;
181
250
  const open = async () => {
251
+ if (closed) throw new ConnectionClosedError("Sync client is closed");
182
252
  if (socket?.readyState === WebSocket.OPEN) return socket;
183
253
  if (connectPromise) return connectPromise;
184
- connectPromise = new Promise((resolve, reject) => {
254
+ const connect = async () => {
255
+ const attempts = reconnect.enabled ? reconnect.maxAttempts : 1;
256
+ let lastError;
257
+ for (let attempt = 0; attempt < attempts; attempt += 1) try {
258
+ return await connectOnce();
259
+ } catch (error) {
260
+ lastError = error;
261
+ if (attempt + 1 < attempts) await wait(reconnectDelay(reconnect, attempt));
262
+ }
263
+ throw lastError;
264
+ };
265
+ const connectOnce = () => new Promise((resolve, reject) => {
185
266
  const url = new URL(options.url);
186
267
  url.searchParams.set("clientId", clientId);
187
268
  const ws = new WebSocket(url);
188
269
  ws.binaryType = "arraybuffer";
270
+ let opened = false;
271
+ const connectionTimer = setTimeout(() => {
272
+ reject(/* @__PURE__ */ new Error(`Sync WebSocket connection timed out after ${connectionTimeoutMs}ms`));
273
+ ws.close();
274
+ }, connectionTimeoutMs);
189
275
  ws.addEventListener("open", () => {
276
+ opened = true;
277
+ clearTimeout(connectionTimer);
190
278
  socket = ws;
191
279
  subscriptions.replay(ws).then(() => {
192
- connectPromise = void 0;
280
+ if (hasOpened) subscriptions.reconnected();
281
+ hasOpened = true;
193
282
  resolve(ws);
194
283
  }).catch((error) => {
195
- connectPromise = void 0;
196
284
  reject(error);
285
+ ws.close();
197
286
  });
198
287
  });
199
288
  ws.addEventListener("message", (event) => {
200
289
  const message = parseServerMessage(event.data);
201
- if ("method" in message) {
202
- subscriptions.dispatch(message.params.input.json);
290
+ if (message.type === "updates") {
291
+ subscriptions.dispatch(message.payload);
203
292
  return;
204
293
  }
205
- if ("error" in message) {
206
- const request = takePending(pending, message.id);
207
- if (!request) return;
208
- request.reject(new Error(message.error.message));
294
+ if (message.type === "error") {
295
+ takePending(pending, message.id)?.reject(new ProtocolError(message.error));
209
296
  return;
210
297
  }
211
- const request = takePending(pending, message.id);
212
- if (!request) return;
213
- request.resolve(message.result.data.json);
298
+ takePending(pending, message.id)?.resolve(message.payload);
214
299
  });
215
300
  ws.addEventListener("close", () => {
216
- socket = void 0;
217
- connectPromise = void 0;
301
+ clearTimeout(connectionTimer);
302
+ if (socket === ws) {
303
+ socket = void 0;
304
+ rejectPending(pending, new ConnectionClosedError());
305
+ subscriptions.disconnected();
306
+ scheduleReconnect();
307
+ }
308
+ if (!opened) reject(/* @__PURE__ */ new Error("Unable to open sync WebSocket"));
218
309
  });
219
310
  ws.addEventListener("error", () => {
311
+ clearTimeout(connectionTimer);
220
312
  reject(/* @__PURE__ */ new Error("Unable to open sync WebSocket"));
221
313
  });
222
314
  });
315
+ connectPromise = connect().finally(() => {
316
+ connectPromise = void 0;
317
+ });
223
318
  return connectPromise;
224
319
  };
320
+ function scheduleReconnect() {
321
+ if (closed || connectPromise || !reconnect.enabled || !subscriptions.hasSubscriptions || reconnectTimer) return;
322
+ reconnectTimer = setTimeout(() => {
323
+ reconnectTimer = void 0;
324
+ open().catch(() => scheduleReconnect());
325
+ }, reconnect.initialDelayMs);
326
+ }
225
327
  const sendRequest = (ws, request) => {
226
328
  return new Promise((resolve, reject) => {
227
329
  const id = request.id;
228
- if (id === null || id === void 0) {
229
- reject(/* @__PURE__ */ new Error("RPC request requires an id"));
230
- return;
231
- }
330
+ const timeout = setTimeout(() => {
331
+ takePending(pending, id)?.reject(new RequestTimeoutError());
332
+ }, requestTimeoutMs);
232
333
  pending.set(String(id), {
233
- resolve: (value) => resolve(value),
234
- reject
334
+ resolve: (value) => {
335
+ clearTimeout(timeout);
336
+ resolve(value);
337
+ },
338
+ reject: (error) => {
339
+ clearTimeout(timeout);
340
+ reject(error);
341
+ }
235
342
  });
236
343
  try {
237
- sendClientRpcRequest(ws, request);
344
+ sendClientMessage(ws, request);
238
345
  } catch (error) {
239
- pending.delete(String(id));
240
- reject(error);
346
+ takePending(pending, id)?.reject(error instanceof Error ? error : new Error(String(error)));
241
347
  }
242
348
  });
243
349
  };
244
- const sendSubscriptionControl = async (ws, method, collection) => {
245
- const id = String(nextId++);
246
- return sendRequest(ws, {
247
- id,
248
- method,
249
- params: { input: { json: { collection } } }
250
- });
251
- };
350
+ const sendSubscriptionControl = async (ws, method, collection) => sendRequest(ws, {
351
+ version: 1,
352
+ id: String(nextId++),
353
+ type: method,
354
+ input: { collection }
355
+ });
252
356
  subscriptions = new ClientSubscriptions({
253
357
  currentSocket: () => socket,
254
358
  open,
255
359
  send: sendSubscriptionControl
256
360
  });
257
- const link = () => {
258
- return ({ op }) => observable((observer) => {
259
- let cancelled = false;
260
- open().then((ws) => {
261
- if (cancelled) return;
262
- const id = String(nextId++);
263
- sendRequest(ws, requestForOperation(id, op)).then((value) => {
264
- observer.next({ result: { data: value } });
265
- observer.complete();
266
- }).catch((error) => observer.error(error));
267
- }).catch((error) => observer.error(error));
268
- return () => {
269
- cancelled = true;
270
- };
271
- });
272
- };
273
- const trpc = createTRPCProxyClient({ links: [link] });
361
+ const sendPush = (batch) => open().then((ws) => sendRequest(ws, {
362
+ version: 1,
363
+ id: String(nextId++),
364
+ type: "push",
365
+ input: batch
366
+ }));
274
367
  return {
275
368
  clientId,
276
- push: (batch) => trpc.push.mutate(batch),
277
- read: (query) => trpc.read.query(query),
278
- changes: (query) => trpc.changes.query(query),
279
- call: (path, ...args) => {
369
+ push: async (batch) => {
370
+ const attempts = reconnect.enabled ? reconnect.maxAttempts : 1;
371
+ for (let attempt = 0;; attempt += 1) try {
372
+ return await sendPush(batch);
373
+ } catch (error) {
374
+ if (!retryableMutationError(error) || attempt + 1 >= attempts) throw error;
375
+ await wait(reconnectDelay(reconnect, attempt));
376
+ }
377
+ },
378
+ read: async (query) => {
379
+ const ws = await open();
380
+ return sendRequest(ws, {
381
+ version: 1,
382
+ id: String(nextId++),
383
+ type: "read",
384
+ input: query
385
+ });
386
+ },
387
+ changes: async (query) => {
388
+ const ws = await open();
389
+ return sendRequest(ws, {
390
+ version: 1,
391
+ id: String(nextId++),
392
+ type: "changes",
393
+ input: query
394
+ });
395
+ },
396
+ call: async (path, ...args) => {
280
397
  const [input] = args;
281
398
  const call = input === void 0 ? { path } : {
282
399
  path,
283
400
  input
284
401
  };
285
- return trpc.api.mutate(call);
402
+ const ws = await open();
403
+ return sendRequest(ws, {
404
+ version: 1,
405
+ id: String(nextId++),
406
+ type: "api",
407
+ input: call
408
+ });
286
409
  },
287
410
  subscribe: (collection, listener) => subscriptions.subscribe(collection, listener),
288
411
  close() {
412
+ closed = true;
413
+ if (reconnectTimer) clearTimeout(reconnectTimer);
414
+ rejectPending(pending, new ConnectionClosedError("Sync client was closed"));
289
415
  socket?.close();
290
416
  socket = void 0;
291
417
  }
292
418
  };
293
419
  }
294
- function acquireSharedClient(options) {
295
- const key = sharedClientKey(options);
296
- let entry = sharedClients.get(key);
297
- if (!entry) {
298
- entry = {
299
- client: createClient({
300
- url: options.url,
301
- ...options.clientId ? { clientId: options.clientId } : {}
302
- }),
303
- refs: 0
304
- };
305
- sharedClients.set(key, entry);
420
+ //#endregion
421
+ //#region src/subsets.ts
422
+ var SubsetTracker = class {
423
+ getKey;
424
+ subsets = /* @__PURE__ */ new Map();
425
+ keyRefs = /* @__PURE__ */ new Map();
426
+ constructor(getKey) {
427
+ this.getKey = getKey;
306
428
  }
307
- entry.refs += 1;
308
- let released = false;
309
- return {
310
- client: entry.client,
311
- release() {
312
- if (released) return;
313
- released = true;
314
- entry.refs -= 1;
315
- if (entry.refs === 0 && sharedClients.get(key) === entry) {
316
- sharedClients.delete(key);
317
- entry.client.close();
429
+ replace(subsetId, rows, filters, predicate) {
430
+ const previous = this.subsets.get(subsetId);
431
+ const refs = previous?.refs ?? 0;
432
+ if (previous) this.releaseKeys(previous.keys);
433
+ const keys = new Set(rows.map((row) => this.getKey(row)));
434
+ this.addKeys(keys);
435
+ this.subsets.set(subsetId, {
436
+ filters,
437
+ keys,
438
+ loaded: true,
439
+ ...predicate ? { predicate } : {},
440
+ refs
441
+ });
442
+ const removed = [];
443
+ previous?.keys.forEach((key) => {
444
+ if (!keys.has(key) && !this.keyRefs.has(key)) removed.push(key);
445
+ });
446
+ return removed;
447
+ }
448
+ retain(subsetId) {
449
+ const subset = this.subsets.get(subsetId);
450
+ if (subset) {
451
+ subset.refs += 1;
452
+ return { loaded: subset.loaded };
453
+ }
454
+ this.subsets.set(subsetId, {
455
+ filters: [],
456
+ keys: /* @__PURE__ */ new Set(),
457
+ loaded: false,
458
+ refs: 1
459
+ });
460
+ return { loaded: false };
461
+ }
462
+ trackRow(row) {
463
+ const result = this.reconcileRow(row);
464
+ return result.before || result.after;
465
+ }
466
+ reconcileRow(row) {
467
+ const key = this.getKey(row);
468
+ let before = false;
469
+ this.subsets.forEach((subset) => {
470
+ const had = subset.keys.has(key);
471
+ before ||= had;
472
+ if (matchesSubset(row, subset)) {
473
+ if (!had) {
474
+ subset.keys.add(key);
475
+ this.keyRefs.set(key, (this.keyRefs.get(key) ?? 0) + 1);
476
+ }
477
+ return;
318
478
  }
479
+ if (had) {
480
+ subset.keys.delete(key);
481
+ this.releaseKey(key);
482
+ }
483
+ });
484
+ return {
485
+ before,
486
+ after: this.keyRefs.has(key)
487
+ };
488
+ }
489
+ deleteKey(key) {
490
+ if (!this.keyRefs.has(key)) return false;
491
+ this.subsets.forEach((subset) => {
492
+ subset.keys.delete(key);
493
+ });
494
+ this.keyRefs.delete(key);
495
+ return true;
496
+ }
497
+ release(subsetId, retain = false) {
498
+ const subset = this.subsets.get(subsetId);
499
+ if (!subset) return [];
500
+ subset.refs -= 1;
501
+ if (subset.refs > 0) return [];
502
+ subset.refs = 0;
503
+ if (retain) return [];
504
+ return this.expire(subsetId);
505
+ }
506
+ expire(subsetId) {
507
+ const subset = this.subsets.get(subsetId);
508
+ if (!subset || subset.refs > 0) return [];
509
+ this.subsets.delete(subsetId);
510
+ return this.releaseKeys(subset.keys);
511
+ }
512
+ clear() {
513
+ const keys = [...this.keyRefs.keys()];
514
+ this.subsets.clear();
515
+ this.keyRefs.clear();
516
+ return keys;
517
+ }
518
+ addKeys(keys) {
519
+ keys.forEach((key) => {
520
+ this.keyRefs.set(key, (this.keyRefs.get(key) ?? 0) + 1);
521
+ });
522
+ }
523
+ releaseKeys(keys) {
524
+ const removed = [];
525
+ keys.forEach((key) => {
526
+ if (this.releaseKey(key)) removed.push(key);
527
+ });
528
+ return removed;
529
+ }
530
+ releaseKey(key) {
531
+ const refs = (this.keyRefs.get(key) ?? 0) - 1;
532
+ if (refs <= 0) {
533
+ this.keyRefs.delete(key);
534
+ return true;
319
535
  }
320
- };
536
+ this.keyRefs.set(key, refs);
537
+ return false;
538
+ }
539
+ };
540
+ function writeRows(collection, rows, getKey, write) {
541
+ for (const row of rows) {
542
+ const key = getKey(row);
543
+ write({
544
+ type: collection.has(key) ? "update" : "insert",
545
+ key,
546
+ value: row
547
+ });
548
+ }
321
549
  }
322
- function sharedClientKey(options) {
323
- const url = new URL(options.url);
324
- url.searchParams.delete("clientId");
325
- return `${url.toString()}\0${options.clientId ?? ""}`;
550
+ function matchesFilters(row, filters) {
551
+ return filters.every((filter) => matchesFilter(row, filter));
552
+ }
553
+ function matchesSubset(row, subset) {
554
+ return matchesFilters(row, subset.filters) && matchesPredicate(row, subset.predicate);
555
+ }
556
+ function matchesPredicate(row, predicate) {
557
+ if (!predicate) return true;
558
+ if (predicate.type === "comparison") return matchesFilter(row, predicate);
559
+ if (predicate.type === "and") return predicate.predicates.every((child) => matchesPredicate(row, child));
560
+ return predicate.predicates.some((child) => matchesPredicate(row, child));
561
+ }
562
+ function matchesFilter(row, filter) {
563
+ const value = valueAtPath(row, filter.field);
564
+ switch (filter.op) {
565
+ case "eq": return value === filter.value;
566
+ case "ne": return value !== filter.value;
567
+ case "gt": return compareValues(value, filter.value, (left, right) => left > right);
568
+ case "gte": return compareValues(value, filter.value, (left, right) => left >= right);
569
+ case "lt": return compareValues(value, filter.value, (left, right) => left < right);
570
+ case "lte": return compareValues(value, filter.value, (left, right) => left <= right);
571
+ case "in": return Array.isArray(filter.value) && filter.value.includes(value);
572
+ }
573
+ return false;
574
+ }
575
+ function compareValues(left, right, compare) {
576
+ if (typeof left === "number" && typeof right === "number") return compare(left, right);
577
+ if (typeof left === "string" && typeof right === "string") return compare(left, right);
578
+ return false;
579
+ }
580
+ function valueAtPath(row, field) {
581
+ return field.split(".").reduce((value, segment) => {
582
+ if (typeof value !== "object" || value === null) return;
583
+ return value[segment];
584
+ }, row);
585
+ }
586
+ //#endregion
587
+ //#region src/collection-broadcast.ts
588
+ function applyCollectionBroadcast(options) {
589
+ if ((options.broadcast.invalidations ?? []).length > 0 && options.syncMode === "on-demand") options.deleteKeys(options.subsets.clear());
590
+ const changes = options.broadcast.updates.flatMap((update) => {
591
+ if (collectionScope$1(update.collection) !== collectionScope$1(options.collectionName)) return [];
592
+ const ownUpdate = update.clientId === options.clientId;
593
+ if (options.ignoreOwnUpdates && ownUpdate) return [];
594
+ if (options.syncMode !== "on-demand") return [toChangeMessage(update, options.collection.has(update.key))];
595
+ const key = update.key;
596
+ const exists = options.collection.has(key);
597
+ if (update.type === "delete") {
598
+ options.subsets.deleteKey(key);
599
+ return exists ? [{
600
+ type: "delete",
601
+ key
602
+ }] : [];
603
+ }
604
+ const current = options.collection.get(key);
605
+ const row = update.type === "update" && current ? {
606
+ ...current,
607
+ ...update.value
608
+ } : update.value;
609
+ const tracked = options.subsets.reconcileRow(row);
610
+ return ownUpdate || exists || tracked.after ? [toChangeMessage(update, exists)] : [];
611
+ });
612
+ if (changes.length === 0) return [];
613
+ options.begin();
614
+ for (const change of changes) options.write(change);
615
+ options.commit();
616
+ return changes;
326
617
  }
327
618
  //#endregion
328
619
  //#region src/read-query.ts
@@ -472,170 +763,292 @@ function isValExpression(value) {
472
763
  return isExpression(value) && value.type === "val";
473
764
  }
474
765
  //#endregion
475
- //#region src/subsets.ts
476
- var SubsetTracker = class {
477
- getKey;
478
- subsets = /* @__PURE__ */ new Map();
479
- keyRefs = /* @__PURE__ */ new Map();
480
- constructor(getKey) {
481
- this.getKey = getKey;
482
- }
483
- replace(subsetId, rows, filters, predicate) {
484
- const previous = this.subsets.get(subsetId);
485
- const refs = previous?.refs ?? 0;
486
- if (previous) this.releaseKeys(previous.keys);
487
- const keys = new Set(rows.map((row) => this.getKey(row)));
488
- this.addKeys(keys);
489
- this.subsets.set(subsetId, {
490
- filters,
491
- keys,
492
- loaded: true,
493
- ...predicate ? { predicate } : {},
494
- refs
766
+ //#region src/initial-sync.ts
767
+ const DEFAULT_MAX_SYNC_ROWS = 1e4;
768
+ const MAX_READ_ROWS = 1e3;
769
+ async function readInitialSyncRows(client, query, maxSyncRows) {
770
+ if (query.limit !== void 0 || query.cursor) return (await client.read(query)).rows;
771
+ const maximum = maxSyncRows ?? 1e4;
772
+ if (maximum !== false && (!Number.isInteger(maximum) || maximum <= 0)) throw new Error("maxSyncRows must be a positive integer or false");
773
+ const rows = [];
774
+ const baseOffset = query.offset ?? 0;
775
+ while (maximum === false || rows.length <= maximum) {
776
+ const remaining = maximum === false ? MAX_READ_ROWS : maximum + 1 - rows.length;
777
+ const limit = Math.min(MAX_READ_ROWS, remaining);
778
+ const page = await client.read({
779
+ ...query,
780
+ limit,
781
+ offset: baseOffset + rows.length
495
782
  });
496
- const removed = [];
497
- previous?.keys.forEach((key) => {
498
- if (!keys.has(key) && !this.keyRefs.has(key)) removed.push(key);
783
+ rows.push(...page.rows);
784
+ if (page.rows.length < limit) return rows;
785
+ }
786
+ throw new Error(`Collection ${query.collection} exceeds the initial sync limit of ${maximum} rows; use syncMode: "on-demand" or maxSyncRows: false`);
787
+ }
788
+ //#endregion
789
+ //#region src/sync-session.ts
790
+ var SyncSession = class {
791
+ options;
792
+ buffered = [];
793
+ cursor = 0;
794
+ disconnectCleanup;
795
+ reconnectCleanup;
796
+ queue = Promise.resolve();
797
+ subscription;
798
+ _state = "idle";
799
+ constructor(options) {
800
+ this.options = options;
801
+ }
802
+ get state() {
803
+ return this._state;
804
+ }
805
+ start() {
806
+ if (this._state !== "idle") return this.queue;
807
+ this._state = "subscribing";
808
+ this.subscription = this.options.client.subscribe(this.options.collection, (broadcast) => {
809
+ this.receive(broadcast);
499
810
  });
500
- return removed;
811
+ this.disconnectCleanup = this.subscription.onDisconnect?.(() => this.disconnected());
812
+ this.reconnectCleanup = this.subscription.onReconnect?.(() => this.reconnected());
813
+ this.queue = this.initialize();
814
+ return this.queue;
501
815
  }
502
- retain(subsetId) {
503
- const subset = this.subsets.get(subsetId);
504
- if (subset) {
505
- subset.refs += 1;
506
- return { loaded: subset.loaded };
816
+ stop() {
817
+ this._state = "stopped";
818
+ this.buffered = [];
819
+ this.disconnectCleanup?.();
820
+ this.reconnectCleanup?.();
821
+ this.subscription?.unsubscribe();
822
+ }
823
+ async initialize() {
824
+ if (this.options.hydrate) {
825
+ await this.options.hydrate();
826
+ if (this.isStopped()) return;
827
+ this.options.hydrated?.();
507
828
  }
508
- this.subsets.set(subsetId, {
509
- filters: [],
510
- keys: /* @__PURE__ */ new Set(),
511
- loaded: false,
512
- refs: 1
513
- });
514
- return { loaded: false };
829
+ await this.subscription?.ready;
830
+ if (this.isStopped()) return;
831
+ const established = await this.options.client.changes({ collections: {} });
832
+ this._state = "loading";
833
+ await this.options.initialize();
834
+ this.cursor = established.watermark;
835
+ this.flush();
836
+ this.markSynchronized();
837
+ }
838
+ receive(broadcast) {
839
+ if (this._state !== "synchronized") {
840
+ this.buffered.push(broadcast);
841
+ return;
842
+ }
843
+ this.applyBroadcast(broadcast);
515
844
  }
516
- trackRow(row) {
517
- const result = this.reconcileRow(row);
518
- return result.before || result.after;
845
+ disconnected() {
846
+ if (this._state === "stopped") return;
847
+ this._state = "disconnected";
519
848
  }
520
- reconcileRow(row) {
521
- const key = this.getKey(row);
522
- let before = false;
523
- this.subsets.forEach((subset) => {
524
- const had = subset.keys.has(key);
525
- before ||= had;
526
- if (matchesSubset(row, subset)) {
527
- if (!had) {
528
- subset.keys.add(key);
529
- this.keyRefs.set(key, (this.keyRefs.get(key) ?? 0) + 1);
530
- }
531
- return;
532
- }
533
- if (had) {
534
- subset.keys.delete(key);
535
- this.releaseKey(key);
849
+ reconnected() {
850
+ if (this._state === "stopped") return;
851
+ this.queue = this.queue.catch(() => void 0).then(() => this.recover());
852
+ }
853
+ async recover() {
854
+ this._state = "catching-up";
855
+ while (!this.isStopped()) {
856
+ const result = await this.options.client.changes({
857
+ collections: { [this.options.collection]: this.cursor },
858
+ limit: 1e3
859
+ });
860
+ if (result.type === "resyncRequired") {
861
+ this._state = "resyncing";
862
+ await this.options.resync();
863
+ this.cursor = result.watermark;
864
+ break;
536
865
  }
866
+ if (result.updates.length > 0) this.applyUpdates(result.updates, result.watermark);
867
+ const nextCursor = result.cursors?.[this.options.collection] ?? visibleCursor(result.updates);
868
+ if (result.hasMore && nextCursor <= this.cursor) throw new Error(`Sync history cursor did not advance for ${this.options.collection}`);
869
+ this.cursor = result.hasMore ? Math.max(this.cursor, nextCursor) : Math.max(this.cursor, result.watermark);
870
+ if (!result.hasMore) break;
871
+ }
872
+ if (this.isStopped()) return;
873
+ this.flush();
874
+ this.markSynchronized();
875
+ }
876
+ flush() {
877
+ const broadcasts = this.buffered.sort((left, right) => left.watermark - right.watermark);
878
+ this.buffered = [];
879
+ for (const broadcast of broadcasts) this.applyBroadcast(broadcast);
880
+ }
881
+ applyBroadcast(broadcast) {
882
+ if (broadcast.watermark <= this.cursor) return;
883
+ const updates = broadcast.updates.filter((update) => update.sequence > this.cursor);
884
+ this.options.apply({
885
+ ...broadcast,
886
+ updates
537
887
  });
538
- return {
539
- before,
540
- after: this.keyRefs.has(key)
541
- };
888
+ this.cursor = Math.max(this.cursor, broadcast.watermark);
542
889
  }
543
- deleteKey(key) {
544
- if (!this.keyRefs.has(key)) return false;
545
- this.subsets.forEach((subset) => {
546
- subset.keys.delete(key);
890
+ applyUpdates(updates, watermark) {
891
+ this.options.apply({
892
+ type: "updates",
893
+ shardId: "history",
894
+ updates,
895
+ watermark
547
896
  });
548
- this.keyRefs.delete(key);
549
- return true;
550
897
  }
551
- release(subsetId, retain = false) {
552
- const subset = this.subsets.get(subsetId);
553
- if (!subset) return [];
554
- subset.refs -= 1;
555
- if (subset.refs > 0) return [];
556
- subset.refs = 0;
557
- if (retain) return [];
558
- return this.expire(subsetId);
898
+ markSynchronized() {
899
+ this._state = "synchronized";
900
+ this.options.synchronized();
559
901
  }
560
- expire(subsetId) {
561
- const subset = this.subsets.get(subsetId);
562
- if (!subset || subset.refs > 0) return [];
563
- this.subsets.delete(subsetId);
564
- return this.releaseKeys(subset.keys);
902
+ isStopped() {
903
+ return this._state === "stopped";
565
904
  }
566
- clear() {
567
- const keys = [...this.keyRefs.keys()];
568
- this.subsets.clear();
569
- this.keyRefs.clear();
570
- return keys;
905
+ };
906
+ function visibleCursor(updates) {
907
+ return updates.reduce((cursor, update) => Math.max(cursor, update.sequence), 0);
908
+ }
909
+ //#endregion
910
+ //#region src/offline-cache.ts
911
+ const databaseVersion = 1;
912
+ const rowStore = "collectionRows";
913
+ const collectionIndex = "byCollection";
914
+ function createOfflineCache(option, id, getKey) {
915
+ if (!option) return void 0;
916
+ if (!id) throw new Error("collectionOptions requires a stable id when offline is enabled");
917
+ if (!globalThis.indexedDB) throw new Error("IndexedDB is unavailable; offline collections must be created in a browser");
918
+ const options = option === true ? {} : option;
919
+ const collectionId = `${id}\0${options.cacheVersion ?? 1}`;
920
+ return new IndexedDBOfflineCache(globalThis.indexedDB, options.databaseName ?? "lsync", collectionId, getKey);
921
+ }
922
+ var IndexedDBOfflineCache = class {
923
+ collectionId;
924
+ getKey;
925
+ database;
926
+ queue = Promise.resolve();
927
+ constructor(factory, databaseName, collectionId, getKey) {
928
+ this.collectionId = collectionId;
929
+ this.getKey = getKey;
930
+ this.database = openDatabase(factory, databaseName);
571
931
  }
572
- addKeys(keys) {
573
- keys.forEach((key) => {
574
- this.keyRefs.set(key, (this.keyRefs.get(key) ?? 0) + 1);
932
+ async load() {
933
+ await this.queue;
934
+ return (await requestResult((await this.database).transaction(rowStore, "readonly").objectStore(rowStore).index(collectionIndex).getAll(this.collectionId))).map((record) => record.value);
935
+ }
936
+ put(rows) {
937
+ if (rows.length === 0) return this.queue;
938
+ return this.enqueue(async () => {
939
+ const transaction = (await this.database).transaction(rowStore, "readwrite");
940
+ const store = transaction.objectStore(rowStore);
941
+ for (const row of rows) store.put(this.record(row));
942
+ await transactionComplete(transaction);
575
943
  });
576
944
  }
577
- releaseKeys(keys) {
578
- const removed = [];
579
- keys.forEach((key) => {
580
- if (this.releaseKey(key)) removed.push(key);
945
+ delete(keys) {
946
+ if (keys.length === 0) return this.queue;
947
+ return this.enqueue(async () => {
948
+ const transaction = (await this.database).transaction(rowStore, "readwrite");
949
+ const store = transaction.objectStore(rowStore);
950
+ for (const key of keys) store.delete([this.collectionId, key]);
951
+ await transactionComplete(transaction);
581
952
  });
582
- return removed;
583
953
  }
584
- releaseKey(key) {
585
- const refs = (this.keyRefs.get(key) ?? 0) - 1;
586
- if (refs <= 0) {
587
- this.keyRefs.delete(key);
588
- return true;
589
- }
590
- this.keyRefs.set(key, refs);
591
- return false;
954
+ replace(rows) {
955
+ return this.enqueue(async () => {
956
+ const transaction = (await this.database).transaction(rowStore, "readwrite");
957
+ const store = transaction.objectStore(rowStore);
958
+ await deleteCollectionRows(store.index(collectionIndex), this.collectionId);
959
+ for (const row of rows) store.put(this.record(row));
960
+ await transactionComplete(transaction);
961
+ });
592
962
  }
593
- };
594
- function writeRows(collection, rows, getKey, write) {
595
- for (const row of rows) {
596
- const key = getKey(row);
597
- write({
598
- type: collection.has(key) ? "update" : "insert",
599
- key,
963
+ record(row) {
964
+ return {
965
+ collectionId: this.collectionId,
966
+ key: this.getKey(row),
600
967
  value: row
601
- });
968
+ };
602
969
  }
970
+ enqueue(operation) {
971
+ const result = this.queue.then(operation);
972
+ this.queue = result.catch(() => void 0);
973
+ return result;
974
+ }
975
+ };
976
+ function openDatabase(factory, name) {
977
+ return new Promise((resolve, reject) => {
978
+ const request = factory.open(name, databaseVersion);
979
+ request.onupgradeneeded = () => {
980
+ request.result.createObjectStore(rowStore, { keyPath: ["collectionId", "key"] }).createIndex(collectionIndex, "collectionId");
981
+ };
982
+ request.onsuccess = () => resolve(request.result);
983
+ request.onerror = () => reject(request.error);
984
+ request.onblocked = () => reject(/* @__PURE__ */ new Error(`IndexedDB database ${name} is blocked`));
985
+ });
603
986
  }
604
- function matchesFilters(row, filters) {
605
- return filters.every((filter) => matchesFilter(row, filter));
987
+ function deleteCollectionRows(index, id) {
988
+ return new Promise((resolve, reject) => {
989
+ const request = index.openKeyCursor(id);
990
+ request.onsuccess = () => {
991
+ const cursor = request.result;
992
+ if (!cursor) {
993
+ resolve();
994
+ return;
995
+ }
996
+ index.objectStore.delete(cursor.primaryKey);
997
+ cursor.continue();
998
+ };
999
+ request.onerror = () => reject(request.error);
1000
+ });
606
1001
  }
607
- function matchesSubset(row, subset) {
608
- return matchesFilters(row, subset.filters) && matchesPredicate(row, subset.predicate);
1002
+ function requestResult(request) {
1003
+ return new Promise((resolve, reject) => {
1004
+ request.onsuccess = () => resolve(request.result);
1005
+ request.onerror = () => reject(request.error);
1006
+ });
609
1007
  }
610
- function matchesPredicate(row, predicate) {
611
- if (!predicate) return true;
612
- if (predicate.type === "comparison") return matchesFilter(row, predicate);
613
- if (predicate.type === "and") return predicate.predicates.every((child) => matchesPredicate(row, child));
614
- return predicate.predicates.some((child) => matchesPredicate(row, child));
1008
+ function transactionComplete(transaction) {
1009
+ return new Promise((resolve, reject) => {
1010
+ transaction.oncomplete = () => resolve();
1011
+ transaction.onerror = () => reject(transaction.error);
1012
+ transaction.onabort = () => reject(transaction.error);
1013
+ });
615
1014
  }
616
- function matchesFilter(row, filter) {
617
- const value = valueAtPath(row, filter.field);
618
- switch (filter.op) {
619
- case "eq": return value === filter.value;
620
- case "ne": return value !== filter.value;
621
- case "gt": return compareValues(value, filter.value, (left, right) => left > right);
622
- case "gte": return compareValues(value, filter.value, (left, right) => left >= right);
623
- case "lt": return compareValues(value, filter.value, (left, right) => left < right);
624
- case "lte": return compareValues(value, filter.value, (left, right) => left <= right);
625
- case "in": return Array.isArray(filter.value) && filter.value.includes(value);
1015
+ //#endregion
1016
+ //#region src/offline-sync.ts
1017
+ function persistChanges(offline, changes, collection, getKey) {
1018
+ if (!offline) return;
1019
+ const rows = [];
1020
+ const deletedKeys = [];
1021
+ for (const change of changes) {
1022
+ if (change.type === "delete") {
1023
+ deletedKeys.push(change.key);
1024
+ continue;
1025
+ }
1026
+ const row = collection.get(getKey(change.value));
1027
+ if (row) rows.push(row);
626
1028
  }
627
- return false;
628
- }
629
- function compareValues(left, right, compare) {
630
- if (typeof left === "number" && typeof right === "number") return compare(left, right);
631
- if (typeof left === "string" && typeof right === "string") return compare(left, right);
632
- return false;
1029
+ offline.put(rows);
1030
+ offline.delete(deletedKeys);
1031
+ }
1032
+ function persistTransaction(offline, transaction) {
1033
+ if (!offline) return;
1034
+ const rows = [];
1035
+ const deletedKeys = [];
1036
+ for (const mutation of transaction.mutations) if (mutation.type === "delete") deletedKeys.push(mutation.key);
1037
+ else rows.push(mutation.modified);
1038
+ offline.put(rows);
1039
+ offline.delete(deletedKeys);
633
1040
  }
634
- function valueAtPath(row, field) {
635
- return field.split(".").reduce((value, segment) => {
636
- if (typeof value !== "object" || value === null) return;
637
- return value[segment];
638
- }, row);
1041
+ //#endregion
1042
+ //#region src/local-transaction.ts
1043
+ function trackLocalTransaction(subsets, syncMode, transaction) {
1044
+ if (syncMode !== "on-demand" || !subsets) return;
1045
+ for (const mutation of transaction.mutations) {
1046
+ if (mutation.type === "delete") {
1047
+ subsets.deleteKey(mutation.key);
1048
+ continue;
1049
+ }
1050
+ subsets.reconcileRow(mutation.modified);
1051
+ }
639
1052
  }
640
1053
  //#endregion
641
1054
  //#region src/collection.ts
@@ -643,6 +1056,9 @@ function collectionOptions(options) {
643
1056
  let lease;
644
1057
  let client = options.client;
645
1058
  let activeSubsets;
1059
+ if (options.offline && options.syncMode === "on-demand") throw new Error("IndexedDB offline caching currently requires eager sync mode");
1060
+ if (options.offline && options.read === false) throw new Error("IndexedDB offline caching requires an initial collection read");
1061
+ const offline = createOfflineCache(options.offline, options.id, options.getKey);
646
1062
  if (!client) {
647
1063
  if (!options.url) throw new Error("collectionOptions requires either client or url");
648
1064
  lease = acquireSharedClient({
@@ -661,11 +1077,12 @@ function collectionOptions(options) {
661
1077
  ...options.defaultIndexType !== void 0 ? { defaultIndexType: options.defaultIndexType } : {},
662
1078
  getKey: options.getKey,
663
1079
  sync: {
664
- sync: ({ begin, write, commit, markReady, collection }) => {
1080
+ sync: ({ begin, write, commit, markReady, truncate, collection }) => {
665
1081
  const subsets = new SubsetTracker(options.getKey);
666
1082
  activeSubsets = subsets;
667
1083
  const subsetGcTime = options.gcTime ?? 3e5;
668
1084
  const retentionTimers = /* @__PURE__ */ new Map();
1085
+ const subsetQueries = /* @__PURE__ */ new Map();
669
1086
  const deleteKeys = (keys) => {
670
1087
  if (keys.length === 0) return;
671
1088
  begin();
@@ -674,6 +1091,7 @@ function collectionOptions(options) {
674
1091
  key
675
1092
  });
676
1093
  commit();
1094
+ offline?.delete(keys);
677
1095
  };
678
1096
  const cancelRetention = (id) => {
679
1097
  const timer = retentionTimers.get(id);
@@ -690,47 +1108,86 @@ function collectionOptions(options) {
690
1108
  cancelRetention(id);
691
1109
  retentionTimers.set(id, setTimeout(() => {
692
1110
  retentionTimers.delete(id);
1111
+ subsetQueries.delete(id);
693
1112
  deleteKeys(subsets.expire(id));
694
1113
  }, subsetGcTime));
695
1114
  };
696
- const subscription = client.subscribe(options.collection, (broadcast) => {
697
- if ((broadcast.invalidations ?? []).length > 0 && options.syncMode === "on-demand") deleteKeys(subsets.clear());
698
- const changes = broadcast.updates.flatMap((update) => {
699
- if (update.collection !== options.collection) return [];
700
- const ownUpdate = update.clientId === client.clientId;
701
- if ((options.ignoreOwnUpdates ?? false) && ownUpdate) return [];
702
- if (options.syncMode !== "on-demand") return [toChangeMessage(update, collection.has(update.key))];
703
- const key = update.key;
704
- const exists = collection.has(key);
705
- if (update.type === "delete") {
706
- subsets.deleteKey(key);
707
- return exists ? [{
708
- type: "delete",
709
- key
710
- }] : [];
711
- }
712
- const current = collection.get(key);
713
- const row = update.type === "update" && current ? {
714
- ...current,
715
- ...update.value
716
- } : update.value;
717
- const tracked = subsets.reconcileRow(row);
718
- if (ownUpdate || exists || tracked.after) return [toChangeMessage(update, exists)];
719
- return [];
720
- });
721
- if (changes.length === 0) return;
722
- begin();
723
- for (const change of changes) write(change);
724
- commit();
1115
+ const initialQuery = initialReadQuery(options.collection, options.read);
1116
+ const loadEager = async (replace = false) => {
1117
+ if (!initialQuery) return;
1118
+ const rows = await readInitialSyncRows(client, initialQuery, options.maxSyncRows);
1119
+ if (replace) truncate();
1120
+ if (rows.length > 0) {
1121
+ begin();
1122
+ writeRows(collection, rows, options.getKey, write);
1123
+ commit();
1124
+ }
1125
+ if (replace) await offline?.replace(rows);
1126
+ else await offline?.put(rows);
1127
+ };
1128
+ const reloadSubsets = async () => {
1129
+ for (const [id, query] of subsetQueries) {
1130
+ const result = await client.read(query);
1131
+ const removedKeys = subsets.replace(id, result.rows, query.filters ?? [], query.predicate);
1132
+ begin();
1133
+ writeRows(collection, result.rows, options.getKey, write);
1134
+ for (const key of removedKeys) write({
1135
+ type: "delete",
1136
+ key
1137
+ });
1138
+ commit();
1139
+ await offline?.put(result.rows);
1140
+ await offline?.delete(removedKeys);
1141
+ }
1142
+ };
1143
+ let markedReady = false;
1144
+ const markCollectionReady = () => {
1145
+ if (!markedReady) markReady();
1146
+ markedReady = true;
1147
+ };
1148
+ const session = new SyncSession({
1149
+ client,
1150
+ collection: options.collection,
1151
+ initialize: () => options.syncMode === "on-demand" ? Promise.resolve() : loadEager(offline !== void 0),
1152
+ resync: () => options.syncMode === "on-demand" ? reloadSubsets() : loadEager(true),
1153
+ apply: (broadcast) => {
1154
+ const changes = applyCollectionBroadcast({
1155
+ broadcast,
1156
+ collectionName: options.collection,
1157
+ clientId: client.clientId,
1158
+ ignoreOwnUpdates: options.ignoreOwnUpdates ?? false,
1159
+ syncMode: options.syncMode === "on-demand" ? "on-demand" : "eager",
1160
+ collection,
1161
+ subsets,
1162
+ begin,
1163
+ write,
1164
+ commit,
1165
+ deleteKeys
1166
+ });
1167
+ persistChanges(offline, changes, collection, options.getKey);
1168
+ },
1169
+ ...offline ? {
1170
+ hydrate: async () => {
1171
+ const rows = await offline.load();
1172
+ if (rows.length === 0) return;
1173
+ begin();
1174
+ writeRows(collection, rows, options.getKey, write);
1175
+ commit();
1176
+ },
1177
+ hydrated: markCollectionReady
1178
+ } : {},
1179
+ synchronized: markCollectionReady
725
1180
  });
1181
+ session.start();
726
1182
  const readSubset = (subsetOptions) => {
727
1183
  const id = subsetId(subsetOptions);
728
1184
  cancelRetention(id);
729
1185
  if (subsets.retain(id).loaded) return true;
730
1186
  return (async () => {
731
1187
  const query = readQueryForSubset(options.collection, options.read === false ? void 0 : options.read, subsetOptions);
732
- await subscription.ready;
1188
+ await session.start();
733
1189
  const result = await client.read(query);
1190
+ subsetQueries.set(id, query);
734
1191
  const removedKeys = subsets.replace(id, result.rows, query.filters ?? [], query.predicate);
735
1192
  begin();
736
1193
  writeRows(collection, result.rows, options.getKey, write);
@@ -744,72 +1201,55 @@ function collectionOptions(options) {
744
1201
  throw error;
745
1202
  });
746
1203
  };
747
- if (options.syncMode === "on-demand") {
748
- markReady();
749
- return {
750
- loadSubset: readSubset,
751
- unloadSubset: (subsetOptions) => {
752
- const id = subsetId(subsetOptions);
753
- const removedKeys = subsets.release(id, subsetGcTime !== 0);
754
- deleteKeys(removedKeys);
755
- if (removedKeys.length === 0) scheduleRetention(id);
756
- },
757
- cleanup: () => {
758
- retentionTimers.forEach((timer) => {
759
- clearTimeout(timer);
760
- });
761
- retentionTimers.clear();
762
- subsets.clear();
763
- if (activeSubsets === subsets) activeSubsets = void 0;
764
- subscription.unsubscribe();
765
- lease?.release();
766
- }
767
- };
768
- }
769
- const initialQuery = initialReadQuery(options.collection, options.read);
770
- if (!initialQuery) subscription.ready.then(() => {
771
- markReady();
772
- });
773
- else subscription.ready.then(() => client.read(initialQuery)).then((result) => {
774
- if (result.rows.length > 0) {
775
- begin();
776
- writeRows(collection, result.rows, options.getKey, write);
777
- commit();
1204
+ if (options.syncMode === "on-demand") return {
1205
+ loadSubset: readSubset,
1206
+ unloadSubset: (subsetOptions) => {
1207
+ const id = subsetId(subsetOptions);
1208
+ const removedKeys = subsets.release(id, subsetGcTime !== 0);
1209
+ deleteKeys(removedKeys);
1210
+ if (removedKeys.length === 0) scheduleRetention(id);
1211
+ else subsetQueries.delete(id);
1212
+ },
1213
+ cleanup: () => {
1214
+ retentionTimers.forEach((timer) => {
1215
+ clearTimeout(timer);
1216
+ });
1217
+ retentionTimers.clear();
1218
+ subsetQueries.clear();
1219
+ subsets.clear();
1220
+ if (activeSubsets === subsets) activeSubsets = void 0;
1221
+ session.stop();
1222
+ lease?.[Symbol.dispose]();
778
1223
  }
779
- markReady();
780
- });
1224
+ };
781
1225
  return () => {
782
1226
  if (activeSubsets === subsets) activeSubsets = void 0;
783
- subscription.unsubscribe();
784
- lease?.release();
1227
+ session.stop();
1228
+ lease?.[Symbol.dispose]();
785
1229
  };
786
1230
  },
787
1231
  rowUpdateMode: "partial"
788
1232
  },
789
1233
  onInsert: async ({ transaction }) => {
790
1234
  trackLocalTransaction(activeSubsets, options.syncMode, transaction);
791
- return client.push(createBatch(options.collection, client.clientId, transaction));
1235
+ const result = await client.push(createBatch(options.collection, client.clientId, transaction));
1236
+ persistTransaction(offline, transaction);
1237
+ return result;
792
1238
  },
793
1239
  onUpdate: async ({ transaction }) => {
794
1240
  trackLocalTransaction(activeSubsets, options.syncMode, transaction);
795
- return client.push(createBatch(options.collection, client.clientId, transaction));
1241
+ const result = await client.push(createBatch(options.collection, client.clientId, transaction));
1242
+ persistTransaction(offline, transaction);
1243
+ return result;
796
1244
  },
797
1245
  onDelete: async ({ transaction }) => {
798
1246
  trackLocalTransaction(activeSubsets, options.syncMode, transaction);
799
- return client.push(createBatch(options.collection, client.clientId, transaction));
1247
+ const result = await client.push(createBatch(options.collection, client.clientId, transaction));
1248
+ persistTransaction(offline, transaction);
1249
+ return result;
800
1250
  }
801
1251
  };
802
1252
  }
803
- function trackLocalTransaction(subsets, syncMode, transaction) {
804
- if (syncMode !== "on-demand" || !subsets) return;
805
- for (const mutation of transaction.mutations) {
806
- if (mutation.type === "delete") {
807
- subsets.deleteKey(mutation.key);
808
- continue;
809
- }
810
- subsets.reconcileRow(mutation.modified);
811
- }
812
- }
813
1253
  //#endregion
814
1254
  //#region src/collection-path.ts
815
1255
  function collectionScope(path, params) {
@@ -851,12 +1291,53 @@ function defaultCollectionApiPath(path, name) {
851
1291
  const prefix = collectionPathSegments(path).filter((segment) => !isCollectionPathParam(segment)).join(".");
852
1292
  return prefix ? `${prefix}.${name}` : name;
853
1293
  }
1294
+ var CollectionCache = class extends Map {
1295
+ maxSize;
1296
+ constructor(maxSize) {
1297
+ super();
1298
+ this.maxSize = normalizeMaxSize(maxSize);
1299
+ }
1300
+ touch(key, entry) {
1301
+ this.delete(key);
1302
+ this.set(key, entry);
1303
+ }
1304
+ evictInactive(protectedKey) {
1305
+ while (this.size > this.maxSize) {
1306
+ const candidate = [...this.entries()].find(([key, entry]) => key !== protectedKey && entry.collection.subscriberCount === 0 && entry.collection.status !== "loading");
1307
+ if (!candidate) return;
1308
+ this.delete(candidate[0]);
1309
+ cleanupCollection(candidate[1].collection).catch(() => {});
1310
+ }
1311
+ }
1312
+ async dispose() {
1313
+ const entries = [...this.values()];
1314
+ this.clear();
1315
+ await Promise.all(entries.map(({ collection }) => cleanupCollection(collection)));
1316
+ }
1317
+ };
1318
+ async function cleanupCollection(collection) {
1319
+ if (collection.status === "loading") try {
1320
+ await collection.preload();
1321
+ } catch {}
1322
+ if (collection.status !== "cleaned-up") await collection.cleanup();
1323
+ }
1324
+ function normalizeMaxSize(maxSize) {
1325
+ if (maxSize === void 0) return 32;
1326
+ if (!Number.isInteger(maxSize) || maxSize < 1) throw new Error("maxCachedCollections must be a positive integer");
1327
+ return maxSize;
1328
+ }
1329
+ //#endregion
1330
+ //#region src/collection-type-config.ts
1331
+ function collectionConfig(options) {
1332
+ const { api, children, id, maxCachedCollections, name, parentParam, path, ...config } = options;
1333
+ return config;
1334
+ }
854
1335
  //#endregion
855
1336
  //#region src/collection-type.ts
856
1337
  function createCollectionTypeFromOptions(options) {
857
1338
  const connection = connectionFrom(options);
858
1339
  if (!connection) throw new Error("CollectionTypes.builder requires either client or url");
859
- return new CollectionTypeManager(options, connection, {}, /* @__PURE__ */ new Map()).api();
1340
+ return new CollectionTypeManager(options, connection, {}, new CollectionCache(options.maxCachedCollections)).api();
860
1341
  }
861
1342
  var CollectionTypeManager = class CollectionTypeManager {
862
1343
  options;
@@ -873,6 +1354,7 @@ var CollectionTypeManager = class CollectionTypeManager {
873
1354
  return withChildren(withApiMethods({
874
1355
  all: (params = {}) => this.all(params),
875
1356
  delete: (...args) => this.all({}).delete(...args),
1357
+ dispose: () => this.dispose(),
876
1358
  insert: (...args) => this.all({}).insert(...args),
877
1359
  with: (params) => this.with(params),
878
1360
  withId: (id, childParam) => this.withId(id, childParam),
@@ -890,6 +1372,8 @@ var CollectionTypeManager = class CollectionTypeManager {
890
1372
  if (existing) {
891
1373
  existing.usage.accessCount += 1;
892
1374
  existing.usage.lastAccessedAt = Date.now();
1375
+ this.cache.touch(key, existing);
1376
+ this.cache.evictInactive(key);
893
1377
  return existing.collection;
894
1378
  }
895
1379
  const id = collectionId(this.options.id, this.options.path, scope);
@@ -911,6 +1395,7 @@ var CollectionTypeManager = class CollectionTypeManager {
911
1395
  collection,
912
1396
  usage
913
1397
  });
1398
+ this.cache.evictInactive(key);
914
1399
  return collection;
915
1400
  }
916
1401
  with(params) {
@@ -930,6 +1415,9 @@ var CollectionTypeManager = class CollectionTypeManager {
930
1415
  usage() {
931
1416
  return [...this.cache.values()].map((entry) => ({ ...entry.usage }));
932
1417
  }
1418
+ async dispose() {
1419
+ await this.cache.dispose();
1420
+ }
933
1421
  apiMethods() {
934
1422
  return Object.fromEntries(Object.entries(this.options.api ?? {}).map(([name, method]) => [name, (...args) => this.callApi(name, method, args[0])]));
935
1423
  }
@@ -949,7 +1437,7 @@ var CollectionTypeManager = class CollectionTypeManager {
949
1437
  try {
950
1438
  return await client.call(path, input);
951
1439
  } finally {
952
- lease?.release();
1440
+ lease?.[Symbol.dispose]();
953
1441
  }
954
1442
  }
955
1443
  childManagers(params) {
@@ -964,10 +1452,6 @@ var CollectionTypeManager = class CollectionTypeManager {
964
1452
  } : this.params;
965
1453
  }
966
1454
  };
967
- function collectionConfig(options) {
968
- const { api, children, id, name, parentParam, path, ...config } = options;
969
- return config;
970
- }
971
1455
  function withChildren(target, children) {
972
1456
  return Object.assign(target, children);
973
1457
  }
@@ -1038,6 +1522,24 @@ var ClientCollectionBuilder = class ClientCollectionBuilder {
1038
1522
  syncMode
1039
1523
  });
1040
1524
  }
1525
+ maxSyncRows(maxSyncRows) {
1526
+ return new ClientCollectionBuilder({
1527
+ ...this.override,
1528
+ maxSyncRows
1529
+ });
1530
+ }
1531
+ offline(options = true) {
1532
+ return new ClientCollectionBuilder({
1533
+ ...this.override,
1534
+ offline: options
1535
+ });
1536
+ }
1537
+ maxCachedCollections(maxCachedCollections) {
1538
+ return new ClientCollectionBuilder({
1539
+ ...this.override,
1540
+ maxCachedCollections
1541
+ });
1542
+ }
1041
1543
  index(autoIndex, defaultIndexType) {
1042
1544
  return new ClientCollectionBuilder({
1043
1545
  ...this.override,
@@ -1067,140 +1569,6 @@ function collectionOptionsFromDefinition(definition, dotPath, overrides) {
1067
1569
  };
1068
1570
  }
1069
1571
  //#endregion
1070
- //#region src/collection-name.ts
1071
- function rootPathForName(name) {
1072
- return `/${encodeURIComponent(name)}/`;
1073
- }
1074
- function childPathForName(parentPath, parentParam, name) {
1075
- return `${parentPath.replace(/\/+$/g, "")}/{${parentParam}}/${encodeURIComponent(name)}/`;
1076
- }
1077
- function parentIdParamForName(name) {
1078
- return `${name}Id`;
1079
- }
1080
- //#endregion
1081
- //#region src/immutable.ts
1082
- function cloneImmutable(value) {
1083
- if (Array.isArray(value)) return Object.freeze(value.map(cloneImmutable));
1084
- if (!isPlainObject(value)) return value;
1085
- return Object.freeze(Object.fromEntries(Object.entries(value).map(([nestedKey, nestedValue]) => [nestedKey, cloneImmutable(nestedValue)])));
1086
- }
1087
- function isPlainObject(value) {
1088
- if (typeof value !== "object" || value === null) return false;
1089
- const prototype = Object.getPrototypeOf(value);
1090
- return prototype === Object.prototype || prototype === null;
1091
- }
1092
- //#endregion
1093
- //#region src/collection-type-builder-state.ts
1094
- function freezeBuilderState(state) {
1095
- return Object.freeze(Object.fromEntries(Object.entries(state).filter(([, value]) => value !== void 0).map(([key, value]) => [key, copyBuilderStateValue(key, value)])));
1096
- }
1097
- function copyBuilderStateValue(key, value) {
1098
- if (key === "api" && isApiMap(value)) return freezeApiMap(value);
1099
- if (key === "url" && value instanceof URL) return value.toString();
1100
- return cloneImmutable(value);
1101
- }
1102
- function freezeApiMap(api) {
1103
- return Object.freeze(Object.fromEntries(Object.entries(api).map(([name, method]) => [name, freezeApiMethod(method)])));
1104
- }
1105
- function freezeApiMethod(method) {
1106
- return Object.freeze({ ...method });
1107
- }
1108
- function isApiMap(value) {
1109
- return typeof value === "object" && value !== null;
1110
- }
1111
- //#endregion
1112
- //#region src/collection-type-builder.ts
1113
- function collectionTypeBuilder() {
1114
- return new CollectionTypeBuilder({});
1115
- }
1116
- const CollectionTypes = {
1117
- builder: collectionTypeBuilder,
1118
- from: collectionTypesFrom
1119
- };
1120
- var CollectionTypeBuilder = class CollectionTypeBuilder {
1121
- state;
1122
- constructor(state) {
1123
- this.state = freezeBuilderState(state);
1124
- }
1125
- client(client) {
1126
- return this.next({
1127
- client,
1128
- url: void 0,
1129
- clientId: void 0
1130
- });
1131
- }
1132
- url(url, options = {}) {
1133
- return this.next({
1134
- client: void 0,
1135
- url,
1136
- ...options.clientId ? { clientId: options.clientId } : {}
1137
- });
1138
- }
1139
- name(name) {
1140
- return this.next({
1141
- name,
1142
- path: rootPathForName(name)
1143
- });
1144
- }
1145
- schema(schema) {
1146
- return this.next({ schema });
1147
- }
1148
- sync(syncMode) {
1149
- return this.next({ syncMode });
1150
- }
1151
- index(autoIndex, defaultIndexType) {
1152
- return this.next({
1153
- autoIndex,
1154
- ...defaultIndexType ? { defaultIndexType } : {}
1155
- });
1156
- }
1157
- child(name, configure) {
1158
- const child = configure(this.childBuilder(name));
1159
- const children = typeof this.state.children === "object" && this.state.children !== null ? this.state.children : {};
1160
- return this.next({ children: {
1161
- ...children,
1162
- [name]: child.state
1163
- } });
1164
- }
1165
- key(getKey) {
1166
- return this.next({ getKey });
1167
- }
1168
- api(name, method) {
1169
- const entry = typeof method === "function" ? localApiMethod(method) : method ?? {};
1170
- return this.next({ api: {
1171
- ...this.state.api,
1172
- [name]: entry
1173
- } });
1174
- }
1175
- build() {
1176
- if (!this.state.name || !this.state.path) throw new Error("CollectionTypes.builder requires a name");
1177
- if (!this.state.getKey) throw new Error("CollectionTypes.builder requires a key function");
1178
- return createCollectionTypeFromOptions(this.state);
1179
- }
1180
- next(updates) {
1181
- return new CollectionTypeBuilder({
1182
- ...this.state,
1183
- ...updates
1184
- });
1185
- }
1186
- childBuilder(name) {
1187
- if (!this.state.name || !this.state.path) throw new Error("CollectionTypes.builder.child requires a parent name");
1188
- const parentParam = parentIdParamForName(this.state.name);
1189
- return new CollectionTypeBuilder({
1190
- name,
1191
- parentParam,
1192
- path: childPathForName(this.state.path, parentParam, name)
1193
- });
1194
- }
1195
- };
1196
- function localApiMethod(handler) {
1197
- return Object.freeze({ handler: ({ input, collection, params, scope }) => handler(input, {
1198
- collection,
1199
- params,
1200
- scope
1201
- }) });
1202
- }
1203
- //#endregion
1204
- export { CollectionTypeBuilder, CollectionTypes, DefinitionCollectionTypesBuilder, collectionOptions, createBatch, createClient, toChangeMessage };
1572
+ export { DEFAULT_MAX_SYNC_ROWS, collectionOptions, collectionTypesFrom, createBatch, createClient, toChangeMessage };
1205
1573
 
1206
1574
  //# sourceMappingURL=index.mjs.map