@musnows/scriverse 1.1.0 → 1.1.2

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.
@@ -0,0 +1,1004 @@
1
+ const DATABASE_VERSION = 1;
2
+ const SCHEMA_VERSION = 2;
3
+ const SYNC_PROTOCOL = 1;
4
+ const EDITABLE_ENTITY_TYPES = new Set(["chapter", "setting"]);
5
+ const OUTBOX_STATUSES = new Set(["pending", "syncing", "conflict", "rejected"]);
6
+ const INITIAL_OFFLINE_DOWNLOAD_KEY = "initial-offline-download";
7
+
8
+ export class MobileSyncStoreError extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.name = "MobileSyncStoreError";
12
+ this.code = code;
13
+ }
14
+ }
15
+ function assertUuid(value, label) {
16
+ if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)) {
17
+ throw new MobileSyncStoreError("SYNC_ID_INVALID", `${label} 无效`);
18
+ }
19
+ return value;
20
+ }
21
+
22
+ function assertEntityType(value) {
23
+ if (!EDITABLE_ENTITY_TYPES.has(value)) throw new MobileSyncStoreError("SYNC_ENTITY_TYPE_UNSUPPORTED", "该类型暂不支持离线编辑");
24
+ return value;
25
+ }
26
+
27
+ function requestValue(request) {
28
+ return new Promise((resolve, reject) => {
29
+ request.addEventListener("success", () => resolve(request.result), { once: true });
30
+ request.addEventListener("error", () => reject(request.error ?? new Error("离线数据读取失败")), { once: true });
31
+ });
32
+ }
33
+
34
+ function transactionDone(transaction) {
35
+ return new Promise((resolve, reject) => {
36
+ transaction.addEventListener("complete", () => resolve(), { once: true });
37
+ transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("离线数据保存已取消")), { once: true });
38
+ transaction.addEventListener("error", () => reject(transaction.error ?? new Error("离线数据保存失败")), { once: true });
39
+ });
40
+ }
41
+
42
+ export function stableSyncJson(value) {
43
+ if (Array.isArray(value)) return `[${value.map((item) => stableSyncJson(item)).join(",")}]`;
44
+ if (value && typeof value === "object") {
45
+ return `{${Object.entries(value)
46
+ .filter(([, item]) => item !== undefined)
47
+ .sort(([left], [right]) => left.localeCompare(right))
48
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableSyncJson(item)}`)
49
+ .join(",")}}`;
50
+ }
51
+ return JSON.stringify(value) ?? "null";
52
+ }
53
+
54
+ export function mobileSyncDatabaseName(profileId, userId) {
55
+ return `scriverse-mobile-sync-v1-${assertUuid(profileId, "profile id")}-${assertUuid(userId, "user id")}`;
56
+ }
57
+
58
+ export function normalizeInitialOfflineDownloadState(value) {
59
+ if (
60
+ !value
61
+ || value.key !== INITIAL_OFFLINE_DOWNLOAD_KEY
62
+ || (value.decision !== "accepted" && value.decision !== "declined")
63
+ || typeof value.completed !== "boolean"
64
+ || typeof value.updatedAt !== "string"
65
+ || !Number.isFinite(Date.parse(value.updatedAt))
66
+ ) return null;
67
+ return {
68
+ decision: value.decision,
69
+ completed: value.completed,
70
+ updatedAt: value.updatedAt
71
+ };
72
+ }
73
+
74
+ export async function cloneSyncSnapshot(snapshot) {
75
+ return structuredClone(snapshot);
76
+ }
77
+
78
+ async function requestHash(value, cryptoImpl = globalThis.crypto) {
79
+ const digest = await cryptoImpl.subtle.digest("SHA-256", new TextEncoder().encode(stableSyncJson(value)));
80
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
81
+ }
82
+
83
+ function entityKey(workId, entityType, entityId) {
84
+ return [workId, entityType, entityId];
85
+ }
86
+
87
+ function openDatabase(indexedDBImpl, name) {
88
+ return new Promise((resolve, reject) => {
89
+ const request = indexedDBImpl.open(name, DATABASE_VERSION);
90
+ request.addEventListener("upgradeneeded", () => {
91
+ const database = request.result;
92
+ if (!database.objectStoreNames.contains("meta")) database.createObjectStore("meta", { keyPath: "key" });
93
+ if (!database.objectStoreNames.contains("works")) database.createObjectStore("works", { keyPath: "workId" });
94
+ if (!database.objectStoreNames.contains("entities")) {
95
+ const entities = database.createObjectStore("entities", { keyPath: ["workId", "entityType", "entityId"] });
96
+ entities.createIndex("by-work", "workId", { unique: false });
97
+ entities.createIndex("by-work-dirty", ["workId", "dirtyFlag"], { unique: false });
98
+ }
99
+ if (!database.objectStoreNames.contains("outbox")) {
100
+ const outbox = database.createObjectStore("outbox", { keyPath: "mutationId" });
101
+ outbox.createIndex("by-work", "workId", { unique: false });
102
+ outbox.createIndex("by-work-status", ["workId", "status"], { unique: false });
103
+ outbox.createIndex("by-entity-status", ["workId", "entityType", "entityId", "status"], { unique: false });
104
+ outbox.createIndex("by-created", ["workId", "createdAt", "mutationId"], { unique: false });
105
+ }
106
+ if (!database.objectStoreNames.contains("conflicts")) {
107
+ const conflicts = database.createObjectStore("conflicts", { keyPath: "mutationId" });
108
+ conflicts.createIndex("by-work", "workId", { unique: false });
109
+ }
110
+ });
111
+ request.addEventListener("success", () => resolve(request.result), { once: true });
112
+ request.addEventListener("error", () => reject(request.error ?? new Error("离线数据无法打开")), { once: true });
113
+ request.addEventListener("blocked", () => reject(new MobileSyncStoreError("SYNC_DATABASE_BLOCKED", "离线数据库升级被其他窗口阻止")), { once: true });
114
+ });
115
+ }
116
+
117
+ export function pullChangeDisposition(entity, change) {
118
+ if (!entity || entity.deleted) return "apply";
119
+ if (!entity.dirty) return "apply";
120
+ if (Number(change.versionNo) === Number(entity.serverVersionNo)) return "keep-local";
121
+ return "conflict";
122
+ }
123
+
124
+ function summaryWorkStatus(workStore, workId, status) {
125
+ const request = workStore.get(workId);
126
+ request.addEventListener("success", () => {
127
+ if (request.result) workStore.put({ ...request.result, status, updatedAt: new Date().toISOString() });
128
+ }, { once: true });
129
+ }
130
+
131
+ export class MobileSyncStore {
132
+ constructor({ profileId, userId, indexedDBImpl = globalThis.indexedDB, cryptoImpl = globalThis.crypto }) {
133
+ this.profileId = assertUuid(profileId, "profile id");
134
+ this.userId = assertUuid(userId, "user id");
135
+ if (!indexedDBImpl) throw new MobileSyncStoreError("SYNC_DATABASE_UNAVAILABLE", "当前环境无法使用离线功能");
136
+ this.indexedDB = indexedDBImpl;
137
+ this.crypto = cryptoImpl;
138
+ this.databasePromise = null;
139
+ }
140
+
141
+ async open() {
142
+ if (!this.databasePromise) {
143
+ this.databasePromise = openDatabase(this.indexedDB, mobileSyncDatabaseName(this.profileId, this.userId))
144
+ .then(async (database) => {
145
+ const transaction = database.transaction(["meta"], "readwrite");
146
+ const store = transaction.objectStore("meta");
147
+ const existing = await requestValue(store.get("identity"));
148
+ if (existing && (
149
+ existing.profileId !== this.profileId
150
+ || existing.userId !== this.userId
151
+ || existing.schemaVersion !== SCHEMA_VERSION
152
+ )) {
153
+ transaction.abort();
154
+ throw new MobileSyncStoreError("SYNC_DATABASE_IDENTITY_MISMATCH", "本机副本与当前工作区不匹配");
155
+ }
156
+ if (!existing) {
157
+ store.put({
158
+ key: "identity",
159
+ profileId: this.profileId,
160
+ userId: this.userId,
161
+ schemaVersion: SCHEMA_VERSION,
162
+ syncProtocol: SYNC_PROTOCOL,
163
+ clientId: crypto.randomUUID(),
164
+ createdAt: new Date().toISOString(),
165
+ lastSuccessfulSyncAt: null
166
+ });
167
+ }
168
+ await transactionDone(transaction);
169
+ return database;
170
+ });
171
+ }
172
+ return this.databasePromise;
173
+ }
174
+
175
+ async identity() {
176
+ const database = await this.open();
177
+ const transaction = database.transaction(["meta"], "readonly");
178
+ const identity = await requestValue(transaction.objectStore("meta").get("identity"));
179
+ await transactionDone(transaction);
180
+ return structuredClone(identity);
181
+ }
182
+
183
+ async getInitialOfflineDownloadState() {
184
+ const database = await this.open();
185
+ const transaction = database.transaction(["meta"], "readonly");
186
+ const value = await requestValue(transaction.objectStore("meta").get(INITIAL_OFFLINE_DOWNLOAD_KEY));
187
+ await transactionDone(transaction);
188
+ return normalizeInitialOfflineDownloadState(value);
189
+ }
190
+
191
+ async setInitialOfflineDownloadState({ decision, completed }) {
192
+ if (decision !== "accepted" && decision !== "declined") {
193
+ throw new MobileSyncStoreError("SYNC_INITIAL_DOWNLOAD_STATE_INVALID", "首次离线下载选择无效");
194
+ }
195
+ if (typeof completed !== "boolean" || (decision === "declined" && completed !== true)) {
196
+ throw new MobileSyncStoreError("SYNC_INITIAL_DOWNLOAD_STATE_INVALID", "首次离线下载完成状态无效");
197
+ }
198
+ const value = {
199
+ key: INITIAL_OFFLINE_DOWNLOAD_KEY,
200
+ decision,
201
+ completed,
202
+ updatedAt: new Date().toISOString()
203
+ };
204
+ const database = await this.open();
205
+ const transaction = database.transaction(["meta"], "readwrite");
206
+ transaction.objectStore("meta").put(value);
207
+ await transactionDone(transaction);
208
+ return normalizeInitialOfflineDownloadState(value);
209
+ }
210
+
211
+ async replaceSnapshot({ workId, cutoffCursor, items, permissionsSnapshot = null }) {
212
+ if (typeof workId !== "string" || workId.length === 0 || !Array.isArray(items)) {
213
+ throw new MobileSyncStoreError("SYNC_SNAPSHOT_INVALID", "同步快照无效");
214
+ }
215
+ const currentStatus = await this.statusSummary(workId);
216
+ if (currentStatus.pending > 0 || currentStatus.syncing > 0 || currentStatus.conflicts > 0 || currentStatus.rejected > 0) {
217
+ throw new MobileSyncStoreError("SYNC_SNAPSHOT_DIRTY", "作品仍有未处理的本地变更,不能覆盖离线副本");
218
+ }
219
+ const workItem = items.find((item) => item.entityType === "work");
220
+ if (!workItem?.data || String(workItem.entityId) !== workId) {
221
+ throw new MobileSyncStoreError("SYNC_SNAPSHOT_INVALID", "同步快照缺少作品摘要");
222
+ }
223
+ const storedEntities = [];
224
+ for (const item of items) {
225
+ if (item.entityType === "work") continue;
226
+ if (!item.data || typeof item.data !== "object") throw new MobileSyncStoreError("SYNC_SNAPSHOT_INVALID", "同步快照实体无效");
227
+ const serverSnapshot = await cloneSyncSnapshot(item.data);
228
+ storedEntities.push({
229
+ workId,
230
+ entityType: item.entityType,
231
+ entityId: String(item.entityId),
232
+ serverVersionNo: Number(item.versionNo),
233
+ baseSnapshot: serverSnapshot,
234
+ serverSnapshot,
235
+ localSnapshot: serverSnapshot,
236
+ localRevisionNo: 0,
237
+ dirty: false,
238
+ dirtyFlag: 0,
239
+ deleted: false,
240
+ locked: false,
241
+ updatedAt: new Date().toISOString()
242
+ });
243
+ }
244
+ const database = await this.open();
245
+ const transaction = database.transaction(["works", "entities"], "readwrite");
246
+ const entityStore = transaction.objectStore("entities");
247
+ const existingKeys = await requestValue(entityStore.index("by-work").getAllKeys(workId));
248
+ for (const key of existingKeys) entityStore.delete(key);
249
+ for (const entity of storedEntities) entityStore.put(entity);
250
+ transaction.objectStore("works").put({
251
+ workId,
252
+ title: String(workItem.data.title ?? "未命名作品"),
253
+ summary: structuredClone(workItem.data),
254
+ cursor: Number(cutoffCursor),
255
+ permissionsSnapshot: structuredClone(permissionsSnapshot),
256
+ status: "ready",
257
+ enabledAt: new Date().toISOString(),
258
+ updatedAt: new Date().toISOString()
259
+ });
260
+ await transactionDone(transaction);
261
+ return { workId, cursor: Number(cutoffCursor), entityCount: storedEntities.length };
262
+ }
263
+
264
+ async listWorks() {
265
+ const database = await this.open();
266
+ const transaction = database.transaction(["works"], "readonly");
267
+ const works = await requestValue(transaction.objectStore("works").getAll());
268
+ await transactionDone(transaction);
269
+ return works.map((work) => structuredClone(work));
270
+ }
271
+
272
+ async getEntity(workId, entityType, entityId) {
273
+ const database = await this.open();
274
+ const transaction = database.transaction(["entities"], "readonly");
275
+ const entity = await requestValue(transaction.objectStore("entities").get(entityKey(workId, entityType, entityId)));
276
+ await transactionDone(transaction);
277
+ if (!entity || entity.deleted) return null;
278
+ const snapshot = await cloneSyncSnapshot(entity.dirty ? entity.localSnapshot : entity.serverSnapshot);
279
+ return { ...structuredClone(entity), snapshot };
280
+ }
281
+
282
+ async listEntities(workId, entityType) {
283
+ const database = await this.open();
284
+ const transaction = database.transaction(["entities"], "readonly");
285
+ const entities = await requestValue(transaction.objectStore("entities").index("by-work").getAll(workId));
286
+ await transactionDone(transaction);
287
+ const selected = entityType ? entities.filter((entity) => entity.entityType === entityType) : entities;
288
+ const result = [];
289
+ for (const entity of selected) {
290
+ if (entity.deleted) continue;
291
+ result.push(await this.getEntity(workId, entity.entityType, entity.entityId));
292
+ }
293
+ return result.filter(Boolean);
294
+ }
295
+
296
+ async saveLocalEntity(workId, entityTypeValue, entityId, localSnapshot, changeNote = "手机端离线修改") {
297
+ const entityType = assertEntityType(entityTypeValue);
298
+ for (let attempt = 0; attempt < 3; attempt += 1) {
299
+ const current = await this.getEntity(workId, entityType, entityId);
300
+ if (!current) throw new MobileSyncStoreError("SYNC_ENTITY_NOT_FOUND", "离线副本中不存在该记录");
301
+ if (current.locked) throw new MobileSyncStoreError("SYNC_ENTITY_READ_ONLY", "该记录已因权限或同步失败锁定为只读");
302
+ const database = await this.open();
303
+ const lookup = database.transaction(["outbox"], "readonly");
304
+ const pending = (await requestValue(lookup.objectStore("outbox").index("by-entity-status").getAll([
305
+ workId,
306
+ entityType,
307
+ entityId,
308
+ "pending"
309
+ ])))[0] ?? null;
310
+ await transactionDone(lookup);
311
+ const mutationId = pending?.mutationId ?? crypto.randomUUID();
312
+ const baseVersionNo = Number(pending?.baseVersionNo ?? current.serverVersionNo);
313
+ const storedLocalSnapshot = await cloneSyncSnapshot(localSnapshot);
314
+ const mutation = {
315
+ mutationId,
316
+ entityType,
317
+ entityId,
318
+ operation: "update",
319
+ baseVersionNo,
320
+ localSnapshot: storedLocalSnapshot,
321
+ changeNote: String(changeNote).trim().slice(0, 500) || "手机端离线修改"
322
+ };
323
+ const hash = await requestHash(mutation, this.crypto);
324
+ const transaction = database.transaction(["entities", "outbox"], "readwrite");
325
+ const entityStore = transaction.objectStore("entities");
326
+ const outboxStore = transaction.objectStore("outbox");
327
+ const lockedEntity = await requestValue(entityStore.get(entityKey(workId, entityType, entityId)));
328
+ const lockedPending = pending ? await requestValue(outboxStore.get(pending.mutationId)) : null;
329
+ if (
330
+ !lockedEntity
331
+ || Number(lockedEntity.localRevisionNo) !== Number(current.localRevisionNo)
332
+ || (pending && lockedPending?.status !== "pending")
333
+ ) {
334
+ transaction.abort();
335
+ await transactionDone(transaction).catch(() => undefined);
336
+ continue;
337
+ }
338
+ const timestamp = new Date().toISOString();
339
+ entityStore.put({
340
+ ...lockedEntity,
341
+ localSnapshot: storedLocalSnapshot,
342
+ localRevisionNo: Number(lockedEntity.localRevisionNo) + 1,
343
+ dirty: true,
344
+ dirtyFlag: 1,
345
+ updatedAt: timestamp
346
+ });
347
+ outboxStore.put({
348
+ mutationId,
349
+ workId,
350
+ entityType,
351
+ entityId,
352
+ operation: "update",
353
+ baseVersionNo,
354
+ localSnapshot: storedLocalSnapshot,
355
+ changeNote: mutation.changeNote,
356
+ requestHash: hash,
357
+ localRevisionNo: Number(lockedEntity.localRevisionNo) + 1,
358
+ status: "pending",
359
+ attempts: Number(pending?.attempts ?? 0),
360
+ nextAttemptAt: null,
361
+ createdAt: pending?.createdAt ?? timestamp,
362
+ updatedAt: timestamp
363
+ });
364
+ await transactionDone(transaction);
365
+ return { mutationId, localRevisionNo: Number(lockedEntity.localRevisionNo) + 1, savedAt: timestamp };
366
+ }
367
+ throw new MobileSyncStoreError("SYNC_LOCAL_SAVE_BUSY", "离线记录被其他窗口持续修改,请重试");
368
+ }
369
+
370
+ async listOutbox(workId, statuses = ["pending", "syncing", "conflict", "rejected"]) {
371
+ if (!Array.isArray(statuses) || statuses.some((status) => !OUTBOX_STATUSES.has(status))) {
372
+ throw new MobileSyncStoreError("SYNC_OUTBOX_STATUS_INVALID", "outbox 状态无效");
373
+ }
374
+ const database = await this.open();
375
+ const transaction = database.transaction(["outbox"], "readonly");
376
+ const rows = [];
377
+ for (const status of statuses) {
378
+ rows.push(...await requestValue(transaction.objectStore("outbox").index("by-work-status").getAll([workId, status])));
379
+ }
380
+ await transactionDone(transaction);
381
+ rows.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.mutationId.localeCompare(right.mutationId));
382
+ const result = [];
383
+ for (const row of rows) {
384
+ const localSnapshot = await cloneSyncSnapshot(row.localSnapshot);
385
+ result.push({ ...structuredClone(row), localSnapshot });
386
+ }
387
+ return result;
388
+ }
389
+
390
+ async getWork(workId) {
391
+ const database = await this.open();
392
+ const transaction = database.transaction(["works"], "readonly");
393
+ const work = await requestValue(transaction.objectStore("works").get(workId));
394
+ await transactionDone(transaction);
395
+ return work ? structuredClone(work) : null;
396
+ }
397
+
398
+ async setWorkStatus(workId, status, reason = null) {
399
+ if (!["ready", "syncing", "offline", "read-only", "error"].includes(status)) {
400
+ throw new MobileSyncStoreError("SYNC_WORK_STATUS_INVALID", "离线作品状态无效");
401
+ }
402
+ const database = await this.open();
403
+ const transaction = database.transaction(["works"], "readwrite");
404
+ const store = transaction.objectStore("works");
405
+ const work = await requestValue(store.get(workId));
406
+ if (!work) {
407
+ transaction.abort();
408
+ throw new MobileSyncStoreError("SYNC_WORK_NOT_FOUND", "离线作品不存在");
409
+ }
410
+ store.put({ ...work, status, statusReason: reason, updatedAt: new Date().toISOString() });
411
+ await transactionDone(transaction);
412
+ }
413
+
414
+ async applyRemoteChanges(workId, changes, nextCursor) {
415
+ if (!Array.isArray(changes) || !Number.isInteger(nextCursor) || nextCursor < 0) {
416
+ throw new MobileSyncStoreError("SYNC_CHANGES_INVALID", "Server 增量变更无效");
417
+ }
418
+ const summary = { applied: 0, keptLocal: 0, conflicts: 0 };
419
+ for (const change of changes) {
420
+ const disposition = await this.applyRemoteChange(workId, change);
421
+ if (disposition === "apply") summary.applied += 1;
422
+ if (disposition === "keep-local") summary.keptLocal += 1;
423
+ if (disposition === "conflict") summary.conflicts += 1;
424
+ }
425
+ const database = await this.open();
426
+ const transaction = database.transaction(["works", "meta"], "readwrite");
427
+ const workStore = transaction.objectStore("works");
428
+ const work = await requestValue(workStore.get(workId));
429
+ if (!work) {
430
+ transaction.abort();
431
+ throw new MobileSyncStoreError("SYNC_WORK_NOT_FOUND", "离线作品不存在");
432
+ }
433
+ const timestamp = new Date().toISOString();
434
+ workStore.put({ ...work, cursor: Math.max(Number(work.cursor), nextCursor), updatedAt: timestamp });
435
+ const metaStore = transaction.objectStore("meta");
436
+ const identity = await requestValue(metaStore.get("identity"));
437
+ metaStore.put({ ...identity, lastSuccessfulSyncAt: timestamp });
438
+ await transactionDone(transaction);
439
+ return summary;
440
+ }
441
+
442
+ async applyRemoteChange(workId, change) {
443
+ if (
444
+ !change
445
+ || (change.entityType !== "chapter" && change.entityType !== "setting")
446
+ || typeof change.entityId !== "string"
447
+ || (change.operation !== "upsert" && change.operation !== "delete")
448
+ || !Number.isInteger(change.versionNo)
449
+ || change.versionNo < 1
450
+ ) throw new MobileSyncStoreError("SYNC_CHANGE_INVALID", "Server 增量变更字段无效");
451
+ for (let attempt = 0; attempt < 3; attempt += 1) {
452
+ const current = await this.rawEntity(workId, change.entityType, change.entityId);
453
+ const disposition = pullChangeDisposition(current, change);
454
+ if (disposition === "keep-local") return disposition;
455
+ const incomingSnapshot = change.operation === "delete"
456
+ ? { id: change.entityId, workId, versionNo: change.versionNo, deleted: true }
457
+ : change.data;
458
+ if (!incomingSnapshot || typeof incomingSnapshot !== "object") {
459
+ throw new MobileSyncStoreError("SYNC_CHANGE_INVALID", "Server upsert 缺少实体快照");
460
+ }
461
+ const serverSnapshot = await cloneSyncSnapshot(incomingSnapshot);
462
+ let conflictRecord = null;
463
+ let relatedMutationIds = [];
464
+ if (disposition === "conflict") {
465
+ const baseSnapshot = await cloneSyncSnapshot(current.baseSnapshot);
466
+ const localSnapshot = await cloneSyncSnapshot(current.localSnapshot);
467
+ const outbox = await this.rawOutboxForEntity(workId, change.entityType, change.entityId);
468
+ const active = outbox.filter((record) => record.status === "pending" || record.status === "syncing");
469
+ if (active.length === 0) throw new MobileSyncStoreError("SYNC_STORE_INVARIANT", "本地 dirty 实体缺少 outbox 记录");
470
+ relatedMutationIds = active.map((record) => record.mutationId);
471
+ const mutationId = relatedMutationIds[0];
472
+ conflictRecord = {
473
+ mutationId,
474
+ relatedMutationIds,
475
+ workId,
476
+ entityType: change.entityType,
477
+ entityId: change.entityId,
478
+ baseVersionNo: Number(current.serverVersionNo),
479
+ currentServerVersionNo: Number(change.versionNo),
480
+ baseSnapshot: await cloneSyncSnapshot(baseSnapshot),
481
+ localSnapshot: await cloneSyncSnapshot(localSnapshot),
482
+ serverSnapshot: await cloneSyncSnapshot(serverSnapshot),
483
+ mergeDraft: await cloneSyncSnapshot(localSnapshot),
484
+ unresolvedBlockCount: null,
485
+ createdAt: new Date().toISOString(),
486
+ updatedAt: new Date().toISOString()
487
+ };
488
+ }
489
+ const database = await this.open();
490
+ const transaction = database.transaction(["entities", "outbox", "conflicts"], "readwrite");
491
+ const entityStore = transaction.objectStore("entities");
492
+ const locked = await requestValue(entityStore.get(entityKey(workId, change.entityType, change.entityId)));
493
+ if (
494
+ (current && (!locked
495
+ || Number(locked.localRevisionNo) !== Number(current.localRevisionNo)
496
+ || Number(locked.serverVersionNo) !== Number(current.serverVersionNo)))
497
+ || (!current && locked)
498
+ ) {
499
+ transaction.abort();
500
+ await transactionDone(transaction).catch(() => undefined);
501
+ continue;
502
+ }
503
+ if (disposition === "conflict") {
504
+ entityStore.put({
505
+ ...locked,
506
+ serverVersionNo: Number(change.versionNo),
507
+ serverSnapshot,
508
+ conflict: true,
509
+ updatedAt: new Date().toISOString()
510
+ });
511
+ const outboxStore = transaction.objectStore("outbox");
512
+ for (const mutationId of relatedMutationIds) {
513
+ const record = await requestValue(outboxStore.get(mutationId));
514
+ if (record) outboxStore.put({ ...record, status: "conflict", updatedAt: new Date().toISOString() });
515
+ }
516
+ transaction.objectStore("conflicts").put(conflictRecord);
517
+ } else if (change.operation === "delete") {
518
+ entityStore.put({
519
+ ...(locked ?? {
520
+ workId,
521
+ entityType: change.entityType,
522
+ entityId: change.entityId,
523
+ localRevisionNo: 0
524
+ }),
525
+ serverVersionNo: Number(change.versionNo),
526
+ baseSnapshot: serverSnapshot,
527
+ serverSnapshot,
528
+ localSnapshot: serverSnapshot,
529
+ dirty: false,
530
+ dirtyFlag: 0,
531
+ deleted: true,
532
+ locked: true,
533
+ conflict: false,
534
+ updatedAt: new Date().toISOString()
535
+ });
536
+ } else {
537
+ entityStore.put({
538
+ workId,
539
+ entityType: change.entityType,
540
+ entityId: change.entityId,
541
+ serverVersionNo: Number(change.versionNo),
542
+ baseSnapshot: serverSnapshot,
543
+ serverSnapshot,
544
+ localSnapshot: serverSnapshot,
545
+ localRevisionNo: Number(locked?.localRevisionNo ?? 0),
546
+ dirty: false,
547
+ dirtyFlag: 0,
548
+ deleted: false,
549
+ locked: false,
550
+ conflict: false,
551
+ updatedAt: new Date().toISOString()
552
+ });
553
+ }
554
+ await transactionDone(transaction);
555
+ return disposition;
556
+ }
557
+ throw new MobileSyncStoreError("SYNC_LOCAL_SAVE_BUSY", "离线记录在拉取期间持续变化,请重试");
558
+ }
559
+
560
+ async preparePushBatch(workId, limit = 20) {
561
+ const identity = await this.identity();
562
+ const pending = (await this.listOutbox(workId, ["pending"]))
563
+ .filter((record) => !record.nextAttemptAt || Date.parse(record.nextAttemptAt) <= Date.now())
564
+ .slice(0, Math.max(1, Math.min(20, Number(limit) || 20)));
565
+ if (pending.length === 0) return { clientId: identity.clientId, mutations: [], mutationIds: [] };
566
+ const mutations = pending.map((record) => ({
567
+ mutationId: record.mutationId,
568
+ entityType: record.entityType,
569
+ entityId: record.entityId,
570
+ operation: "update",
571
+ baseVersionNo: Number(record.baseVersionNo),
572
+ localSnapshot: record.localSnapshot,
573
+ changeNote: record.changeNote
574
+ }));
575
+ const hashes = await Promise.all(mutations.map((mutation) => requestHash(mutation, this.crypto)));
576
+ const database = await this.open();
577
+ const transaction = database.transaction(["outbox"], "readwrite");
578
+ const store = transaction.objectStore("outbox");
579
+ const claimed = [];
580
+ for (let index = 0; index < pending.length; index += 1) {
581
+ const source = pending[index];
582
+ const record = await requestValue(store.get(source.mutationId));
583
+ if (!record || record.status !== "pending" || Number(record.localRevisionNo) !== Number(source.localRevisionNo)) continue;
584
+ store.put({
585
+ ...record,
586
+ requestHash: hashes[index],
587
+ status: "syncing",
588
+ attempts: Number(record.attempts) + 1,
589
+ updatedAt: new Date().toISOString()
590
+ });
591
+ claimed.push(index);
592
+ }
593
+ await transactionDone(transaction);
594
+ return {
595
+ clientId: identity.clientId,
596
+ mutations: claimed.map((index) => mutations[index]),
597
+ mutationIds: claimed.map((index) => pending[index].mutationId)
598
+ };
599
+ }
600
+
601
+ async applyPushResults(workId, results) {
602
+ if (!Array.isArray(results)) throw new MobileSyncStoreError("SYNC_PUSH_RESULT_INVALID", "Server push 结果无效");
603
+ const summary = { applied: 0, conflicts: 0, rejected: 0 };
604
+ for (const result of results) {
605
+ const disposition = await this.applyPushResult(workId, result);
606
+ summary[disposition] += 1;
607
+ }
608
+ return summary;
609
+ }
610
+
611
+ async applyPushResult(workId, result) {
612
+ if (!result || !OUTBOX_STATUSES.has(result.status === "applied" ? "syncing" : result.status)) {
613
+ throw new MobileSyncStoreError("SYNC_PUSH_RESULT_INVALID", "Server push 结果字段无效");
614
+ }
615
+ const database = await this.open();
616
+ const read = database.transaction(["outbox"], "readonly");
617
+ const outbox = await requestValue(read.objectStore("outbox").get(result.mutationId));
618
+ await transactionDone(read);
619
+ if (!outbox || outbox.workId !== workId) return result.status === "applied" ? "applied" : result.status === "conflict" ? "conflicts" : "rejected";
620
+ let serverSnapshot = null;
621
+ let conflictRecord = null;
622
+ if (result.serverSnapshot) {
623
+ serverSnapshot = await cloneSyncSnapshot(result.serverSnapshot);
624
+ }
625
+ if (result.status === "conflict") {
626
+ if (!result.baseSnapshot || !result.localSnapshot || !result.serverSnapshot) {
627
+ throw new MobileSyncStoreError("SYNC_PUSH_RESULT_INVALID", "Server 冲突结果缺少三方快照");
628
+ }
629
+ conflictRecord = {
630
+ mutationId: result.mutationId,
631
+ relatedMutationIds: [result.mutationId],
632
+ workId,
633
+ entityType: outbox.entityType,
634
+ entityId: outbox.entityId,
635
+ baseVersionNo: Number(result.baseVersionNo),
636
+ currentServerVersionNo: Number(result.conflictVersionNo),
637
+ baseSnapshot: await cloneSyncSnapshot(result.baseSnapshot),
638
+ localSnapshot: await cloneSyncSnapshot(result.localSnapshot),
639
+ serverSnapshot: await cloneSyncSnapshot(result.serverSnapshot),
640
+ mergeDraft: await cloneSyncSnapshot(result.localSnapshot),
641
+ unresolvedBlockCount: null,
642
+ createdAt: new Date().toISOString(),
643
+ updatedAt: new Date().toISOString()
644
+ };
645
+ }
646
+ const transaction = database.transaction(["entities", "outbox", "conflicts", "works"], "readwrite");
647
+ const entityStore = transaction.objectStore("entities");
648
+ const outboxStore = transaction.objectStore("outbox");
649
+ const conflictStore = transaction.objectStore("conflicts");
650
+ const workStore = transaction.objectStore("works");
651
+ const entity = await requestValue(entityStore.get(entityKey(workId, outbox.entityType, outbox.entityId)));
652
+ const currentOutbox = await requestValue(outboxStore.get(result.mutationId));
653
+ if (!entity || !currentOutbox) {
654
+ transaction.abort();
655
+ throw new MobileSyncStoreError("SYNC_STORE_INVARIANT", "push 结果找不到本地实体或 outbox");
656
+ }
657
+ if (result.status === "applied") {
658
+ if (!serverSnapshot || !Number.isInteger(result.appliedVersionNo)) {
659
+ transaction.abort();
660
+ throw new MobileSyncStoreError("SYNC_PUSH_RESULT_INVALID", "Server applied 结果缺少权威快照");
661
+ }
662
+ const allOutbox = await requestValue(outboxStore.index("by-work").getAll(workId));
663
+ const successors = allOutbox.filter((record) => (
664
+ record.mutationId !== result.mutationId
665
+ && record.entityType === outbox.entityType
666
+ && record.entityId === outbox.entityId
667
+ && (record.status === "pending" || record.status === "syncing")
668
+ ));
669
+ const hasNewerLocal = Number(entity.localRevisionNo) > Number(outbox.localRevisionNo) || successors.length > 0;
670
+ entityStore.put({
671
+ ...entity,
672
+ serverVersionNo: Number(result.appliedVersionNo),
673
+ baseSnapshot: serverSnapshot,
674
+ serverSnapshot,
675
+ ...(hasNewerLocal ? {} : { localSnapshot: serverSnapshot }),
676
+ dirty: hasNewerLocal,
677
+ dirtyFlag: hasNewerLocal ? 1 : 0,
678
+ deleted: false,
679
+ locked: false,
680
+ conflict: false,
681
+ updatedAt: new Date().toISOString()
682
+ });
683
+ for (const successor of successors) {
684
+ outboxStore.put({
685
+ ...successor,
686
+ baseVersionNo: Number(result.appliedVersionNo),
687
+ requestHash: null,
688
+ status: "pending",
689
+ updatedAt: new Date().toISOString()
690
+ });
691
+ }
692
+ outboxStore.delete(result.mutationId);
693
+ conflictStore.delete(result.mutationId);
694
+ summaryWorkStatus(workStore, workId, "ready");
695
+ await transactionDone(transaction);
696
+ return "applied";
697
+ }
698
+ if (result.status === "conflict") {
699
+ entityStore.put({
700
+ ...entity,
701
+ serverVersionNo: Number(result.conflictVersionNo),
702
+ ...(serverSnapshot ? { serverSnapshot } : {}),
703
+ dirty: true,
704
+ dirtyFlag: 1,
705
+ conflict: true,
706
+ updatedAt: new Date().toISOString()
707
+ });
708
+ outboxStore.put({ ...currentOutbox, status: "conflict", updatedAt: new Date().toISOString() });
709
+ conflictStore.put(conflictRecord);
710
+ await transactionDone(transaction);
711
+ return "conflicts";
712
+ }
713
+ entityStore.put({ ...entity, locked: true, updatedAt: new Date().toISOString() });
714
+ outboxStore.put({
715
+ ...currentOutbox,
716
+ status: "rejected",
717
+ errorCode: result.errorCode ?? "SYNC_MUTATION_REJECTED",
718
+ updatedAt: new Date().toISOString()
719
+ });
720
+ summaryWorkStatus(workStore, workId, "read-only");
721
+ await transactionDone(transaction);
722
+ return "rejected";
723
+ }
724
+
725
+ async returnSyncingToPending(mutationIds, nextAttemptAt = null) {
726
+ const database = await this.open();
727
+ const transaction = database.transaction(["outbox"], "readwrite");
728
+ const store = transaction.objectStore("outbox");
729
+ for (const mutationId of new Set(mutationIds)) {
730
+ const record = await requestValue(store.get(mutationId));
731
+ if (record?.status === "syncing") {
732
+ store.put({ ...record, status: "pending", nextAttemptAt, updatedAt: new Date().toISOString() });
733
+ }
734
+ }
735
+ await transactionDone(transaction);
736
+ }
737
+
738
+ async listConflicts(workId) {
739
+ const database = await this.open();
740
+ const transaction = database.transaction(["conflicts"], "readonly");
741
+ const records = await requestValue(transaction.objectStore("conflicts").index("by-work").getAll(workId));
742
+ await transactionDone(transaction);
743
+ const result = [];
744
+ for (const record of records) {
745
+ result.push({
746
+ ...structuredClone(record),
747
+ baseSnapshot: await cloneSyncSnapshot(record.baseSnapshot),
748
+ localSnapshot: await cloneSyncSnapshot(record.localSnapshot),
749
+ serverSnapshot: await cloneSyncSnapshot(record.serverSnapshot),
750
+ mergeDraft: await cloneSyncSnapshot(record.mergeDraft)
751
+ });
752
+ }
753
+ return result;
754
+ }
755
+
756
+ async saveMergeDraft(mutationId, mergeDraft, unresolvedBlockCount) {
757
+ const database = await this.open();
758
+ const read = database.transaction(["conflicts"], "readonly");
759
+ const conflict = await requestValue(read.objectStore("conflicts").get(mutationId));
760
+ await transactionDone(read);
761
+ if (!conflict) throw new MobileSyncStoreError("SYNC_CONFLICT_NOT_FOUND", "同步冲突不存在");
762
+ const storedMergeDraft = await cloneSyncSnapshot(mergeDraft);
763
+ const transaction = database.transaction(["conflicts"], "readwrite");
764
+ const store = transaction.objectStore("conflicts");
765
+ const locked = await requestValue(store.get(mutationId));
766
+ if (!locked) {
767
+ transaction.abort();
768
+ throw new MobileSyncStoreError("SYNC_CONFLICT_NOT_FOUND", "同步冲突不存在");
769
+ }
770
+ store.put({
771
+ ...locked,
772
+ mergeDraft: storedMergeDraft,
773
+ unresolvedBlockCount: Math.max(0, Number(unresolvedBlockCount) || 0),
774
+ updatedAt: new Date().toISOString()
775
+ });
776
+ await transactionDone(transaction);
777
+ }
778
+
779
+ async resolveConflict(mutationId, finalSnapshot, changeNote = "手机端冲突合并") {
780
+ const conflicts = await this.listConflictsForMutation(mutationId);
781
+ const conflict = conflicts[0];
782
+ if (!conflict) throw new MobileSyncStoreError("SYNC_CONFLICT_NOT_FOUND", "同步冲突不存在");
783
+ const newMutationId = crypto.randomUUID();
784
+ const storedLocalSnapshot = await cloneSyncSnapshot(finalSnapshot);
785
+ const mutation = {
786
+ mutationId: newMutationId,
787
+ entityType: conflict.entityType,
788
+ entityId: conflict.entityId,
789
+ operation: "update",
790
+ baseVersionNo: Number(conflict.currentServerVersionNo),
791
+ localSnapshot: finalSnapshot,
792
+ changeNote: String(changeNote).trim().slice(0, 500) || "手机端冲突合并"
793
+ };
794
+ const hash = await requestHash(mutation, this.crypto);
795
+ const database = await this.open();
796
+ const transaction = database.transaction(["entities", "outbox", "conflicts"], "readwrite");
797
+ const entityStore = transaction.objectStore("entities");
798
+ const entity = await requestValue(entityStore.get(entityKey(conflict.workId, conflict.entityType, conflict.entityId)));
799
+ if (!entity) {
800
+ transaction.abort();
801
+ throw new MobileSyncStoreError("SYNC_ENTITY_NOT_FOUND", "冲突实体不存在");
802
+ }
803
+ entityStore.put({
804
+ ...entity,
805
+ localSnapshot: storedLocalSnapshot,
806
+ localRevisionNo: Number(entity.localRevisionNo) + 1,
807
+ dirty: true,
808
+ dirtyFlag: 1,
809
+ locked: false,
810
+ conflict: false,
811
+ updatedAt: new Date().toISOString()
812
+ });
813
+ const outboxStore = transaction.objectStore("outbox");
814
+ for (const relatedMutationId of conflict.relatedMutationIds ?? [mutationId]) outboxStore.delete(relatedMutationId);
815
+ outboxStore.put({
816
+ mutationId: newMutationId,
817
+ workId: conflict.workId,
818
+ entityType: conflict.entityType,
819
+ entityId: conflict.entityId,
820
+ operation: "update",
821
+ baseVersionNo: Number(conflict.currentServerVersionNo),
822
+ localSnapshot: storedLocalSnapshot,
823
+ localRevisionNo: Number(entity.localRevisionNo) + 1,
824
+ changeNote: mutation.changeNote,
825
+ requestHash: hash,
826
+ status: "pending",
827
+ attempts: 0,
828
+ nextAttemptAt: null,
829
+ createdAt: new Date().toISOString(),
830
+ updatedAt: new Date().toISOString()
831
+ });
832
+ transaction.objectStore("conflicts").delete(mutationId);
833
+ await transactionDone(transaction);
834
+ return { mutationId: newMutationId };
835
+ }
836
+
837
+ async acquireSyncLease(workId, leaseId = crypto.randomUUID(), ttlMs = 30_000) {
838
+ const database = await this.open();
839
+ const transaction = database.transaction(["meta"], "readwrite");
840
+ const store = transaction.objectStore("meta");
841
+ const key = `sync-lease:${workId}`;
842
+ const current = await requestValue(store.get(key));
843
+ const timestamp = Date.now();
844
+ if (current && current.leaseId !== leaseId && Number(current.expiresAtMs) > timestamp) {
845
+ await transactionDone(transaction);
846
+ return null;
847
+ }
848
+ store.put({ key, leaseId, workId, expiresAtMs: timestamp + Math.max(5_000, Number(ttlMs) || 30_000) });
849
+ await transactionDone(transaction);
850
+ return { leaseId, expiresAtMs: timestamp + Math.max(5_000, Number(ttlMs) || 30_000) };
851
+ }
852
+
853
+ async releaseSyncLease(workId, leaseId) {
854
+ const database = await this.open();
855
+ const transaction = database.transaction(["meta"], "readwrite");
856
+ const store = transaction.objectStore("meta");
857
+ const key = `sync-lease:${workId}`;
858
+ const current = await requestValue(store.get(key));
859
+ if (current?.leaseId === leaseId) store.delete(key);
860
+ await transactionDone(transaction);
861
+ }
862
+
863
+ async rawEntity(workId, entityType, entityId) {
864
+ const database = await this.open();
865
+ const transaction = database.transaction(["entities"], "readonly");
866
+ const entity = await requestValue(transaction.objectStore("entities").get(entityKey(workId, entityType, entityId)));
867
+ await transactionDone(transaction);
868
+ return entity ?? null;
869
+ }
870
+
871
+ async rawOutboxForEntity(workId, entityType, entityId) {
872
+ const database = await this.open();
873
+ const transaction = database.transaction(["outbox"], "readonly");
874
+ const records = await requestValue(transaction.objectStore("outbox").index("by-work").getAll(workId));
875
+ await transactionDone(transaction);
876
+ return records
877
+ .filter((record) => record.entityType === entityType && record.entityId === entityId)
878
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
879
+ }
880
+
881
+ async listConflictsForMutation(mutationId) {
882
+ const database = await this.open();
883
+ const transaction = database.transaction(["conflicts"], "readonly");
884
+ const record = await requestValue(transaction.objectStore("conflicts").get(mutationId));
885
+ await transactionDone(transaction);
886
+ if (!record) return [];
887
+ return this.listConflicts(record.workId).then((conflicts) => conflicts.filter((conflict) => conflict.mutationId === mutationId));
888
+ }
889
+
890
+ async markSyncing(mutationIds) {
891
+ const ids = [...new Set(mutationIds)];
892
+ const database = await this.open();
893
+ const transaction = database.transaction(["outbox"], "readwrite");
894
+ const store = transaction.objectStore("outbox");
895
+ const claimed = [];
896
+ for (const mutationId of ids) {
897
+ const record = await requestValue(store.get(mutationId));
898
+ if (!record || record.status !== "pending") continue;
899
+ const updated = {
900
+ ...record,
901
+ status: "syncing",
902
+ attempts: Number(record.attempts) + 1,
903
+ updatedAt: new Date().toISOString()
904
+ };
905
+ store.put(updated);
906
+ claimed.push(updated);
907
+ }
908
+ await transactionDone(transaction);
909
+ return claimed.map((record) => structuredClone(record));
910
+ }
911
+
912
+ async recoverSyncing() {
913
+ const database = await this.open();
914
+ const transaction = database.transaction(["outbox"], "readwrite");
915
+ const store = transaction.objectStore("outbox");
916
+ const records = await requestValue(store.getAll());
917
+ let recovered = 0;
918
+ for (const record of records) {
919
+ if (record.status !== "syncing") continue;
920
+ store.put({ ...record, status: "pending", updatedAt: new Date().toISOString() });
921
+ recovered += 1;
922
+ }
923
+ await transactionDone(transaction);
924
+ return recovered;
925
+ }
926
+
927
+ async statusSummary(workId) {
928
+ const database = await this.open();
929
+ const transaction = database.transaction(["outbox", "conflicts"], "readonly");
930
+ const outbox = transaction.objectStore("outbox");
931
+ const counts = {};
932
+ for (const status of OUTBOX_STATUSES) {
933
+ counts[status] = await requestValue(outbox.index("by-work-status").count([workId, status]));
934
+ }
935
+ const conflicts = await requestValue(transaction.objectStore("conflicts").index("by-work").count(workId));
936
+ await transactionDone(transaction);
937
+ return {
938
+ pending: Number(counts.pending ?? 0),
939
+ syncing: Number(counts.syncing ?? 0),
940
+ conflicts: Number(conflicts),
941
+ rejected: Number(counts.rejected ?? 0)
942
+ };
943
+ }
944
+
945
+ async createRescueBundle(workId) {
946
+ const work = (await this.listWorks()).find((candidate) => candidate.workId === workId);
947
+ if (!work) throw new MobileSyncStoreError("SYNC_WORK_NOT_FOUND", "离线副本中不存在该作品");
948
+ const [identity, outbox, conflicts] = await Promise.all([
949
+ this.identity(),
950
+ this.listOutbox(workId),
951
+ this.listConflicts(workId)
952
+ ]);
953
+ if (outbox.length === 0 && conflicts.length === 0) {
954
+ throw new MobileSyncStoreError("SYNC_RESCUE_NOT_REQUIRED", "该作品没有需要导出的本机修改");
955
+ }
956
+ return {
957
+ format: "scriverse-mobile-rescue",
958
+ version: 1,
959
+ generatedAt: new Date().toISOString(),
960
+ profileId: this.profileId,
961
+ userId: this.userId,
962
+ clientId: String(identity.clientId),
963
+ work: {
964
+ workId: work.workId,
965
+ title: work.title,
966
+ cursor: Number(work.cursor),
967
+ status: work.status,
968
+ updatedAt: work.updatedAt
969
+ },
970
+ outbox: outbox.map((record) => ({
971
+ mutationId: record.mutationId,
972
+ entityType: record.entityType,
973
+ entityId: record.entityId,
974
+ operation: record.operation,
975
+ baseVersionNo: Number(record.baseVersionNo),
976
+ status: record.status,
977
+ changeNote: record.changeNote,
978
+ localSnapshot: record.localSnapshot,
979
+ createdAt: record.createdAt,
980
+ updatedAt: record.updatedAt
981
+ })),
982
+ conflicts: conflicts.map((conflict) => ({
983
+ mutationId: conflict.mutationId,
984
+ relatedMutationIds: conflict.relatedMutationIds,
985
+ entityType: conflict.entityType,
986
+ entityId: conflict.entityId,
987
+ baseVersionNo: Number(conflict.baseVersionNo),
988
+ currentServerVersionNo: Number(conflict.currentServerVersionNo),
989
+ baseSnapshot: conflict.baseSnapshot,
990
+ localSnapshot: conflict.localSnapshot,
991
+ serverSnapshot: conflict.serverSnapshot,
992
+ mergeDraft: conflict.mergeDraft,
993
+ unresolvedBlockCount: conflict.unresolvedBlockCount,
994
+ createdAt: conflict.createdAt,
995
+ updatedAt: conflict.updatedAt
996
+ }))
997
+ };
998
+ }
999
+
1000
+ close() {
1001
+ void this.databasePromise?.then((database) => database.close());
1002
+ this.databasePromise = null;
1003
+ }
1004
+ }