doxum 0.1.1 → 0.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.
- package/README.md +45 -2
- package/dist/{dependency-BdEMyquf.js → access-5D4KKFm7.js} +4 -21
- package/dist/access-5D4KKFm7.js.map +1 -0
- package/dist/{dependency-DLcCvNKq.cjs → access-z56fVqZ2.cjs} +21 -32
- package/dist/access-z56fVqZ2.cjs.map +1 -0
- package/dist/{contract-Otb5W6cQ.d.cts → contract-DGkZcTiP.d.cts} +63 -5
- package/dist/{contract-DwNiKioc.d.ts → contract-Dh3az8aN.d.ts} +63 -5
- package/dist/dependency-BRn1TRDi.js +20 -0
- package/dist/dependency-BRn1TRDi.js.map +1 -0
- package/dist/dependency-DmQXyj7q.cjs +25 -0
- package/dist/dependency-DmQXyj7q.cjs.map +1 -0
- package/dist/index.cjs +46 -2341
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -5
- package/dist/index.d.ts +8 -5
- package/dist/index.js +5 -2304
- package/dist/index.js.map +1 -1
- package/dist/integration.cjs +3 -2
- package/dist/integration.cjs.map +1 -1
- package/dist/integration.d.cts +2 -2
- package/dist/integration.d.ts +2 -2
- package/dist/integration.js +2 -1
- package/dist/integration.js.map +1 -1
- package/dist/local-sync.cjs +660 -0
- package/dist/local-sync.cjs.map +1 -0
- package/dist/local-sync.d.cts +79 -0
- package/dist/local-sync.d.ts +79 -0
- package/dist/local-sync.js +653 -0
- package/dist/local-sync.js.map +1 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js.map +1 -1
- package/dist/readable-B8E3FMq6.cjs +2614 -0
- package/dist/readable-B8E3FMq6.cjs.map +1 -0
- package/dist/readable-CIj02gIj.js +2579 -0
- package/dist/readable-CIj02gIj.js.map +1 -0
- package/package.json +14 -8
- package/skills/doxum-runtime/references/guide.en.md +9 -5
- package/skills/doxum-runtime/references/guide.zh-CN.md +3 -2
- package/skills/doxum-runtime/references/invariants.en.md +13 -8
- package/skills/doxum-runtime/references/invariants.zh-CN.md +2 -2
- package/dist/dependency-BdEMyquf.js.map +0 -1
- package/dist/dependency-DLcCvNKq.cjs.map +0 -1
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_access = require("./access-z56fVqZ2.cjs");
|
|
3
|
+
const require_readable = require("./readable-B8E3FMq6.cjs");
|
|
4
|
+
//#region core/src/local-sync/contract.ts
|
|
5
|
+
var LocalSyncUnavailableError = class extends Error {
|
|
6
|
+
constructor(capability) {
|
|
7
|
+
super(`${capability} is required by doxum/local-sync in this environment.`);
|
|
8
|
+
this.name = "LocalSyncUnavailableError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var LocalSyncSchemaError = class extends Error {
|
|
12
|
+
constructor(documentId, expected, actual) {
|
|
13
|
+
super(`Local document '${documentId}' uses schema version ${actual}, but version ${expected} was requested.`);
|
|
14
|
+
this.name = "LocalSyncSchemaError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var LocalSyncConsistencyError = class extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "LocalSyncConsistencyError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var LocalSyncDisposedError = class extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("Local sync document has been disposed.");
|
|
26
|
+
this.name = "LocalSyncDisposedError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region core/src/local-sync/json.ts
|
|
31
|
+
const defaultJsonCommandLimits = Object.freeze({
|
|
32
|
+
maxOperations: 1e3,
|
|
33
|
+
maxBytes: 1e6,
|
|
34
|
+
maxDepth: 64,
|
|
35
|
+
maxStringLength: 256e3
|
|
36
|
+
});
|
|
37
|
+
var LocalSyncDataError = class extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "LocalSyncDataError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const describePath = (path) => path.length === 0 ? "value" : path.join(".");
|
|
44
|
+
const limit = (value, fallback, label) => {
|
|
45
|
+
if (value === void 0) return fallback;
|
|
46
|
+
if (Number.isSafeInteger(value) && value > 0) return value;
|
|
47
|
+
throw new TypeError(`${label} must be a positive safe integer.`);
|
|
48
|
+
};
|
|
49
|
+
const resolveLimits = (input) => ({
|
|
50
|
+
maxOperations: limit(input?.maxOperations, defaultJsonCommandLimits.maxOperations, "maxOperations"),
|
|
51
|
+
maxBytes: limit(input?.maxBytes, defaultJsonCommandLimits.maxBytes, "maxBytes"),
|
|
52
|
+
maxDepth: limit(input?.maxDepth, defaultJsonCommandLimits.maxDepth, "maxDepth"),
|
|
53
|
+
maxStringLength: limit(input?.maxStringLength, defaultJsonCommandLimits.maxStringLength, "maxStringLength")
|
|
54
|
+
});
|
|
55
|
+
const validate = (value, path, limits, depth, ancestors) => {
|
|
56
|
+
if (limits && depth > limits.maxDepth) throw new LocalSyncDataError(`${describePath(path)} exceeds the maximum JSON depth.`);
|
|
57
|
+
if (value === null || typeof value === "boolean") return value;
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
if (!limits || value.length <= limits.maxStringLength) return value;
|
|
60
|
+
throw new LocalSyncDataError(`${describePath(path)} exceeds the maximum string length.`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof value === "number") {
|
|
63
|
+
if (Number.isFinite(value)) return value;
|
|
64
|
+
throw new LocalSyncDataError(`${describePath(path)} contains a non-finite number.`);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
if (ancestors.has(value)) throw new LocalSyncDataError(`${describePath(path)} contains a cycle.`);
|
|
68
|
+
ancestors.add(value);
|
|
69
|
+
try {
|
|
70
|
+
for (let index = 0; index < value.length; index += 1) validate(value[index], [...path, String(index)], limits, depth + 1, ancestors);
|
|
71
|
+
} finally {
|
|
72
|
+
ancestors.delete(value);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
if (require_access.isPlainObject(value)) {
|
|
77
|
+
if (ancestors.has(value)) throw new LocalSyncDataError(`${describePath(path)} contains a cycle.`);
|
|
78
|
+
ancestors.add(value);
|
|
79
|
+
try {
|
|
80
|
+
for (const [key, entry] of Object.entries(value)) validate(entry, [...path, key], limits, depth + 1, ancestors);
|
|
81
|
+
} finally {
|
|
82
|
+
ancestors.delete(value);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
throw new LocalSyncDataError(`${describePath(path)} must be JSON data.`);
|
|
87
|
+
};
|
|
88
|
+
const json = (value, label) => validate(value, [label], void 0, 0, /* @__PURE__ */ new WeakSet());
|
|
89
|
+
const jsonArray = (value, label, input) => {
|
|
90
|
+
const limits = resolveLimits(input);
|
|
91
|
+
const parsed = validate(value, [label], limits, 0, /* @__PURE__ */ new WeakSet());
|
|
92
|
+
if (!Array.isArray(parsed)) throw new LocalSyncDataError(`${label} must be a JSON array.`);
|
|
93
|
+
if (parsed.length > limits.maxOperations) throw new LocalSyncDataError(`${label} exceeds the maximum operation count.`);
|
|
94
|
+
const serialized = JSON.stringify(parsed);
|
|
95
|
+
if (new TextEncoder().encode(serialized).byteLength > limits.maxBytes) throw new LocalSyncDataError(`${label} exceeds the maximum command size.`);
|
|
96
|
+
return parsed;
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region core/src/local-sync/timeline.ts
|
|
100
|
+
const DATABASE_VERSION = 1;
|
|
101
|
+
const DOCUMENTS = "documents";
|
|
102
|
+
const COMMITS = "commits";
|
|
103
|
+
const ACTORS = "actors";
|
|
104
|
+
const request = (value) => new Promise((resolve, reject) => {
|
|
105
|
+
value.onsuccess = () => resolve(value.result);
|
|
106
|
+
value.onerror = () => reject(value.error ?? /* @__PURE__ */ new Error("IndexedDB request failed."));
|
|
107
|
+
});
|
|
108
|
+
const complete = (transaction) => new Promise((resolve, reject) => {
|
|
109
|
+
transaction.oncomplete = () => resolve();
|
|
110
|
+
transaction.onabort = () => reject(transaction.error ?? /* @__PURE__ */ new Error("IndexedDB transaction was aborted."));
|
|
111
|
+
transaction.onerror = () => reject(transaction.error ?? /* @__PURE__ */ new Error("IndexedDB transaction failed."));
|
|
112
|
+
});
|
|
113
|
+
const factory = () => {
|
|
114
|
+
if (!globalThis.indexedDB) throw new LocalSyncUnavailableError("IndexedDB");
|
|
115
|
+
return globalThis.indexedDB;
|
|
116
|
+
};
|
|
117
|
+
const keyRange = () => {
|
|
118
|
+
if (!globalThis.IDBKeyRange) throw new LocalSyncUnavailableError("IndexedDB");
|
|
119
|
+
return globalThis.IDBKeyRange;
|
|
120
|
+
};
|
|
121
|
+
const string = (value, label) => {
|
|
122
|
+
if (typeof value === "string") return value;
|
|
123
|
+
throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
|
|
124
|
+
};
|
|
125
|
+
const nonNegativeInteger$1 = (value, label) => {
|
|
126
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value;
|
|
127
|
+
throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
|
|
128
|
+
};
|
|
129
|
+
const positiveInteger$1 = (value, label) => {
|
|
130
|
+
const parsed = nonNegativeInteger$1(value, label);
|
|
131
|
+
if (parsed > 0) return parsed;
|
|
132
|
+
throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
|
|
133
|
+
};
|
|
134
|
+
const historyEntry = (value, label) => {
|
|
135
|
+
if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
|
|
136
|
+
const footprint = require_readable.decodeCommandFootprint(value.footprint);
|
|
137
|
+
if (!footprint) throw new LocalSyncConsistencyError(`${label}.footprint is malformed in IndexedDB.`);
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
commandId: string(value.commandId, `${label}.commandId`),
|
|
140
|
+
operations: jsonArray(value.operations, `${label}.operations`),
|
|
141
|
+
inverse: jsonArray(value.inverse, `${label}.inverse`),
|
|
142
|
+
footprint
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
const historyEntries = (value, label) => {
|
|
146
|
+
if (!Array.isArray(value)) throw new LocalSyncConsistencyError(`${label} is malformed in IndexedDB.`);
|
|
147
|
+
return Object.freeze(value.map((entry, index) => historyEntry(entry, `${label}.${index}`)));
|
|
148
|
+
};
|
|
149
|
+
const documentRecord = (value) => {
|
|
150
|
+
if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError("Document record is malformed in IndexedDB.");
|
|
151
|
+
const checkpointSeq = nonNegativeInteger$1(value.checkpointSeq, "document.checkpointSeq");
|
|
152
|
+
const headSeq = nonNegativeInteger$1(value.headSeq, "document.headSeq");
|
|
153
|
+
if (checkpointSeq > headSeq) throw new LocalSyncConsistencyError("Document checkpoint exceeds its head sequence.");
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
documentId: string(value.documentId, "document.documentId"),
|
|
156
|
+
schemaVersion: positiveInteger$1(value.schemaVersion, "document.schemaVersion"),
|
|
157
|
+
checkpointSeq,
|
|
158
|
+
headSeq,
|
|
159
|
+
checkpoint: json(value.checkpoint, "document.checkpoint")
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
const commitRecord = (value) => {
|
|
163
|
+
if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError("Commit record is malformed in IndexedDB.");
|
|
164
|
+
const kind = value.kind;
|
|
165
|
+
if (kind !== "update" && kind !== "undo" && kind !== "redo") throw new LocalSyncConsistencyError("Commit kind is malformed in IndexedDB.");
|
|
166
|
+
const footprint = require_readable.decodeCommandFootprint(value.footprint);
|
|
167
|
+
if (!footprint) throw new LocalSyncConsistencyError("Commit footprint is malformed in IndexedDB.");
|
|
168
|
+
return Object.freeze({
|
|
169
|
+
documentId: string(value.documentId, "commit.documentId"),
|
|
170
|
+
seq: positiveInteger$1(value.seq, "commit.seq"),
|
|
171
|
+
commandId: string(value.commandId, "commit.commandId"),
|
|
172
|
+
actorId: string(value.actorId, "commit.actorId"),
|
|
173
|
+
kind,
|
|
174
|
+
operations: jsonArray(value.operations, "commit.operations"),
|
|
175
|
+
inverse: jsonArray(value.inverse, "commit.inverse"),
|
|
176
|
+
footprint,
|
|
177
|
+
createdAt: nonNegativeInteger$1(value.createdAt, "commit.createdAt")
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const actorRecord = (value, documentId, actorId) => {
|
|
181
|
+
if (value === void 0) return Object.freeze({
|
|
182
|
+
documentId,
|
|
183
|
+
actorId,
|
|
184
|
+
undo: Object.freeze([]),
|
|
185
|
+
redo: Object.freeze([])
|
|
186
|
+
});
|
|
187
|
+
if (!require_access.isRecord(value)) throw new LocalSyncConsistencyError("Actor history is malformed in IndexedDB.");
|
|
188
|
+
const storedDocumentId = string(value.documentId, "actor.documentId");
|
|
189
|
+
const storedActorId = string(value.actorId, "actor.actorId");
|
|
190
|
+
if (storedDocumentId !== documentId || storedActorId !== actorId) throw new LocalSyncConsistencyError("Actor history key does not match its record.");
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
documentId,
|
|
193
|
+
actorId,
|
|
194
|
+
undo: historyEntries(value.undo, "actor.undo"),
|
|
195
|
+
redo: historyEntries(value.redo, "actor.redo")
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const nextHistory = (current, change) => {
|
|
199
|
+
if (change.kind === "record") {
|
|
200
|
+
const undo = [...current.undo, change.entry];
|
|
201
|
+
const start = Math.max(0, undo.length - change.capacity);
|
|
202
|
+
return Object.freeze({
|
|
203
|
+
documentId: current.documentId,
|
|
204
|
+
actorId: current.actorId,
|
|
205
|
+
undo: Object.freeze(undo.slice(start)),
|
|
206
|
+
redo: Object.freeze([])
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const source = change.kind === "undo" ? current.undo : current.redo;
|
|
210
|
+
const latest = source[source.length - 1];
|
|
211
|
+
if (!latest || latest.commandId !== change.expectedCommandId) throw new LocalSyncConsistencyError("Local undo history changed before it could be committed.");
|
|
212
|
+
if (change.kind === "undo") return Object.freeze({
|
|
213
|
+
documentId: current.documentId,
|
|
214
|
+
actorId: current.actorId,
|
|
215
|
+
undo: Object.freeze(current.undo.slice(0, -1)),
|
|
216
|
+
redo: Object.freeze([...current.redo, change.entry])
|
|
217
|
+
});
|
|
218
|
+
return Object.freeze({
|
|
219
|
+
documentId: current.documentId,
|
|
220
|
+
actorId: current.actorId,
|
|
221
|
+
undo: Object.freeze([...current.undo, change.entry]),
|
|
222
|
+
redo: Object.freeze(current.redo.slice(0, -1))
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
const deleteCommitsThrough = async (store, documentId, seq) => {
|
|
226
|
+
const range = keyRange().bound([documentId, 0], [documentId, seq]);
|
|
227
|
+
await new Promise((resolve, reject) => {
|
|
228
|
+
const cursor = store.openCursor(range);
|
|
229
|
+
cursor.onerror = () => reject(cursor.error ?? /* @__PURE__ */ new Error("IndexedDB cursor failed."));
|
|
230
|
+
cursor.onsuccess = () => {
|
|
231
|
+
const current = cursor.result;
|
|
232
|
+
if (!current) {
|
|
233
|
+
resolve();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
current.delete();
|
|
237
|
+
current.continue();
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
const openIndexedDbTimeline = async (databaseName) => {
|
|
242
|
+
const database = await new Promise((resolve, reject) => {
|
|
243
|
+
const open = factory().open(databaseName, DATABASE_VERSION);
|
|
244
|
+
open.onerror = () => reject(open.error ?? /* @__PURE__ */ new Error("Unable to open IndexedDB."));
|
|
245
|
+
open.onblocked = () => reject(/* @__PURE__ */ new Error(`IndexedDB database '${databaseName}' is blocked.`));
|
|
246
|
+
open.onupgradeneeded = () => {
|
|
247
|
+
const db = open.result;
|
|
248
|
+
if (!db.objectStoreNames.contains(DOCUMENTS)) db.createObjectStore(DOCUMENTS, { keyPath: "documentId" });
|
|
249
|
+
if (!db.objectStoreNames.contains(COMMITS)) db.createObjectStore(COMMITS, { keyPath: ["documentId", "seq"] });
|
|
250
|
+
if (!db.objectStoreNames.contains(ACTORS)) db.createObjectStore(ACTORS, { keyPath: ["documentId", "actorId"] });
|
|
251
|
+
};
|
|
252
|
+
open.onsuccess = () => resolve(open.result);
|
|
253
|
+
});
|
|
254
|
+
const readDocument = async (transaction, documentId) => {
|
|
255
|
+
const value = await request(transaction.objectStore(DOCUMENTS).get(documentId));
|
|
256
|
+
if (value === void 0) throw new LocalSyncConsistencyError(`Local document '${documentId}' does not exist.`);
|
|
257
|
+
return documentRecord(value);
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
initialize: async (documentId, schemaVersion, checkpoint) => {
|
|
261
|
+
const transaction = database.transaction(DOCUMENTS, "readwrite");
|
|
262
|
+
const store = transaction.objectStore(DOCUMENTS);
|
|
263
|
+
const existing = await request(store.get(documentId));
|
|
264
|
+
if (existing !== void 0) {
|
|
265
|
+
const record = documentRecord(existing);
|
|
266
|
+
if (record.schemaVersion !== schemaVersion) throw new LocalSyncSchemaError(documentId, schemaVersion, record.schemaVersion);
|
|
267
|
+
await complete(transaction);
|
|
268
|
+
return record;
|
|
269
|
+
}
|
|
270
|
+
const record = {
|
|
271
|
+
documentId,
|
|
272
|
+
schemaVersion,
|
|
273
|
+
checkpointSeq: 0,
|
|
274
|
+
headSeq: 0,
|
|
275
|
+
checkpoint
|
|
276
|
+
};
|
|
277
|
+
store.put(record);
|
|
278
|
+
await complete(transaction);
|
|
279
|
+
return Object.freeze(record);
|
|
280
|
+
},
|
|
281
|
+
read: async (documentId) => {
|
|
282
|
+
const transaction = database.transaction(DOCUMENTS, "readonly");
|
|
283
|
+
const record = await readDocument(transaction, documentId);
|
|
284
|
+
await complete(transaction);
|
|
285
|
+
return record;
|
|
286
|
+
},
|
|
287
|
+
tail: async (documentId, afterSeq) => {
|
|
288
|
+
const transaction = database.transaction(COMMITS, "readonly");
|
|
289
|
+
const range = keyRange().bound([documentId, afterSeq + 1], [documentId, Number.MAX_SAFE_INTEGER]);
|
|
290
|
+
const values = await request(transaction.objectStore(COMMITS).getAll(range));
|
|
291
|
+
await complete(transaction);
|
|
292
|
+
return Object.freeze(values.map(commitRecord).sort((left, right) => left.seq - right.seq));
|
|
293
|
+
},
|
|
294
|
+
history: async (documentId, actorId) => {
|
|
295
|
+
const transaction = database.transaction(ACTORS, "readonly");
|
|
296
|
+
const value = await request(transaction.objectStore(ACTORS).get([documentId, actorId]));
|
|
297
|
+
await complete(transaction);
|
|
298
|
+
return actorRecord(value, documentId, actorId);
|
|
299
|
+
},
|
|
300
|
+
append: async (input) => {
|
|
301
|
+
const transaction = database.transaction([
|
|
302
|
+
DOCUMENTS,
|
|
303
|
+
COMMITS,
|
|
304
|
+
ACTORS
|
|
305
|
+
], "readwrite");
|
|
306
|
+
const documents = transaction.objectStore(DOCUMENTS);
|
|
307
|
+
const commits = transaction.objectStore(COMMITS);
|
|
308
|
+
const actors = transaction.objectStore(ACTORS);
|
|
309
|
+
const current = await readDocument(transaction, input.documentId);
|
|
310
|
+
if (current.headSeq !== input.expectedHeadSeq) throw new LocalSyncConsistencyError("Local document advanced before the writer lock was acquired.");
|
|
311
|
+
const actor = actorRecord(await request(actors.get([input.documentId, input.actorId])), input.documentId, input.actorId);
|
|
312
|
+
const seq = current.headSeq + 1;
|
|
313
|
+
const commit = {
|
|
314
|
+
documentId: input.documentId,
|
|
315
|
+
seq,
|
|
316
|
+
commandId: input.commandId,
|
|
317
|
+
actorId: input.actorId,
|
|
318
|
+
kind: input.kind,
|
|
319
|
+
operations: input.operations,
|
|
320
|
+
inverse: input.inverse,
|
|
321
|
+
footprint: input.footprint,
|
|
322
|
+
createdAt: Date.now()
|
|
323
|
+
};
|
|
324
|
+
const nextDocument = {
|
|
325
|
+
...current,
|
|
326
|
+
headSeq: seq
|
|
327
|
+
};
|
|
328
|
+
commits.put(commit);
|
|
329
|
+
documents.put(nextDocument);
|
|
330
|
+
actors.put(nextHistory(actor, input.history));
|
|
331
|
+
await complete(transaction);
|
|
332
|
+
return Object.freeze(commit);
|
|
333
|
+
},
|
|
334
|
+
compact: async (documentId, seq, checkpoint) => {
|
|
335
|
+
const transaction = database.transaction([DOCUMENTS, COMMITS], "readwrite");
|
|
336
|
+
const current = await readDocument(transaction, documentId);
|
|
337
|
+
if (seq < current.checkpointSeq || seq > current.headSeq) throw new LocalSyncConsistencyError("Local checkpoint sequence is outside the durable timeline.");
|
|
338
|
+
const next = {
|
|
339
|
+
...current,
|
|
340
|
+
checkpointSeq: seq,
|
|
341
|
+
checkpoint
|
|
342
|
+
};
|
|
343
|
+
transaction.objectStore(DOCUMENTS).put(next);
|
|
344
|
+
await deleteCommitsThrough(transaction.objectStore(COMMITS), documentId, seq);
|
|
345
|
+
await complete(transaction);
|
|
346
|
+
return Object.freeze(next);
|
|
347
|
+
},
|
|
348
|
+
close: () => database.close()
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region core/src/local-sync/session.ts
|
|
353
|
+
const locks = () => {
|
|
354
|
+
const value = globalThis.navigator?.locks;
|
|
355
|
+
if (typeof value !== "object" || value === null || !("request" in value) || typeof value.request !== "function") throw new LocalSyncUnavailableError("Web Locks");
|
|
356
|
+
return value;
|
|
357
|
+
};
|
|
358
|
+
const channelConstructor = () => {
|
|
359
|
+
const value = globalThis.BroadcastChannel;
|
|
360
|
+
if (typeof value !== "function") throw new LocalSyncUnavailableError("BroadcastChannel");
|
|
361
|
+
return value;
|
|
362
|
+
};
|
|
363
|
+
const requiredString = (value, label) => {
|
|
364
|
+
if (value.length > 0) return value;
|
|
365
|
+
throw new TypeError(`${label} must not be empty.`);
|
|
366
|
+
};
|
|
367
|
+
const nonNegativeInteger = (value, label) => {
|
|
368
|
+
if (Number.isSafeInteger(value) && value >= 0) return value;
|
|
369
|
+
throw new TypeError(`${label} must be a non-negative safe integer.`);
|
|
370
|
+
};
|
|
371
|
+
const positiveInteger = (value, label) => {
|
|
372
|
+
if (Number.isSafeInteger(value) && value > 0) return value;
|
|
373
|
+
throw new TypeError(`${label} must be a positive safe integer.`);
|
|
374
|
+
};
|
|
375
|
+
const identifier = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
376
|
+
const notification = (value) => {
|
|
377
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
378
|
+
const record = value;
|
|
379
|
+
if (record.kind !== "commit" || typeof record.documentId !== "string" || typeof record.senderId !== "string" || typeof record.headSeq !== "number" || !Number.isSafeInteger(record.headSeq) || record.headSeq < 0) return void 0;
|
|
380
|
+
return record;
|
|
381
|
+
};
|
|
382
|
+
const localCommit = (commit, stored) => Object.freeze({
|
|
383
|
+
...commit,
|
|
384
|
+
seq: stored.seq,
|
|
385
|
+
commandId: stored.commandId,
|
|
386
|
+
actorId: stored.actorId
|
|
387
|
+
});
|
|
388
|
+
const operationResult = (result, stored) => {
|
|
389
|
+
if (result.status !== "committed") throw new LocalSyncConsistencyError("A durable local command could not be applied.");
|
|
390
|
+
return {
|
|
391
|
+
status: "committed",
|
|
392
|
+
commit: localCommit(result.commit, stored),
|
|
393
|
+
observerErrors: result.observerErrors
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
const openLocalDocument = async (input) => {
|
|
397
|
+
const databaseName = requiredString(input.database, "database");
|
|
398
|
+
const documentId = requiredString(input.documentId, "documentId");
|
|
399
|
+
const actorId = requiredString(input.actorId ?? "local", "actorId");
|
|
400
|
+
const schemaVersion = positiveInteger(input.schemaVersion ?? 1, "schemaVersion");
|
|
401
|
+
const historyCapacity = nonNegativeInteger(input.historyCapacity ?? 100, "historyCapacity");
|
|
402
|
+
const checkpointEvery = nonNegativeInteger(input.checkpointEvery ?? 500, "checkpointEvery");
|
|
403
|
+
const commandLimits = input.commandLimits;
|
|
404
|
+
const initial = json(input.initial, "initial document");
|
|
405
|
+
const lockManager = locks();
|
|
406
|
+
const BroadcastChannel = channelConstructor();
|
|
407
|
+
const timeline = await openIndexedDbTimeline(databaseName);
|
|
408
|
+
let channel;
|
|
409
|
+
let runtime;
|
|
410
|
+
try {
|
|
411
|
+
const stored = await timeline.initialize(documentId, schemaVersion, initial);
|
|
412
|
+
const documentRuntime = require_readable.createDocument({
|
|
413
|
+
schema: input.schema,
|
|
414
|
+
initial: stored.checkpoint,
|
|
415
|
+
history: false
|
|
416
|
+
});
|
|
417
|
+
runtime = documentRuntime;
|
|
418
|
+
let headSeq = stored.checkpointSeq;
|
|
419
|
+
let checkpointSeq = stored.checkpointSeq;
|
|
420
|
+
let queued = Promise.resolve();
|
|
421
|
+
let fault;
|
|
422
|
+
let closing = false;
|
|
423
|
+
let disposed = false;
|
|
424
|
+
const senderId = identifier();
|
|
425
|
+
const fail = (error) => {
|
|
426
|
+
if (closing || disposed || fault) return;
|
|
427
|
+
fault = error;
|
|
428
|
+
try {
|
|
429
|
+
input.onError?.(error);
|
|
430
|
+
} catch {}
|
|
431
|
+
};
|
|
432
|
+
const assertOpen = () => {
|
|
433
|
+
if (closing || disposed) throw new LocalSyncDisposedError();
|
|
434
|
+
if (fault) throw fault;
|
|
435
|
+
};
|
|
436
|
+
const applyStored = (commit) => {
|
|
437
|
+
if (commit.seq !== headSeq + 1) throw new LocalSyncConsistencyError("Local commit log contains a sequence gap.");
|
|
438
|
+
const local = operationResult(documentRuntime.apply(commit.operations, {
|
|
439
|
+
source: "local",
|
|
440
|
+
history: false
|
|
441
|
+
}), commit);
|
|
442
|
+
if (local.status !== "committed") throw new LocalSyncConsistencyError("A durable local command was not committed.");
|
|
443
|
+
headSeq = commit.seq;
|
|
444
|
+
return local.commit;
|
|
445
|
+
};
|
|
446
|
+
const catchUp = async () => {
|
|
447
|
+
const current = await timeline.read(documentId);
|
|
448
|
+
if (current.schemaVersion !== schemaVersion) throw new LocalSyncConsistencyError("Local document schema changed while this session was open.");
|
|
449
|
+
if (headSeq < current.checkpointSeq) {
|
|
450
|
+
const replaced = documentRuntime.replace(current.checkpoint, { source: "system" });
|
|
451
|
+
if (replaced.status !== "committed" && replaced.status !== "unchanged") throw new LocalSyncConsistencyError("Local checkpoint could not be restored.");
|
|
452
|
+
headSeq = current.checkpointSeq;
|
|
453
|
+
checkpointSeq = current.checkpointSeq;
|
|
454
|
+
}
|
|
455
|
+
const tail = await timeline.tail(documentId, headSeq);
|
|
456
|
+
for (const commit of tail) applyStored(commit);
|
|
457
|
+
if (headSeq !== current.headSeq) throw new LocalSyncConsistencyError("Local commit log does not reach its recorded head sequence.");
|
|
458
|
+
checkpointSeq = current.checkpointSeq;
|
|
459
|
+
};
|
|
460
|
+
const execute = (run) => {
|
|
461
|
+
const next = queued.then(run, run);
|
|
462
|
+
queued = next.then(() => void 0, () => void 0);
|
|
463
|
+
return next;
|
|
464
|
+
};
|
|
465
|
+
const withWriter = (run) => lockManager.request(`doxum:${documentId}`, { mode: "exclusive" }, async () => {
|
|
466
|
+
assertOpen();
|
|
467
|
+
try {
|
|
468
|
+
await catchUp();
|
|
469
|
+
} catch (error) {
|
|
470
|
+
fail(error);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
return run();
|
|
474
|
+
});
|
|
475
|
+
const persistAndApply = async (input) => {
|
|
476
|
+
try {
|
|
477
|
+
const storedCommit = await timeline.append({
|
|
478
|
+
documentId,
|
|
479
|
+
expectedHeadSeq: headSeq,
|
|
480
|
+
commandId: input.commandId,
|
|
481
|
+
actorId,
|
|
482
|
+
kind: input.kind,
|
|
483
|
+
operations: input.operations,
|
|
484
|
+
inverse: input.inverse,
|
|
485
|
+
footprint: input.footprint,
|
|
486
|
+
history: input.history
|
|
487
|
+
});
|
|
488
|
+
const result = operationResult(documentRuntime.apply(storedCommit.operations, {
|
|
489
|
+
source: "local",
|
|
490
|
+
history: false
|
|
491
|
+
}), storedCommit);
|
|
492
|
+
headSeq = storedCommit.seq;
|
|
493
|
+
if (checkpointEvery > 0 && headSeq - checkpointSeq >= checkpointEvery) {
|
|
494
|
+
const checkpoint = json(documentRuntime.snapshot(), "local checkpoint");
|
|
495
|
+
checkpointSeq = (await timeline.compact(documentId, headSeq, checkpoint)).checkpointSeq;
|
|
496
|
+
}
|
|
497
|
+
channel?.postMessage({
|
|
498
|
+
kind: "commit",
|
|
499
|
+
documentId,
|
|
500
|
+
headSeq,
|
|
501
|
+
senderId
|
|
502
|
+
});
|
|
503
|
+
return result;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
fail(error);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const replay = (operations) => {
|
|
510
|
+
const candidate = require_readable.createDocument({
|
|
511
|
+
schema: input.schema,
|
|
512
|
+
initial: documentRuntime.snapshot(),
|
|
513
|
+
history: false
|
|
514
|
+
});
|
|
515
|
+
try {
|
|
516
|
+
return candidate.apply(operations, {
|
|
517
|
+
source: "local",
|
|
518
|
+
history: false
|
|
519
|
+
});
|
|
520
|
+
} finally {
|
|
521
|
+
candidate.dispose();
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const applyHistory = async (history, kind) => {
|
|
525
|
+
const source = kind === "undo" ? history.undo : history.redo;
|
|
526
|
+
const entry = source[source.length - 1];
|
|
527
|
+
if (!entry) return {
|
|
528
|
+
status: "unchanged",
|
|
529
|
+
revision: documentRuntime.revision()
|
|
530
|
+
};
|
|
531
|
+
const preview = replay(kind === "undo" ? entry.inverse : entry.operations);
|
|
532
|
+
if (preview.status !== "committed") return preview;
|
|
533
|
+
const operations = jsonArray(preview.commit.operations, `${kind} operations`, commandLimits);
|
|
534
|
+
const inverse = jsonArray(preview.commit.inverse, `${kind} inverse`, commandLimits);
|
|
535
|
+
return persistAndApply({
|
|
536
|
+
commandId: identifier(),
|
|
537
|
+
kind,
|
|
538
|
+
operations,
|
|
539
|
+
inverse,
|
|
540
|
+
footprint: require_readable.commandFootprint(preview.commit.operations),
|
|
541
|
+
history: kind === "undo" ? {
|
|
542
|
+
kind: "undo",
|
|
543
|
+
expectedCommandId: entry.commandId,
|
|
544
|
+
entry
|
|
545
|
+
} : {
|
|
546
|
+
kind: "redo",
|
|
547
|
+
expectedCommandId: entry.commandId,
|
|
548
|
+
entry
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
const initialTail = await timeline.tail(documentId, headSeq);
|
|
553
|
+
for (const commit of initialTail) applyStored(commit);
|
|
554
|
+
if (headSeq !== stored.headSeq) throw new LocalSyncConsistencyError("Local commit log does not reach its recorded head sequence.");
|
|
555
|
+
channel = new BroadcastChannel(`doxum:${databaseName}:${documentId}`);
|
|
556
|
+
channel.onmessage = (event) => {
|
|
557
|
+
const message = notification(event.data);
|
|
558
|
+
if (!message || message.documentId !== documentId || message.senderId === senderId || message.headSeq <= headSeq || closing || disposed) return;
|
|
559
|
+
execute(() => withWriter(async () => void 0)).catch(() => void 0);
|
|
560
|
+
};
|
|
561
|
+
const localDocument = {
|
|
562
|
+
document: require_readable.asReadable(documentRuntime),
|
|
563
|
+
state: () => {
|
|
564
|
+
if (disposed) return { status: "disposed" };
|
|
565
|
+
if (fault) return {
|
|
566
|
+
status: "failed",
|
|
567
|
+
error: fault
|
|
568
|
+
};
|
|
569
|
+
return {
|
|
570
|
+
status: "ready",
|
|
571
|
+
headSeq,
|
|
572
|
+
checkpointSeq
|
|
573
|
+
};
|
|
574
|
+
},
|
|
575
|
+
sync: () => execute(() => withWriter(async () => void 0)),
|
|
576
|
+
update: (run) => {
|
|
577
|
+
assertOpen();
|
|
578
|
+
return execute(() => withWriter(async () => {
|
|
579
|
+
const prepared = documentRuntime.prepare(run);
|
|
580
|
+
if (prepared.status === "unchanged") return {
|
|
581
|
+
status: "unchanged",
|
|
582
|
+
value: prepared.value,
|
|
583
|
+
revision: documentRuntime.revision(),
|
|
584
|
+
reports: prepared.reports
|
|
585
|
+
};
|
|
586
|
+
if (prepared.status === "rejected") return {
|
|
587
|
+
status: "rejected",
|
|
588
|
+
issues: prepared.issues,
|
|
589
|
+
revision: documentRuntime.revision()
|
|
590
|
+
};
|
|
591
|
+
const operations = jsonArray(prepared.operations, "local command operations", commandLimits);
|
|
592
|
+
const inverse = jsonArray(prepared.inverse, "local command inverse", commandLimits);
|
|
593
|
+
const commandId = identifier();
|
|
594
|
+
const result = await persistAndApply({
|
|
595
|
+
commandId,
|
|
596
|
+
kind: "update",
|
|
597
|
+
operations,
|
|
598
|
+
inverse,
|
|
599
|
+
footprint: prepared.footprint,
|
|
600
|
+
history: {
|
|
601
|
+
kind: "record",
|
|
602
|
+
entry: Object.freeze({
|
|
603
|
+
commandId,
|
|
604
|
+
operations,
|
|
605
|
+
inverse,
|
|
606
|
+
footprint: prepared.footprint
|
|
607
|
+
}),
|
|
608
|
+
capacity: historyCapacity
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
if (result.status !== "committed") throw new LocalSyncConsistencyError("Prepared local command was not committed.");
|
|
612
|
+
return {
|
|
613
|
+
status: "committed",
|
|
614
|
+
value: prepared.value,
|
|
615
|
+
commit: result.commit,
|
|
616
|
+
reports: prepared.reports,
|
|
617
|
+
observerErrors: result.observerErrors
|
|
618
|
+
};
|
|
619
|
+
}));
|
|
620
|
+
},
|
|
621
|
+
undo: () => {
|
|
622
|
+
assertOpen();
|
|
623
|
+
return execute(() => withWriter(async () => applyHistory(await timeline.history(documentId, actorId), "undo")));
|
|
624
|
+
},
|
|
625
|
+
redo: () => {
|
|
626
|
+
assertOpen();
|
|
627
|
+
return execute(() => withWriter(async () => applyHistory(await timeline.history(documentId, actorId), "redo")));
|
|
628
|
+
},
|
|
629
|
+
dispose: async () => {
|
|
630
|
+
if (disposed) return;
|
|
631
|
+
if (closing) {
|
|
632
|
+
await queued;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
closing = true;
|
|
636
|
+
await queued;
|
|
637
|
+
disposed = true;
|
|
638
|
+
channel?.close();
|
|
639
|
+
timeline.close();
|
|
640
|
+
documentRuntime.dispose();
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
return Object.freeze(localDocument);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
channel?.close();
|
|
646
|
+
runtime?.dispose();
|
|
647
|
+
timeline.close();
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
//#endregion
|
|
652
|
+
exports.LocalSyncConsistencyError = LocalSyncConsistencyError;
|
|
653
|
+
exports.LocalSyncDataError = LocalSyncDataError;
|
|
654
|
+
exports.LocalSyncDisposedError = LocalSyncDisposedError;
|
|
655
|
+
exports.LocalSyncSchemaError = LocalSyncSchemaError;
|
|
656
|
+
exports.LocalSyncUnavailableError = LocalSyncUnavailableError;
|
|
657
|
+
exports.defaultJsonCommandLimits = defaultJsonCommandLimits;
|
|
658
|
+
exports.openLocalDocument = openLocalDocument;
|
|
659
|
+
|
|
660
|
+
//# sourceMappingURL=local-sync.cjs.map
|