@bendyline/docblocks 1.1.2 → 2.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/README.md +76 -11
- package/dist/chunk-6IYMTYMQ.js +1692 -0
- package/dist/chunk-6IYMTYMQ.js.map +1 -0
- package/dist/chunk-AEHCVWU5.js +25 -0
- package/dist/chunk-AEHCVWU5.js.map +1 -0
- package/dist/chunk-B56KWCSE.js +802 -0
- package/dist/chunk-B56KWCSE.js.map +1 -0
- package/dist/{chunk-ME76RUMR.js → chunk-BNKV7MTO.js} +15 -1
- package/dist/chunk-BNKV7MTO.js.map +1 -0
- package/dist/chunk-BXMF5YQJ.js +149 -0
- package/dist/chunk-BXMF5YQJ.js.map +1 -0
- package/dist/chunk-COYQXQK3.js +1071 -0
- package/dist/chunk-COYQXQK3.js.map +1 -0
- package/dist/chunk-DN7A33LL.js +780 -0
- package/dist/chunk-DN7A33LL.js.map +1 -0
- package/dist/chunk-EZKJZUNJ.js +13 -0
- package/dist/chunk-EZKJZUNJ.js.map +1 -0
- package/dist/chunk-FA7UNOXK.js +20 -0
- package/dist/chunk-FA7UNOXK.js.map +1 -0
- package/dist/chunk-FNW556FN.js +75 -0
- package/dist/chunk-FNW556FN.js.map +1 -0
- package/dist/chunk-GM5YXU4L.js +29 -0
- package/dist/chunk-GM5YXU4L.js.map +1 -0
- package/dist/chunk-JS6XFWQK.js +225 -0
- package/dist/chunk-JS6XFWQK.js.map +1 -0
- package/dist/chunk-MNSZ7EZN.js +111 -0
- package/dist/chunk-MNSZ7EZN.js.map +1 -0
- package/dist/chunk-P3DVZQ6S.js +228 -0
- package/dist/chunk-P3DVZQ6S.js.map +1 -0
- package/dist/chunk-P4WATND4.js +52 -0
- package/dist/chunk-P4WATND4.js.map +1 -0
- package/dist/chunk-RVPGQBWR.js +169 -0
- package/dist/chunk-RVPGQBWR.js.map +1 -0
- package/dist/chunk-U722UUFN.js +9 -0
- package/dist/chunk-U722UUFN.js.map +1 -0
- package/dist/chunk-VTCSF7VS.js +553 -0
- package/dist/chunk-VTCSF7VS.js.map +1 -0
- package/dist/chunk-WJKYY5QR.js +1350 -0
- package/dist/chunk-WJKYY5QR.js.map +1 -0
- package/dist/chunk-XUML3MKV.js +945 -0
- package/dist/chunk-XUML3MKV.js.map +1 -0
- package/dist/document/index.d.ts +358 -0
- package/dist/document/index.js +25 -0
- package/dist/document/index.js.map +1 -0
- package/dist/filesystem/electron.d.ts +73 -0
- package/dist/filesystem/electron.js +17 -0
- package/dist/filesystem/electron.js.map +1 -0
- package/dist/filesystem/index.d.ts +228 -8
- package/dist/filesystem/index.js +133 -6
- package/dist/filesystem/index.js.map +1 -1
- package/dist/filesystem/indexeddb.d.ts +284 -0
- package/dist/filesystem/indexeddb.js +23 -0
- package/dist/filesystem/indexeddb.js.map +1 -0
- package/dist/filesystem/memory.d.ts +152 -0
- package/dist/filesystem/memory.js +13 -0
- package/dist/filesystem/memory.js.map +1 -0
- package/dist/filesystem/native.d.ts +136 -0
- package/dist/filesystem/native.js +28 -0
- package/dist/filesystem/native.js.map +1 -0
- package/dist/host/index.d.ts +32 -6
- package/dist/host/index.js +26 -27
- package/dist/host/index.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +204 -8
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts +33 -0
- package/dist/mcp/index.js +32 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/zod.d.ts +5765 -0
- package/dist/mcp/zod.js +724 -0
- package/dist/mcp/zod.js.map +1 -0
- package/dist/share/index.d.ts +43 -0
- package/dist/share/index.js +13 -0
- package/dist/share/index.js.map +1 -0
- package/dist/types-BEg5SQ1_.d.ts +301 -0
- package/dist/types-CX893hgH.d.ts +468 -0
- package/dist/types-CueK0tvU.d.ts +501 -0
- package/dist/vscode/index.d.ts +220 -0
- package/dist/vscode/index.js +383 -0
- package/dist/vscode/index.js.map +1 -0
- package/dist/workspace/index.d.ts +141 -3
- package/dist/workspace/index.js +28 -1
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace-path-CWVrcuPL.d.ts +21 -0
- package/package.json +50 -5
- package/dist/chunk-GUNM43XZ.js +0 -63
- package/dist/chunk-GUNM43XZ.js.map +0 -1
- package/dist/chunk-IM7LCQJ6.js +0 -850
- package/dist/chunk-IM7LCQJ6.js.map +0 -1
- package/dist/chunk-ME76RUMR.js.map +0 -1
- package/dist/filesystem/electron-provider.d.ts +0 -32
- package/dist/filesystem/electron-provider.d.ts.map +0 -1
- package/dist/filesystem/electron-provider.js +0 -64
- package/dist/filesystem/electron-provider.js.map +0 -1
- package/dist/filesystem/file-media-provider.d.ts +0 -23
- package/dist/filesystem/file-media-provider.d.ts.map +0 -1
- package/dist/filesystem/file-media-provider.js +0 -85
- package/dist/filesystem/file-media-provider.js.map +0 -1
- package/dist/filesystem/filesystem-content-container.d.ts +0 -24
- package/dist/filesystem/filesystem-content-container.d.ts.map +0 -1
- package/dist/filesystem/filesystem-content-container.js +0 -116
- package/dist/filesystem/filesystem-content-container.js.map +0 -1
- package/dist/filesystem/index.d.ts.map +0 -1
- package/dist/filesystem/indexeddb-content-container.d.ts +0 -20
- package/dist/filesystem/indexeddb-content-container.d.ts.map +0 -1
- package/dist/filesystem/indexeddb-content-container.js +0 -93
- package/dist/filesystem/indexeddb-content-container.js.map +0 -1
- package/dist/filesystem/indexeddb-provider.d.ts +0 -32
- package/dist/filesystem/indexeddb-provider.d.ts.map +0 -1
- package/dist/filesystem/indexeddb-provider.js +0 -295
- package/dist/filesystem/indexeddb-provider.js.map +0 -1
- package/dist/filesystem/native-provider.d.ts +0 -45
- package/dist/filesystem/native-provider.d.ts.map +0 -1
- package/dist/filesystem/native-provider.js +0 -336
- package/dist/filesystem/native-provider.js.map +0 -1
- package/dist/filesystem/types.d.ts +0 -50
- package/dist/filesystem/types.d.ts.map +0 -1
- package/dist/filesystem/types.js +0 -8
- package/dist/filesystem/types.js.map +0 -1
- package/dist/host/index.d.ts.map +0 -1
- package/dist/host/types.d.ts +0 -149
- package/dist/host/types.d.ts.map +0 -1
- package/dist/host/types.js +0 -10
- package/dist/host/types.js.map +0 -1
- package/dist/index-pgBHBQ9G.d.ts +0 -213
- package/dist/index.d.ts.map +0 -1
- package/dist/workspace/index.d.ts.map +0 -1
- package/dist/workspace/types.d.ts +0 -29
- package/dist/workspace/types.d.ts.map +0 -1
- package/dist/workspace/types.js +0 -6
- package/dist/workspace/types.js.map +0 -1
- package/dist/workspace/workspace-manager.d.ts +0 -35
- package/dist/workspace/workspace-manager.d.ts.map +0 -1
- package/dist/workspace/workspace-manager.js +0 -82
- package/dist/workspace/workspace-manager.js.map +0 -1
- package/src/filesystem/electron-provider.ts +0 -88
- package/src/filesystem/file-media-provider.ts +0 -103
- package/src/filesystem/filesystem-content-container.ts +0 -126
- package/src/filesystem/index.ts +0 -24
- package/src/filesystem/indexeddb-content-container.ts +0 -104
- package/src/filesystem/indexeddb-provider.ts +0 -345
- package/src/filesystem/native-provider.ts +0 -384
- package/src/filesystem/types.ts +0 -69
- package/src/host/index.ts +0 -47
- package/src/host/types.ts +0 -153
- package/src/index.ts +0 -9
- package/src/workspace/index.ts +0 -10
- package/src/workspace/types.ts +0 -29
- package/src/workspace/workspace-manager.ts +0 -90
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isFileSystemMoveStateError
|
|
3
|
+
} from "./chunk-MNSZ7EZN.js";
|
|
4
|
+
import {
|
|
5
|
+
decodeUtf8Text
|
|
6
|
+
} from "./chunk-FA7UNOXK.js";
|
|
7
|
+
import {
|
|
8
|
+
FsError,
|
|
9
|
+
parseWorkspacePath
|
|
10
|
+
} from "./chunk-JS6XFWQK.js";
|
|
11
|
+
import {
|
|
12
|
+
getFileSystemProviderV2
|
|
13
|
+
} from "./chunk-EZKJZUNJ.js";
|
|
14
|
+
|
|
15
|
+
// src/document/document-session.ts
|
|
16
|
+
var DEFAULT_AUTO_SAVE_RETRY_DELAYS_MS = [1e3, 3e3, 1e4];
|
|
17
|
+
var DocumentCommitConflictError = class extends Error {
|
|
18
|
+
constructor(message, externalContent, externalVersion = null) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "DocumentCommitConflictError";
|
|
21
|
+
this.externalContent = externalContent;
|
|
22
|
+
this.externalVersion = externalVersion;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var DocumentSessionConflictError = class extends Error {
|
|
26
|
+
constructor(conflict) {
|
|
27
|
+
super("The document changed outside DocBlocks while local edits were pending.");
|
|
28
|
+
this.name = "DocumentSessionConflictError";
|
|
29
|
+
this.conflict = conflict;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var DocumentSession = class {
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
35
|
+
this.target = null;
|
|
36
|
+
this.content = "";
|
|
37
|
+
this.persistedContent = null;
|
|
38
|
+
this.revision = 0;
|
|
39
|
+
this.persistedRevision = 0;
|
|
40
|
+
this.savingRevision = null;
|
|
41
|
+
this.savingContent = null;
|
|
42
|
+
this.generation = 0;
|
|
43
|
+
this.externalSequence = -1;
|
|
44
|
+
this.lifecycle = "open";
|
|
45
|
+
this.frozen = false;
|
|
46
|
+
this.error = null;
|
|
47
|
+
this.conflict = null;
|
|
48
|
+
this.autoSaveTimer = null;
|
|
49
|
+
this.autoSaveRetryAttempt = 0;
|
|
50
|
+
this.drainPromise = null;
|
|
51
|
+
this.haltDrain = false;
|
|
52
|
+
this.operationTail = Promise.resolve();
|
|
53
|
+
this.subscribe = (listener) => {
|
|
54
|
+
this.listeners.add(listener);
|
|
55
|
+
return () => this.listeners.delete(listener);
|
|
56
|
+
};
|
|
57
|
+
this.getSnapshot = () => this.snapshot;
|
|
58
|
+
this.autoSaveDelayMs = Math.max(0, options.autoSaveDelayMs ?? 500);
|
|
59
|
+
this.autoSaveEnabled = options.autoSaveEnabled ?? true;
|
|
60
|
+
this.autoSaveRetryDelaysMs = normalizeRetryDelays(
|
|
61
|
+
options.autoSaveRetryDelaysMs ?? DEFAULT_AUTO_SAVE_RETRY_DELAYS_MS
|
|
62
|
+
);
|
|
63
|
+
this.recoveryJournal = options.recoveryJournal ?? null;
|
|
64
|
+
this.snapshot = this.createSnapshot();
|
|
65
|
+
}
|
|
66
|
+
/** Enable or disable automatic persistence without weakening explicit or lifecycle flushes. */
|
|
67
|
+
setAutoSaveEnabled(enabled) {
|
|
68
|
+
if (this.autoSaveEnabled === enabled) return;
|
|
69
|
+
this.autoSaveEnabled = enabled;
|
|
70
|
+
if (!enabled) {
|
|
71
|
+
this.clearAutoSaveTimer();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (this.target && this.lifecycle === "open" && !this.frozen && !this.conflict && this.persistedRevision < this.revision) {
|
|
75
|
+
this.scheduleAutoSave();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Record a complete local snapshot and schedule its persistence.
|
|
80
|
+
* Returns the assigned monotonic revision.
|
|
81
|
+
*/
|
|
82
|
+
edit(content, scope) {
|
|
83
|
+
if (!this.target || this.lifecycle !== "open" || this.frozen) {
|
|
84
|
+
throw new Error("Cannot edit a document session that is not open.");
|
|
85
|
+
}
|
|
86
|
+
if (scope.targetKey !== this.target.key || scope.generation !== this.generation) {
|
|
87
|
+
throw new Error("Cannot apply an edit from an obsolete document instance.");
|
|
88
|
+
}
|
|
89
|
+
if (content === this.content) return this.revision;
|
|
90
|
+
this.content = content;
|
|
91
|
+
this.revision += 1;
|
|
92
|
+
this.error = null;
|
|
93
|
+
this.autoSaveRetryAttempt = 0;
|
|
94
|
+
if (this.conflict) {
|
|
95
|
+
this.conflict = {
|
|
96
|
+
...this.conflict,
|
|
97
|
+
localContent: content,
|
|
98
|
+
localRevision: this.revision
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
this.writeRecoverySnapshot();
|
|
102
|
+
if (!this.conflict) this.scheduleAutoSave();
|
|
103
|
+
this.emit();
|
|
104
|
+
return this.revision;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Persist the revision current when flush() is called. A later edit may
|
|
108
|
+
* advance the session again, but commits remain strictly serialized.
|
|
109
|
+
*/
|
|
110
|
+
async flush(reason = "manual") {
|
|
111
|
+
this.clearAutoSaveTimer();
|
|
112
|
+
if (!this.target) return this.snapshot;
|
|
113
|
+
if (this.conflict) throw new DocumentSessionConflictError(this.conflict);
|
|
114
|
+
const requiredRevision = this.revision;
|
|
115
|
+
while (this.target && this.persistedRevision < requiredRevision) {
|
|
116
|
+
if (this.conflict) throw new DocumentSessionConflictError(this.conflict);
|
|
117
|
+
if (this.haltDrain) {
|
|
118
|
+
throw new Error("Document persistence was cancelled before the requested revision saved.");
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await this.ensureDrain(reason);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (this.conflict) throw new DocumentSessionConflictError(this.conflict);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (this.conflict) throw new DocumentSessionConflictError(this.conflict);
|
|
128
|
+
if (this.persistedRevision < requiredRevision) {
|
|
129
|
+
throw new Error("The document target closed before the requested revision saved.");
|
|
130
|
+
}
|
|
131
|
+
return this.snapshot;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Flush the old document, then replace it with a new clean baseline.
|
|
135
|
+
* UI state should change only after this promise resolves.
|
|
136
|
+
*/
|
|
137
|
+
transitionTo(target, content) {
|
|
138
|
+
return this.enqueueOperation(
|
|
139
|
+
() => this.performTransition(async () => ({ target, content })).then((snapshot) => {
|
|
140
|
+
if (!snapshot) throw new Error("A fixed document transition cannot be cancelled.");
|
|
141
|
+
return snapshot;
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Freeze and flush the current document before reading the next one. The
|
|
147
|
+
* loader may return null when a navigation request was superseded, leaving
|
|
148
|
+
* the current target active and clean.
|
|
149
|
+
*/
|
|
150
|
+
transitionWithLoad(load) {
|
|
151
|
+
return this.enqueueOperation(() => this.performTransition(load));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Flush the current path, run the storage move, and only then swap the
|
|
155
|
+
* commit target. No edit can enter between those steps.
|
|
156
|
+
*/
|
|
157
|
+
retarget(nextTarget, moveStorage) {
|
|
158
|
+
return this.enqueueOperation(async () => {
|
|
159
|
+
this.assertNotPreparingClose();
|
|
160
|
+
if (!this.target) throw new Error("Cannot retarget a session without a document.");
|
|
161
|
+
this.lifecycle = "transitioning";
|
|
162
|
+
this.frozen = true;
|
|
163
|
+
this.emit();
|
|
164
|
+
try {
|
|
165
|
+
this.haltDrain = false;
|
|
166
|
+
await this.flush("transition");
|
|
167
|
+
await moveStorage();
|
|
168
|
+
this.generation += 1;
|
|
169
|
+
this.externalSequence = -1;
|
|
170
|
+
this.target = nextTarget;
|
|
171
|
+
this.error = null;
|
|
172
|
+
this.conflict = null;
|
|
173
|
+
this.lifecycle = "open";
|
|
174
|
+
this.frozen = false;
|
|
175
|
+
this.emit();
|
|
176
|
+
return this.snapshot;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (isFileSystemMoveStateError(error)) {
|
|
179
|
+
if (error.documentLocation === "destination") {
|
|
180
|
+
this.generation += 1;
|
|
181
|
+
this.externalSequence = -1;
|
|
182
|
+
this.target = nextTarget;
|
|
183
|
+
}
|
|
184
|
+
this.haltDrain = true;
|
|
185
|
+
this.error = error;
|
|
186
|
+
this.lifecycle = "open";
|
|
187
|
+
this.frozen = true;
|
|
188
|
+
this.emit();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
this.lifecycle = "open";
|
|
192
|
+
this.frozen = false;
|
|
193
|
+
this.emit();
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Cancel queued writes, wait for an already-running write, then delete.
|
|
200
|
+
* The deletion is therefore the final storage operation for this target.
|
|
201
|
+
*/
|
|
202
|
+
delete(deleteStorage) {
|
|
203
|
+
return this.enqueueOperation(async () => {
|
|
204
|
+
this.assertNotPreparingClose();
|
|
205
|
+
this.lifecycle = "transitioning";
|
|
206
|
+
this.frozen = true;
|
|
207
|
+
this.haltDrain = true;
|
|
208
|
+
this.clearAutoSaveTimer();
|
|
209
|
+
this.emit();
|
|
210
|
+
await this.awaitActiveDrain();
|
|
211
|
+
try {
|
|
212
|
+
await deleteStorage();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
this.haltDrain = false;
|
|
215
|
+
this.lifecycle = "open";
|
|
216
|
+
this.frozen = false;
|
|
217
|
+
if (this.target && this.persistedRevision < this.revision && !this.conflict) {
|
|
218
|
+
this.scheduleAutoSave();
|
|
219
|
+
}
|
|
220
|
+
this.emit();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
this.discardRecoverySnapshot();
|
|
224
|
+
this.detach();
|
|
225
|
+
return this.snapshot;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Discard queued changes and detach without mutating storage. This is used
|
|
230
|
+
* for explicitly abandoned sessions, never as an implicit effect cleanup.
|
|
231
|
+
*/
|
|
232
|
+
cancel() {
|
|
233
|
+
return this.enqueueOperation(async () => {
|
|
234
|
+
this.lifecycle = "transitioning";
|
|
235
|
+
this.frozen = true;
|
|
236
|
+
this.haltDrain = true;
|
|
237
|
+
this.clearAutoSaveTimer();
|
|
238
|
+
this.emit();
|
|
239
|
+
await this.awaitActiveDrain();
|
|
240
|
+
this.discardRecoverySnapshot();
|
|
241
|
+
this.detach();
|
|
242
|
+
return this.snapshot;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Observe a filesystem/host change captured for targetKey. Clean sessions
|
|
247
|
+
* adopt it. Dirty or saving sessions preserve local text and enter conflict.
|
|
248
|
+
*/
|
|
249
|
+
observeExternal(change) {
|
|
250
|
+
if (!this.target || change.targetKey !== this.target.key || this.lifecycle === "closed") {
|
|
251
|
+
return "ignored";
|
|
252
|
+
}
|
|
253
|
+
if (change.sequence !== void 0) {
|
|
254
|
+
if (!Number.isSafeInteger(change.sequence) || change.sequence <= this.externalSequence) {
|
|
255
|
+
return "ignored";
|
|
256
|
+
}
|
|
257
|
+
this.externalSequence = change.sequence;
|
|
258
|
+
}
|
|
259
|
+
if (this.conflict && change.content === this.content && this.savingRevision === null && this.lifecycle === "open") {
|
|
260
|
+
this.clearAutoSaveTimer();
|
|
261
|
+
this.autoSaveRetryAttempt = 0;
|
|
262
|
+
this.persistedRevision = this.revision;
|
|
263
|
+
this.persistedContent = this.content;
|
|
264
|
+
this.haltDrain = false;
|
|
265
|
+
this.error = null;
|
|
266
|
+
this.conflict = null;
|
|
267
|
+
this.discardRecoverySnapshot();
|
|
268
|
+
this.emit();
|
|
269
|
+
return "applied";
|
|
270
|
+
}
|
|
271
|
+
if (this.conflict) {
|
|
272
|
+
const externalVersion = change.version ?? null;
|
|
273
|
+
if (this.conflict.localContent === this.content && this.conflict.localRevision === this.revision && this.conflict.externalContent === change.content && this.conflict.externalVersion === externalVersion) {
|
|
274
|
+
return "ignored";
|
|
275
|
+
}
|
|
276
|
+
this.clearAutoSaveTimer();
|
|
277
|
+
this.haltDrain = true;
|
|
278
|
+
this.conflict = {
|
|
279
|
+
targetKey: change.targetKey,
|
|
280
|
+
localContent: this.content,
|
|
281
|
+
localRevision: this.revision,
|
|
282
|
+
externalContent: change.content,
|
|
283
|
+
externalVersion
|
|
284
|
+
};
|
|
285
|
+
this.error = null;
|
|
286
|
+
this.writeRecoverySnapshot();
|
|
287
|
+
this.emit();
|
|
288
|
+
return "conflict";
|
|
289
|
+
}
|
|
290
|
+
if (change.content === this.content || change.content === this.persistedContent || this.savingRevision !== null && change.content === this.savingContent) {
|
|
291
|
+
return "ignored";
|
|
292
|
+
}
|
|
293
|
+
const isClean = this.persistedRevision === this.revision && this.savingRevision === null && this.error === null && this.conflict === null;
|
|
294
|
+
if (isClean && change.content !== null) {
|
|
295
|
+
this.content = change.content;
|
|
296
|
+
this.revision += 1;
|
|
297
|
+
this.persistedRevision = this.revision;
|
|
298
|
+
this.persistedContent = change.content;
|
|
299
|
+
this.generation += 1;
|
|
300
|
+
this.discardRecoverySnapshot();
|
|
301
|
+
this.emit();
|
|
302
|
+
return "applied";
|
|
303
|
+
}
|
|
304
|
+
this.clearAutoSaveTimer();
|
|
305
|
+
this.haltDrain = true;
|
|
306
|
+
this.conflict = {
|
|
307
|
+
targetKey: change.targetKey,
|
|
308
|
+
localContent: this.content,
|
|
309
|
+
localRevision: this.revision,
|
|
310
|
+
externalContent: change.content,
|
|
311
|
+
externalVersion: change.version ?? null
|
|
312
|
+
};
|
|
313
|
+
this.error = null;
|
|
314
|
+
this.writeRecoverySnapshot();
|
|
315
|
+
this.emit();
|
|
316
|
+
return "conflict";
|
|
317
|
+
}
|
|
318
|
+
resolveConflict(strategy) {
|
|
319
|
+
return this.enqueueOperation(async () => {
|
|
320
|
+
if (!this.conflict) return this.snapshot;
|
|
321
|
+
this.clearAutoSaveTimer();
|
|
322
|
+
this.haltDrain = true;
|
|
323
|
+
await this.awaitActiveDrain();
|
|
324
|
+
const currentConflict = this.conflict;
|
|
325
|
+
if (!currentConflict) return this.snapshot;
|
|
326
|
+
if (strategy === "use-external") {
|
|
327
|
+
this.discardRecoverySnapshot();
|
|
328
|
+
this.generation += 1;
|
|
329
|
+
this.revision += 1;
|
|
330
|
+
this.conflict = null;
|
|
331
|
+
this.error = null;
|
|
332
|
+
this.haltDrain = false;
|
|
333
|
+
if (currentConflict.externalContent === null) {
|
|
334
|
+
this.target = null;
|
|
335
|
+
this.content = "";
|
|
336
|
+
this.persistedContent = null;
|
|
337
|
+
this.persistedRevision = this.revision;
|
|
338
|
+
this.lifecycle = "closed";
|
|
339
|
+
this.frozen = true;
|
|
340
|
+
} else {
|
|
341
|
+
this.content = currentConflict.externalContent;
|
|
342
|
+
this.persistedContent = currentConflict.externalContent;
|
|
343
|
+
this.persistedRevision = this.revision;
|
|
344
|
+
this.lifecycle = "open";
|
|
345
|
+
this.frozen = false;
|
|
346
|
+
}
|
|
347
|
+
this.emit();
|
|
348
|
+
return this.snapshot;
|
|
349
|
+
}
|
|
350
|
+
this.persistedContent = currentConflict.externalContent;
|
|
351
|
+
this.revision += 1;
|
|
352
|
+
this.conflict = null;
|
|
353
|
+
this.error = null;
|
|
354
|
+
this.haltDrain = false;
|
|
355
|
+
this.lifecycle = "open";
|
|
356
|
+
this.frozen = false;
|
|
357
|
+
this.writeRecoverySnapshot();
|
|
358
|
+
this.emit();
|
|
359
|
+
return this.flush("conflict");
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Freeze edits and flush through the latest revision. A successful call
|
|
364
|
+
* remains frozen until cancelClose(); the owning host may now destroy it.
|
|
365
|
+
*/
|
|
366
|
+
prepareClose() {
|
|
367
|
+
return this.enqueueOperation(async () => {
|
|
368
|
+
if (this.lifecycle === "closing") return this.snapshot;
|
|
369
|
+
this.lifecycle = "closing";
|
|
370
|
+
this.frozen = true;
|
|
371
|
+
this.haltDrain = false;
|
|
372
|
+
this.emit();
|
|
373
|
+
try {
|
|
374
|
+
await this.flush("close");
|
|
375
|
+
return this.snapshot;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
this.lifecycle = "open";
|
|
378
|
+
this.frozen = false;
|
|
379
|
+
this.emit();
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** Resume editing when a host cancels a prepared close. */
|
|
385
|
+
cancelClose() {
|
|
386
|
+
if (this.lifecycle !== "closing") return;
|
|
387
|
+
this.lifecycle = "open";
|
|
388
|
+
this.frozen = false;
|
|
389
|
+
this.haltDrain = false;
|
|
390
|
+
if (this.target && this.persistedRevision < this.revision && !this.conflict) {
|
|
391
|
+
this.scheduleAutoSave();
|
|
392
|
+
}
|
|
393
|
+
this.emit();
|
|
394
|
+
}
|
|
395
|
+
async performTransition(load) {
|
|
396
|
+
this.assertNotPreparingClose();
|
|
397
|
+
this.lifecycle = "transitioning";
|
|
398
|
+
this.frozen = true;
|
|
399
|
+
this.emit();
|
|
400
|
+
try {
|
|
401
|
+
this.haltDrain = false;
|
|
402
|
+
await this.flush("transition");
|
|
403
|
+
this.clearAutoSaveTimer();
|
|
404
|
+
const next = await load();
|
|
405
|
+
if (!next) {
|
|
406
|
+
this.lifecycle = "open";
|
|
407
|
+
this.frozen = false;
|
|
408
|
+
this.emit();
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
this.generation += 1;
|
|
412
|
+
this.externalSequence = -1;
|
|
413
|
+
this.target = next.target;
|
|
414
|
+
this.content = next.content;
|
|
415
|
+
this.revision += 1;
|
|
416
|
+
this.persistedRevision = this.revision;
|
|
417
|
+
this.persistedContent = next.target ? next.content : null;
|
|
418
|
+
this.savingRevision = null;
|
|
419
|
+
this.savingContent = null;
|
|
420
|
+
this.error = null;
|
|
421
|
+
this.conflict = null;
|
|
422
|
+
this.haltDrain = false;
|
|
423
|
+
this.lifecycle = "open";
|
|
424
|
+
this.frozen = false;
|
|
425
|
+
const recoveryState = this.restoreRecoverySnapshot();
|
|
426
|
+
if (recoveryState === "dirty") this.scheduleAutoSave();
|
|
427
|
+
this.emit();
|
|
428
|
+
return this.snapshot;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
this.lifecycle = "open";
|
|
431
|
+
this.frozen = false;
|
|
432
|
+
this.emit();
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
scheduleAutoSave(delayMs = this.autoSaveDelayMs) {
|
|
437
|
+
this.clearAutoSaveTimer();
|
|
438
|
+
if (!this.autoSaveEnabled) return;
|
|
439
|
+
this.autoSaveTimer = setTimeout(() => {
|
|
440
|
+
this.autoSaveTimer = null;
|
|
441
|
+
const target = this.target;
|
|
442
|
+
const generation = this.generation;
|
|
443
|
+
void this.flush("autosave").then(
|
|
444
|
+
() => {
|
|
445
|
+
this.autoSaveRetryAttempt = 0;
|
|
446
|
+
},
|
|
447
|
+
() => {
|
|
448
|
+
if (this.autoSaveTimer !== null || !target || target !== this.target || generation !== this.generation || this.lifecycle !== "open" || this.frozen || this.haltDrain || this.conflict || this.persistedRevision >= this.revision) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const retryDelay = this.autoSaveRetryDelaysMs[this.autoSaveRetryAttempt];
|
|
452
|
+
if (retryDelay === void 0) return;
|
|
453
|
+
this.autoSaveRetryAttempt += 1;
|
|
454
|
+
this.scheduleAutoSave(retryDelay);
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
}, delayMs);
|
|
458
|
+
}
|
|
459
|
+
clearAutoSaveTimer() {
|
|
460
|
+
if (this.autoSaveTimer === null) return;
|
|
461
|
+
clearTimeout(this.autoSaveTimer);
|
|
462
|
+
this.autoSaveTimer = null;
|
|
463
|
+
}
|
|
464
|
+
ensureDrain(reason) {
|
|
465
|
+
if (this.drainPromise) return this.drainPromise;
|
|
466
|
+
const running = this.drain(reason);
|
|
467
|
+
this.drainPromise = running;
|
|
468
|
+
const clear = () => {
|
|
469
|
+
if (this.drainPromise === running) this.drainPromise = null;
|
|
470
|
+
};
|
|
471
|
+
void running.then(clear, clear);
|
|
472
|
+
return running;
|
|
473
|
+
}
|
|
474
|
+
async drain(reason) {
|
|
475
|
+
while (this.target && !this.haltDrain && !this.conflict && this.persistedRevision < this.revision) {
|
|
476
|
+
const target = this.target;
|
|
477
|
+
const generation = this.generation;
|
|
478
|
+
const saveRevision = this.revision;
|
|
479
|
+
const saveContent = this.content;
|
|
480
|
+
const previousPersistedRevision = this.persistedRevision;
|
|
481
|
+
const previousPersistedContent = this.persistedContent;
|
|
482
|
+
this.savingRevision = saveRevision;
|
|
483
|
+
this.savingContent = saveContent;
|
|
484
|
+
this.error = null;
|
|
485
|
+
this.emit();
|
|
486
|
+
try {
|
|
487
|
+
await target.commit({
|
|
488
|
+
targetKey: target.key,
|
|
489
|
+
content: saveContent,
|
|
490
|
+
revision: saveRevision,
|
|
491
|
+
persistedRevision: previousPersistedRevision,
|
|
492
|
+
persistedContent: previousPersistedContent,
|
|
493
|
+
reason
|
|
494
|
+
});
|
|
495
|
+
} catch (error) {
|
|
496
|
+
this.savingRevision = null;
|
|
497
|
+
this.savingContent = null;
|
|
498
|
+
if (error instanceof DocumentCommitConflictError) {
|
|
499
|
+
this.haltDrain = true;
|
|
500
|
+
this.conflict = {
|
|
501
|
+
targetKey: target.key,
|
|
502
|
+
localContent: this.content,
|
|
503
|
+
localRevision: this.revision,
|
|
504
|
+
externalContent: error.externalContent,
|
|
505
|
+
externalVersion: error.externalVersion
|
|
506
|
+
};
|
|
507
|
+
this.error = null;
|
|
508
|
+
} else {
|
|
509
|
+
this.error = toError(error);
|
|
510
|
+
}
|
|
511
|
+
this.emit();
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
if (generation !== this.generation || target !== this.target) return;
|
|
515
|
+
this.persistedRevision = Math.max(this.persistedRevision, saveRevision);
|
|
516
|
+
this.persistedContent = saveContent;
|
|
517
|
+
this.autoSaveRetryAttempt = 0;
|
|
518
|
+
this.savingRevision = null;
|
|
519
|
+
this.savingContent = null;
|
|
520
|
+
this.error = null;
|
|
521
|
+
const activeConflict = this.getSnapshot().conflict;
|
|
522
|
+
if (activeConflict?.externalContent === saveContent && this.content === saveContent) {
|
|
523
|
+
this.conflict = null;
|
|
524
|
+
this.haltDrain = false;
|
|
525
|
+
}
|
|
526
|
+
this.recoveryJournal?.acknowledge({
|
|
527
|
+
targetKey: target.key,
|
|
528
|
+
generation,
|
|
529
|
+
persistedRevision: this.persistedRevision
|
|
530
|
+
});
|
|
531
|
+
this.emit();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async awaitActiveDrain() {
|
|
535
|
+
const active = this.drainPromise;
|
|
536
|
+
if (!active) return;
|
|
537
|
+
try {
|
|
538
|
+
await active;
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Rehydrate a synchronous crash snapshot after the durable baseline has
|
|
544
|
+
* been read. A matching baseline is safe to resume as dirty; a changed
|
|
545
|
+
* baseline becomes an explicit conflict and is never auto-overwritten.
|
|
546
|
+
*/
|
|
547
|
+
restoreRecoverySnapshot() {
|
|
548
|
+
if (!this.target || !this.recoveryJournal) return "none";
|
|
549
|
+
const recovered = this.recoveryJournal.lookup(this.target.key);
|
|
550
|
+
if (!recovered) return "none";
|
|
551
|
+
if (recovered.content === this.content) {
|
|
552
|
+
this.recoveryJournal.acknowledge({
|
|
553
|
+
targetKey: recovered.targetKey,
|
|
554
|
+
generation: recovered.generation,
|
|
555
|
+
persistedRevision: recovered.revision
|
|
556
|
+
});
|
|
557
|
+
return "none";
|
|
558
|
+
}
|
|
559
|
+
const externalContent = this.content;
|
|
560
|
+
this.generation = Math.max(this.generation, recovered.generation) + 1;
|
|
561
|
+
this.revision = Math.max(this.revision, recovered.revision) + 1;
|
|
562
|
+
this.content = recovered.content;
|
|
563
|
+
if (recovered.persistedContent === externalContent) {
|
|
564
|
+
this.writeRecoverySnapshot();
|
|
565
|
+
return "dirty";
|
|
566
|
+
}
|
|
567
|
+
this.persistedContent = recovered.persistedContent;
|
|
568
|
+
this.haltDrain = true;
|
|
569
|
+
this.conflict = {
|
|
570
|
+
targetKey: this.target.key,
|
|
571
|
+
localContent: recovered.content,
|
|
572
|
+
localRevision: this.revision,
|
|
573
|
+
externalContent,
|
|
574
|
+
externalVersion: null
|
|
575
|
+
};
|
|
576
|
+
this.writeRecoverySnapshot();
|
|
577
|
+
return "conflict";
|
|
578
|
+
}
|
|
579
|
+
writeRecoverySnapshot() {
|
|
580
|
+
if (!this.target || !this.recoveryJournal) return;
|
|
581
|
+
if (this.persistedRevision >= this.revision && !this.conflict) return;
|
|
582
|
+
this.recoveryJournal.write({
|
|
583
|
+
targetKey: this.target.key,
|
|
584
|
+
generation: this.generation,
|
|
585
|
+
revision: this.revision,
|
|
586
|
+
content: this.content,
|
|
587
|
+
persistedContent: this.persistedContent
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
discardRecoverySnapshot() {
|
|
591
|
+
if (!this.target) return;
|
|
592
|
+
this.recoveryJournal?.discard(this.target.key);
|
|
593
|
+
}
|
|
594
|
+
detach() {
|
|
595
|
+
this.clearAutoSaveTimer();
|
|
596
|
+
this.autoSaveRetryAttempt = 0;
|
|
597
|
+
this.generation += 1;
|
|
598
|
+
this.externalSequence = -1;
|
|
599
|
+
this.target = null;
|
|
600
|
+
this.persistedContent = null;
|
|
601
|
+
this.savingRevision = null;
|
|
602
|
+
this.savingContent = null;
|
|
603
|
+
this.error = null;
|
|
604
|
+
this.conflict = null;
|
|
605
|
+
this.haltDrain = false;
|
|
606
|
+
this.lifecycle = "closed";
|
|
607
|
+
this.frozen = true;
|
|
608
|
+
this.emit();
|
|
609
|
+
}
|
|
610
|
+
assertNotPreparingClose() {
|
|
611
|
+
if (this.lifecycle === "closing") {
|
|
612
|
+
throw new Error("The document is already prepared for close.");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
enqueueOperation(operation) {
|
|
616
|
+
const run = this.operationTail.then(operation, operation);
|
|
617
|
+
this.operationTail = run.then(
|
|
618
|
+
() => void 0,
|
|
619
|
+
() => void 0
|
|
620
|
+
);
|
|
621
|
+
return run;
|
|
622
|
+
}
|
|
623
|
+
createSnapshot() {
|
|
624
|
+
return Object.freeze({
|
|
625
|
+
targetKey: this.target?.key ?? null,
|
|
626
|
+
content: this.content,
|
|
627
|
+
revision: this.revision,
|
|
628
|
+
persistedRevision: this.persistedRevision,
|
|
629
|
+
persistedContent: this.persistedContent,
|
|
630
|
+
savingRevision: this.savingRevision,
|
|
631
|
+
generation: this.generation,
|
|
632
|
+
status: this.computeStatus(),
|
|
633
|
+
lifecycle: this.lifecycle,
|
|
634
|
+
frozen: this.frozen,
|
|
635
|
+
error: this.error,
|
|
636
|
+
conflict: this.conflict
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
computeStatus() {
|
|
640
|
+
if (this.lifecycle === "closed") return "closed";
|
|
641
|
+
if (!this.target) return "idle";
|
|
642
|
+
if (this.conflict) return "conflict";
|
|
643
|
+
if (this.error) return "error";
|
|
644
|
+
if (this.savingRevision !== null) return "saving";
|
|
645
|
+
return this.persistedRevision < this.revision ? "dirty" : "saved";
|
|
646
|
+
}
|
|
647
|
+
emit() {
|
|
648
|
+
this.snapshot = this.createSnapshot();
|
|
649
|
+
for (const listener of [...this.listeners]) listener();
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
function toError(error) {
|
|
653
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
654
|
+
}
|
|
655
|
+
function normalizeRetryDelays(delays) {
|
|
656
|
+
return delays.map((delay) => {
|
|
657
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
658
|
+
throw new TypeError("Autosave retry delays must be finite, non-negative numbers.");
|
|
659
|
+
}
|
|
660
|
+
return Math.floor(delay);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/document/filesystem-target.ts
|
|
665
|
+
function createFileSystemDocumentTarget(provider, path, options = {}) {
|
|
666
|
+
const verifyExternalChanges = options.verifyExternalChanges ?? true;
|
|
667
|
+
const canonicalPath = parseWorkspacePath(path);
|
|
668
|
+
const providerV2 = getFileSystemProviderV2(provider);
|
|
669
|
+
return {
|
|
670
|
+
key: provider.id + ":" + canonicalPath,
|
|
671
|
+
async commit(request) {
|
|
672
|
+
if (providerV2 && providerV2.capabilities.conditionalWrite !== "none") {
|
|
673
|
+
const current = await providerV2.readFile(canonicalPath);
|
|
674
|
+
const currentContent = current ? decodeUtf8Text(current.data, {
|
|
675
|
+
label: "The external document",
|
|
676
|
+
path: canonicalPath
|
|
677
|
+
}) : null;
|
|
678
|
+
if (currentContent !== request.persistedContent && currentContent !== request.content) {
|
|
679
|
+
throw new DocumentCommitConflictError(
|
|
680
|
+
"The document changed outside DocBlocks: " + path,
|
|
681
|
+
currentContent,
|
|
682
|
+
current?.entry.version ?? null
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (currentContent === request.content) {
|
|
686
|
+
options.onCommitted?.(request.content, request.revision);
|
|
687
|
+
return { version: current?.entry.version ?? null };
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
const committed = await providerV2.writeFile(
|
|
691
|
+
canonicalPath,
|
|
692
|
+
new TextEncoder().encode(request.content),
|
|
693
|
+
{
|
|
694
|
+
mode: current ? "replace" : "create",
|
|
695
|
+
createParents: true,
|
|
696
|
+
expectedVersion: current?.entry.version ?? null
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
options.onCommitted?.(request.content, request.revision);
|
|
700
|
+
return { version: committed.version };
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (error instanceof FsError && (error.code === "conflict" || error.code === "already-exists" || error.code === "not-found")) {
|
|
703
|
+
const external = await providerV2.readFile(canonicalPath);
|
|
704
|
+
throw new DocumentCommitConflictError(
|
|
705
|
+
"The document changed outside DocBlocks: " + path,
|
|
706
|
+
external ? decodeUtf8Text(external.data, {
|
|
707
|
+
label: "The external document",
|
|
708
|
+
path: canonicalPath
|
|
709
|
+
}) : null,
|
|
710
|
+
external?.entry.version ?? null
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (provider.commitFile) {
|
|
717
|
+
const result = await provider.commitFile(path, request.content, request.persistedContent);
|
|
718
|
+
if (result.status === "conflict") {
|
|
719
|
+
throw new DocumentCommitConflictError(
|
|
720
|
+
"The document changed outside DocBlocks: " + path,
|
|
721
|
+
result.content,
|
|
722
|
+
result.version
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
options.onCommitted?.(request.content, request.revision);
|
|
726
|
+
return { version: result.version };
|
|
727
|
+
}
|
|
728
|
+
if (verifyExternalChanges) {
|
|
729
|
+
const current = await provider.readFile(path);
|
|
730
|
+
if (current !== request.persistedContent && current !== request.content) {
|
|
731
|
+
const meta2 = await provider.stat(path).catch(() => null);
|
|
732
|
+
const version = meta2?.lastModified ?? null;
|
|
733
|
+
throw new DocumentCommitConflictError(
|
|
734
|
+
"The document changed outside DocBlocks: " + path,
|
|
735
|
+
current,
|
|
736
|
+
version
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
await provider.writeFile(path, request.content);
|
|
741
|
+
options.onCommitted?.(request.content, request.revision);
|
|
742
|
+
const meta = await provider.stat(path).catch(() => null);
|
|
743
|
+
return { version: meta?.lastModified ?? null };
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/document/recovery-journal.ts
|
|
749
|
+
var DOCUMENT_RECOVERY_JOURNAL_STORAGE_KEY = "docblocks:document-recovery:v1";
|
|
750
|
+
var DOCUMENT_RECOVERY_JOURNAL_SCHEMA_VERSION = 1;
|
|
751
|
+
var DEFAULT_MAX_ENTRIES = 20;
|
|
752
|
+
var DEFAULT_MAX_STORAGE_BYTES = 2e6;
|
|
753
|
+
var DEFAULT_MAX_RECORD_BYTES = 1e6;
|
|
754
|
+
var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
755
|
+
var DEFAULT_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
756
|
+
var MAX_TARGET_KEY_LENGTH = 4096;
|
|
757
|
+
var DocumentRecoveryJournal = class {
|
|
758
|
+
constructor(storage, options = {}) {
|
|
759
|
+
this.storage = storage;
|
|
760
|
+
this.storageKey = options.storageKey?.trim() || DOCUMENT_RECOVERY_JOURNAL_STORAGE_KEY;
|
|
761
|
+
this.maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_ENTRIES);
|
|
762
|
+
this.maxStorageBytes = positiveInteger(options.maxStorageBytes, DEFAULT_MAX_STORAGE_BYTES);
|
|
763
|
+
this.maxRecordBytes = Math.min(
|
|
764
|
+
positiveInteger(options.maxRecordBytes, DEFAULT_MAX_RECORD_BYTES),
|
|
765
|
+
this.maxStorageBytes
|
|
766
|
+
);
|
|
767
|
+
this.maxAgeMs = nonNegativeFinite(options.maxAgeMs, DEFAULT_MAX_AGE_MS);
|
|
768
|
+
this.maxFutureSkewMs = nonNegativeFinite(options.maxFutureSkewMs, DEFAULT_MAX_FUTURE_SKEW_MS);
|
|
769
|
+
this.now = options.now ?? Date.now;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Synchronously persist the latest dirty snapshot for a target.
|
|
773
|
+
* Storage/quota failures are returned, never thrown into an editor onChange.
|
|
774
|
+
*/
|
|
775
|
+
write(input) {
|
|
776
|
+
const timestamp = this.now();
|
|
777
|
+
if (!isValidWrite(input) || !Number.isFinite(timestamp) || timestamp < 0) {
|
|
778
|
+
return { status: "rejected", reason: "invalid-record", evicted: 0 };
|
|
779
|
+
}
|
|
780
|
+
if (!this.storage) {
|
|
781
|
+
return { status: "rejected", reason: "storage-unavailable", evicted: 0 };
|
|
782
|
+
}
|
|
783
|
+
if (estimatedBytes(input.content) > this.maxRecordBytes || input.persistedContent !== null && estimatedBytes(input.persistedContent) > this.maxRecordBytes) {
|
|
784
|
+
return { status: "rejected", reason: "record-too-large", evicted: 0 };
|
|
785
|
+
}
|
|
786
|
+
const loaded = this.load(timestamp);
|
|
787
|
+
const existing = loaded.records.find((record) => record.targetKey === input.targetKey);
|
|
788
|
+
if (existing) {
|
|
789
|
+
const ordering = compareGenerationRevision(input, existing);
|
|
790
|
+
if (ordering < 0) return { status: "ignored-stale", evicted: 0 };
|
|
791
|
+
if (ordering === 0) {
|
|
792
|
+
if (existing.content === input.content && existing.persistedContent === input.persistedContent) {
|
|
793
|
+
return { status: "unchanged", evicted: 0 };
|
|
794
|
+
}
|
|
795
|
+
return { status: "rejected", reason: "invalid-record", evicted: 0 };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const candidate = {
|
|
799
|
+
targetKey: input.targetKey,
|
|
800
|
+
generation: input.generation,
|
|
801
|
+
revision: input.revision,
|
|
802
|
+
content: input.content,
|
|
803
|
+
persistedContent: input.persistedContent,
|
|
804
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
805
|
+
updatedAt: timestamp
|
|
806
|
+
};
|
|
807
|
+
if (estimatedBytes(JSON.stringify(candidate)) > this.maxRecordBytes) {
|
|
808
|
+
return { status: "rejected", reason: "record-too-large", evicted: 0 };
|
|
809
|
+
}
|
|
810
|
+
if (estimatedBytes(serializeEnvelope([candidate])) > this.maxStorageBytes) {
|
|
811
|
+
return { status: "rejected", reason: "record-too-large", evicted: 0 };
|
|
812
|
+
}
|
|
813
|
+
const next = loaded.records.filter((record) => record.targetKey !== input.targetKey);
|
|
814
|
+
next.push(candidate);
|
|
815
|
+
const persisted = this.persistBounded(next, input.targetKey);
|
|
816
|
+
if (!persisted.stored) {
|
|
817
|
+
return {
|
|
818
|
+
status: "rejected",
|
|
819
|
+
reason: persisted.reason ?? "quota-exceeded",
|
|
820
|
+
evicted: persisted.evicted
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return { status: "stored", evicted: persisted.evicted };
|
|
824
|
+
}
|
|
825
|
+
/** Return the recoverable snapshot for one target, if present and valid. */
|
|
826
|
+
lookup(targetKey, generation) {
|
|
827
|
+
if (!isValidTargetKey(targetKey) || !isOptionalGeneration(generation)) return null;
|
|
828
|
+
const record = this.load(this.now()).records.find(
|
|
829
|
+
(candidate) => candidate.targetKey === targetKey && (generation === void 0 || candidate.generation === generation)
|
|
830
|
+
);
|
|
831
|
+
return record ? cloneRecord(record) : null;
|
|
832
|
+
}
|
|
833
|
+
/** List valid records, newest first. Optionally restrict to one target. */
|
|
834
|
+
list(targetKey) {
|
|
835
|
+
if (targetKey !== void 0 && !isValidTargetKey(targetKey)) return [];
|
|
836
|
+
return this.load(this.now()).records.filter((record) => targetKey === void 0 || record.targetKey === targetKey).sort(compareNewestFirst).map(cloneRecord);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Clear a record only when the same generation has durably acknowledged at
|
|
840
|
+
* least the journaled revision. A stale acknowledgement cannot erase a newer
|
|
841
|
+
* crash-recovery snapshot.
|
|
842
|
+
*/
|
|
843
|
+
acknowledge(input) {
|
|
844
|
+
if (!isValidAcknowledgement(input) || !this.storage) return false;
|
|
845
|
+
const records = this.load(this.now()).records;
|
|
846
|
+
const record = records.find((candidate) => candidate.targetKey === input.targetKey);
|
|
847
|
+
if (!record || record.generation !== input.generation || record.revision > input.persistedRevision) {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
return this.persistExact(records.filter((candidate) => candidate !== record));
|
|
851
|
+
}
|
|
852
|
+
/** Explicitly discard recovery data after a user or lifecycle decision. */
|
|
853
|
+
discard(targetKey, generation) {
|
|
854
|
+
if (!isValidTargetKey(targetKey) || !isOptionalGeneration(generation) || !this.storage) {
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
const records = this.load(this.now()).records;
|
|
858
|
+
const next = records.filter(
|
|
859
|
+
(record) => record.targetKey !== targetKey || generation !== void 0 && record.generation !== generation
|
|
860
|
+
);
|
|
861
|
+
if (next.length === records.length) return false;
|
|
862
|
+
return this.persistExact(next);
|
|
863
|
+
}
|
|
864
|
+
load(timestamp) {
|
|
865
|
+
if (!this.storage) return { records: [], needsRepair: false };
|
|
866
|
+
let raw;
|
|
867
|
+
try {
|
|
868
|
+
raw = this.storage.getItem(this.storageKey);
|
|
869
|
+
} catch {
|
|
870
|
+
return { records: [], needsRepair: false };
|
|
871
|
+
}
|
|
872
|
+
if (raw === null) return { records: [], needsRepair: false };
|
|
873
|
+
let parsed;
|
|
874
|
+
try {
|
|
875
|
+
parsed = JSON.parse(raw);
|
|
876
|
+
} catch {
|
|
877
|
+
this.tryRemove();
|
|
878
|
+
return { records: [], needsRepair: true };
|
|
879
|
+
}
|
|
880
|
+
if (!isEnvelope(parsed)) {
|
|
881
|
+
this.tryRemove();
|
|
882
|
+
return { records: [], needsRepair: true };
|
|
883
|
+
}
|
|
884
|
+
const byTarget = /* @__PURE__ */ new Map();
|
|
885
|
+
let needsRepair = estimatedBytes(raw) > this.maxStorageBytes;
|
|
886
|
+
for (const candidate of parsed.records) {
|
|
887
|
+
if (!isValidStoredRecord(candidate, timestamp, this.maxAgeMs, this.maxFutureSkewMs)) {
|
|
888
|
+
needsRepair = true;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (estimatedBytes(JSON.stringify(candidate)) > this.maxRecordBytes) {
|
|
892
|
+
needsRepair = true;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const current = byTarget.get(candidate.targetKey);
|
|
896
|
+
if (!current || compareStoredRecords(candidate, current) > 0) {
|
|
897
|
+
if (current) needsRepair = true;
|
|
898
|
+
byTarget.set(candidate.targetKey, cloneRecord(candidate));
|
|
899
|
+
} else {
|
|
900
|
+
needsRepair = true;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const records = [...byTarget.values()].sort(compareNewestFirst);
|
|
904
|
+
while (records.length > this.maxEntries) {
|
|
905
|
+
records.pop();
|
|
906
|
+
needsRepair = true;
|
|
907
|
+
}
|
|
908
|
+
while (records.length > 0 && estimatedBytes(serializeEnvelope(records)) > this.maxStorageBytes) {
|
|
909
|
+
records.pop();
|
|
910
|
+
needsRepair = true;
|
|
911
|
+
}
|
|
912
|
+
if (needsRepair) {
|
|
913
|
+
this.persistBounded(records);
|
|
914
|
+
}
|
|
915
|
+
return { records, needsRepair };
|
|
916
|
+
}
|
|
917
|
+
persistBounded(records, protectedTarget) {
|
|
918
|
+
if (!this.storage) {
|
|
919
|
+
return { stored: false, evicted: 0, reason: "storage-unavailable" };
|
|
920
|
+
}
|
|
921
|
+
const next = [...records].sort(compareNewestFirst);
|
|
922
|
+
let evicted = 0;
|
|
923
|
+
while (next.length > this.maxEntries) {
|
|
924
|
+
if (!evictOldest(next, protectedTarget)) break;
|
|
925
|
+
evicted += 1;
|
|
926
|
+
}
|
|
927
|
+
let serialized = serializeEnvelope(next);
|
|
928
|
+
while (estimatedBytes(serialized) > this.maxStorageBytes) {
|
|
929
|
+
if (!evictOldest(next, protectedTarget)) {
|
|
930
|
+
return { stored: false, evicted, reason: "quota-exceeded" };
|
|
931
|
+
}
|
|
932
|
+
evicted += 1;
|
|
933
|
+
serialized = serializeEnvelope(next);
|
|
934
|
+
}
|
|
935
|
+
for (; ; ) {
|
|
936
|
+
try {
|
|
937
|
+
this.storage.setItem(this.storageKey, serialized);
|
|
938
|
+
return { stored: true, evicted };
|
|
939
|
+
} catch {
|
|
940
|
+
if (!evictOldest(next, protectedTarget)) {
|
|
941
|
+
return { stored: false, evicted, reason: "quota-exceeded" };
|
|
942
|
+
}
|
|
943
|
+
evicted += 1;
|
|
944
|
+
serialized = serializeEnvelope(next);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
persistExact(records) {
|
|
949
|
+
if (!this.storage) return false;
|
|
950
|
+
if (records.length === 0) {
|
|
951
|
+
try {
|
|
952
|
+
this.storage.removeItem(this.storageKey);
|
|
953
|
+
return true;
|
|
954
|
+
} catch {
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
this.storage.setItem(this.storageKey, serializeEnvelope(records));
|
|
960
|
+
return true;
|
|
961
|
+
} catch {
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
tryRemove() {
|
|
966
|
+
try {
|
|
967
|
+
this.storage?.removeItem(this.storageKey);
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
function getDefaultDocumentRecoveryStorage() {
|
|
973
|
+
try {
|
|
974
|
+
const candidate = Reflect.get(globalThis, "localStorage");
|
|
975
|
+
if (!isStorage(candidate)) return null;
|
|
976
|
+
return candidate;
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function serializeEnvelope(records) {
|
|
982
|
+
const envelope = {
|
|
983
|
+
schemaVersion: DOCUMENT_RECOVERY_JOURNAL_SCHEMA_VERSION,
|
|
984
|
+
records
|
|
985
|
+
};
|
|
986
|
+
return JSON.stringify(envelope);
|
|
987
|
+
}
|
|
988
|
+
function estimatedBytes(value) {
|
|
989
|
+
return value.length * 2;
|
|
990
|
+
}
|
|
991
|
+
function positiveInteger(value, fallback) {
|
|
992
|
+
return Number.isSafeInteger(value) && (value ?? 0) > 0 ? value : fallback;
|
|
993
|
+
}
|
|
994
|
+
function nonNegativeFinite(value, fallback) {
|
|
995
|
+
return Number.isFinite(value) && (value ?? -1) >= 0 ? value : fallback;
|
|
996
|
+
}
|
|
997
|
+
function isEnvelope(value) {
|
|
998
|
+
if (!isObject(value)) return false;
|
|
999
|
+
return value.schemaVersion === DOCUMENT_RECOVERY_JOURNAL_SCHEMA_VERSION && Array.isArray(value.records);
|
|
1000
|
+
}
|
|
1001
|
+
function isValidStoredRecord(value, timestamp, maxAgeMs, maxFutureSkewMs) {
|
|
1002
|
+
if (!isObject(value)) return false;
|
|
1003
|
+
if (!isValidTargetKey(value.targetKey) || !isNonNegativeSafeInteger(value.generation) || !isNonNegativeSafeInteger(value.revision) || typeof value.content !== "string" || value.persistedContent !== null && typeof value.persistedContent !== "string" || !isNonNegativeFiniteNumber(value.createdAt) || !isNonNegativeFiniteNumber(value.updatedAt) || value.createdAt > value.updatedAt) {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
if (!Number.isFinite(timestamp) || timestamp < 0) return false;
|
|
1007
|
+
if (value.updatedAt > timestamp + maxFutureSkewMs) return false;
|
|
1008
|
+
return timestamp - value.updatedAt <= maxAgeMs;
|
|
1009
|
+
}
|
|
1010
|
+
function isValidWrite(value) {
|
|
1011
|
+
return isValidTargetKey(value.targetKey) && isNonNegativeSafeInteger(value.generation) && isNonNegativeSafeInteger(value.revision) && typeof value.content === "string" && (value.persistedContent === null || typeof value.persistedContent === "string");
|
|
1012
|
+
}
|
|
1013
|
+
function isValidAcknowledgement(value) {
|
|
1014
|
+
return isValidTargetKey(value.targetKey) && isNonNegativeSafeInteger(value.generation) && isNonNegativeSafeInteger(value.persistedRevision);
|
|
1015
|
+
}
|
|
1016
|
+
function isValidTargetKey(value) {
|
|
1017
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_TARGET_KEY_LENGTH;
|
|
1018
|
+
}
|
|
1019
|
+
function isOptionalGeneration(value) {
|
|
1020
|
+
return value === void 0 || isNonNegativeSafeInteger(value);
|
|
1021
|
+
}
|
|
1022
|
+
function isNonNegativeSafeInteger(value) {
|
|
1023
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
1024
|
+
}
|
|
1025
|
+
function isNonNegativeFiniteNumber(value) {
|
|
1026
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
1027
|
+
}
|
|
1028
|
+
function isObject(value) {
|
|
1029
|
+
return typeof value === "object" && value !== null;
|
|
1030
|
+
}
|
|
1031
|
+
function isStorage(value) {
|
|
1032
|
+
if (!isObject(value)) return false;
|
|
1033
|
+
return typeof value.getItem === "function" && typeof value.setItem === "function" && typeof value.removeItem === "function";
|
|
1034
|
+
}
|
|
1035
|
+
function compareGenerationRevision(left, right) {
|
|
1036
|
+
if (left.generation !== right.generation) return left.generation - right.generation;
|
|
1037
|
+
return left.revision - right.revision;
|
|
1038
|
+
}
|
|
1039
|
+
function compareStoredRecords(left, right) {
|
|
1040
|
+
return compareGenerationRevision(left, right) || left.updatedAt - right.updatedAt;
|
|
1041
|
+
}
|
|
1042
|
+
function compareNewestFirst(left, right) {
|
|
1043
|
+
return right.updatedAt - left.updatedAt || right.revision - left.revision;
|
|
1044
|
+
}
|
|
1045
|
+
function cloneRecord(record) {
|
|
1046
|
+
return { ...record };
|
|
1047
|
+
}
|
|
1048
|
+
function evictOldest(records, protectedTarget) {
|
|
1049
|
+
let oldestIndex = -1;
|
|
1050
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
1051
|
+
if (records[index].targetKey === protectedTarget) continue;
|
|
1052
|
+
if (oldestIndex < 0 || compareNewestFirst(records[index], records[oldestIndex]) > 0) {
|
|
1053
|
+
oldestIndex = index;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (oldestIndex < 0) return false;
|
|
1057
|
+
records.splice(oldestIndex, 1);
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export {
|
|
1062
|
+
DocumentCommitConflictError,
|
|
1063
|
+
DocumentSessionConflictError,
|
|
1064
|
+
DocumentSession,
|
|
1065
|
+
createFileSystemDocumentTarget,
|
|
1066
|
+
DOCUMENT_RECOVERY_JOURNAL_STORAGE_KEY,
|
|
1067
|
+
DOCUMENT_RECOVERY_JOURNAL_SCHEMA_VERSION,
|
|
1068
|
+
DocumentRecoveryJournal,
|
|
1069
|
+
getDefaultDocumentRecoveryStorage
|
|
1070
|
+
};
|
|
1071
|
+
//# sourceMappingURL=chunk-COYQXQK3.js.map
|