@markwasfy/loko 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,840 @@
1
+ // src/background.ts
2
+ var BackgroundSync = class {
3
+ constructor(opts) {
4
+ this.opts = opts;
5
+ this.onlineHandler = () => this.opts.onOnline();
6
+ this.offlineHandler = () => this.opts.onOffline();
7
+ }
8
+ start() {
9
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
10
+ window.addEventListener("online", this.onlineHandler);
11
+ window.addEventListener("offline", this.offlineHandler);
12
+ }
13
+ if (this.opts.intervalMs > 0) {
14
+ this.timer = setInterval(() => {
15
+ if (this.opts.shouldTick()) this.opts.tick();
16
+ }, this.opts.intervalMs);
17
+ this.timer.unref?.();
18
+ }
19
+ }
20
+ /** Current network status, defaulting to online where unknown. */
21
+ isOnline() {
22
+ const nav = globalThis.navigator;
23
+ return nav ? nav.onLine : true;
24
+ }
25
+ stop() {
26
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
27
+ window.removeEventListener("online", this.onlineHandler);
28
+ window.removeEventListener("offline", this.offlineHandler);
29
+ }
30
+ if (this.timer !== void 0) {
31
+ clearInterval(this.timer);
32
+ this.timer = void 0;
33
+ }
34
+ }
35
+ };
36
+ async function registerBackgroundSync(tag = "loko-sync") {
37
+ try {
38
+ const nav = globalThis.navigator;
39
+ if (!nav?.serviceWorker) return false;
40
+ const reg = await nav.serviceWorker.ready;
41
+ if (!reg.sync) return false;
42
+ await reg.sync.register(tag);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ // src/collection.ts
50
+ var Collection = class {
51
+ constructor(name, ctx) {
52
+ this.subscribers = /* @__PURE__ */ new Set();
53
+ this.oneSubscribers = /* @__PURE__ */ new Map();
54
+ this.refreshScheduled = false;
55
+ this.name = name;
56
+ this.ctx = ctx;
57
+ }
58
+ idOf(record) {
59
+ const id = record[this.ctx.primaryKey];
60
+ if (id === void 0 || id === null || id === "") {
61
+ throw new Error(
62
+ `Collection "${this.name}": record is missing primary key "${this.ctx.primaryKey}".`
63
+ );
64
+ }
65
+ return String(id);
66
+ }
67
+ /** Read a single live record by id (tombstones return `undefined`). */
68
+ async get(id) {
69
+ await this.ctx.ready;
70
+ const doc = await this.ctx.storage.get(this.name, id);
71
+ if (!doc || doc.deletedAt !== null) return void 0;
72
+ return doc.data;
73
+ }
74
+ /** All live records in the collection (tombstones excluded). */
75
+ async all() {
76
+ await this.ctx.ready;
77
+ const docs = await this.ctx.storage.getAll(this.name);
78
+ return docs.filter((d) => d.deletedAt === null).map((d) => d.data);
79
+ }
80
+ /** Insert or update a record. Returns the stored record. */
81
+ async put(record) {
82
+ await this.ctx.ready;
83
+ const id = this.idOf(record);
84
+ const existing = await this.ctx.storage.get(this.name, id);
85
+ const doc = this.nextDoc(id, record, false, existing);
86
+ await this.ctx.storage.put(doc);
87
+ this.ctx.onLocalWrite(doc);
88
+ return record;
89
+ }
90
+ /** Insert or update many records atomically (one transaction). */
91
+ async bulkPut(records) {
92
+ if (records.length === 0) return;
93
+ await this.ctx.ready;
94
+ const docs = await Promise.all(
95
+ records.map(async (record) => {
96
+ const id = this.idOf(record);
97
+ const existing = await this.ctx.storage.get(this.name, id);
98
+ return this.nextDoc(id, record, false, existing);
99
+ })
100
+ );
101
+ await this.ctx.storage.transact((tx) => {
102
+ for (const doc of docs) tx.put(doc);
103
+ });
104
+ for (const doc of docs) this.ctx.onLocalWrite(doc);
105
+ }
106
+ /** Soft-delete a record (writes a tombstone so the deletion syncs). */
107
+ async delete(id) {
108
+ await this.ctx.ready;
109
+ const existing = await this.ctx.storage.get(this.name, id);
110
+ if (!existing || existing.deletedAt !== null) return;
111
+ const doc = {
112
+ ...existing,
113
+ data: existing.data,
114
+ deletedAt: Date.now(),
115
+ updatedAt: Date.now(),
116
+ localRev: existing.localRev + 1
117
+ };
118
+ await this.ctx.storage.put(doc);
119
+ this.ctx.onLocalWrite(doc);
120
+ }
121
+ /** Build the next StoredDoc for a local write, bumping `localRev`. */
122
+ nextDoc(id, record, deleted, existing) {
123
+ const now = Date.now();
124
+ return {
125
+ id,
126
+ collection: this.name,
127
+ data: record,
128
+ updatedAt: now,
129
+ deletedAt: deleted ? now : null,
130
+ localRev: (existing?.localRev ?? 0) + 1,
131
+ lastPushedLocalRev: existing?.lastPushedLocalRev ?? 0,
132
+ remoteVersion: existing?.remoteVersion ?? 0,
133
+ syncedAt: existing?.syncedAt ?? null
134
+ };
135
+ }
136
+ /**
137
+ * Subscribe to the whole collection. The callback fires immediately with the
138
+ * current records, then on every change (local, sync, or other tab).
139
+ * Returns an unsubscribe function.
140
+ */
141
+ subscribe(cb) {
142
+ this.subscribers.add(cb);
143
+ void this.all().then(cb);
144
+ return () => {
145
+ this.subscribers.delete(cb);
146
+ };
147
+ }
148
+ /** Subscribe to a single record by id. */
149
+ subscribeOne(id, cb) {
150
+ let set = this.oneSubscribers.get(id);
151
+ if (!set) {
152
+ set = /* @__PURE__ */ new Set();
153
+ this.oneSubscribers.set(id, set);
154
+ }
155
+ set.add(cb);
156
+ void this.get(id).then(cb);
157
+ return () => {
158
+ const s = this.oneSubscribers.get(id);
159
+ s?.delete(cb);
160
+ if (s && s.size === 0) this.oneSubscribers.delete(id);
161
+ };
162
+ }
163
+ /**
164
+ * Notify subscribers that this collection changed. Coalesces bursts (e.g.
165
+ * bulkPut, a sync batch) into a single refresh per microtask. Called by the
166
+ * Sync engine for local writes, pulled changes, and cross-tab updates.
167
+ */
168
+ notify() {
169
+ if (this.refreshScheduled) return;
170
+ this.refreshScheduled = true;
171
+ queueMicrotask(() => {
172
+ this.refreshScheduled = false;
173
+ if (this.subscribers.size > 0) {
174
+ void this.all().then((items) => {
175
+ for (const cb of [...this.subscribers]) cb(items);
176
+ });
177
+ }
178
+ for (const [id, set] of this.oneSubscribers) {
179
+ void this.get(id).then((item) => {
180
+ for (const cb of [...set]) cb(item);
181
+ });
182
+ }
183
+ });
184
+ }
185
+ };
186
+
187
+ // src/conflict/lww.ts
188
+ function docFromRemote(remote, now = Date.now()) {
189
+ return {
190
+ id: remote.id,
191
+ collection: remote.collection,
192
+ // For a deletion the payload is null; the tombstone (`deletedAt`) carries meaning.
193
+ data: remote.data,
194
+ updatedAt: remote.updatedAt,
195
+ deletedAt: remote.deleted ? now : null,
196
+ localRev: 0,
197
+ lastPushedLocalRev: 0,
198
+ remoteVersion: remote.version,
199
+ syncedAt: now
200
+ };
201
+ }
202
+ function lastWriteWins() {
203
+ return (local, remote) => {
204
+ const now = Date.now();
205
+ if (!local) return docFromRemote(remote, now);
206
+ const remoteWins = remote.updatedAt >= local.updatedAt;
207
+ if (remoteWins) return docFromRemote(remote, now);
208
+ return { ...local, remoteVersion: remote.version };
209
+ };
210
+ }
211
+
212
+ // src/emitter.ts
213
+ var Emitter = class {
214
+ constructor() {
215
+ this.listeners = {};
216
+ }
217
+ /** Subscribe to an event. Returns an unsubscribe function. */
218
+ on(event, fn) {
219
+ let set = this.listeners[event];
220
+ if (!set) {
221
+ set = /* @__PURE__ */ new Set();
222
+ this.listeners[event] = set;
223
+ }
224
+ set.add(fn);
225
+ return () => {
226
+ set?.delete(fn);
227
+ };
228
+ }
229
+ /** Subscribe to an event for a single emission. */
230
+ once(event, fn) {
231
+ const off = this.on(event, (payload) => {
232
+ off();
233
+ fn(payload);
234
+ });
235
+ return off;
236
+ }
237
+ /** Emit an event to all current listeners. Listener errors are isolated. */
238
+ emit(event, payload) {
239
+ const set = this.listeners[event];
240
+ if (!set) return;
241
+ for (const fn of [...set]) {
242
+ try {
243
+ fn(payload);
244
+ } catch {
245
+ }
246
+ }
247
+ }
248
+ /** Remove all listeners (optionally for a single event). */
249
+ clear(event) {
250
+ if (event === void 0) {
251
+ this.listeners = {};
252
+ } else {
253
+ delete this.listeners[event];
254
+ }
255
+ }
256
+ };
257
+
258
+ // src/tabs.ts
259
+ var TabCoordinator = class {
260
+ constructor(name, onMessage) {
261
+ this.name = name;
262
+ this.leader = false;
263
+ if (typeof BroadcastChannel !== "undefined") {
264
+ this.channel = new BroadcastChannel("loko:" + name);
265
+ this.channel.onmessage = (e) => onMessage(e.data);
266
+ }
267
+ }
268
+ broadcast(msg) {
269
+ this.channel?.postMessage(msg);
270
+ }
271
+ /** `true` once this tab has won leadership (or when no Web Locks API exists). */
272
+ get isLeader() {
273
+ return this.leader;
274
+ }
275
+ /**
276
+ * Acquire leadership. The exclusive lock is held until the tab closes; when it
277
+ * does, another tab automatically takes over. Resolves once leadership is won.
278
+ */
279
+ electLeader(onBecomeLeader) {
280
+ const locks = globalThis.navigator?.locks;
281
+ if (!locks) {
282
+ this.leader = true;
283
+ onBecomeLeader?.();
284
+ return;
285
+ }
286
+ void locks.request("loko-leader:" + this.name, () => {
287
+ this.leader = true;
288
+ onBecomeLeader?.();
289
+ return new Promise(() => {
290
+ });
291
+ });
292
+ }
293
+ close() {
294
+ this.channel?.close();
295
+ this.channel = void 0;
296
+ }
297
+ };
298
+
299
+ // src/types.ts
300
+ function isDirty(doc) {
301
+ return doc.localRev > doc.lastPushedLocalRev;
302
+ }
303
+
304
+ // src/sync.ts
305
+ var CURSOR_KEY = "__cursor";
306
+ var CLIENT_ID_KEY = "__clientId";
307
+ var KV_COLLECTION = "_kv";
308
+ function docToChange(doc) {
309
+ const deleted = doc.deletedAt !== null;
310
+ return {
311
+ collection: doc.collection,
312
+ id: doc.id,
313
+ data: deleted ? null : doc.data,
314
+ version: doc.remoteVersion,
315
+ updatedAt: doc.updatedAt,
316
+ deleted
317
+ };
318
+ }
319
+ var Sync = class {
320
+ constructor(config) {
321
+ this.emitter = new Emitter();
322
+ this.collections = /* @__PURE__ */ new Map();
323
+ this.options = /* @__PURE__ */ new Map();
324
+ this._status = "idle";
325
+ this.clientId = "";
326
+ this.storage = config.storage;
327
+ this.adapter = config.adapter;
328
+ this.resolver = config.conflict ?? lastWriteWins();
329
+ this.autoSync = config.autoSync !== false && config.adapter !== void 0;
330
+ const intervalMs = typeof config.autoSync === "number" ? config.autoSync : this.autoSync ? 3e4 : 0;
331
+ this.tabs = new TabCoordinator(config.name, (msg) => this.onTabMessage(msg));
332
+ this.background = new BackgroundSync({
333
+ intervalMs,
334
+ onOnline: () => {
335
+ this.setStatus("idle");
336
+ if (this.autoSync) void this.sync().catch(() => {
337
+ });
338
+ },
339
+ onOffline: () => this.setStatus("offline"),
340
+ tick: () => void this.sync().catch(() => {
341
+ }),
342
+ // Only the leader tab polls.
343
+ shouldTick: () => this.tabs.isLeader && this.background.isOnline()
344
+ });
345
+ this.ready = this.init(config.name);
346
+ }
347
+ async init(name) {
348
+ await this.storage.init(name);
349
+ let id = await this.storage.getMeta(CLIENT_ID_KEY);
350
+ if (!id) {
351
+ id = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
352
+ await this.storage.setMeta(CLIENT_ID_KEY, id);
353
+ }
354
+ this.clientId = id;
355
+ if (!this.background.isOnline()) this.setStatus("offline");
356
+ this.tabs.electLeader();
357
+ this.background.start();
358
+ }
359
+ // ── Collections ────────────────────────────────────────────────────────────
360
+ /** Declare a collection's options once (at init). Returns the collection. */
361
+ defineCollection(name, options = {}) {
362
+ const resolved = {
363
+ primaryKey: options.primaryKey ?? "id",
364
+ indexes: options.indexes ?? []
365
+ };
366
+ this.options.set(name, resolved);
367
+ return this.getOrCreate(name, resolved);
368
+ }
369
+ /** Access a collection. Auto-defines with defaults if not yet declared. */
370
+ collection(name) {
371
+ const opts = this.options.get(name);
372
+ return this.getOrCreate(
373
+ name,
374
+ opts ?? { primaryKey: "id", indexes: [] }
375
+ );
376
+ }
377
+ getOrCreate(name, opts) {
378
+ const existing = this.collections.get(name);
379
+ if (existing) return existing;
380
+ const ctx = {
381
+ storage: this.storage,
382
+ primaryKey: opts.primaryKey,
383
+ indexes: opts.indexes,
384
+ ready: this.ready,
385
+ onLocalWrite: (doc) => this.handleLocalWrite(doc)
386
+ };
387
+ const collection = new Collection(name, ctx);
388
+ this.collections.set(name, collection);
389
+ return collection;
390
+ }
391
+ // ── Key/value sugar (over the reserved `_kv` collection) ─────────────────────
392
+ /**
393
+ * Convenience for simple key -> value cases. Stored as a SINGLE record in the
394
+ * reserved `_kv` collection, so it syncs as one coarse record. Not smart
395
+ * per-item sync — use real collections (`collection().put`) for that.
396
+ */
397
+ async store(key2, value) {
398
+ await this.ready;
399
+ await this.collection(KV_COLLECTION).put({ id: key2, value });
400
+ }
401
+ /** Read a value previously written with {@link store}. */
402
+ async get(key2) {
403
+ await this.ready;
404
+ const rec = await this.collection(KV_COLLECTION).get(key2);
405
+ return rec?.value;
406
+ }
407
+ // ── Sync engine ──────────────────────────────────────────────────────────────
408
+ get status() {
409
+ return this._status;
410
+ }
411
+ /** Resolves once storage is initialized (clientId loaded, leader elected). */
412
+ whenReady() {
413
+ return this.ready;
414
+ }
415
+ /** Subscribe to a lifecycle event. Returns an unsubscribe function. */
416
+ on(event, fn) {
417
+ return this.emitter.on(event, fn);
418
+ }
419
+ /**
420
+ * Run a full sync cycle: push local changes, then pull remote ones. Concurrent
421
+ * calls share the same in-flight run. No-op when no adapter is configured.
422
+ */
423
+ async sync() {
424
+ await this.ready;
425
+ if (!this.adapter) return { pushed: 0, pulled: 0 };
426
+ if (this.inFlight) return this.inFlight;
427
+ this.inFlight = (async () => {
428
+ this.setStatus("syncing");
429
+ try {
430
+ const pushed = await this.push();
431
+ const pulled = await this.pull();
432
+ this.setStatus(this.background.isOnline() ? "idle" : "offline");
433
+ this.emitter.emit("sync", { pushed, pulled });
434
+ return { pushed, pulled };
435
+ } catch (error) {
436
+ this.setStatus("error");
437
+ this.emitter.emit("error", { error });
438
+ throw error;
439
+ } finally {
440
+ this.inFlight = void 0;
441
+ }
442
+ })();
443
+ return this.inFlight;
444
+ }
445
+ async push() {
446
+ const dirty = await this.storage.getDirty();
447
+ if (dirty.length === 0) return 0;
448
+ const pushedRev = /* @__PURE__ */ new Map();
449
+ for (const doc of dirty) pushedRev.set(doc.collection + " " + doc.id, doc.localRev);
450
+ const result = await this.adapter.push(dirty.map(docToChange));
451
+ const affected = /* @__PURE__ */ new Set();
452
+ for (const ack of result.acked) {
453
+ const current = await this.storage.get(ack.collection, ack.id);
454
+ if (!current) continue;
455
+ const rev = pushedRev.get(ack.collection + " " + ack.id) ?? current.localRev;
456
+ await this.storage.put({
457
+ ...current,
458
+ remoteVersion: ack.version,
459
+ // If the record was edited again mid-push it stays dirty.
460
+ lastPushedLocalRev: Math.max(current.lastPushedLocalRev, rev),
461
+ syncedAt: Date.now()
462
+ });
463
+ affected.add(ack.collection);
464
+ }
465
+ for (const conflict of result.conflicts ?? []) {
466
+ await this.applyRemote(conflict);
467
+ affected.add(conflict.collection);
468
+ }
469
+ this.notifyAndBroadcast(affected, true);
470
+ return result.acked.length;
471
+ }
472
+ async pull() {
473
+ const cursor = await this.storage.getMeta(CURSOR_KEY) ?? 0;
474
+ const result = await this.adapter.pull(cursor);
475
+ const affected = /* @__PURE__ */ new Set();
476
+ const writes = [];
477
+ for (const change of result.changes) {
478
+ const resolved = await this.resolveRemote(change);
479
+ if (resolved) {
480
+ writes.push(resolved);
481
+ affected.add(change.collection);
482
+ }
483
+ }
484
+ await this.storage.transact((tx) => {
485
+ for (const doc of writes) tx.put(doc);
486
+ tx.setMeta(CURSOR_KEY, result.cursor);
487
+ });
488
+ this.notifyAndBroadcast(affected, true);
489
+ return writes.length;
490
+ }
491
+ /** Apply one remote change, returning the StoredDoc to write, or `undefined` to skip. */
492
+ async resolveRemote(remote) {
493
+ const local = await this.storage.get(remote.collection, remote.id);
494
+ if (local && remote.version <= local.remoteVersion) return void 0;
495
+ let resolved;
496
+ if (local && isDirty(local)) {
497
+ resolved = this.resolver(local, remote);
498
+ } else {
499
+ resolved = docFromRemote(remote);
500
+ }
501
+ return { ...resolved, remoteVersion: remote.version, syncedAt: Date.now() };
502
+ }
503
+ async applyRemote(remote) {
504
+ const resolved = await this.resolveRemote(remote);
505
+ if (resolved) await this.storage.put(resolved);
506
+ }
507
+ // ── Internal plumbing ────────────────────────────────────────────────────────
508
+ handleLocalWrite(doc) {
509
+ this.emitter.emit("change", { collection: doc.collection, id: doc.id, doc });
510
+ this.collections.get(doc.collection)?.notify();
511
+ this.tabs.broadcast({ type: "write", collection: doc.collection, id: doc.id });
512
+ if (this.autoSync && this.background.isOnline()) {
513
+ void this.sync().catch(() => {
514
+ });
515
+ }
516
+ }
517
+ notifyAndBroadcast(collections, broadcast) {
518
+ for (const name of collections) {
519
+ this.collections.get(name)?.notify();
520
+ this.emitter.emit("change", { collection: name, id: "*", doc: void 0 });
521
+ }
522
+ if (broadcast && collections.size > 0) this.tabs.broadcast({ type: "sync" });
523
+ }
524
+ onTabMessage(msg) {
525
+ if (msg.type === "write") {
526
+ this.collections.get(msg.collection)?.notify();
527
+ this.emitter.emit("change", { collection: msg.collection, id: msg.id, doc: void 0 });
528
+ } else {
529
+ for (const collection of this.collections.values()) collection.notify();
530
+ }
531
+ }
532
+ setStatus(status) {
533
+ if (this._status === status) return;
534
+ this._status = status;
535
+ this.emitter.emit("status", { status });
536
+ }
537
+ /** Opt into Service Worker Background Sync (no-op if unsupported). */
538
+ registerBackgroundSync(tag) {
539
+ return registerBackgroundSync(tag);
540
+ }
541
+ /** Tear down listeners, timers, and channels, and close storage. */
542
+ async close() {
543
+ this.background.stop();
544
+ this.tabs.close();
545
+ this.emitter.clear();
546
+ await this.storage.close();
547
+ }
548
+ };
549
+ function createSync(config) {
550
+ return new Sync(config);
551
+ }
552
+
553
+ // src/storage/indexeddb.ts
554
+ var DOCS = "docs";
555
+ var META = "meta";
556
+ function req(request) {
557
+ return new Promise((resolve, reject) => {
558
+ request.onsuccess = () => resolve(request.result);
559
+ request.onerror = () => reject(request.error);
560
+ });
561
+ }
562
+ function txDone(tx) {
563
+ return new Promise((resolve, reject) => {
564
+ tx.oncomplete = () => resolve();
565
+ tx.onerror = () => reject(tx.error);
566
+ tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
567
+ });
568
+ }
569
+ function indexedDBStorage(options = {}) {
570
+ const idb = options.indexedDB ?? (typeof indexedDB !== "undefined" ? indexedDB : void 0);
571
+ if (!idb) {
572
+ throw new Error(
573
+ "indexedDBStorage(): no IndexedDB available. Pass `{ indexedDB }` (e.g. fake-indexeddb) or use memoryStorage()."
574
+ );
575
+ }
576
+ let db;
577
+ function open(name) {
578
+ return new Promise((resolve, reject) => {
579
+ const request = idb.open(name, 1);
580
+ request.onupgradeneeded = () => {
581
+ const database2 = request.result;
582
+ if (!database2.objectStoreNames.contains(DOCS)) {
583
+ const store = database2.createObjectStore(DOCS, { keyPath: ["collection", "id"] });
584
+ store.createIndex("collection", "collection", { unique: false });
585
+ }
586
+ if (!database2.objectStoreNames.contains(META)) {
587
+ database2.createObjectStore(META);
588
+ }
589
+ };
590
+ request.onsuccess = () => resolve(request.result);
591
+ request.onerror = () => reject(request.error);
592
+ });
593
+ }
594
+ function database() {
595
+ if (!db) throw new Error("indexedDBStorage(): init() was not called.");
596
+ return db;
597
+ }
598
+ return {
599
+ async init(name) {
600
+ if (!db) db = await open(name);
601
+ },
602
+ async get(collection, id) {
603
+ const tx = database().transaction(DOCS, "readonly");
604
+ const result = await req(
605
+ tx.objectStore(DOCS).get([collection, id])
606
+ );
607
+ return result;
608
+ },
609
+ async getAll(collection) {
610
+ const tx = database().transaction(DOCS, "readonly");
611
+ const index = tx.objectStore(DOCS).index("collection");
612
+ return req(index.getAll(collection));
613
+ },
614
+ async put(doc) {
615
+ const tx = database().transaction(DOCS, "readwrite");
616
+ tx.objectStore(DOCS).put(doc);
617
+ await txDone(tx);
618
+ },
619
+ async bulkPut(docs) {
620
+ await this.transact((tx) => {
621
+ for (const doc of docs) tx.put(doc);
622
+ });
623
+ },
624
+ async transact(fn) {
625
+ const puts = [];
626
+ const deletes = [];
627
+ const metaWrites = [];
628
+ const tx = {
629
+ put: (doc) => void puts.push(doc),
630
+ delete: (collection, id) => void deletes.push([collection, id]),
631
+ setMeta: (key2, value) => void metaWrites.push([key2, value])
632
+ };
633
+ await fn(tx);
634
+ const stores = metaWrites.length > 0 ? [DOCS, META] : [DOCS];
635
+ const idbTx = database().transaction(stores, "readwrite");
636
+ const docStore = idbTx.objectStore(DOCS);
637
+ for (const doc of puts) docStore.put(doc);
638
+ for (const [collection, id] of deletes) docStore.delete([collection, id]);
639
+ if (metaWrites.length > 0) {
640
+ const metaStore = idbTx.objectStore(META);
641
+ for (const [key2, value] of metaWrites) metaStore.put(value, key2);
642
+ }
643
+ await txDone(idbTx);
644
+ },
645
+ async getDirty() {
646
+ const tx = database().transaction(DOCS, "readonly");
647
+ const all = await req(tx.objectStore(DOCS).getAll());
648
+ return all.filter(isDirty);
649
+ },
650
+ async getMeta(key2) {
651
+ const tx = database().transaction(META, "readonly");
652
+ return req(tx.objectStore(META).get(key2));
653
+ },
654
+ async setMeta(key2, value) {
655
+ const tx = database().transaction(META, "readwrite");
656
+ tx.objectStore(META).put(value, key2);
657
+ await txDone(tx);
658
+ },
659
+ async close() {
660
+ db?.close();
661
+ db = void 0;
662
+ }
663
+ };
664
+ }
665
+
666
+ // src/storage/memory.ts
667
+ function key(collection, id) {
668
+ return collection + "\0" + id;
669
+ }
670
+ function clone(doc) {
671
+ return structuredClone(doc);
672
+ }
673
+ function memoryStorage() {
674
+ const docs = /* @__PURE__ */ new Map();
675
+ const meta = /* @__PURE__ */ new Map();
676
+ function commit(staged) {
677
+ for (const k of staged.deletes) docs.delete(k);
678
+ for (const [k, v] of staged.puts) docs.set(k, v);
679
+ for (const [k, v] of staged.meta) meta.set(k, v);
680
+ }
681
+ return {
682
+ async init() {
683
+ },
684
+ async get(collection, id) {
685
+ const doc = docs.get(key(collection, id));
686
+ return doc ? clone(doc) : void 0;
687
+ },
688
+ async getAll(collection) {
689
+ const out = [];
690
+ for (const doc of docs.values()) {
691
+ if (doc.collection === collection) out.push(clone(doc));
692
+ }
693
+ return out;
694
+ },
695
+ async put(doc) {
696
+ docs.set(key(doc.collection, doc.id), clone(doc));
697
+ },
698
+ async bulkPut(input) {
699
+ await this.transact((tx) => {
700
+ for (const doc of input) tx.put(doc);
701
+ });
702
+ },
703
+ async transact(fn) {
704
+ const staged = {
705
+ puts: /* @__PURE__ */ new Map(),
706
+ deletes: /* @__PURE__ */ new Set(),
707
+ meta: /* @__PURE__ */ new Map()
708
+ };
709
+ const tx = {
710
+ put(doc) {
711
+ const k = key(doc.collection, doc.id);
712
+ staged.puts.set(k, clone(doc));
713
+ staged.deletes.delete(k);
714
+ },
715
+ delete(collection, id) {
716
+ const k = key(collection, id);
717
+ staged.deletes.add(k);
718
+ staged.puts.delete(k);
719
+ },
720
+ setMeta(metaKey, value) {
721
+ staged.meta.set(metaKey, value);
722
+ }
723
+ };
724
+ await fn(tx);
725
+ commit(staged);
726
+ },
727
+ async getDirty() {
728
+ const out = [];
729
+ for (const doc of docs.values()) {
730
+ if (isDirty(doc)) out.push(clone(doc));
731
+ }
732
+ return out;
733
+ },
734
+ async getMeta(metaKey) {
735
+ return meta.has(metaKey) ? structuredClone(meta.get(metaKey)) : void 0;
736
+ },
737
+ async setMeta(metaKey, value) {
738
+ meta.set(metaKey, structuredClone(value));
739
+ },
740
+ async close() {
741
+ docs.clear();
742
+ meta.clear();
743
+ }
744
+ };
745
+ }
746
+
747
+ // src/adapters/rest.ts
748
+ function restAdapter(options) {
749
+ const base = options.url.replace(/\/$/, "");
750
+ const doFetch = options.fetch ?? globalThis.fetch;
751
+ if (!doFetch) {
752
+ throw new Error("restAdapter(): no global fetch available. Pass `{ fetch }`.");
753
+ }
754
+ async function headers() {
755
+ const extra = options.headers ? await options.headers() : {};
756
+ return { "content-type": "application/json", ...extra };
757
+ }
758
+ return {
759
+ async push(changes) {
760
+ const res = await doFetch(`${base}/push`, {
761
+ method: "POST",
762
+ headers: await headers(),
763
+ body: JSON.stringify({ changes })
764
+ });
765
+ if (!res.ok) throw new Error(`loko push failed: ${res.status} ${res.statusText}`);
766
+ return await res.json();
767
+ },
768
+ async pull(since) {
769
+ const res = await doFetch(`${base}/pull?since=${encodeURIComponent(String(since))}`, {
770
+ method: "GET",
771
+ headers: await headers()
772
+ });
773
+ if (!res.ok) throw new Error(`loko pull failed: ${res.status} ${res.statusText}`);
774
+ return await res.json();
775
+ }
776
+ };
777
+ }
778
+
779
+ // src/adapters/memory.ts
780
+ var MemoryServer = class {
781
+ constructor() {
782
+ this.records = /* @__PURE__ */ new Map();
783
+ this.seq = 0;
784
+ }
785
+ key(collection, id) {
786
+ return collection + " " + id;
787
+ }
788
+ commit(changes) {
789
+ const acked = [];
790
+ const conflicts = [];
791
+ for (const change of changes) {
792
+ const k = this.key(change.collection, change.id);
793
+ const current = this.records.get(k);
794
+ if (current && current.version > change.version) {
795
+ conflicts.push(this.toChange(current));
796
+ continue;
797
+ }
798
+ const version = ++this.seq;
799
+ this.records.set(k, {
800
+ collection: change.collection,
801
+ id: change.id,
802
+ data: change.data,
803
+ deleted: change.deleted,
804
+ updatedAt: change.updatedAt,
805
+ version
806
+ });
807
+ acked.push({ id: change.id, collection: change.collection, version });
808
+ }
809
+ return { acked, conflicts, cursor: this.seq };
810
+ }
811
+ changesSince(since) {
812
+ const sinceNum = Number(since) || 0;
813
+ const changes = [...this.records.values()].filter((r) => r.version > sinceNum).sort((a, b) => a.version - b.version).map((r) => this.toChange(r));
814
+ return { changes, cursor: this.seq };
815
+ }
816
+ toChange(r) {
817
+ return {
818
+ collection: r.collection,
819
+ id: r.id,
820
+ data: r.deleted ? null : r.data,
821
+ version: r.version,
822
+ updatedAt: r.updatedAt,
823
+ deleted: r.deleted
824
+ };
825
+ }
826
+ };
827
+ function memoryAdapter(server = new MemoryServer()) {
828
+ return {
829
+ async push(changes) {
830
+ return server.commit(changes);
831
+ },
832
+ async pull(since) {
833
+ return server.changesSince(since);
834
+ }
835
+ };
836
+ }
837
+
838
+ export { Collection, MemoryServer, Sync, createSync, docFromRemote, indexedDBStorage, isDirty, lastWriteWins, memoryAdapter, memoryStorage, registerBackgroundSync, restAdapter };
839
+ //# sourceMappingURL=index.js.map
840
+ //# sourceMappingURL=index.js.map