@keepkit/core 0.28.5 → 0.28.7

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.
@@ -61,18 +61,22 @@ async function removeKeepItems(storage, ids) {
61
61
  for (const id of ids) await storage.remove(id);
62
62
  }
63
63
  function mergeKeepItemLists(remoteItems, localItems) {
64
- const byId = new Map(remoteItems.map((item) => [item.id, item]));
64
+ const byScopeAndId = new Map(remoteItems.map((item) => [itemStorageKey(item), item]));
65
65
  for (const localItem of localItems) {
66
- const remoteItem = byId.get(localItem.id);
66
+ const key = itemStorageKey(localItem);
67
+ const remoteItem = byScopeAndId.get(key);
67
68
  if (!remoteItem) {
68
- byId.set(localItem.id, localItem);
69
+ byScopeAndId.set(key, localItem);
69
70
  continue;
70
71
  }
71
72
  const content = localItem.updatedAt > remoteItem.updatedAt ? localItem : remoteItem;
72
73
  const lastOpenedAt = remoteItem.lastOpenedAt === void 0 ? localItem.lastOpenedAt : localItem.lastOpenedAt === void 0 ? remoteItem.lastOpenedAt : Math.max(remoteItem.lastOpenedAt, localItem.lastOpenedAt);
73
- byId.set(localItem.id, lastOpenedAt === void 0 ? content : { ...content, lastOpenedAt });
74
+ byScopeAndId.set(key, lastOpenedAt === void 0 ? content : { ...content, lastOpenedAt });
74
75
  }
75
- return [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
76
+ return [...byScopeAndId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
77
+ }
78
+ function itemStorageKey(item) {
79
+ return JSON.stringify([item.scope?.tenantId ?? null, item.scope?.userId ?? null, item.id]);
76
80
  }
77
81
  function isKeepItem(value) {
78
82
  if (!isRecord(value)) return false;
@@ -82,7 +86,7 @@ function isKeepItemArray(value) {
82
86
  return Array.isArray(value) && value.every(isKeepItem);
83
87
  }
84
88
  function isKeepCollectionDefinition(value) {
85
- return isRecord(value) && typeof value.id === "string" && value.id.trim().length > 0 && typeof value.name === "string" && value.name.trim().length > 0 && (value.scope === void 0 || isSyncScope(value.scope));
89
+ return isRecord(value) && typeof value.id === "string" && value.id.trim().length > 0 && typeof value.name === "string" && value.name.trim().length > 0 && (value.revision === void 0 || typeof value.revision === "string") && (value.updatedAt === void 0 || typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)) && (value.scope === void 0 || isSyncScope(value.scope));
86
90
  }
87
91
  function isKeepCollectionMembership(value) {
88
92
  return isRecord(value) && typeof value.collectionId === "string" && value.collectionId.trim().length > 0 && typeof value.itemId === "string" && value.itemId.trim().length > 0 && typeof value.order === "number" && Number.isInteger(value.order) && Number.isFinite(value.order) && value.order >= 0 && (value.scope === void 0 || isSyncScope(value.scope));
@@ -96,7 +100,7 @@ function isSyncScope(value) {
96
100
 
97
101
  // src/features/persistence/scope.ts
98
102
  function getKeepScopeKey(scope) {
99
- if (!scope?.userId && !scope?.tenantId) return "";
103
+ if (scope?.userId === void 0 && scope?.tenantId === void 0) return "";
100
104
  return `:${encodeURIComponent(scope.tenantId ?? "_")}:${encodeURIComponent(scope.userId ?? "_")}`;
101
105
  }
102
106
  function isSameKeepScope(left, right) {
@@ -143,37 +147,66 @@ var ScopedStorageAdapter = class {
143
147
  const scoped = items.map((item) => ({ ...item, ...this.scope ? { scope: this.scope } : {} }));
144
148
  const ids = new Set(scoped.map((item) => item.id));
145
149
  const next = current.filter((item) => !ids.has(item.id) || !isSameKeepScope(item.scope, this.scope));
146
- await writeAll(this.base, [...next, ...scoped]);
150
+ await writeAll(this.base, [...next, ...scoped], this.scope);
147
151
  }
148
152
  async remove(id) {
149
153
  const current = await this.base.getAll();
150
154
  await writeAll(
151
155
  this.base,
152
- current.filter((item) => item.id !== id || !isSameKeepScope(item.scope, this.scope))
156
+ current.filter((item) => item.id !== id || !isSameKeepScope(item.scope, this.scope)),
157
+ this.scope
153
158
  );
159
+ await this.removeMembershipsForItems([id]);
154
160
  }
155
161
  async removeMany(ids) {
156
162
  const idSet = new Set(ids);
157
163
  const current = await this.base.getAll();
158
164
  await writeAll(
159
165
  this.base,
160
- current.filter((item) => !idSet.has(item.id) || !isSameKeepScope(item.scope, this.scope))
166
+ current.filter((item) => !idSet.has(item.id) || !isSameKeepScope(item.scope, this.scope)),
167
+ this.scope
161
168
  );
169
+ await this.removeMembershipsForItems([...idSet]);
162
170
  }
163
171
  async clear() {
164
172
  const current = await this.base.getAll();
173
+ const removedIds = current.filter((item) => isSameKeepScope(item.scope, this.scope)).map((item) => item.id);
165
174
  await writeAll(
166
175
  this.base,
167
- current.filter((item) => !isSameKeepScope(item.scope, this.scope))
176
+ current.filter((item) => !isSameKeepScope(item.scope, this.scope)),
177
+ this.scope
168
178
  );
179
+ if (removedIds.length > 0) await this.removeMembershipsForItems(removedIds);
180
+ await this.clearMembershipsForScope();
169
181
  }
170
182
  async merge(items) {
171
- await this.setMany(items);
172
- return this.getAll();
183
+ const scopedItems = items.map((item) => ({ ...item, ...this.scope ? { scope: this.scope } : {} }));
184
+ const merged = mergeKeepItemLists(await this.getAll(), scopedItems);
185
+ await this.setMany(merged);
186
+ return merged;
173
187
  }
174
188
  subscribe(listener) {
175
189
  return this.base.subscribe?.(listener) ?? (() => void 0);
176
190
  }
191
+ async removeMembershipsForItems(ids) {
192
+ if (!this.base.getCollectionMemberships || !this.base.removeCollectionMembership || ids.length === 0) return;
193
+ const removedIds = new Set(ids);
194
+ const memberships = await this.base.getCollectionMemberships();
195
+ for (const membership of memberships) {
196
+ if (removedIds.has(membership.itemId) && isSameKeepScope(membership.scope, this.scope)) {
197
+ await this.base.removeCollectionMembership(membership.collectionId, membership.itemId, this.scope);
198
+ }
199
+ }
200
+ }
201
+ async clearMembershipsForScope() {
202
+ if (!this.base.getCollectionMemberships || !this.base.removeCollectionMembership) return;
203
+ const memberships = await this.base.getCollectionMemberships();
204
+ for (const membership of memberships) {
205
+ if (isSameKeepScope(membership.scope, this.scope)) {
206
+ await this.base.removeCollectionMembership(membership.collectionId, membership.itemId, this.scope);
207
+ }
208
+ }
209
+ }
177
210
  };
178
211
  function createScopedStorageAdapter(base, scope) {
179
212
  return new ScopedStorageAdapter(base, scope);
@@ -193,32 +226,38 @@ var ScopedSyncQueueAdapter = class {
193
226
  const ids = new Set(scoped.map((operation) => operation.operationId));
194
227
  await this.base.setMany([
195
228
  ...current.filter(
196
- (operation) => !ids.has(operation.operationId) && !isSameKeepScope(operation.scope, this.scope)
229
+ (operation) => !ids.has(operation.operationId) || !isSameKeepScope(operation.scope, this.scope)
197
230
  ),
198
231
  ...scoped
199
232
  ]);
200
233
  }
201
- remove(operationIds) {
202
- return this.base.remove(operationIds);
234
+ async remove(operationIds) {
235
+ const ids = new Set(operationIds);
236
+ const scopedIds = (await this.base.getAll()).filter((operation) => ids.has(operation.operationId) && isSameKeepScope(operation.scope, this.scope)).map((operation) => operation.operationId);
237
+ await this.base.remove(scopedIds);
203
238
  }
204
- clear() {
205
- return this.base.getAll().then(
206
- (operations) => this.base.setMany(operations.filter((operation) => !isSameKeepScope(operation.scope, this.scope)))
207
- );
239
+ async clear() {
240
+ const operationIds = (await this.base.getAll()).filter((operation) => isSameKeepScope(operation.scope, this.scope)).map((operation) => operation.operationId);
241
+ await this.base.remove(operationIds);
208
242
  }
209
243
  };
210
- async function writeAll(base, items) {
244
+ async function writeAll(base, items, scope) {
245
+ const existing = await base.getAll();
246
+ const desiredItems = new Set(items.filter((item) => isSameKeepScope(item.scope, scope)).map(itemStorageKey2));
247
+ const removedIds = existing.filter((item) => isSameKeepScope(item.scope, scope) && !desiredItems.has(itemStorageKey2(item))).map((item) => item.id);
248
+ if (removedIds.length > 0) {
249
+ if (base.removeMany) await base.removeMany(removedIds, scope);
250
+ else await Promise.all(removedIds.map((id) => base.remove(id, scope)));
251
+ }
211
252
  if (base.setMany) {
212
253
  await base.setMany(items);
213
254
  return;
214
255
  }
215
- const existing = await base.getAll();
216
- const ids = new Set(items.map((item) => item.id));
217
- for (const item of existing) {
218
- if (!ids.has(item.id)) await base.remove(item.id);
219
- }
220
256
  for (const item of items) await base.set(item);
221
257
  }
258
+ function itemStorageKey2(item) {
259
+ return JSON.stringify([item.scope?.tenantId ?? null, item.scope?.userId ?? null, item.id]);
260
+ }
222
261
 
223
262
  // src/storage/sync.ts
224
263
  var DEFAULT_SYNC_QUEUE_KEY = "keepkit:sync-queue";
@@ -381,11 +420,12 @@ var SyncStorageAdapter = class {
381
420
  };
382
421
  this.local = options.local;
383
422
  this.remote = options.remote;
384
- this.queue = options.queue ?? createDefaultQueue(options);
423
+ this.scope = getSyncScope(options);
424
+ const queue = options.queue ?? createDefaultQueue(options);
425
+ this.queue = this.scope ? new ScopedSyncQueueAdapter(queue, this.scope) : queue;
385
426
  this.clientId = options.clientId ?? createId();
386
427
  this.now = options.now ?? Date.now;
387
428
  this.resolveConflict = options.resolveConflict;
388
- this.scope = getSyncScope(options);
389
429
  this.maxRetries = Math.max(0, options.maxRetries ?? 3);
390
430
  this.retryDelayMs = Math.max(0, options.retryDelayMs ?? 0);
391
431
  this.retryBackoff = Math.max(1, options.retryBackoff ?? 2);
@@ -402,7 +442,13 @@ var SyncStorageAdapter = class {
402
442
  this.setCollection = async (collection) => {
403
443
  const scopedCollection = { ...collection, ...this.scope ? { scope: this.scope } : {} };
404
444
  if (this.remote.pushCollection) {
405
- const operation = this.createCollectionOperation("upsert", scopedCollection.id, scopedCollection);
445
+ const current = (await this.getCollections?.())?.find((entry) => entry.id === scopedCollection.id);
446
+ const operation = this.createCollectionOperation(
447
+ "upsert",
448
+ scopedCollection.id,
449
+ scopedCollection,
450
+ current?.revision
451
+ );
406
452
  await this.enqueueBeforeLocalWrite(operation);
407
453
  try {
408
454
  await setCollection(scopedCollection);
@@ -418,13 +464,16 @@ var SyncStorageAdapter = class {
418
464
  this.notifyDataListeners();
419
465
  };
420
466
  this.removeCollection = async (id) => {
467
+ const memberships = await this.getCollectionMemberships?.() ?? [];
468
+ const membershipOperations = this.remote.pushMembership ? memberships.filter((membership) => membership.collectionId === id).map((membership) => this.createMembershipOperation("remove", membership.collectionId, membership.itemId)) : [];
421
469
  if (this.remote.pushCollection) {
422
- const operation = this.createCollectionOperation("remove", id);
423
- await this.enqueueBeforeLocalWrite(operation);
470
+ const current = (await this.getCollections?.())?.find((collection) => collection.id === id);
471
+ const operation = this.createCollectionOperation("remove", id, void 0, current?.revision);
472
+ await this.enqueueManyBeforeLocalWrite([...membershipOperations, operation]);
424
473
  try {
425
474
  await removeCollection(id, this.scope);
426
475
  } catch (cause) {
427
- await this.removeQueued(operation.operationId);
476
+ await this.removeQueued([operation.operationId, ...membershipOperations.map((entry) => entry.operationId)]);
428
477
  throw cause;
429
478
  }
430
479
  this.notifyDataListeners();
@@ -445,11 +494,36 @@ var SyncStorageAdapter = class {
445
494
  return scope ? memberships.filter((membership) => sameScope(membership.scope, scope)) : memberships;
446
495
  };
447
496
  this.setCollectionMembership = async (membership) => {
448
- await setMembership({ ...membership, ...this.scope ? { scope: this.scope } : {} });
497
+ const scoped = { ...membership, ...this.scope ? { scope: this.scope } : {} };
498
+ if (this.remote.pushMembership) {
499
+ const operation = this.createMembershipOperation("upsert", scoped.collectionId, scoped.itemId, scoped);
500
+ await this.enqueueBeforeLocalWrite(operation);
501
+ try {
502
+ await setMembership(scoped);
503
+ } catch (cause) {
504
+ await this.removeQueued(operation.operationId);
505
+ throw cause;
506
+ }
507
+ this.setPendingState();
508
+ } else {
509
+ await setMembership(scoped);
510
+ }
449
511
  this.notifyDataListeners();
450
512
  };
451
513
  this.removeCollectionMembership = async (collectionId, itemId) => {
452
- await removeMembership(collectionId, itemId, this.scope);
514
+ if (this.remote.pushMembership) {
515
+ const operation = this.createMembershipOperation("remove", collectionId, itemId);
516
+ await this.enqueueBeforeLocalWrite(operation);
517
+ try {
518
+ await removeMembership(collectionId, itemId, this.scope);
519
+ } catch (cause) {
520
+ await this.removeQueued(operation.operationId);
521
+ throw cause;
522
+ }
523
+ this.setPendingState();
524
+ } else {
525
+ await removeMembership(collectionId, itemId, this.scope);
526
+ }
453
527
  this.notifyDataListeners();
454
528
  };
455
529
  }
@@ -493,11 +567,13 @@ var SyncStorageAdapter = class {
493
567
  }
494
568
  async remove(id) {
495
569
  const operation = this.createOperation("remove", id);
496
- await this.enqueueBeforeLocalWrite(operation);
570
+ const memberships = await this.getCollectionMemberships?.() ?? [];
571
+ const membershipOperations = this.remote.pushMembership ? memberships.filter((membership) => membership.itemId === id).map((membership) => this.createMembershipOperation("remove", membership.collectionId, membership.itemId)) : [];
572
+ await this.enqueueManyBeforeLocalWrite([...membershipOperations, operation]);
497
573
  try {
498
- await this.local.remove(id);
574
+ await this.local.remove(id, this.scope);
499
575
  } catch (cause) {
500
- await this.removeQueued(operation.operationId);
576
+ await this.removeQueued([operation.operationId, ...membershipOperations.map((entry) => entry.operationId)]);
501
577
  throw cause;
502
578
  }
503
579
  this.notifyDataListeners();
@@ -505,11 +581,15 @@ var SyncStorageAdapter = class {
505
581
  }
506
582
  async removeMany(ids) {
507
583
  const operations = [...new Set(ids)].map((id) => this.createOperation("remove", id));
508
- await this.enqueueManyBeforeLocalWrite(operations);
584
+ const idSet = new Set(ids);
585
+ const memberships = await this.getCollectionMemberships?.() ?? [];
586
+ const membershipOperations = this.remote.pushMembership ? memberships.filter((membership) => idSet.has(membership.itemId)).map((membership) => this.createMembershipOperation("remove", membership.collectionId, membership.itemId)) : [];
587
+ await this.enqueueManyBeforeLocalWrite([...membershipOperations, ...operations]);
509
588
  try {
510
- await removeKeepItems(this.local, ids);
589
+ if (this.local.removeMany) await this.local.removeMany(ids, this.scope);
590
+ else await Promise.all(ids.map((id) => this.local.remove(id, this.scope)));
511
591
  } catch (cause) {
512
- await this.removeQueued(operations.map((operation) => operation.operationId));
592
+ await this.removeQueued([...operations, ...membershipOperations].map((operation) => operation.operationId));
513
593
  throw cause;
514
594
  }
515
595
  this.notifyDataListeners();
@@ -518,9 +598,17 @@ var SyncStorageAdapter = class {
518
598
  async clear() {
519
599
  const items = await this.getAll();
520
600
  await this.removeMany(items.map((item) => item.id));
521
- await this.local.clear();
601
+ if (!this.scope) await this.local.clear();
522
602
  }
523
603
  async merge(localItems) {
604
+ if (this.scope) {
605
+ const merged2 = mergeKeepItemLists(
606
+ await this.getAll(),
607
+ localItems.map((item) => this.applyScope(item))
608
+ );
609
+ await this.setMany(localItems);
610
+ return merged2;
611
+ }
524
612
  const merged = this.local.merge ? await this.local.merge(localItems) : mergeKeepItemLists(await this.local.getAll(), localItems);
525
613
  if (!this.local.merge) await persistKeepItems(this.local, merged);
526
614
  await this.setMany(localItems);
@@ -569,6 +657,47 @@ var SyncStorageAdapter = class {
569
657
  });
570
658
  if (resolution !== "remote" && this.queueItems.length > 0) await this.flushSync();
571
659
  }
660
+ async resolveCollectionSyncConflict(id, resolution, collection) {
661
+ await this.loadQueue();
662
+ const conflict = (this.state.collectionConflicts ?? []).find((entry) => entry.operation.id === id);
663
+ if (!conflict) return;
664
+ if (!this.local.getCollections || !this.local.setCollection || !this.local.removeCollection) {
665
+ throw new Error("KeepKit collection storage is unavailable.");
666
+ }
667
+ if (resolution === "remote") {
668
+ if (conflict.deleted) await this.local.removeCollection(id, this.scope);
669
+ else {
670
+ if (!conflict.collection) throw new Error("Remote collection conflict is missing its selected definition.");
671
+ await this.local.setCollection({
672
+ ...conflict.collection,
673
+ ...conflict.revision ? { revision: conflict.revision } : {},
674
+ ...this.scope ? { scope: this.scope } : {}
675
+ });
676
+ }
677
+ await this.removeQueued(conflict.operation.operationId);
678
+ this.updateState({
679
+ collectionConflicts: (this.state.collectionConflicts ?? []).filter((entry) => entry.operation.id !== id)
680
+ });
681
+ return;
682
+ }
683
+ if (collection && collection.id !== id) {
684
+ throw new TypeError("KeepKit collection conflict resolution must use the requested collection ID.");
685
+ }
686
+ const chosen = collection ?? (await this.getCollections?.())?.find((entry) => entry.id === id);
687
+ const retry = {
688
+ ...conflict.operation,
689
+ operationId: `${this.clientId}:${this.now()}:${createId()}`,
690
+ ...conflict.operation.type === "upsert" && chosen ? { collection: { ...chosen, ...this.scope ? { scope: this.scope } : {} } } : {},
691
+ createdAt: this.now(),
692
+ baseRevision: conflict.revision,
693
+ attempts: 0
694
+ };
695
+ await this.replaceQueued(conflict.operation, retry);
696
+ this.updateState({
697
+ collectionConflicts: (this.state.collectionConflicts ?? []).filter((entry) => entry.operation.id !== id)
698
+ });
699
+ await this.flushSync();
700
+ }
572
701
  dispose() {
573
702
  this.disposed = true;
574
703
  if (this.onlineHandler) window.removeEventListener("online", this.onlineHandler);
@@ -589,7 +718,29 @@ var SyncStorageAdapter = class {
589
718
  try {
590
719
  if (isCollectionSyncOperation(operation)) {
591
720
  if (!this.remote.pushCollection) throw new Error("Remote sync driver does not support collection sync.");
592
- await this.pushCollectionWithRetry(operation);
721
+ const result2 = await this.pushCollectionWithRetry(operation);
722
+ if (result2?.type === "conflict" && result2.resolution === "manual") {
723
+ const conflict = createCollectionSyncConflict(operation, result2);
724
+ this.updateState({
725
+ status: "conflict",
726
+ collectionConflicts: [
727
+ ...(this.state.collectionConflicts ?? []).filter((entry) => entry.operation.id !== operation.id),
728
+ conflict
729
+ ]
730
+ });
731
+ continue;
732
+ }
733
+ await this.applyCollectionPushResult(operation, result2);
734
+ await this.removeQueued(operation.operationId);
735
+ this.updateState({
736
+ status: this.queueItems.length > 0 ? "syncing" : "synced",
737
+ lastSyncedAt: this.now()
738
+ });
739
+ continue;
740
+ }
741
+ if (isMembershipSyncOperation(operation)) {
742
+ if (!this.remote.pushMembership) throw new Error("Remote sync driver does not support membership sync.");
743
+ await this.pushMembershipWithRetry(operation);
593
744
  await this.removeQueued(operation.operationId);
594
745
  this.updateState({
595
746
  status: this.queueItems.length > 0 ? "syncing" : "synced",
@@ -661,7 +812,7 @@ var SyncStorageAdapter = class {
661
812
  ...this.scope ? { scope: this.scope } : {}
662
813
  };
663
814
  }
664
- createCollectionOperation(type, id, collection) {
815
+ createCollectionOperation(type, id, collection, baseRevision) {
665
816
  return {
666
817
  operationId: `${this.clientId}:${this.now()}:${createId()}`,
667
818
  type,
@@ -669,6 +820,20 @@ var SyncStorageAdapter = class {
669
820
  id,
670
821
  ...collection ? { collection } : {},
671
822
  createdAt: this.now(),
823
+ ...baseRevision ? { baseRevision } : {},
824
+ ...this.scope ? { scope: this.scope } : {}
825
+ };
826
+ }
827
+ createMembershipOperation(type, collectionId, itemId, membership) {
828
+ return {
829
+ operationId: `${this.clientId}:${this.now()}:${createId()}`,
830
+ type,
831
+ entity: "membership",
832
+ id: getMembershipId(collectionId, itemId),
833
+ collectionId,
834
+ itemId,
835
+ ...membership ? { membership } : {},
836
+ createdAt: this.now(),
672
837
  ...this.scope ? { scope: this.scope } : {}
673
838
  };
674
839
  }
@@ -692,7 +857,48 @@ var SyncStorageAdapter = class {
692
857
  let attempt = 0;
693
858
  while (true) {
694
859
  try {
695
- await this.remote.pushCollection?.({ ...operation, attempts: attempt });
860
+ return await this.remote.pushCollection?.({ ...operation, attempts: attempt });
861
+ } catch (error) {
862
+ if (isKeepSyncAuthError(error) || attempt >= this.maxRetries) throw error;
863
+ attempt += 1;
864
+ const delay = this.retryDelayMs * this.retryBackoff ** (attempt - 1);
865
+ if (delay > 0) await wait(delay);
866
+ }
867
+ }
868
+ }
869
+ async applyCollectionPushResult(operation, result) {
870
+ if (!this.local.getCollections || !this.local.setCollection || !this.local.removeCollection) return;
871
+ if (!result) return;
872
+ const outcome = result.type === "conflict" ? result.resolution : "remote";
873
+ if (outcome === "manual") return;
874
+ const deleted = outcome === "local" ? operation.type === "remove" : result.deleted ?? (operation.type === "remove" && outcome === "remote");
875
+ if (deleted) {
876
+ await this.local.removeCollection(operation.id, this.scope);
877
+ } else if (result.type === "synced" || outcome === "remote" || outcome === "local") {
878
+ const current = (outcome === "local" ? operation.collection : result.collection) ?? (await this.getCollections?.())?.find((entry) => entry.id === operation.id);
879
+ if (!current) return;
880
+ await this.local.setCollection({
881
+ ...current,
882
+ ...result.revision ? { revision: result.revision } : current.revision ? { revision: current.revision } : {},
883
+ ...this.scope ? { scope: this.scope } : {}
884
+ });
885
+ }
886
+ if (result.type === "conflict") {
887
+ const conflict = createCollectionSyncConflict(operation, result);
888
+ this.updateState({
889
+ status: "conflict",
890
+ collectionConflicts: [
891
+ ...(this.state.collectionConflicts ?? []).filter((entry) => entry.operation.id !== operation.id),
892
+ conflict
893
+ ]
894
+ });
895
+ }
896
+ }
897
+ async pushMembershipWithRetry(operation) {
898
+ let attempt = 0;
899
+ while (true) {
900
+ try {
901
+ await this.remote.pushMembership?.({ ...operation, attempts: attempt });
696
902
  return;
697
903
  } catch (error) {
698
904
  if (isKeepSyncAuthError(error) || attempt >= this.maxRetries) throw error;
@@ -771,7 +977,7 @@ var SyncStorageAdapter = class {
771
977
  });
772
978
  }
773
979
  async pullRemote() {
774
- if (!this.remote.pull && !this.remote.pullCollections) return true;
980
+ if (!this.remote.pull && !this.remote.pullCollections && !this.remote.pullMemberships) return true;
775
981
  try {
776
982
  if (this.remote.pull) {
777
983
  const remoteItems = await this.remote.pull();
@@ -805,7 +1011,7 @@ var SyncStorageAdapter = class {
805
1011
  const localCollections = await this.local.getCollections();
806
1012
  const pendingIds = new Set(this.queueItems.filter(isCollectionSyncOperation).map((operation) => operation.id));
807
1013
  const remoteById = new Map(
808
- remoteCollections.filter((collection) => !this.scope || sameScope(collection.scope, this.scope)).map((collection) => [collection.id, collection])
1014
+ remoteCollections.filter((collection) => !this.scope || !collection.scope || sameScope(collection.scope, this.scope)).map((collection) => [collection.id, collection])
809
1015
  );
810
1016
  for (const collection of remoteById.values()) {
811
1017
  if (pendingIds.has(collection.id)) continue;
@@ -818,6 +1024,32 @@ var SyncStorageAdapter = class {
818
1024
  }
819
1025
  this.notifyDataListeners();
820
1026
  }
1027
+ if (this.remote.pullMemberships && this.getCollections && this.local.getCollectionMemberships && this.local.setCollectionMembership && this.local.removeCollectionMembership) {
1028
+ const remoteMemberships = await this.remote.pullMemberships();
1029
+ const localMemberships = await this.local.getCollectionMemberships();
1030
+ const pendingIds = new Set(this.queueItems.filter(isMembershipSyncOperation).map((operation) => operation.id));
1031
+ const itemIds = new Set((await this.getAll()).map((item) => item.id));
1032
+ const collectionIds = new Set((await this.getCollections()).map((collection) => collection.id));
1033
+ const remoteById = new Map(
1034
+ remoteMemberships.filter((membership) => {
1035
+ if (this.scope && membership.scope && !sameScope(membership.scope, this.scope)) return false;
1036
+ if (!itemIds.has(membership.itemId)) return false;
1037
+ return collectionIds.has(membership.collectionId);
1038
+ }).map((membership) => [getMembershipId(membership.collectionId, membership.itemId), membership])
1039
+ );
1040
+ for (const membership of remoteById.values()) {
1041
+ const id = getMembershipId(membership.collectionId, membership.itemId);
1042
+ if (pendingIds.has(id)) continue;
1043
+ await this.local.setCollectionMembership({ ...membership, ...this.scope ? { scope: this.scope } : {} });
1044
+ }
1045
+ for (const membership of localMemberships) {
1046
+ const id = getMembershipId(membership.collectionId, membership.itemId);
1047
+ if (pendingIds.has(id) || remoteById.has(id)) continue;
1048
+ if (this.scope && !sameScope(membership.scope, this.scope)) continue;
1049
+ await this.local.removeCollectionMembership(membership.collectionId, membership.itemId, this.scope);
1050
+ }
1051
+ this.notifyDataListeners();
1052
+ }
821
1053
  return true;
822
1054
  } catch (error) {
823
1055
  this.updateState({ status: "error", error });
@@ -844,11 +1076,27 @@ function isCollectionSyncOperation(value) {
844
1076
  if (!isRecord(value)) return false;
845
1077
  return typeof value.operationId === "string" && value.entity === "collection" && (value.type === "upsert" || value.type === "remove") && typeof value.id === "string" && typeof value.createdAt === "number" && (value.collection === void 0 || isKeepCollectionDefinition(value.collection));
846
1078
  }
1079
+ function isMembershipSyncOperation(value) {
1080
+ if (!isRecord(value)) return false;
1081
+ return typeof value.operationId === "string" && value.entity === "membership" && (value.type === "upsert" || value.type === "remove") && typeof value.id === "string" && typeof value.collectionId === "string" && typeof value.itemId === "string" && typeof value.createdAt === "number" && (value.membership === void 0 || isKeepCollectionMembership(value.membership)) && (value.type !== "upsert" || isKeepCollectionMembership(value.membership));
1082
+ }
847
1083
  function isSyncQueueOperation(value) {
848
- return isSyncOperation(value) || isCollectionSyncOperation(value);
1084
+ return isSyncOperation(value) || isCollectionSyncOperation(value) || isMembershipSyncOperation(value);
849
1085
  }
850
1086
  function getQueueIdentity(operation) {
851
- return `${operation.entity === "collection" ? "collection" : "item"}:${operation.id}`;
1087
+ return `${operation.entity ?? "item"}:${operation.id}`;
1088
+ }
1089
+ function getMembershipId(collectionId, itemId) {
1090
+ return `${collectionId}\0${itemId}`;
1091
+ }
1092
+ function createCollectionSyncConflict(operation, result) {
1093
+ const details = {
1094
+ operation,
1095
+ resolution: result.resolution,
1096
+ ...result.revision ? { revision: result.revision } : {}
1097
+ };
1098
+ if (result.deleted) return { ...details, deleted: true };
1099
+ return { ...details, deleted: false, collection: result.collection };
852
1100
  }
853
1101
  function createId() {
854
1102
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
@@ -931,6 +1179,7 @@ var FallbackStorageAdapter = class {
931
1179
  this.migrateFallbackOnEmpty = options.migrateFallbackOnEmpty ?? false;
932
1180
  this.mirrorWrites = options.mirrorWrites ?? false;
933
1181
  this.storageKey = options.fallback.storageKey ?? options.primary.storageKey;
1182
+ this.scope = options.primary.scope ?? options.fallback.scope;
934
1183
  if (this.primary.getCollections && this.primary.setCollection && this.primary.removeCollection && this.fallback.getCollections && this.fallback.setCollection && this.fallback.removeCollection) {
935
1184
  const fallbackGetCollections = this.fallback.getCollections.bind(this.fallback);
936
1185
  this.getCollections = async () => {
@@ -985,12 +1234,12 @@ var FallbackStorageAdapter = class {
985
1234
  (adapter) => adapter.setMany ? adapter.setMany(items) : Promise.all(items.map((item) => adapter.set(item))).then(() => void 0)
986
1235
  );
987
1236
  }
988
- remove(id) {
989
- return this.executeWrite((adapter) => adapter.remove(id));
1237
+ remove(id, scope) {
1238
+ return this.executeWrite((adapter) => adapter.remove(id, scope));
990
1239
  }
991
- removeMany(ids) {
1240
+ removeMany(ids, scope) {
992
1241
  return this.executeWrite(
993
- (adapter) => adapter.removeMany ? adapter.removeMany(ids) : Promise.all(ids.map((id) => adapter.remove(id))).then(() => void 0)
1242
+ (adapter) => adapter.removeMany ? adapter.removeMany(ids, scope) : Promise.all(ids.map((id) => adapter.remove(id, scope))).then(() => void 0)
994
1243
  );
995
1244
  }
996
1245
  clear() {
@@ -1114,8 +1363,8 @@ function createStorageAdapter(options) {
1114
1363
  } : {},
1115
1364
  set: async (item) => options.set(item),
1116
1365
  ...setMany ? { setMany: async (items) => setMany(items) } : {},
1117
- remove: async (id) => options.remove(id),
1118
- ...removeMany ? { removeMany: async (ids) => removeMany(ids) } : {},
1366
+ remove: async (id, scope) => options.remove(id, scope),
1367
+ ...removeMany ? { removeMany: async (ids, scope) => removeMany(ids, scope) } : {},
1119
1368
  clear: async () => options.clear(),
1120
1369
  ...merge ? { merge: async (items) => merge(items) } : {},
1121
1370
  ...subscribe ? {
@@ -1205,6 +1454,9 @@ var LocalStorageAdapter = class {
1205
1454
  );
1206
1455
  }
1207
1456
  async getCollectionMemberships() {
1457
+ return this.readMemberships();
1458
+ }
1459
+ readMemberships() {
1208
1460
  if (!this.storage) return [];
1209
1461
  const storageKey = `${this.storageKey}:memberships`;
1210
1462
  let raw;
@@ -1227,12 +1479,12 @@ var LocalStorageAdapter = class {
1227
1479
  }
1228
1480
  async setCollectionMembership(membership) {
1229
1481
  if (!isKeepCollectionMembership(membership)) throw new Error("Collection membership is invalid.");
1230
- const current = await this.getCollectionMemberships();
1482
+ const current = this.readMemberships();
1231
1483
  const sameMembership = (entry) => entry.collectionId === membership.collectionId && entry.itemId === membership.itemId && isSameKeepScope(entry.scope, membership.scope);
1232
1484
  await this.writeMemberships([...current.filter((entry) => !sameMembership(entry)), membership], "set");
1233
1485
  }
1234
1486
  async removeCollectionMembership(collectionId, itemId, scope) {
1235
- const current = await this.getCollectionMemberships();
1487
+ const current = this.readMemberships();
1236
1488
  await this.writeMemberships(
1237
1489
  current.filter(
1238
1490
  (entry) => entry.collectionId !== collectionId || entry.itemId !== itemId || !isSameKeepScope(entry.scope, scope)
@@ -1245,24 +1497,37 @@ var LocalStorageAdapter = class {
1245
1497
  }
1246
1498
  async setMany(items) {
1247
1499
  const current = await this.getAll();
1248
- const byId = new Map(current.map((item) => [item.id, item]));
1249
- for (const item of items) byId.set(item.id, item);
1250
- this.write([...byId.values()], "set");
1500
+ const byScopeAndId = new Map(current.map((item) => [getItemStorageKey(item), item]));
1501
+ for (const item of items) byScopeAndId.set(getItemStorageKey(item), item);
1502
+ this.write([...byScopeAndId.values()], "set");
1251
1503
  }
1252
- async remove(id) {
1253
- await this.removeMany([id]);
1504
+ async remove(id, scope) {
1505
+ await this.removeMany([id], scope);
1254
1506
  }
1255
- async removeMany(ids) {
1507
+ async removeMany(ids, scope) {
1256
1508
  const idSet = new Set(ids);
1257
1509
  const items = await this.getAll();
1258
1510
  this.write(
1259
- items.filter((item) => !idSet.has(item.id)),
1511
+ items.filter((item) => !idSet.has(item.id) || scope !== void 0 && !isSameKeepScope(item.scope, scope)),
1260
1512
  "remove"
1261
1513
  );
1514
+ const memberships = await this.getCollectionMemberships();
1515
+ const membershipIds = memberships.filter(
1516
+ (membership) => idSet.has(membership.itemId) && (scope === void 0 || isSameKeepScope(membership.scope, scope))
1517
+ );
1518
+ for (const membership of membershipIds) {
1519
+ await this.removeCollectionMembership(membership.collectionId, membership.itemId, membership.scope);
1520
+ }
1262
1521
  }
1263
1522
  async clear() {
1264
1523
  if (!this.storage) return;
1265
1524
  try {
1525
+ const items = await this.getAll();
1526
+ if (items.length > 0) await this.removeMany(items.map((item) => item.id));
1527
+ const memberships = await this.getCollectionMemberships();
1528
+ for (const membership of memberships) {
1529
+ await this.removeCollectionMembership(membership.collectionId, membership.itemId, membership.scope);
1530
+ }
1266
1531
  this.storage.removeItem(this.storageKey);
1267
1532
  } catch (cause) {
1268
1533
  throw new KeepStorageAccessError({
@@ -1344,7 +1609,7 @@ var IndexedDBAdapter = class {
1344
1609
  if (!isKeepItemArray(value)) {
1345
1610
  throw new KeepStorageParseError({ operation: "getAll", storageKey: this.storageKey });
1346
1611
  }
1347
- return value;
1612
+ return value.map(decodeIndexedDBItem);
1348
1613
  } catch (cause) {
1349
1614
  if (cause instanceof KeepStorageParseError) throw cause;
1350
1615
  throw new KeepStorageAccessError({ operation: "getAll", storageKey: this.storageKey, cause });
@@ -1415,9 +1680,12 @@ var IndexedDBAdapter = class {
1415
1680
  const database = await this.open("set");
1416
1681
  if (!database) return;
1417
1682
  try {
1683
+ const identities = new Set(items.map(getItemIdentity));
1684
+ const previous = (await this.getAll()).filter((item) => identities.has(getItemIdentity(item)));
1418
1685
  const transaction = database.transaction(this.storeName, "readwrite");
1419
1686
  const objectStore = transaction.objectStore(this.storeName);
1420
- for (const item of items) objectStore.put(item);
1687
+ for (const item of previous) objectStore.delete(getStoredIndexedDBItemId(item));
1688
+ for (const item of items) objectStore.put(encodeIndexedDBItem(item));
1421
1689
  await transactionToPromise2(transaction);
1422
1690
  this.notifySubscribers();
1423
1691
  } catch (cause) {
@@ -1427,17 +1695,25 @@ var IndexedDBAdapter = class {
1427
1695
  throw new KeepStorageAccessError({ operation: "set", storageKey: this.storageKey, cause });
1428
1696
  }
1429
1697
  }
1430
- async remove(id) {
1431
- return this.removeMany([id]);
1698
+ async remove(id, scope) {
1699
+ return this.removeMany([id], scope);
1432
1700
  }
1433
- async removeMany(ids) {
1701
+ async removeMany(ids, scope) {
1434
1702
  const database = await this.open("remove");
1435
1703
  if (!database) return;
1436
1704
  try {
1705
+ const idSet = new Set(ids);
1706
+ const removableIds = (await this.getAll()).filter((item) => idSet.has(item.id) && (scope === void 0 || isSameKeepScope(item.scope, scope))).map(getStoredIndexedDBItemId);
1437
1707
  const transaction = database.transaction(this.storeName, "readwrite");
1438
1708
  const objectStore = transaction.objectStore(this.storeName);
1439
- for (const id of new Set(ids)) objectStore.delete(id);
1709
+ for (const id of removableIds) objectStore.delete(id);
1440
1710
  await transactionToPromise2(transaction);
1711
+ const memberships = await this.getCollectionMemberships();
1712
+ for (const membership of memberships) {
1713
+ if (idSet.has(membership.itemId) && (scope === void 0 || isSameKeepScope(membership.scope, scope))) {
1714
+ await this.removeCollectionMembership(membership.collectionId, membership.itemId, membership.scope);
1715
+ }
1716
+ }
1441
1717
  this.notifySubscribers();
1442
1718
  } catch (cause) {
1443
1719
  throw new KeepStorageAccessError({ operation: "remove", storageKey: this.storageKey, cause });
@@ -1447,6 +1723,12 @@ var IndexedDBAdapter = class {
1447
1723
  const database = await this.open("clear");
1448
1724
  if (!database) return;
1449
1725
  try {
1726
+ const items = await this.getAll();
1727
+ if (items.length > 0) await this.removeMany(items.map((item) => item.id));
1728
+ const memberships = await this.getCollectionMemberships();
1729
+ for (const membership of memberships) {
1730
+ await this.removeCollectionMembership(membership.collectionId, membership.itemId, membership.scope);
1731
+ }
1450
1732
  const transaction = database.transaction(this.storeName, "readwrite");
1451
1733
  transaction.objectStore(this.storeName).clear();
1452
1734
  await transactionToPromise2(transaction);
@@ -1563,9 +1845,39 @@ var IndexedDBAdapter = class {
1563
1845
  });
1564
1846
  }
1565
1847
  };
1848
+ var indexedDBStoredItemId = /* @__PURE__ */ Symbol("keepkit.indexeddb-stored-item-id");
1849
+ function encodeIndexedDBItem(item) {
1850
+ if (!item.scope) return item;
1851
+ return {
1852
+ ...item,
1853
+ id: createScopedIndexedDBItemId(item),
1854
+ __keepkitOriginalId: item.id
1855
+ };
1856
+ }
1857
+ function decodeIndexedDBItem(item) {
1858
+ const originalId = item.__keepkitOriginalId;
1859
+ const isEncoded = typeof originalId === "string" && item.scope !== void 0 && item.id === createScopedIndexedDBItemId({ ...item, id: originalId });
1860
+ const { __keepkitOriginalId: _encodedOriginalId, ...record } = item;
1861
+ const decoded = isEncoded ? { ...record, id: originalId } : item;
1862
+ Object.defineProperty(decoded, indexedDBStoredItemId, { value: item.id });
1863
+ return decoded;
1864
+ }
1865
+ function getStoredIndexedDBItemId(item) {
1866
+ return item[indexedDBStoredItemId] ?? createScopedIndexedDBItemId(item);
1867
+ }
1868
+ function createScopedIndexedDBItemId(item) {
1869
+ if (!item.scope) return item.id;
1870
+ return `\0keepkit-scope:${JSON.stringify([item.scope.tenantId ?? null, item.scope.userId ?? null, item.id])}`;
1871
+ }
1872
+ function getItemIdentity(item) {
1873
+ return JSON.stringify([item.scope?.tenantId ?? null, item.scope?.userId ?? null, item.id]);
1874
+ }
1566
1875
  function collectionKey(id, scope) {
1567
1876
  return JSON.stringify([scope?.tenantId ?? null, scope?.userId ?? null, id]);
1568
1877
  }
1878
+ function getItemStorageKey(item) {
1879
+ return JSON.stringify([item.scope?.tenantId ?? null, item.scope?.userId ?? null, item.id]);
1880
+ }
1569
1881
  function membershipKey(collectionId, itemId, scope) {
1570
1882
  return JSON.stringify([scope?.tenantId ?? null, scope?.userId ?? null, collectionId, itemId]);
1571
1883
  }
@@ -1638,4 +1950,4 @@ export {
1638
1950
  LocalStorageAdapter,
1639
1951
  IndexedDBAdapter
1640
1952
  };
1641
- //# sourceMappingURL=chunk-NZLAW4LY.js.map
1953
+ //# sourceMappingURL=chunk-SLH4DFYQ.js.map