@lsync/client 0.0.2 → 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,7 +1,5 @@
1
+ import { ProtocolError, parseServerMessage, sendClientMessage } from "@lsync/transport";
1
2
  import { createCollection } from "@tanstack/db";
2
- import { createTRPCProxyClient } from "@trpc/client";
3
- import { parseServerMessage, sendClientRpcRequest } from "@lsync/transport";
4
- import { observable } from "@trpc/server/observable";
5
3
  //#region src/batch.ts
6
4
  function createBatch(collection, clientId, transaction) {
7
5
  return { updates: transaction.mutations.map((mutation) => toUpdate(collection, clientId, mutation)) };
@@ -44,42 +42,14 @@ function toChangeMessage(update, existing) {
44
42
  };
45
43
  }
46
44
  //#endregion
47
- //#region src/collection-path.ts
48
- function collectionScope$1(path, params) {
49
- return `/${collectionPathSegments(path).map((segment) => segmentValue(segment, params)).map(normalizeSegment$1).join("/")}/`;
50
- }
51
- function firstNewPathParam(childPath, parentPath) {
52
- if (!childPath) return void 0;
53
- const parentParams = new Set(pathParams(parentPath));
54
- return pathParams(childPath).find((param) => !parentParams.has(param));
55
- }
56
- function collectionPathSegments(path) {
57
- return path.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
58
- }
59
- function collectionPathParamName(segment) {
60
- return segment.match(/^\{([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1] ?? segment.match(/^:([A-Za-z_][A-Za-z0-9_]*)$/)?.[1];
61
- }
62
- function isCollectionPathParam(segment) {
63
- return collectionPathParamName(segment) !== void 0;
64
- }
65
- function segmentValue(segment, params) {
66
- const param = collectionPathParamName(segment);
67
- if (!param) return segment;
68
- const value = params[param];
69
- if (value === void 0) throw new Error(`Missing collection path parameter: ${param}`);
70
- return String(value);
71
- }
72
- function pathParams(path) {
73
- return collectionPathSegments(path).flatMap((segment) => {
74
- const param = collectionPathParamName(segment);
75
- return param ? [param] : [];
76
- });
77
- }
78
- function normalizeSegment$1(segment) {
79
- return encodeURIComponent(decodeURIComponent(segment));
80
- }
81
- //#endregion
82
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
+ }
83
53
  function takePending(pending, id) {
84
54
  if (id === null || id === void 0) return;
85
55
  const key = String(id);
@@ -87,45 +57,10 @@ function takePending(pending, id) {
87
57
  pending.delete(key);
88
58
  return request;
89
59
  }
90
- function requestForOperation(id, op) {
91
- if (op.type === "mutation" && op.path === "push") return {
92
- id,
93
- method: "mutation",
94
- params: {
95
- path: "push",
96
- input: { json: op.input }
97
- }
98
- };
99
- if (op.type === "query" && op.path === "read") return {
100
- id,
101
- method: "query",
102
- params: {
103
- path: "read",
104
- input: { json: op.input }
105
- }
106
- };
107
- if (op.type === "query" && op.path === "changes") return {
108
- id,
109
- method: "query",
110
- params: {
111
- path: "changes",
112
- input: { json: op.input }
113
- }
114
- };
115
- if (op.type === "mutation" && op.path === "api") return {
116
- id,
117
- method: "mutation",
118
- params: {
119
- path: "api",
120
- input: { json: op.input }
121
- }
122
- };
123
- throw new Error(`Unsupported operation: ${op.type}.${op.path}`);
124
- }
125
- function collectionScope(collection) {
126
- return `/${collection.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean).map(normalizeSegment).join("/")}/`;
60
+ function collectionScope$1(collection) {
61
+ return `/${collection.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean).map(normalizeSegment$1).join("/")}/`;
127
62
  }
128
- function normalizeSegment(segment) {
63
+ function normalizeSegment$1(segment) {
129
64
  return encodeURIComponent(decodeURIComponent(segment));
130
65
  }
131
66
  //#endregion
@@ -137,13 +72,15 @@ var ClientSubscriptions = class {
137
72
  this.controls = controls;
138
73
  }
139
74
  subscribe(collection, listener) {
140
- const key = collectionScope(collection);
75
+ const key = collectionScope$1(collection);
141
76
  let entry = this.subscriptions.get(key);
142
77
  const shouldSubscribe = !entry;
143
78
  if (!entry) {
144
79
  entry = {
145
80
  collection: key,
81
+ disconnectListeners: /* @__PURE__ */ new Set(),
146
82
  listeners: /* @__PURE__ */ new Set(),
83
+ reconnectListeners: /* @__PURE__ */ new Set(),
147
84
  ready: Promise.resolve()
148
85
  };
149
86
  this.subscriptions.set(key, entry);
@@ -152,9 +89,20 @@ var ClientSubscriptions = class {
152
89
  if (shouldSubscribe) entry.ready = this.start(entry);
153
90
  return {
154
91
  ready: entry.ready,
92
+ onDisconnect: (next) => addLifecycleListener(entry.disconnectListeners, next),
93
+ onReconnect: (next) => addLifecycleListener(entry.reconnectListeners, next),
155
94
  unsubscribe: () => this.unsubscribe(key, listener)
156
95
  };
157
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
+ }
158
106
  async replay(ws) {
159
107
  await Promise.all([...this.subscriptions.values()].map((entry) => this.subscribeOnSocket(ws, entry)));
160
108
  }
@@ -162,20 +110,20 @@ var ClientSubscriptions = class {
162
110
  const updatesByCollection = /* @__PURE__ */ new Map();
163
111
  const invalidationsByCollection = /* @__PURE__ */ new Map();
164
112
  for (const update of broadcast.updates) {
165
- const collection = collectionScope(update.collection);
113
+ const collection = collectionScope$1(update.collection);
166
114
  const updates = updatesByCollection.get(collection) ?? [];
167
115
  updates.push(update);
168
116
  updatesByCollection.set(collection, updates);
169
117
  }
170
118
  for (const invalidation of broadcast.invalidations ?? []) {
171
- const collection = collectionScope(invalidation.collection);
119
+ const collection = collectionScope$1(invalidation.collection);
172
120
  const invalidations = invalidationsByCollection.get(collection) ?? [];
173
121
  invalidations.push(invalidation);
174
122
  invalidationsByCollection.set(collection, invalidations);
175
123
  }
176
124
  for (const entry of this.subscriptions.values()) {
177
- const updates = updatesByCollection.get(collectionScope(entry.collection));
178
- const invalidations = invalidationsByCollection.get(collectionScope(entry.collection));
125
+ const updates = updatesByCollection.get(collectionScope$1(entry.collection));
126
+ const invalidations = invalidationsByCollection.get(collectionScope$1(entry.collection));
179
127
  if ((!updates || updates.length === 0) && (!invalidations || invalidations.length === 0)) continue;
180
128
  const scopedBroadcast = {
181
129
  ...broadcast,
@@ -190,7 +138,7 @@ var ClientSubscriptions = class {
190
138
  return socket?.readyState === WebSocket.OPEN ? this.subscribeOnSocket(socket, entry) : this.controls.open().then(() => void 0);
191
139
  }
192
140
  async subscribeOnSocket(ws, entry) {
193
- if (this.subscriptions.get(collectionScope(entry.collection)) !== entry) return;
141
+ if (this.subscriptions.get(collectionScope$1(entry.collection)) !== entry) return;
194
142
  entry.collection = (await this.controls.send(ws, "subscribe", entry.collection)).collection;
195
143
  }
196
144
  unsubscribe(key, listener) {
@@ -200,164 +148,472 @@ var ClientSubscriptions = class {
200
148
  if (entry.listeners.size > 0) return;
201
149
  this.subscriptions.delete(key);
202
150
  const socket = this.controls.currentSocket();
203
- 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);
204
152
  }
205
153
  };
154
+ function addLifecycleListener(listeners, listener) {
155
+ listeners.add(listener);
156
+ return () => listeners.delete(listener);
157
+ }
206
158
  //#endregion
207
- //#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
208
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
209
237
  function createClient(options) {
210
238
  const clientId = options.clientId ?? crypto.randomUUID();
211
239
  const pending = /* @__PURE__ */ new Map();
240
+ const reconnect = reconnectPolicy(options.reconnect);
241
+ const connectionTimeoutMs = options.connectionTimeoutMs ?? 1e4;
242
+ const requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
212
243
  let nextId = 1;
213
244
  let socket;
214
245
  let connectPromise;
246
+ let reconnectTimer;
247
+ let closed = false;
248
+ let hasOpened = false;
215
249
  let subscriptions;
216
250
  const open = async () => {
251
+ if (closed) throw new ConnectionClosedError("Sync client is closed");
217
252
  if (socket?.readyState === WebSocket.OPEN) return socket;
218
253
  if (connectPromise) return connectPromise;
219
- 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) => {
220
266
  const url = new URL(options.url);
221
267
  url.searchParams.set("clientId", clientId);
222
268
  const ws = new WebSocket(url);
223
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);
224
275
  ws.addEventListener("open", () => {
276
+ opened = true;
277
+ clearTimeout(connectionTimer);
225
278
  socket = ws;
226
279
  subscriptions.replay(ws).then(() => {
227
- connectPromise = void 0;
280
+ if (hasOpened) subscriptions.reconnected();
281
+ hasOpened = true;
228
282
  resolve(ws);
229
283
  }).catch((error) => {
230
- connectPromise = void 0;
231
284
  reject(error);
285
+ ws.close();
232
286
  });
233
287
  });
234
288
  ws.addEventListener("message", (event) => {
235
289
  const message = parseServerMessage(event.data);
236
- if ("method" in message) {
237
- subscriptions.dispatch(message.params.input.json);
290
+ if (message.type === "updates") {
291
+ subscriptions.dispatch(message.payload);
238
292
  return;
239
293
  }
240
- if ("error" in message) {
241
- const request = takePending(pending, message.id);
242
- if (!request) return;
243
- request.reject(new Error(message.error.message));
294
+ if (message.type === "error") {
295
+ takePending(pending, message.id)?.reject(new ProtocolError(message.error));
244
296
  return;
245
297
  }
246
- const request = takePending(pending, message.id);
247
- if (!request) return;
248
- request.resolve(message.result.data.json);
298
+ takePending(pending, message.id)?.resolve(message.payload);
249
299
  });
250
300
  ws.addEventListener("close", () => {
251
- socket = void 0;
252
- 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"));
253
309
  });
254
310
  ws.addEventListener("error", () => {
311
+ clearTimeout(connectionTimer);
255
312
  reject(/* @__PURE__ */ new Error("Unable to open sync WebSocket"));
256
313
  });
257
314
  });
315
+ connectPromise = connect().finally(() => {
316
+ connectPromise = void 0;
317
+ });
258
318
  return connectPromise;
259
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
+ }
260
327
  const sendRequest = (ws, request) => {
261
328
  return new Promise((resolve, reject) => {
262
329
  const id = request.id;
263
- if (id === null || id === void 0) {
264
- reject(/* @__PURE__ */ new Error("RPC request requires an id"));
265
- return;
266
- }
330
+ const timeout = setTimeout(() => {
331
+ takePending(pending, id)?.reject(new RequestTimeoutError());
332
+ }, requestTimeoutMs);
267
333
  pending.set(String(id), {
268
- resolve: (value) => resolve(value),
269
- reject
334
+ resolve: (value) => {
335
+ clearTimeout(timeout);
336
+ resolve(value);
337
+ },
338
+ reject: (error) => {
339
+ clearTimeout(timeout);
340
+ reject(error);
341
+ }
270
342
  });
271
343
  try {
272
- sendClientRpcRequest(ws, request);
344
+ sendClientMessage(ws, request);
273
345
  } catch (error) {
274
- pending.delete(String(id));
275
- reject(error);
346
+ takePending(pending, id)?.reject(error instanceof Error ? error : new Error(String(error)));
276
347
  }
277
348
  });
278
349
  };
279
- const sendSubscriptionControl = async (ws, method, collection) => {
280
- const id = String(nextId++);
281
- return sendRequest(ws, {
282
- id,
283
- method,
284
- params: { input: { json: { collection } } }
285
- });
286
- };
350
+ const sendSubscriptionControl = async (ws, method, collection) => sendRequest(ws, {
351
+ version: 1,
352
+ id: String(nextId++),
353
+ type: method,
354
+ input: { collection }
355
+ });
287
356
  subscriptions = new ClientSubscriptions({
288
357
  currentSocket: () => socket,
289
358
  open,
290
359
  send: sendSubscriptionControl
291
360
  });
292
- const link = () => {
293
- return ({ op }) => observable((observer) => {
294
- let cancelled = false;
295
- open().then((ws) => {
296
- if (cancelled) return;
297
- const id = String(nextId++);
298
- sendRequest(ws, requestForOperation(id, op)).then((value) => {
299
- observer.next({ result: { data: value } });
300
- observer.complete();
301
- }).catch((error) => observer.error(error));
302
- }).catch((error) => observer.error(error));
303
- return () => {
304
- cancelled = true;
305
- };
306
- });
307
- };
308
- 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
+ }));
309
367
  return {
310
368
  clientId,
311
- push: (batch) => trpc.push.mutate(batch),
312
- read: (query) => trpc.read.query(query),
313
- changes: (query) => trpc.changes.query(query),
314
- 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) => {
315
397
  const [input] = args;
316
398
  const call = input === void 0 ? { path } : {
317
399
  path,
318
400
  input
319
401
  };
320
- 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
+ });
321
409
  },
322
410
  subscribe: (collection, listener) => subscriptions.subscribe(collection, listener),
323
411
  close() {
412
+ closed = true;
413
+ if (reconnectTimer) clearTimeout(reconnectTimer);
414
+ rejectPending(pending, new ConnectionClosedError("Sync client was closed"));
324
415
  socket?.close();
325
416
  socket = void 0;
326
417
  }
327
418
  };
328
419
  }
329
- function acquireSharedClient(options) {
330
- const key = sharedClientKey(options);
331
- let entry = sharedClients.get(key);
332
- if (!entry) {
333
- entry = {
334
- client: createClient({
335
- url: options.url,
336
- ...options.clientId ? { clientId: options.clientId } : {}
337
- }),
338
- refs: 0
339
- };
340
- 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;
341
428
  }
342
- entry.refs += 1;
343
- let released = false;
344
- return {
345
- client: entry.client,
346
- release() {
347
- if (released) return;
348
- released = true;
349
- entry.refs -= 1;
350
- if (entry.refs === 0 && sharedClients.get(key) === entry) {
351
- sharedClients.delete(key);
352
- 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;
478
+ }
479
+ if (had) {
480
+ subset.keys.delete(key);
481
+ this.releaseKey(key);
353
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;
354
535
  }
355
- };
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
+ }
356
549
  }
357
- function sharedClientKey(options) {
358
- const url = new URL(options.url);
359
- url.searchParams.delete("clientId");
360
- 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;
361
617
  }
362
618
  //#endregion
363
619
  //#region src/read-query.ts
@@ -507,170 +763,292 @@ function isValExpression(value) {
507
763
  return isExpression(value) && value.type === "val";
508
764
  }
509
765
  //#endregion
510
- //#region src/subsets.ts
511
- var SubsetTracker = class {
512
- getKey;
513
- subsets = /* @__PURE__ */ new Map();
514
- keyRefs = /* @__PURE__ */ new Map();
515
- constructor(getKey) {
516
- this.getKey = getKey;
517
- }
518
- replace(subsetId, rows, filters, predicate) {
519
- const previous = this.subsets.get(subsetId);
520
- const refs = previous?.refs ?? 0;
521
- if (previous) this.releaseKeys(previous.keys);
522
- const keys = new Set(rows.map((row) => this.getKey(row)));
523
- this.addKeys(keys);
524
- this.subsets.set(subsetId, {
525
- filters,
526
- keys,
527
- loaded: true,
528
- ...predicate ? { predicate } : {},
529
- 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
530
782
  });
531
- const removed = [];
532
- previous?.keys.forEach((key) => {
533
- 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);
534
810
  });
535
- 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;
536
815
  }
537
- retain(subsetId) {
538
- const subset = this.subsets.get(subsetId);
539
- if (subset) {
540
- subset.refs += 1;
541
- 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?.();
542
828
  }
543
- this.subsets.set(subsetId, {
544
- filters: [],
545
- keys: /* @__PURE__ */ new Set(),
546
- loaded: false,
547
- refs: 1
548
- });
549
- 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();
550
837
  }
551
- trackRow(row) {
552
- const result = this.reconcileRow(row);
553
- return result.before || result.after;
838
+ receive(broadcast) {
839
+ if (this._state !== "synchronized") {
840
+ this.buffered.push(broadcast);
841
+ return;
842
+ }
843
+ this.applyBroadcast(broadcast);
554
844
  }
555
- reconcileRow(row) {
556
- const key = this.getKey(row);
557
- let before = false;
558
- this.subsets.forEach((subset) => {
559
- const had = subset.keys.has(key);
560
- before ||= had;
561
- if (matchesSubset(row, subset)) {
562
- if (!had) {
563
- subset.keys.add(key);
564
- this.keyRefs.set(key, (this.keyRefs.get(key) ?? 0) + 1);
565
- }
566
- return;
567
- }
568
- if (had) {
569
- subset.keys.delete(key);
570
- this.releaseKey(key);
845
+ disconnected() {
846
+ if (this._state === "stopped") return;
847
+ this._state = "disconnected";
848
+ }
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;
571
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
572
887
  });
573
- return {
574
- before,
575
- after: this.keyRefs.has(key)
576
- };
888
+ this.cursor = Math.max(this.cursor, broadcast.watermark);
577
889
  }
578
- deleteKey(key) {
579
- if (!this.keyRefs.has(key)) return false;
580
- this.subsets.forEach((subset) => {
581
- subset.keys.delete(key);
890
+ applyUpdates(updates, watermark) {
891
+ this.options.apply({
892
+ type: "updates",
893
+ shardId: "history",
894
+ updates,
895
+ watermark
582
896
  });
583
- this.keyRefs.delete(key);
584
- return true;
585
897
  }
586
- release(subsetId, retain = false) {
587
- const subset = this.subsets.get(subsetId);
588
- if (!subset) return [];
589
- subset.refs -= 1;
590
- if (subset.refs > 0) return [];
591
- subset.refs = 0;
592
- if (retain) return [];
593
- return this.expire(subsetId);
898
+ markSynchronized() {
899
+ this._state = "synchronized";
900
+ this.options.synchronized();
594
901
  }
595
- expire(subsetId) {
596
- const subset = this.subsets.get(subsetId);
597
- if (!subset || subset.refs > 0) return [];
598
- this.subsets.delete(subsetId);
599
- return this.releaseKeys(subset.keys);
902
+ isStopped() {
903
+ return this._state === "stopped";
600
904
  }
601
- clear() {
602
- const keys = [...this.keyRefs.keys()];
603
- this.subsets.clear();
604
- this.keyRefs.clear();
605
- 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);
606
931
  }
607
- addKeys(keys) {
608
- keys.forEach((key) => {
609
- 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);
610
943
  });
611
944
  }
612
- releaseKeys(keys) {
613
- const removed = [];
614
- keys.forEach((key) => {
615
- 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);
616
952
  });
617
- return removed;
618
953
  }
619
- releaseKey(key) {
620
- const refs = (this.keyRefs.get(key) ?? 0) - 1;
621
- if (refs <= 0) {
622
- this.keyRefs.delete(key);
623
- return true;
624
- }
625
- this.keyRefs.set(key, refs);
626
- 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
+ });
627
962
  }
628
- };
629
- function writeRows(collection, rows, getKey, write) {
630
- for (const row of rows) {
631
- const key = getKey(row);
632
- write({
633
- type: collection.has(key) ? "update" : "insert",
634
- key,
963
+ record(row) {
964
+ return {
965
+ collectionId: this.collectionId,
966
+ key: this.getKey(row),
635
967
  value: row
636
- });
968
+ };
969
+ }
970
+ enqueue(operation) {
971
+ const result = this.queue.then(operation);
972
+ this.queue = result.catch(() => void 0);
973
+ return result;
637
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
+ });
638
986
  }
639
- function matchesFilters(row, filters) {
640
- 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
+ });
641
1001
  }
642
- function matchesSubset(row, subset) {
643
- 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
+ });
644
1007
  }
