@keepkit/core 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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/chunk-H4A322SZ.js +768 -0
- package/dist/chunk-H4A322SZ.js.map +1 -0
- package/dist/index.d.ts +224 -0
- package/dist/index.js +1093 -0
- package/dist/index.js.map +1 -0
- package/dist/storage-DPIKGno6.d.ts +316 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +29 -0
- package/dist/storage.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var KeepStorageError = class extends Error {
|
|
3
|
+
constructor(message, options) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "KeepStorageError";
|
|
6
|
+
this.operation = options.operation;
|
|
7
|
+
this.storageKey = options.storageKey;
|
|
8
|
+
if (options.cause !== void 0) this.cause = options.cause;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var KeepStorageQuotaError = class extends KeepStorageError {
|
|
12
|
+
constructor(options) {
|
|
13
|
+
super("KeepKit storage quota was exceeded.", options);
|
|
14
|
+
this.name = "KeepStorageQuotaError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var KeepStorageAccessError = class extends KeepStorageError {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super("KeepKit could not access the configured storage.", options);
|
|
20
|
+
this.name = "KeepStorageAccessError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var KeepStorageParseError = class extends KeepStorageError {
|
|
24
|
+
constructor(options) {
|
|
25
|
+
super("KeepKit found invalid data in the configured storage.", options);
|
|
26
|
+
this.name = "KeepStorageParseError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function normalizeKeepTags(tags) {
|
|
30
|
+
if (!tags) return void 0;
|
|
31
|
+
const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
|
32
|
+
return normalized.length > 0 ? normalized : void 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/storage/sync.ts
|
|
36
|
+
var DEFAULT_SYNC_QUEUE_KEY = "keepkit:sync-queue";
|
|
37
|
+
var DEFAULT_SYNC_QUEUE_DATABASE = "keepkit-sync";
|
|
38
|
+
var DEFAULT_SYNC_QUEUE_STORE = "sync-queue";
|
|
39
|
+
var LocalStorageSyncQueueAdapter = class {
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.key = options.key ?? DEFAULT_SYNC_QUEUE_KEY;
|
|
42
|
+
this.storage = options.storage ?? getBrowserStorage();
|
|
43
|
+
}
|
|
44
|
+
async getAll() {
|
|
45
|
+
if (!this.storage) return [];
|
|
46
|
+
const raw = this.storage.getItem(this.key);
|
|
47
|
+
if (!raw) return [];
|
|
48
|
+
let value;
|
|
49
|
+
try {
|
|
50
|
+
value = JSON.parse(raw);
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
throw Object.assign(new Error("KeepKit sync queue contains invalid JSON."), { cause });
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(value) || !value.every(isSyncOperation)) {
|
|
55
|
+
throw new Error("KeepKit sync queue contains invalid operations.");
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
async setMany(operations) {
|
|
60
|
+
if (!this.storage) return;
|
|
61
|
+
this.storage.setItem(this.key, JSON.stringify(operations));
|
|
62
|
+
}
|
|
63
|
+
async remove(operationIds) {
|
|
64
|
+
const ids = new Set(operationIds);
|
|
65
|
+
const current = await this.getAll();
|
|
66
|
+
await this.setMany(current.filter((operation) => !ids.has(operation.operationId)));
|
|
67
|
+
}
|
|
68
|
+
async clear() {
|
|
69
|
+
this.storage?.removeItem(this.key);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var IndexedDBSyncQueueAdapter = class {
|
|
73
|
+
constructor(options = {}) {
|
|
74
|
+
this.databaseName = options.databaseName ?? DEFAULT_SYNC_QUEUE_DATABASE;
|
|
75
|
+
this.storeName = options.storeName ?? DEFAULT_SYNC_QUEUE_STORE;
|
|
76
|
+
this.version = options.version ?? 1;
|
|
77
|
+
this.indexedDB = options.indexedDB ?? getBrowserIndexedDB();
|
|
78
|
+
}
|
|
79
|
+
async getAll() {
|
|
80
|
+
const database = await this.open();
|
|
81
|
+
if (!database) return [];
|
|
82
|
+
const transaction = database.transaction(this.storeName, "readonly");
|
|
83
|
+
const value = await requestToPromise(transaction.objectStore(this.storeName).getAll());
|
|
84
|
+
if (!Array.isArray(value) || !value.every(isSyncOperation)) {
|
|
85
|
+
throw new Error("KeepKit sync queue contains invalid operations.");
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
async setMany(operations) {
|
|
90
|
+
const database = await this.open();
|
|
91
|
+
if (!database) return;
|
|
92
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
93
|
+
const store = transaction.objectStore(this.storeName);
|
|
94
|
+
for (const operation of operations) store.put(operation);
|
|
95
|
+
await transactionToPromise(transaction);
|
|
96
|
+
}
|
|
97
|
+
async remove(operationIds) {
|
|
98
|
+
const database = await this.open();
|
|
99
|
+
if (!database) return;
|
|
100
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
101
|
+
const store = transaction.objectStore(this.storeName);
|
|
102
|
+
for (const operationId of new Set(operationIds)) store.delete(operationId);
|
|
103
|
+
await transactionToPromise(transaction);
|
|
104
|
+
}
|
|
105
|
+
async clear() {
|
|
106
|
+
const database = await this.open();
|
|
107
|
+
if (!database) return;
|
|
108
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
109
|
+
transaction.objectStore(this.storeName).clear();
|
|
110
|
+
await transactionToPromise(transaction);
|
|
111
|
+
}
|
|
112
|
+
open() {
|
|
113
|
+
if (!this.indexedDB) return Promise.resolve(void 0);
|
|
114
|
+
if (!this.databasePromise) {
|
|
115
|
+
this.databasePromise = new Promise((resolve, reject) => {
|
|
116
|
+
let request;
|
|
117
|
+
try {
|
|
118
|
+
request = this.indexedDB?.open(this.databaseName, this.version);
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
reject(cause);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
request.onupgradeneeded = () => {
|
|
124
|
+
if (!request.result.objectStoreNames.contains(this.storeName)) {
|
|
125
|
+
request.result.createObjectStore(this.storeName, { keyPath: "operationId" });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
request.onsuccess = () => resolve(request.result);
|
|
129
|
+
request.onerror = () => reject(request.error);
|
|
130
|
+
request.onblocked = () => reject(request.error ?? new Error("IndexedDB open was blocked."));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return this.databasePromise.catch((cause) => {
|
|
134
|
+
this.databasePromise = void 0;
|
|
135
|
+
throw cause;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var SyncStorageAdapter = class {
|
|
140
|
+
constructor(options) {
|
|
141
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
142
|
+
this.dataListeners = /* @__PURE__ */ new Set();
|
|
143
|
+
this.queueItems = [];
|
|
144
|
+
this.queueLoaded = false;
|
|
145
|
+
this.state = { status: "idle", pendingCount: 0, conflictIds: [] };
|
|
146
|
+
this.getSyncState = () => this.state;
|
|
147
|
+
this.subscribeSync = (listener) => {
|
|
148
|
+
this.listeners.add(listener);
|
|
149
|
+
return () => this.listeners.delete(listener);
|
|
150
|
+
};
|
|
151
|
+
this.subscribe = (listener) => {
|
|
152
|
+
this.dataListeners.add(listener);
|
|
153
|
+
const unsubscribeLocal = this.local.subscribe?.(listener) ?? (() => void 0);
|
|
154
|
+
return () => {
|
|
155
|
+
this.dataListeners.delete(listener);
|
|
156
|
+
unsubscribeLocal();
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
this.local = options.local;
|
|
160
|
+
this.remote = options.remote;
|
|
161
|
+
this.queue = options.queue ?? (getBrowserIndexedDB() ? new IndexedDBSyncQueueAdapter({
|
|
162
|
+
databaseName: options.queueDatabaseName
|
|
163
|
+
}) : new LocalStorageSyncQueueAdapter({
|
|
164
|
+
key: options.queueKey ?? `${DEFAULT_SYNC_QUEUE_KEY}:${options.local.storageKey ?? "default"}`
|
|
165
|
+
}));
|
|
166
|
+
this.clientId = options.clientId ?? createId();
|
|
167
|
+
this.now = options.now ?? Date.now;
|
|
168
|
+
this.resolveConflict = options.resolveConflict;
|
|
169
|
+
this.storageKey = this.local.storageKey;
|
|
170
|
+
if (typeof window !== "undefined") {
|
|
171
|
+
this.onlineHandler = () => void this.flushSync();
|
|
172
|
+
window.addEventListener("online", this.onlineHandler);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
getAll() {
|
|
176
|
+
return this.local.getAll();
|
|
177
|
+
}
|
|
178
|
+
async set(item) {
|
|
179
|
+
const operation = this.createOperation("upsert", item.id, item);
|
|
180
|
+
await this.enqueueBeforeLocalWrite(operation);
|
|
181
|
+
try {
|
|
182
|
+
await this.local.set(item);
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
await this.removeQueued(operation.operationId);
|
|
185
|
+
throw cause;
|
|
186
|
+
}
|
|
187
|
+
this.notifyDataListeners();
|
|
188
|
+
this.setPendingState();
|
|
189
|
+
}
|
|
190
|
+
async setMany(items) {
|
|
191
|
+
const operations = items.map((item) => this.createOperation("upsert", item.id, item));
|
|
192
|
+
await this.enqueueManyBeforeLocalWrite(operations);
|
|
193
|
+
try {
|
|
194
|
+
if (this.local.setMany) await this.local.setMany(items);
|
|
195
|
+
else for (const item of items) await this.local.set(item);
|
|
196
|
+
} catch (cause) {
|
|
197
|
+
await this.removeQueued(operations.map((operation) => operation.operationId));
|
|
198
|
+
throw cause;
|
|
199
|
+
}
|
|
200
|
+
this.notifyDataListeners();
|
|
201
|
+
this.setPendingState();
|
|
202
|
+
}
|
|
203
|
+
async remove(id) {
|
|
204
|
+
const operation = this.createOperation("remove", id);
|
|
205
|
+
await this.enqueueBeforeLocalWrite(operation);
|
|
206
|
+
try {
|
|
207
|
+
await this.local.remove(id);
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
await this.removeQueued(operation.operationId);
|
|
210
|
+
throw cause;
|
|
211
|
+
}
|
|
212
|
+
this.notifyDataListeners();
|
|
213
|
+
this.setPendingState();
|
|
214
|
+
}
|
|
215
|
+
async removeMany(ids) {
|
|
216
|
+
const operations = [...new Set(ids)].map((id) => this.createOperation("remove", id));
|
|
217
|
+
await this.enqueueManyBeforeLocalWrite(operations);
|
|
218
|
+
try {
|
|
219
|
+
if (this.local.removeMany) await this.local.removeMany(ids);
|
|
220
|
+
else for (const id of ids) await this.local.remove(id);
|
|
221
|
+
} catch (cause) {
|
|
222
|
+
await this.removeQueued(operations.map((operation) => operation.operationId));
|
|
223
|
+
throw cause;
|
|
224
|
+
}
|
|
225
|
+
this.notifyDataListeners();
|
|
226
|
+
this.setPendingState();
|
|
227
|
+
}
|
|
228
|
+
async clear() {
|
|
229
|
+
const items = await this.local.getAll();
|
|
230
|
+
await this.removeMany(items.map((item) => item.id));
|
|
231
|
+
await this.local.clear();
|
|
232
|
+
}
|
|
233
|
+
async merge(localItems) {
|
|
234
|
+
const merged = this.local.merge ? await this.local.merge(localItems) : await mergeLocalItems(localItems, this.local);
|
|
235
|
+
await this.setMany(localItems);
|
|
236
|
+
return merged;
|
|
237
|
+
}
|
|
238
|
+
async flushSync() {
|
|
239
|
+
if (this.flushPromise) return this.flushPromise;
|
|
240
|
+
this.flushPromise = this.runFlush().finally(() => {
|
|
241
|
+
this.flushPromise = void 0;
|
|
242
|
+
});
|
|
243
|
+
return this.flushPromise;
|
|
244
|
+
}
|
|
245
|
+
dispose() {
|
|
246
|
+
if (this.onlineHandler) window.removeEventListener("online", this.onlineHandler);
|
|
247
|
+
this.listeners.clear();
|
|
248
|
+
this.dataListeners.clear();
|
|
249
|
+
}
|
|
250
|
+
async runFlush() {
|
|
251
|
+
await this.loadQueue();
|
|
252
|
+
if (!await this.pullRemote()) return;
|
|
253
|
+
if (this.queueItems.length === 0) {
|
|
254
|
+
this.updateState({ status: "synced", pendingCount: 0, error: void 0 });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.updateState({ status: "syncing", error: void 0 });
|
|
258
|
+
for (const operation of [...this.queueItems]) {
|
|
259
|
+
try {
|
|
260
|
+
const result = await this.remote.push(operation);
|
|
261
|
+
if (result.type === "conflict") {
|
|
262
|
+
const local = operation.item;
|
|
263
|
+
const resolved = this.resolveConflict ? await this.resolveConflict(local, result.remote, {
|
|
264
|
+
operation,
|
|
265
|
+
remoteRevision: result.revision
|
|
266
|
+
}) : void 0;
|
|
267
|
+
if (!resolved) {
|
|
268
|
+
this.updateState({
|
|
269
|
+
status: "conflict",
|
|
270
|
+
conflictIds: [.../* @__PURE__ */ new Set([...this.state.conflictIds, operation.id])]
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const retry = this.createOperation("upsert", resolved.id, {
|
|
275
|
+
...resolved,
|
|
276
|
+
revision: result.revision ?? resolved.revision
|
|
277
|
+
});
|
|
278
|
+
await this.local.set(retry.item);
|
|
279
|
+
await this.replaceQueued(operation, retry);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (result.item) {
|
|
283
|
+
await this.local.set({
|
|
284
|
+
...result.item,
|
|
285
|
+
...result.revision ? { revision: result.revision } : {}
|
|
286
|
+
});
|
|
287
|
+
this.notifyDataListeners();
|
|
288
|
+
}
|
|
289
|
+
await this.removeQueued(operation.operationId);
|
|
290
|
+
this.updateState({
|
|
291
|
+
status: this.queueItems.length > 0 ? "syncing" : "synced",
|
|
292
|
+
lastSyncedAt: this.now(),
|
|
293
|
+
conflictIds: this.state.conflictIds.filter((id) => id !== operation.id)
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.updateState({ status: "error", error });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (this.queueItems.length === 0) this.updateState({ status: "synced", pendingCount: 0 });
|
|
301
|
+
}
|
|
302
|
+
createOperation(type, id, item) {
|
|
303
|
+
return {
|
|
304
|
+
operationId: `${this.clientId}:${this.now()}:${createId()}`,
|
|
305
|
+
type,
|
|
306
|
+
id,
|
|
307
|
+
...item ? { item } : {},
|
|
308
|
+
createdAt: this.now(),
|
|
309
|
+
...item?.revision ? { baseRevision: item.revision } : {}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async enqueueBeforeLocalWrite(operation) {
|
|
313
|
+
await this.enqueueManyBeforeLocalWrite([operation]);
|
|
314
|
+
}
|
|
315
|
+
async enqueueManyBeforeLocalWrite(operations) {
|
|
316
|
+
await this.loadQueue();
|
|
317
|
+
const next = [...this.queueItems];
|
|
318
|
+
for (const operation of operations) {
|
|
319
|
+
for (let index = next.length - 1; index >= 0; index -= 1) {
|
|
320
|
+
if (next[index]?.id !== operation.id) continue;
|
|
321
|
+
next.splice(index, 1);
|
|
322
|
+
}
|
|
323
|
+
next.push(operation);
|
|
324
|
+
}
|
|
325
|
+
await this.persistQueue(next);
|
|
326
|
+
this.updateState({ status: "pending", pendingCount: this.queueItems.length });
|
|
327
|
+
}
|
|
328
|
+
async loadQueue() {
|
|
329
|
+
if (this.queueLoaded) return;
|
|
330
|
+
if (!this.queueLoadPromise) {
|
|
331
|
+
this.queueLoadPromise = this.queue.getAll().then((items) => {
|
|
332
|
+
this.queueItems = items;
|
|
333
|
+
this.queueLoaded = true;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
await this.queueLoadPromise;
|
|
337
|
+
}
|
|
338
|
+
async persistQueue(next) {
|
|
339
|
+
const previousIds = new Set(this.queueItems.map((operation) => operation.operationId));
|
|
340
|
+
const nextIds = new Set(next.map((operation) => operation.operationId));
|
|
341
|
+
const removed = [...previousIds].filter((id) => !nextIds.has(id));
|
|
342
|
+
if (removed.length > 0) await this.queue.remove(removed);
|
|
343
|
+
if (next.length > 0) await this.queue.setMany(next);
|
|
344
|
+
this.queueItems = next;
|
|
345
|
+
}
|
|
346
|
+
async removeQueued(operationIds) {
|
|
347
|
+
await this.loadQueue();
|
|
348
|
+
const ids = new Set(typeof operationIds === "string" ? [operationIds] : operationIds);
|
|
349
|
+
await this.queue.remove([...ids]);
|
|
350
|
+
this.queueItems = this.queueItems.filter((operation) => !ids.has(operation.operationId));
|
|
351
|
+
this.setPendingState();
|
|
352
|
+
}
|
|
353
|
+
async replaceQueued(previous, next) {
|
|
354
|
+
await this.persistQueue(
|
|
355
|
+
this.queueItems.map(
|
|
356
|
+
(operation) => operation.operationId === previous.operationId ? next : operation
|
|
357
|
+
)
|
|
358
|
+
);
|
|
359
|
+
this.setPendingState();
|
|
360
|
+
}
|
|
361
|
+
setPendingState() {
|
|
362
|
+
this.updateState({
|
|
363
|
+
status: this.queueItems.length > 0 ? "pending" : "synced",
|
|
364
|
+
pendingCount: this.queueItems.length
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async pullRemote() {
|
|
368
|
+
if (!this.remote.pull) return true;
|
|
369
|
+
try {
|
|
370
|
+
const remoteItems = await this.remote.pull();
|
|
371
|
+
const pendingIds = new Set(this.queueItems.map((operation) => operation.id));
|
|
372
|
+
const localItems = await this.local.getAll();
|
|
373
|
+
const localById = new Map(localItems.map((item) => [item.id, item]));
|
|
374
|
+
const incoming = remoteItems.filter((item) => {
|
|
375
|
+
const current = localById.get(item.id);
|
|
376
|
+
return !pendingIds.has(item.id) && (!current || item.updatedAt >= current.updatedAt);
|
|
377
|
+
});
|
|
378
|
+
if (incoming.length === 0) return true;
|
|
379
|
+
if (this.local.setMany) await this.local.setMany(incoming);
|
|
380
|
+
else for (const item of incoming) await this.local.set(item);
|
|
381
|
+
this.notifyDataListeners();
|
|
382
|
+
return true;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
this.updateState({ status: "error", error });
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
notifyDataListeners() {
|
|
389
|
+
for (const listener of this.dataListeners) listener();
|
|
390
|
+
}
|
|
391
|
+
updateState(next) {
|
|
392
|
+
this.state = {
|
|
393
|
+
...this.state,
|
|
394
|
+
...next,
|
|
395
|
+
pendingCount: next.pendingCount ?? this.queueItems.length
|
|
396
|
+
};
|
|
397
|
+
for (const listener of this.listeners) listener();
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
async function mergeLocalItems(localItems, target) {
|
|
401
|
+
const remoteItems = await target.getAll();
|
|
402
|
+
const byId = new Map(remoteItems.map((item) => [item.id, item]));
|
|
403
|
+
for (const item of localItems) {
|
|
404
|
+
const current = byId.get(item.id);
|
|
405
|
+
if (!current || item.updatedAt > current.updatedAt) byId.set(item.id, item);
|
|
406
|
+
}
|
|
407
|
+
const merged = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
408
|
+
if (target.setMany) await target.setMany(merged);
|
|
409
|
+
else for (const item of merged) await target.set(item);
|
|
410
|
+
return merged;
|
|
411
|
+
}
|
|
412
|
+
function isSyncOperation(value) {
|
|
413
|
+
if (!isRecord(value)) return false;
|
|
414
|
+
return typeof value.operationId === "string" && (value.type === "upsert" || value.type === "remove") && typeof value.id === "string" && typeof value.createdAt === "number";
|
|
415
|
+
}
|
|
416
|
+
function isRecord(value) {
|
|
417
|
+
return typeof value === "object" && value !== null;
|
|
418
|
+
}
|
|
419
|
+
function createId() {
|
|
420
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
|
421
|
+
return Math.random().toString(36).slice(2);
|
|
422
|
+
}
|
|
423
|
+
function getBrowserStorage() {
|
|
424
|
+
if (typeof window === "undefined") return void 0;
|
|
425
|
+
try {
|
|
426
|
+
return window.localStorage;
|
|
427
|
+
} catch {
|
|
428
|
+
return void 0;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function getBrowserIndexedDB() {
|
|
432
|
+
if (typeof indexedDB === "undefined") return void 0;
|
|
433
|
+
return indexedDB;
|
|
434
|
+
}
|
|
435
|
+
function requestToPromise(request) {
|
|
436
|
+
return new Promise((resolve, reject) => {
|
|
437
|
+
request.onsuccess = () => resolve(request.result);
|
|
438
|
+
request.onerror = () => reject(request.error);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function transactionToPromise(transaction) {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
transaction.oncomplete = () => resolve();
|
|
444
|
+
transaction.onerror = () => reject(transaction.error);
|
|
445
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/storage/index.ts
|
|
450
|
+
var DEFAULT_STORAGE_KEY = "keepkit:items";
|
|
451
|
+
var DEFAULT_INDEXEDDB_DATABASE = "keepkit";
|
|
452
|
+
var DEFAULT_INDEXEDDB_STORE = "items";
|
|
453
|
+
function createStorageAdapter(options) {
|
|
454
|
+
const merge = options.merge;
|
|
455
|
+
const subscribe = options.subscribe;
|
|
456
|
+
const setMany = options.setMany;
|
|
457
|
+
const removeMany = options.removeMany;
|
|
458
|
+
return {
|
|
459
|
+
getAll: async () => options.getAll(),
|
|
460
|
+
set: async (item) => options.set(item),
|
|
461
|
+
...setMany ? { setMany: async (items) => setMany(items) } : {},
|
|
462
|
+
remove: async (id) => options.remove(id),
|
|
463
|
+
...removeMany ? { removeMany: async (ids) => removeMany(ids) } : {},
|
|
464
|
+
clear: async () => options.clear(),
|
|
465
|
+
...merge ? { merge: async (items) => merge(items) } : {},
|
|
466
|
+
...subscribe ? {
|
|
467
|
+
subscribe: (listener) => subscribe(listener) ?? (() => void 0)
|
|
468
|
+
} : {},
|
|
469
|
+
...options.storageKey ? { storageKey: options.storageKey } : {}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
var LocalStorageAdapter = class {
|
|
473
|
+
constructor(options = {}) {
|
|
474
|
+
this.storageKey = options.key ?? DEFAULT_STORAGE_KEY;
|
|
475
|
+
this.storage = options.storage ?? getBrowserStorage2();
|
|
476
|
+
}
|
|
477
|
+
async getAll() {
|
|
478
|
+
if (!this.storage) return [];
|
|
479
|
+
let raw;
|
|
480
|
+
try {
|
|
481
|
+
raw = this.storage.getItem(this.storageKey);
|
|
482
|
+
} catch (cause) {
|
|
483
|
+
throw new KeepStorageAccessError({
|
|
484
|
+
operation: "getAll",
|
|
485
|
+
storageKey: this.storageKey,
|
|
486
|
+
cause
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (!raw) return [];
|
|
490
|
+
try {
|
|
491
|
+
const value = JSON.parse(raw);
|
|
492
|
+
if (!isKeepItemArray(value)) {
|
|
493
|
+
throw new KeepStorageParseError({
|
|
494
|
+
operation: "getAll",
|
|
495
|
+
storageKey: this.storageKey
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return value;
|
|
499
|
+
} catch (cause) {
|
|
500
|
+
if (cause instanceof KeepStorageParseError) throw cause;
|
|
501
|
+
throw new KeepStorageParseError({
|
|
502
|
+
operation: "getAll",
|
|
503
|
+
storageKey: this.storageKey,
|
|
504
|
+
cause
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async set(item) {
|
|
509
|
+
await this.setMany([item]);
|
|
510
|
+
}
|
|
511
|
+
async setMany(items) {
|
|
512
|
+
const current = await this.getAll();
|
|
513
|
+
const byId = new Map(current.map((item) => [item.id, item]));
|
|
514
|
+
for (const item of items) byId.set(item.id, item);
|
|
515
|
+
this.write([...byId.values()], "set");
|
|
516
|
+
}
|
|
517
|
+
async remove(id) {
|
|
518
|
+
await this.removeMany([id]);
|
|
519
|
+
}
|
|
520
|
+
async removeMany(ids) {
|
|
521
|
+
const idSet = new Set(ids);
|
|
522
|
+
const items = await this.getAll();
|
|
523
|
+
this.write(
|
|
524
|
+
items.filter((item) => !idSet.has(item.id)),
|
|
525
|
+
"remove"
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
async clear() {
|
|
529
|
+
if (!this.storage) return;
|
|
530
|
+
try {
|
|
531
|
+
this.storage.removeItem(this.storageKey);
|
|
532
|
+
} catch (cause) {
|
|
533
|
+
throw new KeepStorageAccessError({
|
|
534
|
+
operation: "clear",
|
|
535
|
+
storageKey: this.storageKey,
|
|
536
|
+
cause
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async merge(localItems) {
|
|
541
|
+
const remoteItems = await this.getAll();
|
|
542
|
+
const byId = new Map(remoteItems.map((item) => [item.id, item]));
|
|
543
|
+
for (const localItem of localItems) {
|
|
544
|
+
const remoteItem = byId.get(localItem.id);
|
|
545
|
+
if (!remoteItem || localItem.updatedAt > remoteItem.updatedAt) {
|
|
546
|
+
byId.set(localItem.id, localItem);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const merged = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
550
|
+
this.write(merged, "merge");
|
|
551
|
+
return merged;
|
|
552
|
+
}
|
|
553
|
+
subscribe(listener) {
|
|
554
|
+
if (typeof window === "undefined") return () => void 0;
|
|
555
|
+
const handleStorage = (event) => {
|
|
556
|
+
if (event.key !== null && event.key !== this.storageKey) return;
|
|
557
|
+
listener();
|
|
558
|
+
};
|
|
559
|
+
window.addEventListener("storage", handleStorage);
|
|
560
|
+
return () => window.removeEventListener("storage", handleStorage);
|
|
561
|
+
}
|
|
562
|
+
write(items, operation) {
|
|
563
|
+
if (!this.storage) return;
|
|
564
|
+
try {
|
|
565
|
+
this.storage.setItem(this.storageKey, JSON.stringify(items));
|
|
566
|
+
} catch (cause) {
|
|
567
|
+
if (isQuotaExceededError(cause)) {
|
|
568
|
+
throw new KeepStorageQuotaError({
|
|
569
|
+
operation,
|
|
570
|
+
storageKey: this.storageKey,
|
|
571
|
+
cause
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
throw new KeepStorageAccessError({
|
|
575
|
+
operation,
|
|
576
|
+
storageKey: this.storageKey,
|
|
577
|
+
cause
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
var IndexedDBAdapter = class {
|
|
583
|
+
constructor(options = {}) {
|
|
584
|
+
this.databaseName = options.databaseName ?? options.dbName ?? options.key ?? DEFAULT_INDEXEDDB_DATABASE;
|
|
585
|
+
this.storeName = options.storeName ?? DEFAULT_INDEXEDDB_STORE;
|
|
586
|
+
this.version = options.version ?? 1;
|
|
587
|
+
this.indexedDB = options.indexedDB ?? getBrowserIndexedDB2();
|
|
588
|
+
this.storageKey = `${this.databaseName}:${this.storeName}`;
|
|
589
|
+
}
|
|
590
|
+
async getAll() {
|
|
591
|
+
const database = await this.open("getAll");
|
|
592
|
+
if (!database) return [];
|
|
593
|
+
try {
|
|
594
|
+
const transaction = database.transaction(this.storeName, "readonly");
|
|
595
|
+
const value = await requestToPromise2(
|
|
596
|
+
transaction.objectStore(this.storeName).getAll()
|
|
597
|
+
);
|
|
598
|
+
if (!isKeepItemArray(value)) {
|
|
599
|
+
throw new KeepStorageParseError({ operation: "getAll", storageKey: this.storageKey });
|
|
600
|
+
}
|
|
601
|
+
return value;
|
|
602
|
+
} catch (cause) {
|
|
603
|
+
if (cause instanceof KeepStorageParseError) throw cause;
|
|
604
|
+
throw new KeepStorageAccessError({ operation: "getAll", storageKey: this.storageKey, cause });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async set(item) {
|
|
608
|
+
return this.setMany([item]);
|
|
609
|
+
}
|
|
610
|
+
async setMany(items) {
|
|
611
|
+
const database = await this.open("set");
|
|
612
|
+
if (!database) return;
|
|
613
|
+
try {
|
|
614
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
615
|
+
const objectStore = transaction.objectStore(this.storeName);
|
|
616
|
+
for (const item of items) objectStore.put(item);
|
|
617
|
+
await transactionToPromise2(transaction);
|
|
618
|
+
this.notifySubscribers();
|
|
619
|
+
} catch (cause) {
|
|
620
|
+
if (isQuotaExceededError(cause)) {
|
|
621
|
+
throw new KeepStorageQuotaError({ operation: "set", storageKey: this.storageKey, cause });
|
|
622
|
+
}
|
|
623
|
+
throw new KeepStorageAccessError({ operation: "set", storageKey: this.storageKey, cause });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async remove(id) {
|
|
627
|
+
return this.removeMany([id]);
|
|
628
|
+
}
|
|
629
|
+
async removeMany(ids) {
|
|
630
|
+
const database = await this.open("remove");
|
|
631
|
+
if (!database) return;
|
|
632
|
+
try {
|
|
633
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
634
|
+
const objectStore = transaction.objectStore(this.storeName);
|
|
635
|
+
for (const id of new Set(ids)) objectStore.delete(id);
|
|
636
|
+
await transactionToPromise2(transaction);
|
|
637
|
+
this.notifySubscribers();
|
|
638
|
+
} catch (cause) {
|
|
639
|
+
throw new KeepStorageAccessError({ operation: "remove", storageKey: this.storageKey, cause });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async clear() {
|
|
643
|
+
const database = await this.open("clear");
|
|
644
|
+
if (!database) return;
|
|
645
|
+
try {
|
|
646
|
+
const transaction = database.transaction(this.storeName, "readwrite");
|
|
647
|
+
transaction.objectStore(this.storeName).clear();
|
|
648
|
+
await transactionToPromise2(transaction);
|
|
649
|
+
this.notifySubscribers();
|
|
650
|
+
} catch (cause) {
|
|
651
|
+
throw new KeepStorageAccessError({ operation: "clear", storageKey: this.storageKey, cause });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async merge(localItems) {
|
|
655
|
+
try {
|
|
656
|
+
const remoteItems = await this.getAll();
|
|
657
|
+
const byId = new Map(remoteItems.map((item) => [item.id, item]));
|
|
658
|
+
for (const localItem of localItems) {
|
|
659
|
+
const remoteItem = byId.get(localItem.id);
|
|
660
|
+
if (!remoteItem || localItem.updatedAt > remoteItem.updatedAt)
|
|
661
|
+
byId.set(localItem.id, localItem);
|
|
662
|
+
}
|
|
663
|
+
const merged = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
664
|
+
await this.setMany(merged);
|
|
665
|
+
return merged;
|
|
666
|
+
} catch (cause) {
|
|
667
|
+
if (cause instanceof KeepStorageError) throw cause;
|
|
668
|
+
throw new KeepStorageAccessError({ operation: "merge", storageKey: this.storageKey, cause });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
subscribe(listener) {
|
|
672
|
+
if (!this.indexedDB || typeof BroadcastChannel === "undefined") return () => void 0;
|
|
673
|
+
const channel = new BroadcastChannel(`keepkit:${this.storageKey}`);
|
|
674
|
+
channel.onmessage = () => listener();
|
|
675
|
+
return () => channel.close();
|
|
676
|
+
}
|
|
677
|
+
notifySubscribers() {
|
|
678
|
+
if (!this.indexedDB || typeof BroadcastChannel === "undefined") return;
|
|
679
|
+
const channel = new BroadcastChannel(`keepkit:${this.storageKey}`);
|
|
680
|
+
channel.postMessage({ type: "keepkit:changed" });
|
|
681
|
+
channel.close();
|
|
682
|
+
}
|
|
683
|
+
open(operation) {
|
|
684
|
+
if (!this.indexedDB) return Promise.resolve(void 0);
|
|
685
|
+
if (!this.databasePromise) {
|
|
686
|
+
this.databasePromise = new Promise((resolve, reject) => {
|
|
687
|
+
let request;
|
|
688
|
+
try {
|
|
689
|
+
request = this.indexedDB?.open(this.databaseName, this.version);
|
|
690
|
+
} catch (cause) {
|
|
691
|
+
reject(new KeepStorageAccessError({ operation, storageKey: this.storageKey, cause }));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
request.onupgradeneeded = () => {
|
|
695
|
+
if (!request.result.objectStoreNames.contains(this.storeName)) {
|
|
696
|
+
request.result.createObjectStore(this.storeName, { keyPath: "id" });
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
request.onsuccess = () => resolve(request.result);
|
|
700
|
+
request.onerror = () => reject(request.error);
|
|
701
|
+
request.onblocked = () => reject(request.error ?? new Error("IndexedDB open was blocked."));
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return this.databasePromise.catch((cause) => {
|
|
705
|
+
this.databasePromise = void 0;
|
|
706
|
+
if (cause instanceof KeepStorageError) throw cause;
|
|
707
|
+
throw new KeepStorageAccessError({ operation, storageKey: this.storageKey, cause });
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
function isKeepItemArray(value) {
|
|
712
|
+
return Array.isArray(value) && value.every(
|
|
713
|
+
(item) => isRecord2(item) && typeof item.id === "string" && typeof item.savedAt === "number" && Number.isFinite(item.savedAt) && typeof item.updatedAt === "number" && Number.isFinite(item.updatedAt) && "meta" in item && (item.targetType === void 0 || typeof item.targetType === "string") && (item.note === void 0 || typeof item.note === "string") && (item.schemaVersion === void 0 || typeof item.schemaVersion === "number" && Number.isFinite(item.schemaVersion)) && (item.revision === void 0 || typeof item.revision === "string") && (item.tags === void 0 || Array.isArray(item.tags) && item.tags.every((tag) => typeof tag === "string"))
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
function isRecord2(value) {
|
|
717
|
+
return typeof value === "object" && value !== null;
|
|
718
|
+
}
|
|
719
|
+
function getBrowserStorage2() {
|
|
720
|
+
if (typeof window === "undefined") return void 0;
|
|
721
|
+
try {
|
|
722
|
+
return window.localStorage;
|
|
723
|
+
} catch {
|
|
724
|
+
return void 0;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function getBrowserIndexedDB2() {
|
|
728
|
+
if (typeof indexedDB === "undefined") return void 0;
|
|
729
|
+
return indexedDB;
|
|
730
|
+
}
|
|
731
|
+
function requestToPromise2(request) {
|
|
732
|
+
return new Promise((resolve, reject) => {
|
|
733
|
+
request.onsuccess = () => resolve(request.result);
|
|
734
|
+
request.onerror = () => reject(request.error);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
function transactionToPromise2(transaction) {
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
transaction.oncomplete = () => resolve();
|
|
740
|
+
transaction.onerror = () => reject(transaction.error);
|
|
741
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function isQuotaExceededError(cause) {
|
|
745
|
+
if (!isRecord2(cause)) return false;
|
|
746
|
+
return cause.name === "QuotaExceededError" || cause.name === "NS_ERROR_DOM_QUOTA_REACHED" || cause.code === 22 || cause.code === 1014;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export {
|
|
750
|
+
KeepStorageError,
|
|
751
|
+
KeepStorageQuotaError,
|
|
752
|
+
KeepStorageAccessError,
|
|
753
|
+
KeepStorageParseError,
|
|
754
|
+
normalizeKeepTags,
|
|
755
|
+
DEFAULT_SYNC_QUEUE_KEY,
|
|
756
|
+
DEFAULT_SYNC_QUEUE_DATABASE,
|
|
757
|
+
DEFAULT_SYNC_QUEUE_STORE,
|
|
758
|
+
LocalStorageSyncQueueAdapter,
|
|
759
|
+
IndexedDBSyncQueueAdapter,
|
|
760
|
+
SyncStorageAdapter,
|
|
761
|
+
DEFAULT_STORAGE_KEY,
|
|
762
|
+
DEFAULT_INDEXEDDB_DATABASE,
|
|
763
|
+
DEFAULT_INDEXEDDB_STORE,
|
|
764
|
+
createStorageAdapter,
|
|
765
|
+
LocalStorageAdapter,
|
|
766
|
+
IndexedDBAdapter
|
|
767
|
+
};
|
|
768
|
+
//# sourceMappingURL=chunk-H4A322SZ.js.map
|