@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,1692 @@
|
|
|
1
|
+
import {
|
|
2
|
+
withFileCommitLock
|
|
3
|
+
} from "./chunk-AEHCVWU5.js";
|
|
4
|
+
import {
|
|
5
|
+
parentChainWithRoot
|
|
6
|
+
} from "./chunk-GM5YXU4L.js";
|
|
7
|
+
import {
|
|
8
|
+
isRecord
|
|
9
|
+
} from "./chunk-U722UUFN.js";
|
|
10
|
+
import {
|
|
11
|
+
bytesEqual,
|
|
12
|
+
compareSnapshotsByName,
|
|
13
|
+
compareText,
|
|
14
|
+
copyBytes,
|
|
15
|
+
parseFileSystemVersion
|
|
16
|
+
} from "./chunk-P4WATND4.js";
|
|
17
|
+
import {
|
|
18
|
+
decodeUtf8Text
|
|
19
|
+
} from "./chunk-FA7UNOXK.js";
|
|
20
|
+
import {
|
|
21
|
+
FsError,
|
|
22
|
+
WORKSPACE_ROOT,
|
|
23
|
+
fsErrorFromUnknown,
|
|
24
|
+
parseWorkspacePath,
|
|
25
|
+
workspacePathBasename,
|
|
26
|
+
workspacePathContains,
|
|
27
|
+
workspacePathDirname
|
|
28
|
+
} from "./chunk-JS6XFWQK.js";
|
|
29
|
+
import {
|
|
30
|
+
RawIndexedDBFileSystemStore,
|
|
31
|
+
indexedDBWorkspaceDatabaseName
|
|
32
|
+
} from "./chunk-RVPGQBWR.js";
|
|
33
|
+
|
|
34
|
+
// src/filesystem/indexeddb-provider-v2.ts
|
|
35
|
+
var ENTRY_SCHEMA_VERSION = 1;
|
|
36
|
+
var STATE_SCHEMA_VERSION = 1;
|
|
37
|
+
var LEGACY_CONFLICT_SCHEMA_VERSION = 2;
|
|
38
|
+
var LEGACY_CONFLICT_SCHEMA_VERSION_V1 = 1;
|
|
39
|
+
var LEGACY_OPAQUE_SCHEMA_VERSION = 2;
|
|
40
|
+
var LEGACY_OPAQUE_SCHEMA_VERSION_V1 = 1;
|
|
41
|
+
var ENTRY_PREFIX = "v2:entry:";
|
|
42
|
+
var STATE_KEY = "v2:state";
|
|
43
|
+
var LEGACY_CONFLICT_PREFIX = "v2:legacy-conflict:";
|
|
44
|
+
var LEGACY_IGNORED_PREFIX = "v2:legacy-ignored:";
|
|
45
|
+
var LEGACY_OPAQUE_PREFIX = "v2:legacy-opaque:";
|
|
46
|
+
var LEGACY_DIRS_KEY = "fs:dirs";
|
|
47
|
+
var LEGACY_PREFIX = "fs:";
|
|
48
|
+
var LEGACY_CONTENT_SUFFIX = ":content";
|
|
49
|
+
var LEGACY_BINARY_SUFFIX = ":binary";
|
|
50
|
+
var LEGACY_META_SUFFIX = ":meta";
|
|
51
|
+
var LegacyNamespaceDetectedError = class extends Error {
|
|
52
|
+
};
|
|
53
|
+
var INDEXEDDB_V2_CAPABILITIES = Object.freeze({
|
|
54
|
+
writeAtomicity: "cross-context",
|
|
55
|
+
moveAtomicity: "cross-context",
|
|
56
|
+
snapshotAtomicity: "cross-context",
|
|
57
|
+
conditionalWrite: "storage-atomic",
|
|
58
|
+
recursiveRemove: true,
|
|
59
|
+
watch: false,
|
|
60
|
+
caseSensitivity: "sensitive",
|
|
61
|
+
symlinkPolicy: "unsupported",
|
|
62
|
+
durability: "best-effort"
|
|
63
|
+
});
|
|
64
|
+
var IndexedDBFileSystemProviderV2 = class {
|
|
65
|
+
constructor(id, label, options = {}) {
|
|
66
|
+
this.capabilities = INDEXEDDB_V2_CAPABILITIES;
|
|
67
|
+
this.subscriptions = /* @__PURE__ */ new Set();
|
|
68
|
+
this.inFlight = /* @__PURE__ */ new Set();
|
|
69
|
+
this.initialization = null;
|
|
70
|
+
this.disposePromise = null;
|
|
71
|
+
this.storeClosed = false;
|
|
72
|
+
this.disposed = false;
|
|
73
|
+
this.id = id;
|
|
74
|
+
this.label = label;
|
|
75
|
+
this.store = options.store ?? new RawIndexedDBFileSystemStore(indexedDBWorkspaceDatabaseName(id), "files");
|
|
76
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
77
|
+
this.closeStoreOnDispose = options.closeStoreOnDispose ?? true;
|
|
78
|
+
this.versionPrefix = `idb-${encodeURIComponent(id).slice(0, 900) || "workspace"}`;
|
|
79
|
+
}
|
|
80
|
+
/** Track accepted storage work so disposal can join it before closing. */
|
|
81
|
+
runSharedTransaction(mode, operation, work) {
|
|
82
|
+
this.assertOpen(operation);
|
|
83
|
+
const transaction = this.store.transaction(mode, work);
|
|
84
|
+
this.inFlight.add(transaction);
|
|
85
|
+
void transaction.then(
|
|
86
|
+
() => this.inFlight.delete(transaction),
|
|
87
|
+
() => this.inFlight.delete(transaction)
|
|
88
|
+
);
|
|
89
|
+
return transaction;
|
|
90
|
+
}
|
|
91
|
+
async stat(path) {
|
|
92
|
+
this.assertOpen("stat");
|
|
93
|
+
const canonical = parseWorkspacePath(path);
|
|
94
|
+
await this.ensureInitialized("stat");
|
|
95
|
+
return this.transaction("readonly", { operation: "stat", path: canonical }, async (tx) => {
|
|
96
|
+
const entry = await readEntry(tx, canonical);
|
|
97
|
+
return entry ? toEntrySnapshot(entry) : null;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async readFile(path) {
|
|
101
|
+
this.assertOpen("read");
|
|
102
|
+
const canonical = parseWorkspacePath(path);
|
|
103
|
+
await this.ensureInitialized("read");
|
|
104
|
+
return this.transaction("readonly", { operation: "read", path: canonical }, async (tx) => {
|
|
105
|
+
const entry = await readEntry(tx, canonical);
|
|
106
|
+
if (!entry) return null;
|
|
107
|
+
if (entry.kind !== "file") {
|
|
108
|
+
throw this.error(
|
|
109
|
+
"type-mismatch",
|
|
110
|
+
"read",
|
|
111
|
+
canonical,
|
|
112
|
+
"Expected a file but found a directory."
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze({ entry: toFileSnapshot(entry), data: copyBytes(entry.data) });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async readDirectory(path) {
|
|
119
|
+
this.assertOpen("list");
|
|
120
|
+
const canonical = parseWorkspacePath(path);
|
|
121
|
+
await this.ensureInitialized("list");
|
|
122
|
+
return this.transaction("readonly", { operation: "list", path: canonical }, async (tx) => {
|
|
123
|
+
const directory = await readEntry(tx, canonical);
|
|
124
|
+
if (!directory) {
|
|
125
|
+
throw this.error("not-found", "list", canonical, "Directory does not exist.");
|
|
126
|
+
}
|
|
127
|
+
if (directory.kind !== "directory") {
|
|
128
|
+
throw this.error(
|
|
129
|
+
"type-mismatch",
|
|
130
|
+
"list",
|
|
131
|
+
canonical,
|
|
132
|
+
"Expected a directory but found a file."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const paths = await listEntryPaths(tx);
|
|
136
|
+
const children = [];
|
|
137
|
+
for (const candidate of paths) {
|
|
138
|
+
if (candidate && workspacePathDirname(candidate) === canonical) {
|
|
139
|
+
const entry = await readRequiredEntry(tx, candidate);
|
|
140
|
+
children.push(toEntrySnapshot(entry));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
children.sort(compareSnapshotsByName);
|
|
144
|
+
return Object.freeze(children);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async writeFile(path, data, options = {}) {
|
|
148
|
+
this.assertOpen("write");
|
|
149
|
+
const canonical = this.canonicalNonRoot(path, "write");
|
|
150
|
+
const ownedData = copyBytes(data);
|
|
151
|
+
await this.ensureInitialized("write");
|
|
152
|
+
return this.withMutationLock(
|
|
153
|
+
() => this.transaction("readwrite", { operation: "write", path: canonical }, async (tx) => {
|
|
154
|
+
await assertNoLegacyConflict(tx, canonical, "write");
|
|
155
|
+
const mutation = new EntryMutation(tx);
|
|
156
|
+
const existing = await mutation.get(canonical);
|
|
157
|
+
if (existing?.kind === "directory") {
|
|
158
|
+
throw this.error(
|
|
159
|
+
"type-mismatch",
|
|
160
|
+
"write",
|
|
161
|
+
canonical,
|
|
162
|
+
"Cannot replace a directory with a file."
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const mode = options.mode ?? "upsert";
|
|
166
|
+
if (mode === "create" && existing) {
|
|
167
|
+
throw this.error("already-exists", "write", canonical, "File already exists.");
|
|
168
|
+
}
|
|
169
|
+
if (mode === "replace" && !existing) {
|
|
170
|
+
throw this.error("not-found", "write", canonical, "File does not exist.");
|
|
171
|
+
}
|
|
172
|
+
this.assertExpectedVersion(existing, options.expectedVersion, "write", canonical);
|
|
173
|
+
const missingParents = await this.planParents(
|
|
174
|
+
mutation,
|
|
175
|
+
canonical,
|
|
176
|
+
options.createParents ?? false,
|
|
177
|
+
"write"
|
|
178
|
+
);
|
|
179
|
+
const next = await this.nextMutationVersion(tx);
|
|
180
|
+
this.addDirectories(mutation, missingParents, next);
|
|
181
|
+
const file = {
|
|
182
|
+
schemaVersion: ENTRY_SCHEMA_VERSION,
|
|
183
|
+
kind: "file",
|
|
184
|
+
path: canonical,
|
|
185
|
+
data: ownedData,
|
|
186
|
+
version: next.version,
|
|
187
|
+
lastModified: next.lastModified
|
|
188
|
+
};
|
|
189
|
+
mutation.set(canonical, file);
|
|
190
|
+
await this.touchParents(mutation, canonical, next);
|
|
191
|
+
await mutation.commit(next.state);
|
|
192
|
+
return toFileSnapshot(file);
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
async createDirectory(path, options = {}) {
|
|
197
|
+
this.assertOpen("create-directory");
|
|
198
|
+
const canonical = parseWorkspacePath(path);
|
|
199
|
+
await this.ensureInitialized("create-directory");
|
|
200
|
+
return this.withMutationLock(
|
|
201
|
+
() => this.transaction(
|
|
202
|
+
"readwrite",
|
|
203
|
+
{ operation: "create-directory", path: canonical },
|
|
204
|
+
async (tx) => {
|
|
205
|
+
await assertNoLegacyConflict(tx, canonical, "create-directory");
|
|
206
|
+
const mutation = new EntryMutation(tx);
|
|
207
|
+
const existing = await mutation.get(canonical);
|
|
208
|
+
if (existing) {
|
|
209
|
+
if (existing.kind !== "directory") {
|
|
210
|
+
throw this.error(
|
|
211
|
+
"type-mismatch",
|
|
212
|
+
"create-directory",
|
|
213
|
+
canonical,
|
|
214
|
+
"Cannot replace a file with a directory."
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if ((options.mode ?? "ensure") === "create") {
|
|
218
|
+
throw this.error(
|
|
219
|
+
"already-exists",
|
|
220
|
+
"create-directory",
|
|
221
|
+
canonical,
|
|
222
|
+
"Directory already exists."
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return toDirectorySnapshot(existing);
|
|
226
|
+
}
|
|
227
|
+
const missingParents = await this.planParents(
|
|
228
|
+
mutation,
|
|
229
|
+
canonical,
|
|
230
|
+
options.createParents ?? false,
|
|
231
|
+
"create-directory"
|
|
232
|
+
);
|
|
233
|
+
const next = await this.nextMutationVersion(tx);
|
|
234
|
+
this.addDirectories(mutation, [...missingParents, canonical], next);
|
|
235
|
+
await this.touchParents(mutation, canonical, next);
|
|
236
|
+
await mutation.commit(next.state);
|
|
237
|
+
const created = await mutation.get(canonical);
|
|
238
|
+
if (!created || created.kind !== "directory") {
|
|
239
|
+
throw corrupt("Created directory was not staged.", canonical);
|
|
240
|
+
}
|
|
241
|
+
return toDirectorySnapshot(created);
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
async remove(path, options = {}) {
|
|
247
|
+
this.assertOpen("remove");
|
|
248
|
+
const canonical = this.canonicalNonRoot(path, "remove");
|
|
249
|
+
await this.ensureInitialized("remove");
|
|
250
|
+
return this.withMutationLock(
|
|
251
|
+
() => this.transaction("readwrite", { operation: "remove", path: canonical }, async (tx) => {
|
|
252
|
+
await assertNoLegacyConflictInSubtree(tx, canonical, "remove");
|
|
253
|
+
const mutation = new EntryMutation(tx);
|
|
254
|
+
const existing = await mutation.get(canonical);
|
|
255
|
+
const state = await readRequiredState(tx);
|
|
256
|
+
if (!existing) {
|
|
257
|
+
if ((options.missing ?? "error") === "ignore") {
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
removed: false,
|
|
260
|
+
version: parseFileSystemVersion(state.treeVersion)
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
throw this.error("not-found", "remove", canonical, "Entry does not exist.");
|
|
264
|
+
}
|
|
265
|
+
this.assertExpectedVersion(existing, options.expectedVersion, "remove", canonical);
|
|
266
|
+
const paths = await mutation.paths();
|
|
267
|
+
const descendants = paths.filter(
|
|
268
|
+
(candidate) => workspacePathContains(canonical, candidate, false)
|
|
269
|
+
);
|
|
270
|
+
if (existing.kind === "directory" && descendants.length > 0 && !options.recursive) {
|
|
271
|
+
throw this.error("not-empty", "remove", canonical, "Directory is not empty.");
|
|
272
|
+
}
|
|
273
|
+
const next = this.advanceState(state);
|
|
274
|
+
mutation.delete(canonical);
|
|
275
|
+
if (existing.kind === "directory") {
|
|
276
|
+
for (const descendant of descendants) mutation.delete(descendant);
|
|
277
|
+
}
|
|
278
|
+
await this.touchParents(mutation, canonical, next);
|
|
279
|
+
await mutation.commit(next.state);
|
|
280
|
+
return Object.freeze({ removed: true, version: next.version });
|
|
281
|
+
})
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
async move(oldPath, newPath, options = {}) {
|
|
285
|
+
this.assertOpen("move");
|
|
286
|
+
const sourcePath = this.canonicalNonRoot(oldPath, "move");
|
|
287
|
+
const destinationPath = this.canonicalNonRoot(newPath, "move");
|
|
288
|
+
await this.ensureInitialized("move");
|
|
289
|
+
return this.withMutationLock(
|
|
290
|
+
() => this.transaction(
|
|
291
|
+
"readwrite",
|
|
292
|
+
{ operation: "move", path: sourcePath, destinationPath },
|
|
293
|
+
async (tx) => {
|
|
294
|
+
await assertNoLegacyConflictInSubtree(tx, sourcePath, "move");
|
|
295
|
+
await assertNoLegacyConflictInSubtree(tx, destinationPath, "move");
|
|
296
|
+
const mutation = new EntryMutation(tx);
|
|
297
|
+
const source = await mutation.get(sourcePath);
|
|
298
|
+
if (!source) {
|
|
299
|
+
throw this.error("not-found", "move", sourcePath, "Source does not exist.");
|
|
300
|
+
}
|
|
301
|
+
this.assertExpectedVersion(source, options.expectedVersion, "move", sourcePath);
|
|
302
|
+
if (sourcePath === destinationPath) return toEntrySnapshot(source);
|
|
303
|
+
if (await mutation.get(destinationPath)) {
|
|
304
|
+
throw new FsError("already-exists", "Destination already exists.", {
|
|
305
|
+
operation: "move",
|
|
306
|
+
path: sourcePath,
|
|
307
|
+
destinationPath
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (source.kind === "directory" && workspacePathContains(sourcePath, destinationPath, false)) {
|
|
311
|
+
throw new FsError("invalid-path", "Cannot move a directory into itself.", {
|
|
312
|
+
operation: "move",
|
|
313
|
+
path: sourcePath,
|
|
314
|
+
destinationPath
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const missingParents = await this.planParents(
|
|
318
|
+
mutation,
|
|
319
|
+
destinationPath,
|
|
320
|
+
options.createParents ?? false,
|
|
321
|
+
"move"
|
|
322
|
+
);
|
|
323
|
+
const paths = await mutation.paths();
|
|
324
|
+
const sourcePaths = paths.filter(
|
|
325
|
+
(candidate) => workspacePathContains(sourcePath, candidate)
|
|
326
|
+
);
|
|
327
|
+
const sourceSet = new Set(sourcePaths);
|
|
328
|
+
const remapped = [];
|
|
329
|
+
for (const currentPath of sourcePaths) {
|
|
330
|
+
const suffix = currentPath.slice(sourcePath.length);
|
|
331
|
+
const targetPath = parseWorkspacePath(`${destinationPath}${suffix}`);
|
|
332
|
+
const collision = await mutation.get(targetPath);
|
|
333
|
+
if (collision && !sourceSet.has(targetPath)) {
|
|
334
|
+
throw new FsError("already-exists", "A destination descendant already exists.", {
|
|
335
|
+
operation: "move",
|
|
336
|
+
path: currentPath,
|
|
337
|
+
destinationPath: targetPath
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
remapped.push([currentPath, targetPath, await mutation.require(currentPath)]);
|
|
341
|
+
}
|
|
342
|
+
const state = await readRequiredState(tx);
|
|
343
|
+
const next = this.advanceState(state);
|
|
344
|
+
for (const currentPath of sourcePaths) mutation.delete(currentPath);
|
|
345
|
+
this.addDirectories(mutation, missingParents, next);
|
|
346
|
+
for (const [, targetPath, current] of remapped) {
|
|
347
|
+
mutation.set(targetPath, withPathAndVersion(current, targetPath, next));
|
|
348
|
+
}
|
|
349
|
+
await this.touchParents(mutation, sourcePath, next);
|
|
350
|
+
await this.touchParents(mutation, destinationPath, next);
|
|
351
|
+
await mutation.commit(next.state);
|
|
352
|
+
return toEntrySnapshot(await mutation.require(destinationPath));
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
async snapshot() {
|
|
358
|
+
this.assertOpen("snapshot");
|
|
359
|
+
await this.ensureInitialized("snapshot");
|
|
360
|
+
return this.transaction("readonly", { operation: "snapshot" }, async (tx) => {
|
|
361
|
+
const [state, paths] = await Promise.all([readRequiredState(tx), listEntryPaths(tx)]);
|
|
362
|
+
const entries = [];
|
|
363
|
+
for (const path of paths.sort(compareText)) {
|
|
364
|
+
const entry = await readRequiredEntry(tx, path);
|
|
365
|
+
entries.push(
|
|
366
|
+
entry.kind === "file" ? Object.freeze({ ...toFileSnapshot(entry), data: copyBytes(entry.data) }) : toDirectorySnapshot(entry)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return Object.freeze({
|
|
370
|
+
version: parseFileSystemVersion(state.treeVersion),
|
|
371
|
+
entries: Object.freeze(entries)
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** IndexedDB has no reliable cross-browser external-change observer. */
|
|
376
|
+
watch(_listener, options = {}) {
|
|
377
|
+
this.assertOpen("watch");
|
|
378
|
+
const error = new FsError("not-supported", "IndexedDB filesystem watching is unavailable.", {
|
|
379
|
+
operation: "watch"
|
|
380
|
+
});
|
|
381
|
+
let closed = false;
|
|
382
|
+
let disposePromise = null;
|
|
383
|
+
const ready = Promise.reject(error);
|
|
384
|
+
void ready.catch((caught) => {
|
|
385
|
+
try {
|
|
386
|
+
options.onError?.(caught);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
const subscription = {
|
|
391
|
+
get closed() {
|
|
392
|
+
return closed;
|
|
393
|
+
},
|
|
394
|
+
ready,
|
|
395
|
+
dispose: () => {
|
|
396
|
+
if (disposePromise) return disposePromise;
|
|
397
|
+
closed = true;
|
|
398
|
+
this.subscriptions.delete(subscription);
|
|
399
|
+
disposePromise = Promise.resolve();
|
|
400
|
+
return disposePromise;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
this.subscriptions.add(subscription);
|
|
404
|
+
return subscription;
|
|
405
|
+
}
|
|
406
|
+
dispose() {
|
|
407
|
+
if (this.disposePromise) return this.disposePromise;
|
|
408
|
+
this.disposed = true;
|
|
409
|
+
this.disposePromise = (async () => {
|
|
410
|
+
try {
|
|
411
|
+
await Promise.allSettled([...this.inFlight]);
|
|
412
|
+
await Promise.all([...this.subscriptions].map((subscription) => subscription.dispose()));
|
|
413
|
+
} finally {
|
|
414
|
+
if (this.closeStoreOnDispose && !this.storeClosed) {
|
|
415
|
+
this.storeClosed = true;
|
|
416
|
+
this.store.close?.();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
})();
|
|
420
|
+
return this.disposePromise;
|
|
421
|
+
}
|
|
422
|
+
/** List unresolved, byte-preserving conflicts found during v1 migration. */
|
|
423
|
+
async listLegacyMigrationConflicts() {
|
|
424
|
+
this.assertOpen("read");
|
|
425
|
+
await this.ensureInitialized("read");
|
|
426
|
+
return this.transaction("readonly", { operation: "read" }, async (tx) => {
|
|
427
|
+
const keys = (await tx.keys()).filter((key) => key.startsWith(LEGACY_CONFLICT_PREFIX));
|
|
428
|
+
const conflicts = [];
|
|
429
|
+
for (const key of keys) {
|
|
430
|
+
const raw = await tx.get(key);
|
|
431
|
+
conflicts.push(toPublicLegacyConflict(validateLegacyConflict(raw)));
|
|
432
|
+
}
|
|
433
|
+
conflicts.sort((left, right) => compareText(left.path, right.path));
|
|
434
|
+
return Object.freeze(conflicts);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/** Resolve quarantined legacy data by choosing v2 or one legacy byte payload. */
|
|
438
|
+
async resolveLegacyMigrationConflict(path, resolution) {
|
|
439
|
+
this.assertOpen("write");
|
|
440
|
+
const canonical = this.canonicalNonRoot(path, "write");
|
|
441
|
+
await this.ensureInitialized("write");
|
|
442
|
+
return this.withMutationLock(
|
|
443
|
+
() => this.transaction("readwrite", { operation: "write", path: canonical }, async (tx) => {
|
|
444
|
+
const key = legacyConflictKey(canonical);
|
|
445
|
+
const conflict = validateLegacyConflict(await tx.get(key));
|
|
446
|
+
if (conflict.path !== canonical) throw corrupt("Legacy conflict key mismatch.", canonical);
|
|
447
|
+
const mutation = new EntryMutation(tx);
|
|
448
|
+
const existing = await mutation.get(canonical);
|
|
449
|
+
if (resolution === "v2") {
|
|
450
|
+
if (!existing) {
|
|
451
|
+
throw this.error(
|
|
452
|
+
"type-mismatch",
|
|
453
|
+
"write",
|
|
454
|
+
canonical,
|
|
455
|
+
"There is no v2 authority to keep at this path."
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
await tx.delete(key);
|
|
459
|
+
return toEntrySnapshot(existing);
|
|
460
|
+
}
|
|
461
|
+
const selected = selectLegacyPayload(conflict, resolution, canonical);
|
|
462
|
+
if (selected.kind === "directory") {
|
|
463
|
+
if (existing?.kind === "file") {
|
|
464
|
+
throw this.error(
|
|
465
|
+
"type-mismatch",
|
|
466
|
+
"write",
|
|
467
|
+
canonical,
|
|
468
|
+
"Move or remove the v2 file before restoring a legacy directory at this path."
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (existing) {
|
|
472
|
+
await tx.delete(key);
|
|
473
|
+
return toDirectorySnapshot(existing);
|
|
474
|
+
}
|
|
475
|
+
const missingParents2 = await this.planParents(mutation, canonical, true, "write");
|
|
476
|
+
const next2 = await this.nextMutationVersion(tx);
|
|
477
|
+
this.addDirectories(mutation, [...missingParents2, canonical], next2);
|
|
478
|
+
await this.touchParents(mutation, canonical, next2);
|
|
479
|
+
await mutation.commit(next2.state);
|
|
480
|
+
await tx.delete(key);
|
|
481
|
+
const restored = await mutation.require(canonical);
|
|
482
|
+
if (restored.kind !== "directory") {
|
|
483
|
+
throw corrupt("Restored legacy directory was not staged as a directory.", canonical);
|
|
484
|
+
}
|
|
485
|
+
return toDirectorySnapshot(restored);
|
|
486
|
+
}
|
|
487
|
+
if (existing?.kind === "directory") {
|
|
488
|
+
throw this.error(
|
|
489
|
+
"type-mismatch",
|
|
490
|
+
"write",
|
|
491
|
+
canonical,
|
|
492
|
+
"Move or remove the v2 directory before restoring a legacy file at this path."
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const missingParents = await this.planParents(mutation, canonical, true, "write");
|
|
496
|
+
const next = await this.nextMutationVersion(tx);
|
|
497
|
+
this.addDirectories(mutation, missingParents, next);
|
|
498
|
+
const file = {
|
|
499
|
+
schemaVersion: ENTRY_SCHEMA_VERSION,
|
|
500
|
+
kind: "file",
|
|
501
|
+
path: canonical,
|
|
502
|
+
data: copyBytes(selected.data),
|
|
503
|
+
version: next.version,
|
|
504
|
+
lastModified: next.lastModified
|
|
505
|
+
};
|
|
506
|
+
mutation.set(canonical, file);
|
|
507
|
+
await this.touchParents(mutation, canonical, next);
|
|
508
|
+
await mutation.commit(next.state);
|
|
509
|
+
await tx.delete(key);
|
|
510
|
+
return toFileSnapshot(file);
|
|
511
|
+
})
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
/** Explicitly discard every quarantined legacy candidate at a path. */
|
|
515
|
+
async discardLegacyMigrationConflict(path) {
|
|
516
|
+
this.assertOpen("remove");
|
|
517
|
+
const canonical = this.canonicalNonRoot(path, "remove");
|
|
518
|
+
await this.ensureInitialized("remove");
|
|
519
|
+
return this.withMutationLock(
|
|
520
|
+
() => this.transaction("readwrite", { operation: "remove", path: canonical }, async (tx) => {
|
|
521
|
+
const key = legacyConflictKey(canonical);
|
|
522
|
+
if (await tx.get(key) === null) return false;
|
|
523
|
+
await tx.delete(key);
|
|
524
|
+
return true;
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* List the unrecognized legacy keys this workspace archived during migration.
|
|
530
|
+
*
|
|
531
|
+
* Migration deletes the legacy `fs:` namespace, but a key it cannot interpret
|
|
532
|
+
* is copied aside first rather than dropped: those bytes belong to whatever
|
|
533
|
+
* tool wrote them against this origin, and DocBlocks is not entitled to
|
|
534
|
+
* decide they are worthless. Nothing else reads that archive, so without this
|
|
535
|
+
* call the bytes are invisible and their quota is unaccountable.
|
|
536
|
+
*
|
|
537
|
+
* Values are deliberately excluded — an archive large enough to matter is an
|
|
538
|
+
* archive too large to materialize just to count it. Use
|
|
539
|
+
* {@link readLegacyOpaqueRecord} for the values themselves.
|
|
540
|
+
*/
|
|
541
|
+
async listLegacyOpaqueRecords() {
|
|
542
|
+
this.assertOpen("read");
|
|
543
|
+
await this.ensureInitialized("read");
|
|
544
|
+
return this.transaction("readonly", { operation: "read" }, async (tx) => {
|
|
545
|
+
const keys = (await tx.keys()).filter((key) => key.startsWith(LEGACY_OPAQUE_PREFIX));
|
|
546
|
+
const summaries = [];
|
|
547
|
+
for (const key of keys) {
|
|
548
|
+
summaries.push(
|
|
549
|
+
toOpaqueSummary(validateLegacyOpaqueRecord(await tx.get(key), key))
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
summaries.sort((left, right) => compareText(left.key, right.key));
|
|
553
|
+
return Object.freeze(summaries);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/** Retrieve every archived value for one unrecognized legacy key. */
|
|
557
|
+
async readLegacyOpaqueRecord(key) {
|
|
558
|
+
this.assertOpen("read");
|
|
559
|
+
await this.ensureInitialized("read");
|
|
560
|
+
return this.transaction("readonly", { operation: "read" }, async (tx) => {
|
|
561
|
+
const archiveKey = legacyOpaqueKey(key);
|
|
562
|
+
const raw = await tx.get(archiveKey);
|
|
563
|
+
if (raw === null) return null;
|
|
564
|
+
return toPublicOpaqueRecord(validateLegacyOpaqueRecord(raw, archiveKey));
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Permanently destroy archived legacy values. This is the only operation that
|
|
569
|
+
* can lose data the archive exists to protect.
|
|
570
|
+
*
|
|
571
|
+
* For every target, all of that key's archived value snapshots are erased and
|
|
572
|
+
* the quota they held is reclaimed. There is no undo, no tombstone, and no
|
|
573
|
+
* other copy: migration already deleted the original `fs:` key, so the
|
|
574
|
+
* archive is the last one. Retrieve anything worth keeping with
|
|
575
|
+
* {@link readLegacyOpaqueRecord} before calling this.
|
|
576
|
+
*
|
|
577
|
+
* Each target must carry the `valueCount` the caller actually observed, which
|
|
578
|
+
* is why a target is a value returned by {@link listLegacyOpaqueRecords} or
|
|
579
|
+
* {@link readLegacyOpaqueRecord} rather than a bare key. A stale client can
|
|
580
|
+
* append a new snapshot at any time; if any target has grown since it was
|
|
581
|
+
* observed, the whole call fails with `conflict` and destroys nothing, so
|
|
582
|
+
* re-list and look again. A key that is already absent is not an error —
|
|
583
|
+
* discarding what is gone is a no-op — but a key whose count disagrees is.
|
|
584
|
+
*
|
|
585
|
+
* @returns how many records were destroyed.
|
|
586
|
+
*/
|
|
587
|
+
async discardLegacyOpaqueRecords(targets) {
|
|
588
|
+
this.assertOpen("remove");
|
|
589
|
+
if (targets.length === 0) return 0;
|
|
590
|
+
await this.ensureInitialized("remove");
|
|
591
|
+
return this.withMutationLock(
|
|
592
|
+
() => this.transaction("readwrite", { operation: "remove" }, async (tx) => {
|
|
593
|
+
const doomed = [];
|
|
594
|
+
for (const target of targets) {
|
|
595
|
+
const archiveKey = legacyOpaqueKey(target.key);
|
|
596
|
+
const raw = await tx.get(archiveKey);
|
|
597
|
+
if (raw === null) continue;
|
|
598
|
+
const record = validateLegacyOpaqueRecord(raw, archiveKey);
|
|
599
|
+
if (record.values.length !== target.valueCount) {
|
|
600
|
+
throw new FsError(
|
|
601
|
+
"conflict",
|
|
602
|
+
`Archived legacy key ${target.key} now holds ${record.values.length} values, not ${target.valueCount}. Read it again before discarding it.`,
|
|
603
|
+
{ operation: "remove", retryable: false }
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
doomed.push(archiveKey);
|
|
607
|
+
}
|
|
608
|
+
for (const archiveKey of doomed) await tx.delete(archiveKey);
|
|
609
|
+
return doomed.length;
|
|
610
|
+
})
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
async ensureInitialized(operation) {
|
|
614
|
+
this.assertOpen(operation);
|
|
615
|
+
if (this.initialization) return this.initialization;
|
|
616
|
+
const attempt = this.withMutationLock(
|
|
617
|
+
() => this.runSharedTransaction("readwrite", operation, (tx) => this.migrateLegacy(tx))
|
|
618
|
+
);
|
|
619
|
+
this.initialization = attempt.catch((error) => {
|
|
620
|
+
this.initialization = null;
|
|
621
|
+
throw fsErrorFromUnknown(error, { operation });
|
|
622
|
+
});
|
|
623
|
+
return this.initialization;
|
|
624
|
+
}
|
|
625
|
+
async migrateLegacy(tx) {
|
|
626
|
+
const existingState = await readState(tx);
|
|
627
|
+
const keys = await tx.keys();
|
|
628
|
+
if (existingState) {
|
|
629
|
+
const root = await readEntry(tx, WORKSPACE_ROOT);
|
|
630
|
+
if (!root || root.kind !== "directory") {
|
|
631
|
+
throw corrupt("Initialized v2 filesystem has no root directory.", WORKSPACE_ROOT);
|
|
632
|
+
}
|
|
633
|
+
if (existingState.migrationVersion === 1 || keys.some((key) => key.startsWith(LEGACY_PREFIX))) {
|
|
634
|
+
await this.quarantineExistingLegacy(
|
|
635
|
+
tx,
|
|
636
|
+
keys,
|
|
637
|
+
existingState,
|
|
638
|
+
existingState.migrationVersion === 1 ? "authority-collision" : "stale-client-write"
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const now = this.now().toISOString();
|
|
644
|
+
const version = this.makeVersion(1);
|
|
645
|
+
const entries = /* @__PURE__ */ new Map();
|
|
646
|
+
for (const path of entryPathsFromKeys(keys)) {
|
|
647
|
+
entries.set(path, await readRequiredEntry(tx, path));
|
|
648
|
+
}
|
|
649
|
+
if (entries.get(WORKSPACE_ROOT)?.kind === "file") {
|
|
650
|
+
throw corrupt(
|
|
651
|
+
"Pre-release v2 authority stored a file at the workspace root.",
|
|
652
|
+
WORKSPACE_ROOT
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
entries.set(
|
|
656
|
+
WORKSPACE_ROOT,
|
|
657
|
+
entries.get(WORKSPACE_ROOT) ?? directoryRecord(WORKSPACE_ROOT, version, now)
|
|
658
|
+
);
|
|
659
|
+
const legacyDirectories = await readLegacyDirectories(tx);
|
|
660
|
+
const legacyFilePaths = legacyPathsFromKeys(keys);
|
|
661
|
+
for (const directory of legacyDirectories) {
|
|
662
|
+
const existing = entries.get(directory);
|
|
663
|
+
if (existing?.kind === "file" || hasFileAncestor(entries, directory)) {
|
|
664
|
+
await appendLegacyConflict(
|
|
665
|
+
tx,
|
|
666
|
+
directory,
|
|
667
|
+
directoryRecoveryCandidate("authority-collision", now)
|
|
668
|
+
);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
addParentDirectories(entries, directory, version, now);
|
|
672
|
+
if (!existing) entries.set(directory, directoryRecord(directory, version, now));
|
|
673
|
+
}
|
|
674
|
+
for (const path of legacyFilePaths) {
|
|
675
|
+
const legacy = await readLegacyFile(tx, path);
|
|
676
|
+
const existing = entries.get(path);
|
|
677
|
+
if (existing?.kind === "file") {
|
|
678
|
+
if (!legacyPayloadsMatchEntry(legacy, existing)) {
|
|
679
|
+
await appendLegacyConflict(
|
|
680
|
+
tx,
|
|
681
|
+
path,
|
|
682
|
+
fileRecoveryCandidate(legacy, "authority-collision", now)
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const kindCollision = existing?.kind === "directory" || hasFileAncestor(entries, path);
|
|
688
|
+
const payloadsDisagree = legacy.content !== null && legacy.binary !== null && !bytesEqual(new TextEncoder().encode(legacy.content), legacy.binary);
|
|
689
|
+
if (payloadsDisagree || kindCollision || legacy.content === null && legacy.binary === null) {
|
|
690
|
+
await appendLegacyConflict(
|
|
691
|
+
tx,
|
|
692
|
+
path,
|
|
693
|
+
fileRecoveryCandidate(
|
|
694
|
+
legacy,
|
|
695
|
+
kindCollision ? "kind-collision" : payloadsDisagree ? "dual-payload" : "authority-collision",
|
|
696
|
+
now
|
|
697
|
+
)
|
|
698
|
+
);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
addParentDirectories(entries, path, version, now);
|
|
702
|
+
const data = legacy.content !== null ? new TextEncoder().encode(legacy.content).buffer : copyBytes(legacy.binary);
|
|
703
|
+
entries.set(path, {
|
|
704
|
+
schemaVersion: ENTRY_SCHEMA_VERSION,
|
|
705
|
+
kind: "file",
|
|
706
|
+
path,
|
|
707
|
+
data: copyBytes(data),
|
|
708
|
+
version,
|
|
709
|
+
lastModified: legacy.metadata?.lastModified ?? now
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
for (const entry of entries.values()) {
|
|
713
|
+
await tx.put(entryKey(entry.path), entry);
|
|
714
|
+
}
|
|
715
|
+
await deleteLegacyNamespace(tx, keys, now);
|
|
716
|
+
const state = {
|
|
717
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
718
|
+
migrationVersion: 2,
|
|
719
|
+
mutationCounter: 1,
|
|
720
|
+
treeVersion: version
|
|
721
|
+
};
|
|
722
|
+
await tx.put(STATE_KEY, state);
|
|
723
|
+
}
|
|
724
|
+
async quarantineExistingLegacy(tx, keys, state, reason) {
|
|
725
|
+
const now = this.now().toISOString();
|
|
726
|
+
const ignored = await readMarkerPaths(
|
|
727
|
+
tx,
|
|
728
|
+
keys,
|
|
729
|
+
LEGACY_IGNORED_PREFIX,
|
|
730
|
+
validateIgnoredLegacyPath
|
|
731
|
+
);
|
|
732
|
+
const legacyDirectories = await readLegacyDirectories(tx);
|
|
733
|
+
for (const path of legacyDirectories) {
|
|
734
|
+
if (ignored.has(path)) continue;
|
|
735
|
+
const entry = await readEntry(tx, path);
|
|
736
|
+
if (entry?.kind === "directory") continue;
|
|
737
|
+
await appendLegacyConflict(
|
|
738
|
+
tx,
|
|
739
|
+
path,
|
|
740
|
+
directoryRecoveryCandidate(entry ? "kind-collision" : reason, now)
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
for (const path of legacyPathsFromKeys(keys)) {
|
|
744
|
+
if (ignored.has(path)) continue;
|
|
745
|
+
const legacy = await readLegacyFile(tx, path);
|
|
746
|
+
const entry = await readEntry(tx, path);
|
|
747
|
+
if (entry?.kind === "file" && legacyPayloadsMatchEntry(legacy, entry)) continue;
|
|
748
|
+
await appendLegacyConflict(
|
|
749
|
+
tx,
|
|
750
|
+
path,
|
|
751
|
+
fileRecoveryCandidate(legacy, entry?.kind === "directory" ? "kind-collision" : reason, now)
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
await deleteLegacyNamespace(tx, keys, now);
|
|
755
|
+
for (const key of keys) {
|
|
756
|
+
if (key.startsWith(LEGACY_IGNORED_PREFIX)) await tx.delete(key);
|
|
757
|
+
}
|
|
758
|
+
if (state.migrationVersion === 1) {
|
|
759
|
+
await tx.put(STATE_KEY, { ...state, migrationVersion: 2 });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async transaction(mode, context, operation) {
|
|
763
|
+
this.assertOpen(context.operation ?? "read");
|
|
764
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
765
|
+
try {
|
|
766
|
+
const outcome = await this.runSharedTransaction(
|
|
767
|
+
mode,
|
|
768
|
+
context.operation ?? "read",
|
|
769
|
+
async (transaction) => {
|
|
770
|
+
if (await transaction.hasKeyWithPrefix(LEGACY_PREFIX)) {
|
|
771
|
+
if (mode === "readonly") throw new LegacyNamespaceDetectedError();
|
|
772
|
+
const state = await readRequiredState(transaction);
|
|
773
|
+
await this.quarantineExistingLegacy(
|
|
774
|
+
transaction,
|
|
775
|
+
await transaction.keys(),
|
|
776
|
+
state,
|
|
777
|
+
"stale-client-write"
|
|
778
|
+
);
|
|
779
|
+
return { retry: true };
|
|
780
|
+
}
|
|
781
|
+
return { retry: false, value: await operation(transaction) };
|
|
782
|
+
}
|
|
783
|
+
);
|
|
784
|
+
if (outcome.retry) continue;
|
|
785
|
+
return outcome.value;
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (!(error instanceof LegacyNamespaceDetectedError)) {
|
|
788
|
+
throw fsErrorFromUnknown(error, context);
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
await this.withMutationLock(
|
|
792
|
+
() => this.runSharedTransaction(
|
|
793
|
+
"readwrite",
|
|
794
|
+
context.operation ?? "read",
|
|
795
|
+
async (transaction) => {
|
|
796
|
+
if (!await transaction.hasKeyWithPrefix(LEGACY_PREFIX)) return;
|
|
797
|
+
const state = await readRequiredState(transaction);
|
|
798
|
+
await this.quarantineExistingLegacy(
|
|
799
|
+
transaction,
|
|
800
|
+
await transaction.keys(),
|
|
801
|
+
state,
|
|
802
|
+
"stale-client-write"
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
);
|
|
807
|
+
} catch (quarantineError) {
|
|
808
|
+
throw fsErrorFromUnknown(quarantineError, context);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
throw new FsError("busy", "A stale IndexedDB client is continuously writing legacy records.", {
|
|
813
|
+
...context,
|
|
814
|
+
retryable: true
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
withMutationLock(operation) {
|
|
818
|
+
return withFileCommitLock(`indexeddb:${this.id}:workspace`, operation);
|
|
819
|
+
}
|
|
820
|
+
async nextMutationVersion(tx) {
|
|
821
|
+
return this.advanceState(await readRequiredState(tx));
|
|
822
|
+
}
|
|
823
|
+
advanceState(state) {
|
|
824
|
+
if (state.mutationCounter >= Number.MAX_SAFE_INTEGER) {
|
|
825
|
+
throw corrupt("IndexedDB filesystem mutation counter overflowed.");
|
|
826
|
+
}
|
|
827
|
+
const mutationCounter = state.mutationCounter + 1;
|
|
828
|
+
const version = this.makeVersion(mutationCounter);
|
|
829
|
+
const nextState = {
|
|
830
|
+
...state,
|
|
831
|
+
mutationCounter,
|
|
832
|
+
treeVersion: version
|
|
833
|
+
};
|
|
834
|
+
return { state: nextState, version, lastModified: this.now().toISOString() };
|
|
835
|
+
}
|
|
836
|
+
makeVersion(counter) {
|
|
837
|
+
return parseFileSystemVersion(`${this.versionPrefix}:${counter}`);
|
|
838
|
+
}
|
|
839
|
+
async planParents(mutation, path, createParents, operation) {
|
|
840
|
+
const missing = [];
|
|
841
|
+
for (const parent of parentChainWithRoot(path)) {
|
|
842
|
+
const entry = await mutation.get(parent);
|
|
843
|
+
if (entry?.kind === "file") {
|
|
844
|
+
throw this.error(
|
|
845
|
+
"type-mismatch",
|
|
846
|
+
operation,
|
|
847
|
+
parent,
|
|
848
|
+
"A parent path is a file, not a directory."
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (!entry) {
|
|
852
|
+
if (!createParents) {
|
|
853
|
+
throw this.error("not-found", operation, parent, "Parent directory does not exist.");
|
|
854
|
+
}
|
|
855
|
+
missing.push(parent);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return missing;
|
|
859
|
+
}
|
|
860
|
+
addDirectories(mutation, paths, next) {
|
|
861
|
+
for (const path of paths) {
|
|
862
|
+
mutation.set(path, directoryRecord(path, next.version, next.lastModified));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async touchParents(mutation, path, next) {
|
|
866
|
+
for (const parent of parentChainWithRoot(path)) {
|
|
867
|
+
const entry = await mutation.get(parent);
|
|
868
|
+
if (entry?.kind === "directory") {
|
|
869
|
+
mutation.set(parent, directoryRecord(parent, next.version, next.lastModified));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
canonicalNonRoot(path, operation) {
|
|
874
|
+
const canonical = parseWorkspacePath(path);
|
|
875
|
+
if (!canonical) {
|
|
876
|
+
throw this.error(
|
|
877
|
+
"invalid-path",
|
|
878
|
+
operation,
|
|
879
|
+
canonical,
|
|
880
|
+
"The workspace root is not a file entry."
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
return canonical;
|
|
884
|
+
}
|
|
885
|
+
assertExpectedVersion(entry, expected, operation, path) {
|
|
886
|
+
if (expected === void 0) return;
|
|
887
|
+
if (expected === null ? !entry : entry?.version === expected) return;
|
|
888
|
+
throw this.error(
|
|
889
|
+
"conflict",
|
|
890
|
+
operation,
|
|
891
|
+
path,
|
|
892
|
+
"Entry version does not match the expected version."
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
assertOpen(operation) {
|
|
896
|
+
if (!this.disposed) return;
|
|
897
|
+
throw new FsError("disposed", "Filesystem provider is disposed.", { operation });
|
|
898
|
+
}
|
|
899
|
+
error(code, operation, path, message) {
|
|
900
|
+
return new FsError(code, message, { operation, path });
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
var EntryMutation = class {
|
|
904
|
+
constructor(transaction) {
|
|
905
|
+
this.transaction = transaction;
|
|
906
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
907
|
+
this.changed = /* @__PURE__ */ new Map();
|
|
908
|
+
this.deleted = /* @__PURE__ */ new Set();
|
|
909
|
+
}
|
|
910
|
+
async get(path) {
|
|
911
|
+
if (this.deleted.has(path)) return null;
|
|
912
|
+
const cached = this.cache.get(path);
|
|
913
|
+
if (cached !== void 0) return cached;
|
|
914
|
+
const entry = await readEntry(this.transaction, path);
|
|
915
|
+
this.cache.set(path, entry);
|
|
916
|
+
return entry;
|
|
917
|
+
}
|
|
918
|
+
async require(path) {
|
|
919
|
+
const entry = await this.get(path);
|
|
920
|
+
if (!entry) throw corrupt(`Missing staged entry: ${path}.`, path);
|
|
921
|
+
return entry;
|
|
922
|
+
}
|
|
923
|
+
set(path, entry) {
|
|
924
|
+
const owned = copyEntry(entry);
|
|
925
|
+
this.cache.set(path, owned);
|
|
926
|
+
this.changed.set(path, owned);
|
|
927
|
+
this.deleted.delete(path);
|
|
928
|
+
}
|
|
929
|
+
delete(path) {
|
|
930
|
+
this.cache.set(path, null);
|
|
931
|
+
this.changed.delete(path);
|
|
932
|
+
this.deleted.add(path);
|
|
933
|
+
}
|
|
934
|
+
async paths() {
|
|
935
|
+
const paths = new Set(await listEntryPaths(this.transaction));
|
|
936
|
+
for (const path of this.deleted) paths.delete(path);
|
|
937
|
+
for (const path of this.changed.keys()) paths.add(path);
|
|
938
|
+
return [...paths];
|
|
939
|
+
}
|
|
940
|
+
async commit(state) {
|
|
941
|
+
for (const path of this.deleted) await this.transaction.delete(entryKey(path));
|
|
942
|
+
for (const [path, entry] of this.changed) await this.transaction.put(entryKey(path), entry);
|
|
943
|
+
await this.transaction.put(STATE_KEY, state);
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
function entryKey(path) {
|
|
947
|
+
return `${ENTRY_PREFIX}${path}`;
|
|
948
|
+
}
|
|
949
|
+
function legacyConflictKey(path) {
|
|
950
|
+
return `${LEGACY_CONFLICT_PREFIX}${path}`;
|
|
951
|
+
}
|
|
952
|
+
function entryPathsFromKeys(keys) {
|
|
953
|
+
return keys.filter((key) => key.startsWith(ENTRY_PREFIX)).map((key) => {
|
|
954
|
+
const rawPath = key.slice(ENTRY_PREFIX.length);
|
|
955
|
+
const path = parseWorkspacePath(rawPath);
|
|
956
|
+
if (path !== rawPath) throw corrupt(`Non-canonical v2 entry key: ${key}.`);
|
|
957
|
+
return path;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
async function listEntryPaths(tx) {
|
|
961
|
+
return entryPathsFromKeys(await tx.keys());
|
|
962
|
+
}
|
|
963
|
+
async function readEntry(tx, path) {
|
|
964
|
+
const raw = await tx.get(entryKey(path));
|
|
965
|
+
return raw === null ? null : validateEntry(raw, path);
|
|
966
|
+
}
|
|
967
|
+
async function readRequiredEntry(tx, path) {
|
|
968
|
+
const entry = await readEntry(tx, path);
|
|
969
|
+
if (!entry) throw corrupt(`Missing v2 entry record: ${path}.`, path);
|
|
970
|
+
return entry;
|
|
971
|
+
}
|
|
972
|
+
function validateEntry(value, expectedPath) {
|
|
973
|
+
if (!isRecord(value)) throw corrupt(`Corrupt v2 entry record: ${expectedPath}.`, expectedPath);
|
|
974
|
+
const path = parseWorkspacePath(readString(value.path, "entry path"));
|
|
975
|
+
if (path !== expectedPath) throw corrupt(`V2 entry path does not match key: ${expectedPath}.`);
|
|
976
|
+
if (value.schemaVersion !== ENTRY_SCHEMA_VERSION) {
|
|
977
|
+
throw corrupt(`Unsupported v2 entry schema at ${expectedPath}.`, expectedPath);
|
|
978
|
+
}
|
|
979
|
+
const version = parseFileSystemVersion(value.version);
|
|
980
|
+
const lastModified = readString(value.lastModified, "lastModified");
|
|
981
|
+
if (value.kind === "directory") {
|
|
982
|
+
return { schemaVersion: ENTRY_SCHEMA_VERSION, kind: "directory", path, version, lastModified };
|
|
983
|
+
}
|
|
984
|
+
if (value.kind !== "file") throw corrupt(`Unknown entry kind at ${expectedPath}.`, expectedPath);
|
|
985
|
+
return {
|
|
986
|
+
schemaVersion: ENTRY_SCHEMA_VERSION,
|
|
987
|
+
kind: "file",
|
|
988
|
+
path,
|
|
989
|
+
version,
|
|
990
|
+
lastModified,
|
|
991
|
+
data: validateBytes(value.data, expectedPath)
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
async function readState(tx) {
|
|
995
|
+
const raw = await tx.get(STATE_KEY);
|
|
996
|
+
return raw === null ? null : validateState(raw);
|
|
997
|
+
}
|
|
998
|
+
async function readRequiredState(tx) {
|
|
999
|
+
const state = await readState(tx);
|
|
1000
|
+
if (!state) throw corrupt("IndexedDB v2 state record is missing.");
|
|
1001
|
+
return state;
|
|
1002
|
+
}
|
|
1003
|
+
function validateState(value) {
|
|
1004
|
+
if (!isRecord(value) || value.schemaVersion !== STATE_SCHEMA_VERSION || value.migrationVersion !== 1 && value.migrationVersion !== 2 || typeof value.mutationCounter !== "number" || !Number.isSafeInteger(value.mutationCounter) || value.mutationCounter < 0) {
|
|
1005
|
+
throw corrupt("IndexedDB v2 state record is corrupt.");
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
1009
|
+
migrationVersion: value.migrationVersion,
|
|
1010
|
+
mutationCounter: value.mutationCounter,
|
|
1011
|
+
treeVersion: parseFileSystemVersion(value.treeVersion)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
async function readLegacyDirectories(tx) {
|
|
1015
|
+
const raw = await tx.get(LEGACY_DIRS_KEY);
|
|
1016
|
+
if (raw === null) return [];
|
|
1017
|
+
if (!Array.isArray(raw)) throw corrupt("Legacy directory index is corrupt.");
|
|
1018
|
+
const directories = [];
|
|
1019
|
+
for (const value of raw) {
|
|
1020
|
+
if (typeof value !== "string") throw corrupt("Legacy directory index is corrupt.");
|
|
1021
|
+
const path = parseWorkspacePath(value);
|
|
1022
|
+
if (path) directories.push(path);
|
|
1023
|
+
}
|
|
1024
|
+
return directories;
|
|
1025
|
+
}
|
|
1026
|
+
function legacyPathsFromKeys(keys) {
|
|
1027
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1028
|
+
for (const key of keys) {
|
|
1029
|
+
if (!key.startsWith(LEGACY_PREFIX) || key === LEGACY_DIRS_KEY) continue;
|
|
1030
|
+
const suffix = [LEGACY_CONTENT_SUFFIX, LEGACY_BINARY_SUFFIX, LEGACY_META_SUFFIX].find(
|
|
1031
|
+
(candidate) => key.endsWith(candidate)
|
|
1032
|
+
);
|
|
1033
|
+
if (!suffix) continue;
|
|
1034
|
+
const rawPath = key.slice(LEGACY_PREFIX.length, -suffix.length);
|
|
1035
|
+
try {
|
|
1036
|
+
const path = parseWorkspacePath(rawPath);
|
|
1037
|
+
if (path) paths.add(path);
|
|
1038
|
+
} catch {
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return [...paths];
|
|
1042
|
+
}
|
|
1043
|
+
async function readLegacyFile(tx, path) {
|
|
1044
|
+
const [rawContent, rawBinary, rawMetadata] = await Promise.all([
|
|
1045
|
+
tx.get(`${LEGACY_PREFIX}${path}${LEGACY_CONTENT_SUFFIX}`),
|
|
1046
|
+
tx.get(`${LEGACY_PREFIX}${path}${LEGACY_BINARY_SUFFIX}`),
|
|
1047
|
+
tx.get(`${LEGACY_PREFIX}${path}${LEGACY_META_SUFFIX}`)
|
|
1048
|
+
]);
|
|
1049
|
+
if (rawContent !== null && typeof rawContent !== "string") {
|
|
1050
|
+
throw corrupt(`Legacy text payload is corrupt: ${path}.`, path);
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
content: rawContent,
|
|
1054
|
+
binary: rawBinary === null ? null : validateBytes(rawBinary, path),
|
|
1055
|
+
metadata: validateLegacyMetadata(rawMetadata, path)
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
async function readMarkerPaths(transaction, keys, prefix, validate) {
|
|
1059
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1060
|
+
for (const key of keys) {
|
|
1061
|
+
if (!key.startsWith(prefix)) continue;
|
|
1062
|
+
const expectedPath = parseWorkspacePath(key.slice(prefix.length));
|
|
1063
|
+
if (!expectedPath) throw corrupt(`Recovery marker cannot target the workspace root: ${key}.`);
|
|
1064
|
+
const value = await transaction.get(key);
|
|
1065
|
+
const path = validate(value);
|
|
1066
|
+
if (path !== expectedPath)
|
|
1067
|
+
throw corrupt(`Recovery marker path does not match its key: ${key}.`);
|
|
1068
|
+
paths.add(path);
|
|
1069
|
+
}
|
|
1070
|
+
return paths;
|
|
1071
|
+
}
|
|
1072
|
+
function validateIgnoredLegacyPath(value) {
|
|
1073
|
+
if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.ignoredAt !== "string") {
|
|
1074
|
+
throw corrupt("Ignored legacy recovery marker is corrupt.");
|
|
1075
|
+
}
|
|
1076
|
+
const path = parseWorkspacePath(readString(value.path, "ignored legacy path"));
|
|
1077
|
+
if (!path) throw corrupt("Ignored legacy recovery marker cannot target the workspace root.");
|
|
1078
|
+
return path;
|
|
1079
|
+
}
|
|
1080
|
+
async function assertNoLegacyConflict(transaction, path, operation) {
|
|
1081
|
+
if (await transaction.get(legacyConflictKey(path)) === null) return;
|
|
1082
|
+
throw legacyConflictBlocked(path, operation);
|
|
1083
|
+
}
|
|
1084
|
+
async function assertNoLegacyConflictInSubtree(transaction, path, operation) {
|
|
1085
|
+
for (const key of await transaction.keys()) {
|
|
1086
|
+
if (!key.startsWith(LEGACY_CONFLICT_PREFIX)) continue;
|
|
1087
|
+
const conflictPath = parseWorkspacePath(key.slice(LEGACY_CONFLICT_PREFIX.length));
|
|
1088
|
+
if (conflictPath === path || workspacePathContains(path, conflictPath, false)) {
|
|
1089
|
+
throw legacyConflictBlocked(conflictPath, operation);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function legacyConflictBlocked(path, operation) {
|
|
1094
|
+
return new FsError(
|
|
1095
|
+
"conflict",
|
|
1096
|
+
"Resolve the legacy migration conflict before mutating this path.",
|
|
1097
|
+
{
|
|
1098
|
+
operation,
|
|
1099
|
+
path,
|
|
1100
|
+
retryable: false
|
|
1101
|
+
}
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
function validateLegacyMetadata(value, path) {
|
|
1105
|
+
if (value === null) return null;
|
|
1106
|
+
if (!isRecord(value) || typeof value.name !== "string" || typeof value.path !== "string" || typeof value.size !== "number" || !Number.isFinite(value.size) || typeof value.lastModified !== "string") {
|
|
1107
|
+
throw corrupt(`Legacy metadata is corrupt: ${path}.`, path);
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
name: value.name,
|
|
1111
|
+
path: value.path,
|
|
1112
|
+
size: value.size,
|
|
1113
|
+
lastModified: value.lastModified
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
function validateLegacyConflict(value) {
|
|
1117
|
+
if (!isRecord(value)) {
|
|
1118
|
+
throw corrupt("Legacy migration conflict record is corrupt.");
|
|
1119
|
+
}
|
|
1120
|
+
const path = parseWorkspacePath(readString(value.path, "legacy conflict path"));
|
|
1121
|
+
if (!path) throw corrupt("Legacy migration conflict cannot target the workspace root.");
|
|
1122
|
+
if (value.schemaVersion === LEGACY_CONFLICT_SCHEMA_VERSION_V1) {
|
|
1123
|
+
const reason = validateLegacyConflictReason(value.reason);
|
|
1124
|
+
const candidate = {
|
|
1125
|
+
kind: "file",
|
|
1126
|
+
reason,
|
|
1127
|
+
text: value.text === null ? null : readString(value.text, "legacy conflict text"),
|
|
1128
|
+
binary: value.binary === null ? null : validateBytes(value.binary, path),
|
|
1129
|
+
metadata: validateLegacyMetadata(value.metadata, path),
|
|
1130
|
+
detectedAt: readString(value.detectedAt, "legacy conflict detectedAt")
|
|
1131
|
+
};
|
|
1132
|
+
return {
|
|
1133
|
+
schemaVersion: LEGACY_CONFLICT_SCHEMA_VERSION,
|
|
1134
|
+
path,
|
|
1135
|
+
candidates: Object.freeze([candidate])
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
if (value.schemaVersion !== LEGACY_CONFLICT_SCHEMA_VERSION || !Array.isArray(value.candidates)) {
|
|
1139
|
+
throw corrupt("Legacy migration conflict record is corrupt.");
|
|
1140
|
+
}
|
|
1141
|
+
const candidates = value.candidates.map(
|
|
1142
|
+
(candidate) => validateLegacyRecoveryCandidate(candidate, path)
|
|
1143
|
+
);
|
|
1144
|
+
if (candidates.length === 0) throw corrupt("Legacy migration conflict has no candidates.", path);
|
|
1145
|
+
return {
|
|
1146
|
+
schemaVersion: LEGACY_CONFLICT_SCHEMA_VERSION,
|
|
1147
|
+
path,
|
|
1148
|
+
candidates: Object.freeze(candidates)
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
function toPublicLegacyConflict(conflict) {
|
|
1152
|
+
const candidates = conflict.candidates.map(toPublicLegacyCandidate);
|
|
1153
|
+
const latest = candidates[candidates.length - 1];
|
|
1154
|
+
return Object.freeze({
|
|
1155
|
+
path: parseWorkspacePath(conflict.path),
|
|
1156
|
+
reason: latest.reason,
|
|
1157
|
+
kind: latest.kind,
|
|
1158
|
+
text: latest.text,
|
|
1159
|
+
binary: latest.binary,
|
|
1160
|
+
metadata: latest.metadata,
|
|
1161
|
+
detectedAt: latest.detectedAt,
|
|
1162
|
+
candidates: Object.freeze(candidates)
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
function validateLegacyRecoveryCandidate(value, path) {
|
|
1166
|
+
if (!isRecord(value) || value.kind !== "file" && value.kind !== "directory") {
|
|
1167
|
+
throw corrupt("Legacy recovery candidate is corrupt.", path);
|
|
1168
|
+
}
|
|
1169
|
+
const kind = value.kind;
|
|
1170
|
+
const text = value.text === null ? null : readString(value.text, "legacy recovery text");
|
|
1171
|
+
const binary = value.binary === null ? null : validateBytes(value.binary, path);
|
|
1172
|
+
const metadata = validateLegacyMetadata(value.metadata, path);
|
|
1173
|
+
if (kind === "directory" && (text !== null || binary !== null || metadata !== null)) {
|
|
1174
|
+
throw corrupt("A legacy directory recovery candidate contains file data.", path);
|
|
1175
|
+
}
|
|
1176
|
+
return {
|
|
1177
|
+
kind,
|
|
1178
|
+
reason: validateLegacyConflictReason(value.reason),
|
|
1179
|
+
text,
|
|
1180
|
+
binary,
|
|
1181
|
+
metadata,
|
|
1182
|
+
detectedAt: readString(value.detectedAt, "legacy recovery detectedAt")
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
function validateLegacyConflictReason(value) {
|
|
1186
|
+
if (value === "dual-payload" || value === "kind-collision" || value === "authority-collision" || value === "stale-client-write") {
|
|
1187
|
+
return value;
|
|
1188
|
+
}
|
|
1189
|
+
throw corrupt("Legacy recovery reason is invalid.");
|
|
1190
|
+
}
|
|
1191
|
+
function toPublicLegacyCandidate(candidate) {
|
|
1192
|
+
return Object.freeze({
|
|
1193
|
+
kind: candidate.kind,
|
|
1194
|
+
reason: candidate.reason,
|
|
1195
|
+
text: candidate.text,
|
|
1196
|
+
binary: candidate.binary ? copyBytes(candidate.binary) : null,
|
|
1197
|
+
metadata: candidate.metadata ? Object.freeze({ ...candidate.metadata }) : null,
|
|
1198
|
+
detectedAt: candidate.detectedAt
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
async function appendLegacyConflict(transaction, path, candidate) {
|
|
1202
|
+
const key = legacyConflictKey(path);
|
|
1203
|
+
const raw = await transaction.get(key);
|
|
1204
|
+
const existing = raw === null ? null : validateLegacyConflict(raw);
|
|
1205
|
+
if (existing?.candidates.some((item) => recoveryCandidatesEqual(item, candidate))) return;
|
|
1206
|
+
const candidates = [...existing?.candidates ?? [], copyRecoveryCandidate(candidate)];
|
|
1207
|
+
await transaction.put(key, {
|
|
1208
|
+
schemaVersion: LEGACY_CONFLICT_SCHEMA_VERSION,
|
|
1209
|
+
path,
|
|
1210
|
+
candidates
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
function fileRecoveryCandidate(legacy, reason, detectedAt) {
|
|
1214
|
+
return {
|
|
1215
|
+
kind: "file",
|
|
1216
|
+
reason,
|
|
1217
|
+
text: legacy.content,
|
|
1218
|
+
binary: legacy.binary ? copyBytes(legacy.binary) : null,
|
|
1219
|
+
metadata: legacy.metadata ? { ...legacy.metadata } : null,
|
|
1220
|
+
detectedAt
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function directoryRecoveryCandidate(reason, detectedAt) {
|
|
1224
|
+
return { kind: "directory", reason, text: null, binary: null, metadata: null, detectedAt };
|
|
1225
|
+
}
|
|
1226
|
+
function copyRecoveryCandidate(candidate) {
|
|
1227
|
+
return {
|
|
1228
|
+
...candidate,
|
|
1229
|
+
binary: candidate.binary ? copyBytes(candidate.binary) : null,
|
|
1230
|
+
metadata: candidate.metadata ? { ...candidate.metadata } : null
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function recoveryCandidatesEqual(left, right) {
|
|
1234
|
+
return left.kind === right.kind && left.text === right.text && nullableBytesEqual(left.binary, right.binary) && nullableMetadataEqual(left.metadata, right.metadata);
|
|
1235
|
+
}
|
|
1236
|
+
function nullableBytesEqual(left, right) {
|
|
1237
|
+
if (left === null || right === null) return left === right;
|
|
1238
|
+
return bytesEqual(left, right);
|
|
1239
|
+
}
|
|
1240
|
+
function nullableMetadataEqual(left, right) {
|
|
1241
|
+
if (left === null || right === null) return left === right;
|
|
1242
|
+
return left.name === right.name && left.path === right.path && left.size === right.size && left.lastModified === right.lastModified;
|
|
1243
|
+
}
|
|
1244
|
+
function legacyPayloadsMatchEntry(legacy, entry) {
|
|
1245
|
+
if (legacy.content === null && legacy.binary === null) return false;
|
|
1246
|
+
return (legacy.content === null || bytesEqual(new TextEncoder().encode(legacy.content), entry.data)) && (legacy.binary === null || bytesEqual(legacy.binary, entry.data));
|
|
1247
|
+
}
|
|
1248
|
+
function hasFileAncestor(entries, path) {
|
|
1249
|
+
return parentChainWithRoot(path).some((parent) => entries.get(parent)?.kind === "file");
|
|
1250
|
+
}
|
|
1251
|
+
function legacyOpaqueKey(key) {
|
|
1252
|
+
return `${LEGACY_OPAQUE_PREFIX}${encodeURIComponent(key)}`;
|
|
1253
|
+
}
|
|
1254
|
+
function validateLegacyOpaqueRecord(value, archiveKey) {
|
|
1255
|
+
if (!isRecord(value) || !Array.isArray(value.values)) {
|
|
1256
|
+
throw corrupt(`Archived legacy record is corrupt: ${archiveKey}.`);
|
|
1257
|
+
}
|
|
1258
|
+
const key = readString(value.key, "archived legacy key");
|
|
1259
|
+
if (legacyOpaqueKey(key) !== archiveKey) {
|
|
1260
|
+
throw corrupt(`Archived legacy record key does not match its key: ${archiveKey}.`);
|
|
1261
|
+
}
|
|
1262
|
+
if (value.schemaVersion === LEGACY_OPAQUE_SCHEMA_VERSION_V1) {
|
|
1263
|
+
return {
|
|
1264
|
+
schemaVersion: LEGACY_OPAQUE_SCHEMA_VERSION,
|
|
1265
|
+
key,
|
|
1266
|
+
values: Object.freeze(
|
|
1267
|
+
value.values.map(
|
|
1268
|
+
(item) => ({ value: item, archivedAt: null })
|
|
1269
|
+
)
|
|
1270
|
+
)
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (value.schemaVersion !== LEGACY_OPAQUE_SCHEMA_VERSION) {
|
|
1274
|
+
throw corrupt(`Unsupported archived legacy record schema: ${archiveKey}.`);
|
|
1275
|
+
}
|
|
1276
|
+
const values = value.values.map((item) => {
|
|
1277
|
+
if (!isRecord(item)) throw corrupt(`Archived legacy value is corrupt: ${archiveKey}.`);
|
|
1278
|
+
return {
|
|
1279
|
+
value: item.value,
|
|
1280
|
+
// A v2 record legitimately carries null here for values it inherited from
|
|
1281
|
+
// the untimestamped v1 shape, so this must stay nullable on the way back
|
|
1282
|
+
// in or re-archiving a v1 record would make it permanently unreadable.
|
|
1283
|
+
archivedAt: item.archivedAt === null ? null : readString(item.archivedAt, "archived legacy timestamp")
|
|
1284
|
+
};
|
|
1285
|
+
});
|
|
1286
|
+
if (values.length === 0) throw corrupt(`Archived legacy record has no values: ${archiveKey}.`);
|
|
1287
|
+
return { schemaVersion: LEGACY_OPAQUE_SCHEMA_VERSION, key, values: Object.freeze(values) };
|
|
1288
|
+
}
|
|
1289
|
+
function opaqueValueByteLength(value) {
|
|
1290
|
+
if (value instanceof ArrayBuffer) return value.byteLength;
|
|
1291
|
+
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
1292
|
+
if (typeof value === "string") return new TextEncoder().encode(value).byteLength;
|
|
1293
|
+
return null;
|
|
1294
|
+
}
|
|
1295
|
+
function toOpaqueSummary(record) {
|
|
1296
|
+
let byteLength = 0;
|
|
1297
|
+
for (const item of record.values) {
|
|
1298
|
+
const size = opaqueValueByteLength(item.value);
|
|
1299
|
+
byteLength = size === null || byteLength === null ? null : byteLength + size;
|
|
1300
|
+
}
|
|
1301
|
+
const stamps = record.values.map((item) => item.archivedAt);
|
|
1302
|
+
return Object.freeze({
|
|
1303
|
+
key: record.key,
|
|
1304
|
+
valueCount: record.values.length,
|
|
1305
|
+
byteLength,
|
|
1306
|
+
firstArchivedAt: stamps[0] ?? null,
|
|
1307
|
+
lastArchivedAt: stamps[stamps.length - 1] ?? null
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
function toPublicOpaqueRecord(record) {
|
|
1311
|
+
return Object.freeze({
|
|
1312
|
+
...toOpaqueSummary(record),
|
|
1313
|
+
values: Object.freeze(
|
|
1314
|
+
record.values.map(
|
|
1315
|
+
(item) => Object.freeze({
|
|
1316
|
+
value: item.value instanceof ArrayBuffer ? copyBytes(item.value) : item.value,
|
|
1317
|
+
byteLength: opaqueValueByteLength(item.value),
|
|
1318
|
+
archivedAt: item.archivedAt
|
|
1319
|
+
})
|
|
1320
|
+
)
|
|
1321
|
+
)
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
async function deleteLegacyNamespace(transaction, keys, archivedAt) {
|
|
1325
|
+
for (const key of keys) {
|
|
1326
|
+
if (key.startsWith(LEGACY_PREFIX) && !isRecognizedLegacyKey(key)) {
|
|
1327
|
+
const archiveKey = legacyOpaqueKey(key);
|
|
1328
|
+
const existing = await transaction.get(archiveKey);
|
|
1329
|
+
const values = existing === null ? [] : validateLegacyOpaqueRecord(existing, archiveKey).values;
|
|
1330
|
+
await transaction.put(archiveKey, {
|
|
1331
|
+
schemaVersion: LEGACY_OPAQUE_SCHEMA_VERSION,
|
|
1332
|
+
key,
|
|
1333
|
+
values: [...values, { value: await transaction.get(key), archivedAt }]
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
if (key.startsWith(LEGACY_PREFIX) || key.startsWith(LEGACY_IGNORED_PREFIX))
|
|
1337
|
+
await transaction.delete(key);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
function isRecognizedLegacyKey(key) {
|
|
1341
|
+
if (key === LEGACY_DIRS_KEY) return true;
|
|
1342
|
+
const suffix = [LEGACY_CONTENT_SUFFIX, LEGACY_BINARY_SUFFIX, LEGACY_META_SUFFIX].find(
|
|
1343
|
+
(item) => key.endsWith(item)
|
|
1344
|
+
);
|
|
1345
|
+
if (!suffix) return false;
|
|
1346
|
+
const rawPath = key.slice(LEGACY_PREFIX.length, -suffix.length);
|
|
1347
|
+
try {
|
|
1348
|
+
const path = parseWorkspacePath(rawPath);
|
|
1349
|
+
return path !== WORKSPACE_ROOT && path === rawPath;
|
|
1350
|
+
} catch {
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
function selectLegacyPayload(conflict, resolution, path) {
|
|
1355
|
+
const payload = typeof resolution === "string" ? resolution : resolution.payload;
|
|
1356
|
+
const eligible = typeof resolution === "string" ? conflict.candidates.filter(
|
|
1357
|
+
(candidate2) => candidate2.kind === "file" && (payload === "text" ? candidate2.text !== null : candidate2.binary !== null)
|
|
1358
|
+
) : [conflict.candidates[resolution.candidate]].filter(
|
|
1359
|
+
(candidate2) => candidate2 !== void 0
|
|
1360
|
+
);
|
|
1361
|
+
if (eligible.length !== 1) {
|
|
1362
|
+
throw new FsError(
|
|
1363
|
+
"conflict",
|
|
1364
|
+
"Choose an explicit recovery candidate because multiple legacy versions are quarantined.",
|
|
1365
|
+
{ operation: "write", path, retryable: false }
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
const candidate = eligible[0];
|
|
1369
|
+
if (payload === "directory") {
|
|
1370
|
+
if (candidate.kind !== "directory") {
|
|
1371
|
+
throw new FsError("type-mismatch", "The selected recovery candidate is not a directory.", {
|
|
1372
|
+
operation: "write",
|
|
1373
|
+
path
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
return { kind: "directory" };
|
|
1377
|
+
}
|
|
1378
|
+
if (candidate.kind !== "file") {
|
|
1379
|
+
throw new FsError("type-mismatch", "The selected recovery candidate is a directory.", {
|
|
1380
|
+
operation: "write",
|
|
1381
|
+
path
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
const data = payload === "text" ? candidate.text === null ? null : new TextEncoder().encode(candidate.text).buffer : candidate.binary;
|
|
1385
|
+
if (data === null) {
|
|
1386
|
+
throw new FsError("type-mismatch", `The selected candidate has no ${payload} payload.`, {
|
|
1387
|
+
operation: "write",
|
|
1388
|
+
path
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
return { kind: "file", data: copyBytes(data) };
|
|
1392
|
+
}
|
|
1393
|
+
function addParentDirectories(entries, path, version, lastModified) {
|
|
1394
|
+
for (const parent of parentChainWithRoot(path)) {
|
|
1395
|
+
if (!entries.has(parent)) entries.set(parent, directoryRecord(parent, version, lastModified));
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
function directoryRecord(path, version, lastModified) {
|
|
1399
|
+
return {
|
|
1400
|
+
schemaVersion: ENTRY_SCHEMA_VERSION,
|
|
1401
|
+
kind: "directory",
|
|
1402
|
+
path,
|
|
1403
|
+
version,
|
|
1404
|
+
lastModified
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
function withPathAndVersion(entry, path, next) {
|
|
1408
|
+
return entry.kind === "file" ? {
|
|
1409
|
+
schemaVersion: ENTRY_SCHEMA_VERSION,
|
|
1410
|
+
kind: "file",
|
|
1411
|
+
path,
|
|
1412
|
+
data: copyBytes(entry.data),
|
|
1413
|
+
version: next.version,
|
|
1414
|
+
lastModified: next.lastModified
|
|
1415
|
+
} : directoryRecord(path, next.version, next.lastModified);
|
|
1416
|
+
}
|
|
1417
|
+
function copyEntry(entry) {
|
|
1418
|
+
return entry.kind === "file" ? { ...entry, data: copyBytes(entry.data) } : { ...entry };
|
|
1419
|
+
}
|
|
1420
|
+
function toEntrySnapshot(entry) {
|
|
1421
|
+
return entry.kind === "file" ? toFileSnapshot(entry) : toDirectorySnapshot(entry);
|
|
1422
|
+
}
|
|
1423
|
+
function toFileSnapshot(entry) {
|
|
1424
|
+
return Object.freeze({
|
|
1425
|
+
kind: "file",
|
|
1426
|
+
path: parseWorkspacePath(entry.path),
|
|
1427
|
+
name: workspacePathBasename(parseWorkspacePath(entry.path)),
|
|
1428
|
+
size: entry.data.byteLength,
|
|
1429
|
+
version: parseFileSystemVersion(entry.version),
|
|
1430
|
+
lastModified: entry.lastModified
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
function toDirectorySnapshot(entry) {
|
|
1434
|
+
const path = parseWorkspacePath(entry.path);
|
|
1435
|
+
return Object.freeze({
|
|
1436
|
+
kind: "directory",
|
|
1437
|
+
path,
|
|
1438
|
+
name: workspacePathBasename(path),
|
|
1439
|
+
size: null,
|
|
1440
|
+
version: parseFileSystemVersion(entry.version),
|
|
1441
|
+
lastModified: entry.lastModified
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
function validateBytes(value, path) {
|
|
1445
|
+
if (value instanceof ArrayBuffer) return copyBytes(value);
|
|
1446
|
+
if (value instanceof Uint8Array) return copyBytes(value);
|
|
1447
|
+
throw corrupt(`Stored file bytes are corrupt: ${path}.`, path);
|
|
1448
|
+
}
|
|
1449
|
+
function readString(value, label) {
|
|
1450
|
+
if (typeof value !== "string") throw corrupt(`Invalid ${label}.`);
|
|
1451
|
+
return value;
|
|
1452
|
+
}
|
|
1453
|
+
function corrupt(message, path) {
|
|
1454
|
+
return new FsError("io", message, { operation: "read", path, retryable: false });
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/filesystem/indexeddb-provider.ts
|
|
1458
|
+
var IndexedDBFileSystemProvider = class {
|
|
1459
|
+
constructor(id, label, options = {}) {
|
|
1460
|
+
this.disposed = false;
|
|
1461
|
+
this.id = id;
|
|
1462
|
+
this.label = label;
|
|
1463
|
+
const store = options.store ?? new RawIndexedDBFileSystemStore(indexedDBWorkspaceDatabaseName(id), "files");
|
|
1464
|
+
this.v2 = new IndexedDBFileSystemProviderV2(id, label, {
|
|
1465
|
+
store,
|
|
1466
|
+
now: options.now,
|
|
1467
|
+
closeStoreOnDispose: true
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
/** Release the underlying database connection after accepted work settles. */
|
|
1471
|
+
dispose() {
|
|
1472
|
+
this.disposed = true;
|
|
1473
|
+
return this.v2.dispose();
|
|
1474
|
+
}
|
|
1475
|
+
async readFile(path) {
|
|
1476
|
+
this.assertOpen("read");
|
|
1477
|
+
const canonical = this.path(path);
|
|
1478
|
+
const read = await this.v2.readFile(canonical);
|
|
1479
|
+
return read ? this.decode(read.data, canonical, "read") : null;
|
|
1480
|
+
}
|
|
1481
|
+
async writeFile(path, content) {
|
|
1482
|
+
this.assertOpen("write");
|
|
1483
|
+
await this.v2.writeFile(this.path(path), new TextEncoder().encode(content), {
|
|
1484
|
+
createParents: true
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
async commitFile(path, content, expectedContent) {
|
|
1488
|
+
this.assertOpen("write");
|
|
1489
|
+
const canonical = this.path(path);
|
|
1490
|
+
const current = await this.v2.readFile(canonical);
|
|
1491
|
+
const currentContent = current ? this.decode(current.data, canonical, "write") : null;
|
|
1492
|
+
if (currentContent !== expectedContent && currentContent !== content) {
|
|
1493
|
+
return {
|
|
1494
|
+
status: "conflict",
|
|
1495
|
+
content: currentContent,
|
|
1496
|
+
version: current?.entry.version ?? null
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
try {
|
|
1500
|
+
const committed = await this.v2.writeFile(canonical, new TextEncoder().encode(content), {
|
|
1501
|
+
createParents: true,
|
|
1502
|
+
expectedVersion: current?.entry.version ?? null
|
|
1503
|
+
});
|
|
1504
|
+
return { status: "committed", version: committed.version };
|
|
1505
|
+
} catch (error) {
|
|
1506
|
+
if (!(error instanceof FsError) || error.code !== "conflict") throw error;
|
|
1507
|
+
const latest = await this.v2.readFile(canonical);
|
|
1508
|
+
return {
|
|
1509
|
+
status: "conflict",
|
|
1510
|
+
content: latest ? this.decode(latest.data, canonical, "write") : null,
|
|
1511
|
+
version: latest?.entry.version ?? null
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
async delete(path) {
|
|
1516
|
+
this.assertOpen("remove");
|
|
1517
|
+
await this.v2.remove(this.path(path), {
|
|
1518
|
+
recursive: true,
|
|
1519
|
+
missing: "ignore"
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
async rename(oldPath, newPath) {
|
|
1523
|
+
this.assertOpen("move");
|
|
1524
|
+
await this.v2.move(this.path(oldPath), this.path(newPath), {
|
|
1525
|
+
createParents: true
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
async readDirectory(path) {
|
|
1529
|
+
this.assertOpen("list");
|
|
1530
|
+
try {
|
|
1531
|
+
const entries = await this.v2.readDirectory(this.path(path));
|
|
1532
|
+
return entries.map((entry) => ({
|
|
1533
|
+
kind: entry.kind,
|
|
1534
|
+
name: entry.name,
|
|
1535
|
+
path: entry.path
|
|
1536
|
+
}));
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
if (error instanceof FsError && error.code === "not-found") return [];
|
|
1539
|
+
throw error;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
async exists(path) {
|
|
1543
|
+
this.assertOpen("stat");
|
|
1544
|
+
return await this.v2.stat(this.path(path)) !== null;
|
|
1545
|
+
}
|
|
1546
|
+
async createDirectory(path) {
|
|
1547
|
+
this.assertOpen("create-directory");
|
|
1548
|
+
await this.v2.createDirectory(this.path(path), {
|
|
1549
|
+
createParents: true,
|
|
1550
|
+
mode: "ensure"
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
async stat(path) {
|
|
1554
|
+
this.assertOpen("stat");
|
|
1555
|
+
const entry = await this.v2.stat(this.path(path));
|
|
1556
|
+
if (!entry || entry.kind !== "file") return null;
|
|
1557
|
+
return {
|
|
1558
|
+
name: entry.name,
|
|
1559
|
+
path: entry.path,
|
|
1560
|
+
size: entry.size,
|
|
1561
|
+
lastModified: entry.lastModified
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
async readBinary(path) {
|
|
1565
|
+
this.assertOpen("read");
|
|
1566
|
+
return (await this.v2.readFile(this.path(path)))?.data ?? null;
|
|
1567
|
+
}
|
|
1568
|
+
async writeBinary(path, data) {
|
|
1569
|
+
this.assertOpen("write");
|
|
1570
|
+
await this.v2.writeFile(this.path(path), data, { createParents: true });
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Canonicalize only. Root policy is the v2 authority's to state: it accepts
|
|
1574
|
+
* the root wherever the root is a legitimate target (`stat`, `readDirectory`,
|
|
1575
|
+
* `createDirectory`) and rejects it with one consistent code and message
|
|
1576
|
+
* everywhere else. Re-deriving that policy here is how this facade previously
|
|
1577
|
+
* drifted from both v2 and its sibling facades.
|
|
1578
|
+
*/
|
|
1579
|
+
path(path) {
|
|
1580
|
+
return parseWorkspacePath(path);
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Byte-authoritative entries may hold payloads that are not valid UTF-8. A
|
|
1584
|
+
* lossy decode would hand the caller replacement characters that a later save
|
|
1585
|
+
* writes back over the user's original bytes, so surface the fault instead.
|
|
1586
|
+
*/
|
|
1587
|
+
decode(data, path, operation) {
|
|
1588
|
+
return decodeUtf8Text(data, { label: "The file", operation, path });
|
|
1589
|
+
}
|
|
1590
|
+
assertOpen(operation) {
|
|
1591
|
+
if (!this.disposed) return;
|
|
1592
|
+
throw new FsError("disposed", "Filesystem provider is disposed.", { operation });
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
// src/filesystem/indexeddb-content-container.ts
|
|
1597
|
+
import { findDocumentPath } from "@bendyline/squisq/storage";
|
|
1598
|
+
|
|
1599
|
+
// src/filesystem/internal/mime.ts
|
|
1600
|
+
var EXTENSION_MIME_MAP = {
|
|
1601
|
+
".md": "text/markdown",
|
|
1602
|
+
".txt": "text/plain",
|
|
1603
|
+
".json": "application/json",
|
|
1604
|
+
".jpg": "image/jpeg",
|
|
1605
|
+
".jpeg": "image/jpeg",
|
|
1606
|
+
".png": "image/png",
|
|
1607
|
+
".gif": "image/gif",
|
|
1608
|
+
".svg": "image/svg+xml",
|
|
1609
|
+
".webp": "image/webp",
|
|
1610
|
+
".avif": "image/avif",
|
|
1611
|
+
".mp4": "video/mp4",
|
|
1612
|
+
".webm": "video/webm",
|
|
1613
|
+
".mp3": "audio/mpeg",
|
|
1614
|
+
".wav": "audio/wav",
|
|
1615
|
+
".ogg": "audio/ogg"
|
|
1616
|
+
};
|
|
1617
|
+
function guessMimeType(path) {
|
|
1618
|
+
const dot = path.lastIndexOf(".");
|
|
1619
|
+
if (dot === -1) return "application/octet-stream";
|
|
1620
|
+
const ext = path.slice(dot).toLowerCase();
|
|
1621
|
+
return EXTENSION_MIME_MAP[ext] ?? "application/octet-stream";
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// src/filesystem/indexeddb-content-container.ts
|
|
1625
|
+
var IndexedDBContentContainer = class {
|
|
1626
|
+
constructor(workspaceId) {
|
|
1627
|
+
this.provider = new IndexedDBFileSystemProviderV2(`${workspaceId}-media`, "Media Storage");
|
|
1628
|
+
}
|
|
1629
|
+
async readFile(path) {
|
|
1630
|
+
return (await this.provider.readFile(parseWorkspacePath(path)))?.data ?? null;
|
|
1631
|
+
}
|
|
1632
|
+
async writeFile(path, data, _mimeType) {
|
|
1633
|
+
await this.provider.writeFile(parseWorkspacePath(path), data, {
|
|
1634
|
+
mode: "upsert",
|
|
1635
|
+
createParents: true
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
async removeFile(path) {
|
|
1639
|
+
await this.provider.remove(parseWorkspacePath(path), { missing: "ignore" });
|
|
1640
|
+
}
|
|
1641
|
+
async listFiles(prefix) {
|
|
1642
|
+
const entries = [];
|
|
1643
|
+
const walk = async (dir) => {
|
|
1644
|
+
const children = await this.provider.readDirectory(parseWorkspacePath(dir));
|
|
1645
|
+
for (const child of children) {
|
|
1646
|
+
if (child.kind === "directory") {
|
|
1647
|
+
await walk(child.path);
|
|
1648
|
+
} else {
|
|
1649
|
+
const filePath = child.path;
|
|
1650
|
+
if (prefix && !filePath.startsWith(prefix)) continue;
|
|
1651
|
+
entries.push({
|
|
1652
|
+
path: filePath,
|
|
1653
|
+
mimeType: guessMimeType(filePath),
|
|
1654
|
+
size: child.size
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
await walk(WORKSPACE_ROOT);
|
|
1660
|
+
return entries;
|
|
1661
|
+
}
|
|
1662
|
+
async exists(path) {
|
|
1663
|
+
return await this.provider.stat(parseWorkspacePath(path)) !== null;
|
|
1664
|
+
}
|
|
1665
|
+
async getDocumentPath() {
|
|
1666
|
+
return findDocumentPath(await this.listFiles());
|
|
1667
|
+
}
|
|
1668
|
+
async readDocument() {
|
|
1669
|
+
const docPath = await this.getDocumentPath();
|
|
1670
|
+
if (!docPath) return null;
|
|
1671
|
+
const data = await this.readFile(docPath);
|
|
1672
|
+
if (!data) return null;
|
|
1673
|
+
return new TextDecoder().decode(data);
|
|
1674
|
+
}
|
|
1675
|
+
async writeDocument(markdown, filename) {
|
|
1676
|
+
const name = filename ?? "index.md";
|
|
1677
|
+
const data = new TextEncoder().encode(markdown);
|
|
1678
|
+
await this.writeFile(name, data, "text/markdown");
|
|
1679
|
+
}
|
|
1680
|
+
/** Release the IndexedDB connection when a long-lived container is retired. */
|
|
1681
|
+
async dispose() {
|
|
1682
|
+
await this.provider.dispose();
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
export {
|
|
1687
|
+
IndexedDBFileSystemProviderV2,
|
|
1688
|
+
IndexedDBFileSystemProvider,
|
|
1689
|
+
guessMimeType,
|
|
1690
|
+
IndexedDBContentContainer
|
|
1691
|
+
};
|
|
1692
|
+
//# sourceMappingURL=chunk-6IYMTYMQ.js.map
|