645
- function matchesPredicate(row, predicate) {
646
- if (!predicate) return true;
647
- if (predicate.type === "comparison") return matchesFilter(row, predicate);
648
- if (predicate.type === "and") return predicate.predicates.every((child) => matchesPredicate(row, child));
649
- 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
+ });
650
1014
  }
651
- function matchesFilter(row, filter) {
652
- const value = valueAtPath(row, filter.field);
653
- switch (filter.op) {
654
- case "eq": return value === filter.value;
655
- case "ne": return value !== filter.value;
656
- case "gt": return compareValues(value, filter.value, (left, right) => left > right);
657
- case "gte": return compareValues(value, filter.value, (left, right) => left >= right);
658
- case "lt": return compareValues(value, filter.value, (left, right) => left < right);
659
- case "lte": return compareValues(value, filter.value, (left, right) => left <= right);
660
- 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);
661
1028
  }
662
- return false;
1029
+ offline.put(rows);
1030
+ offline.delete(deletedKeys);
663
1031
  }
664
- function compareValues(left, right, compare) {
665
- if (typeof left === "number" && typeof right === "number") return compare(left, right);
666
- if (typeof left === "string" && typeof right === "string") return compare(left, right);
667
- return false;
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);
668
1040
  }
669
- function valueAtPath(row, field) {
670
- return field.split(".").reduce((value, segment) => {
671
- if (typeof value !== "object" || value === null) return;
672
- return value[segment];
673
- }, 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
+ }
674
1052
  }
