@keepkit/core 0.28.6 → 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.
package/dist/core.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  reconcileKeepItems,
21
21
  reorderKeepItems,
22
22
  revalidateKeepItems
23
- } from "./chunk-5XSSH3TO.js";
23
+ } from "./chunk-EMOPFB5D.js";
24
24
  import {
25
25
  KeepSchemaValidationError,
26
26
  parseKeepMeta,
@@ -54,7 +54,23 @@ import {
54
54
  isKeepSyncAuthError,
55
55
  isSameKeepScope,
56
56
  normalizeKeepTags
57
- } from "./chunk-QRPQYE7K.js";
57
+ } from "./chunk-SLH4DFYQ.js";
58
+
59
+ // src/features/persistence/write-lock.ts
60
+ var writeQueues = /* @__PURE__ */ new Map();
61
+ function withLocalStorageWriteLock(key, operation, lockUnavailableError) {
62
+ const lockName = `keepkit:local-storage:${key}`;
63
+ if (typeof navigator !== "undefined" && navigator.locks) {
64
+ return navigator.locks.request(lockName, operation);
65
+ }
66
+ if (typeof window !== "undefined") return Promise.reject(lockUnavailableError);
67
+ const previous = writeQueues.get(lockName) ?? Promise.resolve();
68
+ const next = previous.catch(() => void 0).then(operation);
69
+ writeQueues.set(lockName, next);
70
+ return next.finally(() => {
71
+ if (writeQueues.get(lockName) === next) writeQueues.delete(lockName);
72
+ });
73
+ }
58
74
 
59
75
  // src/features/items/activity.ts
60
76
  var DEFAULT_KEEP_HISTORY_KEY = "keepkit:view-history";
@@ -62,7 +78,6 @@ var DEFAULT_KEEP_VIEWING_RECORDS_KEY = "keepkit:viewing-records";
62
78
  var DEFAULT_KEEP_HISTORY_LIMIT = 100;