675
1053
  //#endregion
676
1054
  //#region src/collection.ts
@@ -678,6 +1056,9 @@ function collectionOptions(options) {
678
1056
  let lease;
679
1057
  let client = options.client;
680
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);
681
1062
  if (!client) {
682
1063
  if (!options.url) throw new Error("collectionOptions requires either client or url");
683
1064
  lease = acquireSharedClient({
@@ -696,11 +1077,12 @@ function collectionOptions(options) {
696
1077
  ...options.defaultIndexType !== void 0 ? { defaultIndexType: options.defaultIndexType } : {},
697
1078
  getKey: options.getKey,
698
1079
  sync: {
699
- sync: ({ begin, write, commit, markReady, collection }) => {
1080
+ sync: ({ begin, write, commit, markReady, truncate, collection }) => {
700
1081
  const subsets = new SubsetTracker(options.getKey);
701
1082
  activeSubsets = subsets;
702
1083
  const subsetGcTime = options.gcTime ?? 3e5;
703
1084
  const retentionTimers = /* @__PURE__ */ new Map();
1085
+ const subsetQueries = /* @__PURE__ */ new Map();
704
1086
  const deleteKeys = (keys) => {
705
1087
  if (keys.length === 0) return;
706
1088
  begin();
@@ -709,6 +1091,7 @@ function collectionOptions(options) {
709
1091
  key
710
1092
  });
711
1093
  commit();
1094
+ offline?.delete(keys);
712
1095
  };
713
1096
  const cancelRetention = (id) => {
714
1097
  const timer = retentionTimers.get(id);
@@ -725,47 +1108,86 @@ function collectionOptions(options) {
725
1108
  cancelRetention(id);
726
1109
  retentionTimers.set(id, setTimeout(() => {
727
1110
  retentionTimers.delete(id);
1111
+ subsetQueries.delete(id);
728
1112
  deleteKeys(subsets.expire(id));
729
1113
  }, subsetGcTime));
730
1114
  };
731
- const subscription = client.subscribe(options.collection, (broadcast) => {
732
- if ((broadcast.invalidations ?? []).length > 0 && options.syncMode === "on-demand") deleteKeys(subsets.clear());
733
- const changes = broadcast.updates.flatMap((update) => {
734
- if (update.collection !== options.collection) return [];
735
- const ownUpdate = update.clientId === client.clientId;
736
- if ((options.ignoreOwnUpdates ?? false) && ownUpdate) return [];
737
- if (options.syncMode !== "on-demand") return [toChangeMessage(update, collection.has(update.key))];
738
- const key = update.key;
739
- const exists = collection.has(key);
740
- if (update.type === "delete") {
741
- subsets.deleteKey(key);
742
- return exists ? [{
743
- type: "delete",
744
- key
745
- }] : [];
746
- }
747
- const current = collection.get(key);
748
- const row = update.type === "update" && current ? {
749
- ...current,
750
- ...update.value
751
- } : update.value;
752
- const tracked = subsets.reconcileRow(row);
753
- if (ownUpdate || exists || tracked.after) return [toChangeMessage(update, exists)];
754
- return [];
755
- });
756
- if (changes.length === 0) return;
757
- begin();
758
- for (const change of changes) write(change);
759
- 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
760
1180
  });
1181
+ session.start();
761
1182
  const readSubset = (subsetOptions) => {
762
1183
  const id = subsetId(subsetOptions);
763
1184
  cancelRetention(id);
764
1185
  if (subsets.retain(id).loaded) return true;
765
1186
  return (async () => {
766
1187
  const query = readQueryForSubset(options.collection, options.read === false ? void 0 : options.read, subsetOptions);
767
- await subscription.ready;
1188
+ await session.start();
768
1189
  const result = await client.read(query);
1190
+ subsetQueries.set(id, query);
769
1191
  const removedKeys = subsets.replace(id, result.rows, query.filters ?? [], query.predicate);
770
1192
  begin();
771
1193
  writeRows(collection, result.rows, options.getKey, write);
@@ -779,71 +1201,89 @@ function collectionOptions(options) {
779
1201
  throw error;
780
1202
  });
781
1203
  };
782
- if (options.syncMode === "on-demand") {
783
- markReady();
784
- return {
785
- loadSubset: readSubset,
786
- unloadSubset: (subsetOptions) => {
787
- const id = subsetId(subsetOptions);
788
- const removedKeys = subsets.release(id, subsetGcTime !== 0);
789
- deleteKeys(removedKeys);
790
- if (removedKeys.length === 0) scheduleRetention(id);
791
- },
792
- cleanup: () => {
793
- retentionTimers.forEach((timer) => {
794
- clearTimeout(timer);
795
- });
796
- retentionTimers.clear();
797
- subsets.clear();
798
- if (activeSubsets === subsets) activeSubsets = void 0;
799
- subscription.unsubscribe();
800
- lease?.release();
801
- }
802
- };
803
- }
804
- const initialQuery = initialReadQuery(options.collection, options.read);
805
- if (!initialQuery) subscription.ready.then(() => {
806
- markReady();
807
- });
808
- else subscription.ready.then(() => client.read(initialQuery)).then((result) => {
809
- if (result.rows.length > 0) {
810
- begin();
811
- writeRows(collection, result.rows, options.getKey, write);
812
- 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]();
813
1223
  }
814
- markReady();
815
- });
1224
+ };
816
1225
  return () => {
817
1226
  if (activeSubsets === subsets) activeSubsets = void 0;
818
- subscription.unsubscribe();
819
- lease?.release();
1227
+ session.stop();
1228
+ lease?.[Symbol.dispose]();
820
1229
  };
821
1230
  },
822
1231
  rowUpdateMode: "partial"
823
1232
  },
824
1233
  onInsert: async ({ transaction }) => {
825
1234
  trackLocalTransaction(activeSubsets, options.syncMode, transaction);
826
- 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;
827
1238
  },
828
1239
  onUpdate: async ({ transaction }) => {
829
1240
  trackLocalTransaction(activeSubsets, options.syncMode, transaction);
830
- 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;
831
1244
  },
832
1245
  onDelete: async ({ transaction }) => {
833
1246
  trackLocalTransaction(activeSubsets, options.syncMode, transaction);
834
- 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;
835
1250
  }
836
1251
  };
837
1252
  }
838
- function trackLocalTransaction(subsets, syncMode, transaction) {
839
- if (syncMode !== "on-demand" || !subsets) return;
840
- for (const mutation of transaction.mutations) {
841
- if (mutation.type === "delete") {
842
- subsets.deleteKey(mutation.key);
843
- continue;
844
- }
845
- subsets.reconcileRow(mutation.modified);
846
- }
1253
+ //#endregion
1254
+ //#region src/collection-path.ts
1255
+ function collectionScope(path, params) {
1256
+ return `/${collectionPathSegments(path).map((segment) => segmentValue(segment, params)).map(normalizeSegment).join("/")}/`;
1257
+ }
1258
+ function firstNewPathParam(childPath, parentPath) {
1259
+ if (!childPath) return void 0;
1260
+ const parentParams = new Set(pathParams(parentPath));
1261
+ return pathParams(childPath).find((param) => !parentParams.has(param));
1262
+ }
1263
+ function collectionPathSegments(path) {
1264
+ return path.trim().replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
1265
+ }
1266
+ function collectionPathParamName(segment) {
1267
+ return segment.match(/^\{([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1] ?? segment.match(/^:([A-Za-z_][A-Za-z0-9_]*)$/)?.[1];
1268
+ }
1269
+ function isCollectionPathParam(segment) {
1270
+ return collectionPathParamName(segment) !== void 0;
1271
+ }
1272
+ function segmentValue(segment, params) {
1273
+ const param = collectionPathParamName(segment);
1274
+ if (!param) return segment;
1275
+ const value = params[param];
1276
+ if (value === void 0) throw new Error(`Missing collection path parameter: ${param}`);
1277
+ return String(value);
1278
+ }
1279
+ function pathParams(path) {
1280
+ return collectionPathSegments(path).flatMap((segment) => {
1281
+ const param = collectionPathParamName(segment);
1282
+ return param ? [param] : [];
1283
+ });
1284
+ }
1285
+ function normalizeSegment(segment) {
1286
+ return encodeURIComponent(decodeURIComponent(segment));
847
1287
  }
848
1288
  //#endregion
849
1289
  //#region src/collection-api.ts
@@ -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),
@@ -881,7 +1363,7 @@ var CollectionTypeManager = class CollectionTypeManager {
881
1363
  }, this.apiMethods()), this.childManagers(this.params));
882
1364
  }
883
1365
  all(params) {
884
- const scope = collectionScope$1(this.options.path, {
1366
+ const scope = collectionScope(this.options.path, {
885
1367
  ...this.params,
886
1368
  ...params
887
1369
  });
@@ -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,12 +1415,15 @@ 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
  }
936
1424
  async callApi(name, method, input) {
937
1425
  if (method.handler) {
938
- const scope = collectionScope$1(this.options.path, this.params);
1426
+ const scope = collectionScope(this.options.path, this.params);
939
1427
  return method.handler({
940
1428
  input,
941
1429
  collection: this.all({}),
@@ -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,6 +1569,6 @@ function collectionOptionsFromDefinition(definition, dotPath, overrides) {
1067
1569
  };
1068
1570
  }
1069
1571
  //#endregion
1070
- export { collectionTypesFrom, createBatch, toChangeMessage };
1572
+ export { DEFAULT_MAX_SYNC_ROWS, collectionOptions, collectionTypesFrom, createBatch, createClient, toChangeMessage };
1071
1573
 
1072
1574
  //# sourceMappingURL=index.mjs.map