63
79
  var LocalStorageKeepHistoryStorage = class {
64
80
  constructor(options = {}) {
65
- this.writes = Promise.resolve();
66
81
  this.key = options.key ?? DEFAULT_KEEP_HISTORY_KEY;
67
82
  this.limitKey = `${this.key}:limit`;
68
83
  this.storage = options.storage ?? getBrowserStorage();
@@ -125,9 +140,12 @@ var LocalStorageKeepHistoryStorage = class {
125
140
  this.writeRaw(this.key, JSON.stringify(entries), "history");
126
141
  }
127
142
  withWriteLock(operation) {
128
- const next = this.writes.then(operation);
129
- this.writes = next.catch(() => void 0);
130
- return next;
143
+ if (!this.storage) return operation();
144
+ return withLocalStorageWriteLock(
145
+ this.key,
146
+ operation,
147
+ new KeepActivityStorageError("KeepKit needs the Web Locks API to safely update history across tabs.", this.key)
148
+ );
131
149
  }
132
150
  async read(key, validate, label) {
133
151
  const raw = this.readRaw(key, label);
@@ -155,7 +173,6 @@ var LocalStorageKeepHistoryStorage = class {
155
173
  };
156
174
  var LocalStorageKeepViewingRecordStorage = class {
157
175
  constructor(options = {}) {
158
- this.writes = Promise.resolve();
159
176
  this.key = options.key ?? DEFAULT_KEEP_VIEWING_RECORDS_KEY;
160
177
  this.storage = options.storage ?? getBrowserStorage();
161
178
  this.now = options.now ?? Date.now;
@@ -186,11 +203,14 @@ var LocalStorageKeepViewingRecordStorage = class {
186
203
  return this.withWriteLock(async () => {
187
204
  const records = await this.readRecords();
188
205
  const previous = records.find((entry) => entry.id === normalized.id);
206
+ if (previous?.updatedAt !== void 0 && normalized.updatedAt !== void 0 && previous.updatedAt > normalized.updatedAt) {
207
+ return { ...previous };
208
+ }
189
209
  const now = this.now();
190
210
  const next = {
191
211
  ...normalized,
192
212
  createdAt: previous?.createdAt ?? normalized.createdAt ?? now,
193
- updatedAt: now
213
+ updatedAt: Math.max(now, (previous?.updatedAt ?? -1) + 1, normalized.updatedAt ?? -1)
194
214
  };
195
215
  this.writeRecords([...records.filter((entry) => entry.id !== normalized.id), next]);
196
216
  return { ...next };
@@ -237,9 +257,15 @@ var LocalStorageKeepViewingRecordStorage = class {
237
257
  }
238
258
  }
239
259
  withWriteLock(operation) {
240
- const next = this.writes.then(operation);
241
- this.writes = next.catch(() => void 0);
242
- return next;
260
+ if (!this.storage) return operation();
261
+ return withLocalStorageWriteLock(
262
+ this.key,
263
+ operation,
264
+ new KeepActivityStorageError(
265
+ "KeepKit needs the Web Locks API to safely update viewing records across tabs.",
266
+ this.key
267
+ )
268
+ );
243
269
  }
244
270
  };
245
271
  var KeepActivityStorageError = class extends Error {
@@ -320,6 +346,7 @@ function createRecordId() {
320
346
  }
321
347
 
322
348
  // src/features/items/collections.ts
349
+ var collectionMutationQueues = /* @__PURE__ */ new WeakMap();
323
350
  async function getKeepCollectionItems(storage, collectionId) {
324
351
  const memberships = await requireMemberships(storage);
325
352
  const items = await storage.getAll();
@@ -330,48 +357,62 @@ async function getKeepCollectionItems(storage, collectionId) {
330
357
  });
331
358
  }
332
359
  async function addKeepItemToCollection(storage, collectionId, itemId, order) {
333
- if (!(await storage.getAll()).some((item) => item.id === itemId)) {
334
- throw new Error(`KeepKit cannot add missing item "${itemId}" to a collection.`);
335
- }
336
- const collectionMemberships = (await requireMemberships(storage)).filter((membership) => membership.collectionId === collectionId).filter((membership) => membership.itemId !== itemId).sort((left, right) => left.order - right.order);
337
- const insertionIndex = order ?? collectionMemberships.length;
338
- if (!Number.isInteger(insertionIndex) || insertionIndex < 0) {
339
- throw new RangeError("Collection order must be a non-negative integer.");
340
- }
341
- collectionMemberships.splice(Math.min(insertionIndex, collectionMemberships.length), 0, {
342
- collectionId,
343
- itemId,
344
- order: insertionIndex
360
+ return withCollectionMutationLock(storage, async () => {
361
+ if (!(await storage.getAll()).some((item) => item.id === itemId)) {
362
+ throw new Error(`KeepKit cannot add missing item "${itemId}" to a collection.`);
363
+ }
364
+ const collectionMemberships = (await requireMemberships(storage)).filter((membership) => membership.collectionId === collectionId).filter((membership) => membership.itemId !== itemId).sort((left, right) => left.order - right.order);
365
+ const insertionIndex = order ?? collectionMemberships.length;
366
+ if (!Number.isInteger(insertionIndex) || insertionIndex < 0) {
367
+ throw new RangeError("Collection order must be a non-negative integer.");
368
+ }
369
+ collectionMemberships.splice(Math.min(insertionIndex, collectionMemberships.length), 0, {
370
+ collectionId,
371
+ itemId,
372
+ order: insertionIndex
373
+ });
374
+ const reordered = collectionMemberships.map((membership, index) => ({ ...membership, order: index }));
375
+ for (const membership of reordered) await storage.setCollectionMembership?.(membership);
376
+ const added = reordered.find((membership) => membership.itemId === itemId);
377
+ if (!added) throw new Error("KeepKit failed to add the collection membership.");
378
+ return added;
345
379
  });
346
- const reordered = collectionMemberships.map((membership, index) => ({ ...membership, order: index }));
347
- for (const membership of reordered) await storage.setCollectionMembership?.(membership);
348
- const added = reordered.find((membership) => membership.itemId === itemId);
349
- if (!added) throw new Error("KeepKit failed to add the collection membership.");
350
- return added;
351
380
  }
352
381
  async function removeKeepItemFromCollection(storage, collectionId, itemId) {
353
- const memberships = await requireMemberships(storage);
354
- await storage.removeCollectionMembership?.(collectionId, itemId);
355
- const remaining = memberships.filter((membership) => membership.collectionId === collectionId && membership.itemId !== itemId).sort((left, right) => left.order - right.order).map((membership, order) => ({ ...membership, order }));
356
- for (const membership of remaining) await storage.setCollectionMembership?.(membership);
382
+ await withCollectionMutationLock(storage, async () => {
383
+ const memberships = await requireMemberships(storage);
384
+ await storage.removeCollectionMembership?.(collectionId, itemId);
385
+ const remaining = memberships.filter((membership) => membership.collectionId === collectionId && membership.itemId !== itemId).sort((left, right) => left.order - right.order).map((membership, order) => ({ ...membership, order }));
386
+ for (const membership of remaining) await storage.setCollectionMembership?.(membership);
387
+ });
357
388
  }
358
389
  async function reorderKeepCollectionItems(storage, collectionId, itemIds) {
359
- const allMemberships = await requireMemberships(storage);
360
- const current = allMemberships.filter((membership) => membership.collectionId === collectionId).sort((left, right) => left.order - right.order);
361
- const currentIds = new Set(current.map((membership) => membership.itemId));
362
- const orderedIds = [...itemIds.filter((id, index) => currentIds.has(id) && itemIds.indexOf(id) === index)];
363
- for (const membership of current) {
364
- if (!orderedIds.includes(membership.itemId)) orderedIds.push(membership.itemId);
365
- }
366
- const currentByItemId = new Map(current.map((membership) => [membership.itemId, membership]));
367
- const reordered = orderedIds.map((itemId, order) => ({
368
- ...currentByItemId.get(itemId) ?? {},
369
- collectionId,
370
- itemId,
371
- order
372
- }));
373
- for (const membership of reordered) await storage.setCollectionMembership?.(membership);
374
- return reordered;
390
+ return withCollectionMutationLock(storage, async () => {
391
+ const allMemberships = await requireMemberships(storage);
392
+ const current = allMemberships.filter((membership) => membership.collectionId === collectionId).sort((left, right) => left.order - right.order);
393
+ const currentIds = new Set(current.map((membership) => membership.itemId));
394
+ const orderedIds = [...itemIds.filter((id, index) => currentIds.has(id) && itemIds.indexOf(id) === index)];
395
+ for (const membership of current) {
396
+ if (!orderedIds.includes(membership.itemId)) orderedIds.push(membership.itemId);
397
+ }
398
+ const currentByItemId = new Map(current.map((membership) => [membership.itemId, membership]));
399
+ const reordered = orderedIds.map((itemId, order) => ({
400
+ ...currentByItemId.get(itemId) ?? {},
401
+ collectionId,
402
+ itemId,
403
+ order
404
+ }));
405
+ for (const membership of reordered) await storage.setCollectionMembership?.(membership);
406
+ return reordered;
407
+ });
408
+ }
409
+ function withCollectionMutationLock(storage, operation) {
410
+ const previous = collectionMutationQueues.get(storage) ?? Promise.resolve();
411
+ const next = previous.catch(() => void 0).then(operation);
412
+ collectionMutationQueues.set(storage, next);
413
+ return next.finally(() => {
414
+ if (collectionMutationQueues.get(storage) === next) collectionMutationQueues.delete(storage);
415
+ });
375
416
  }
376
417
  async function requireMemberships(storage) {
377
418
  if (!storage.getCollectionMemberships || !storage.setCollectionMembership || !storage.removeCollectionMembership) {
@@ -411,7 +452,6 @@ var createKeepKitSetup = createKeepKitPreset;
411
452
  var DEFAULT_KEEP_PROGRESS_KEY = "keepkit:progress";
412
453
  var LocalStorageKeepProgressStorage = class {
413
454
  constructor(options = {}) {
414
- this.writes = Promise.resolve();
415
455
  this.key = options.key ?? DEFAULT_KEEP_PROGRESS_KEY;
416
456
  this.storage = options.storage ?? getBrowserStorage2();
417
457
  this.now = options.now ?? Date.now;
@@ -443,32 +483,53 @@ var LocalStorageKeepProgressStorage = class {
443
483
  return record;
444
484
  }
445
485
  async saveCourse(courseId, currentItemId) {
446
- const record = {
447
- kind: "course",
448
- courseId: normalizeId2(courseId, "course"),
449
- ...currentItemId?.trim() ? { currentItemId: currentItemId.trim() } : {},
450
- updatedAt: this.now()
451
- };
452
- await this.set(record);
453
- return record;
486
+ const id = normalizeId2(courseId, "course");
487
+ return this.withWriteLock(async () => {
488
+ const records = await this.getAll();
489
+ const previous = records.find((entry) => entry.kind === "course" && entry.courseId === id);
490
+ const record = {
491
+ kind: "course",
492
+ courseId: id,
493
+ ...currentItemId?.trim() ? { currentItemId: currentItemId.trim() } : {},
494
+ updatedAt: Math.max(this.now(), (previous?.updatedAt ?? -1) + 1)
495
+ };
496
+ this.writeRecords([
497
+ ...records.filter((entry) => getProgressIdentity(entry) !== getProgressIdentity(record)),
498
+ record
499
+ ]);
500
+ return record;
501
+ });
454
502
  }
455
503
  async saveItem(itemId, progress) {
456
- const record = {
457
- kind: "item",
458
- itemId: normalizeId2(itemId, "item"),
459
- ...progress,
460
- ...progress.audioId?.trim() ? { audioId: progress.audioId.trim() } : {},
461
- updatedAt: this.now()
462
- };
463
- await this.set(record);
464
- return record;
504
+ const id = normalizeId2(itemId, "item");
505
+ return this.withWriteLock(async () => {
506
+ const records = await this.getAll();
507
+ const previous = records.find((entry) => entry.kind === "item" && entry.itemId === id);
508
+ const record = {
509
+ ...previous,
510
+ ...progress,
511
+ kind: "item",
512
+ itemId: id,
513
+ ...progress.audioId !== void 0 ? progress.audioId.trim() ? { audioId: progress.audioId.trim() } : { audioId: void 0 } : {},
514
+ updatedAt: Math.max(this.now(), (previous?.updatedAt ?? -1) + 1)
515
+ };
516
+ const normalized = normalizeProgressRecord(record);
517
+ if (normalized.kind !== "item") throw new TypeError("KeepKit progress record is invalid.");
518
+ this.writeRecords([
519
+ ...records.filter((entry) => getProgressIdentity(entry) !== getProgressIdentity(normalized)),
520
+ normalized
521
+ ]);
522
+ return normalized;
523
+ });
465
524
  }
466
525
  async set(record) {
467
526
  await this.withWriteLock(async () => {
468
527
  const normalized = normalizeProgressRecord(record);
469
528
  const records = await this.getAll();
470
529
  const identity = getProgressIdentity(normalized);
471
- this.writeRecords([...records.filter((entry) => getProgressIdentity(entry) !== identity), normalized]);
530
+ const previous = records.find((entry) => getProgressIdentity(entry) === identity);
531
+ const resolved = previous && previous.updatedAt > normalized.updatedAt ? previous : normalized;
532
+ this.writeRecords([...records.filter((entry) => getProgressIdentity(entry) !== identity), resolved]);
472
533
  });
473
534
  }
474
535
  async resetCourse(courseId) {
@@ -504,9 +565,12 @@ var LocalStorageKeepProgressStorage = class {
504
565
  }
505
566
  }
506
567
  withWriteLock(operation) {
507
- const next = this.writes.then(operation);
508
- this.writes = next.catch(() => void 0);
509
- return next;
568
+ if (!this.storage) return operation();
569
+ return withLocalStorageWriteLock(
570
+ this.key,
571
+ operation,
572
+ new KeepProgressStorageError("KeepKit needs the Web Locks API to safely update progress across tabs.", this.key)
573
+ );
510
574
  }
511
575
  };
512
576
  function isProgressCompatible(record, compatibility) {