@filen/sync 0.2.1 → 0.3.1
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/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APIError,
|
|
3
|
+
type CloudItem,
|
|
4
|
+
type FileMetadata,
|
|
5
|
+
type FolderMetadata,
|
|
6
|
+
type FileEncryptionVersion,
|
|
7
|
+
type FilenSDK
|
|
8
|
+
} from "@filen/sdk"
|
|
9
|
+
import { v4 as uuidv4 } from "uuid"
|
|
10
|
+
import crypto from "crypto"
|
|
11
|
+
import pathModule from "path"
|
|
12
|
+
import { type SyncFS } from "../../src/lib/environment"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A stateful, in-memory fake of the exact `@filen/sdk` surface the sync engine consumes,
|
|
16
|
+
* built to be a 1:1 behavioral match of the real SDK + backend (verified against the SDK
|
|
17
|
+
* source in node_modules and confirmed backend semantics):
|
|
18
|
+
*
|
|
19
|
+
* - `api(3).dir().tree()` returns the full tree on the first call for a deviceId and an empty
|
|
20
|
+
* `{files:[],folders:[]}` "unchanged" response on subsequent calls when nothing changed since
|
|
21
|
+
* that deviceId last fetched; `skipCache:true` always returns the full tree.
|
|
22
|
+
* - `api(3).dir().present()` reports trashed nodes as `{present:true,trash:true}` and permanently
|
|
23
|
+
* deleted nodes as `{present:false}`.
|
|
24
|
+
* - Names are unique per parent **case-insensitively**. A file/folder type clash errors. Uploading
|
|
25
|
+
* a file over an existing file versions it: a fresh uuid supersedes, the old uuid leaves the tree.
|
|
26
|
+
* - `cloud()` mutations mirror the SDK: `overwriteIfExists` trashes the occupant, `renameFile`/
|
|
27
|
+
* `moveFile` no-op when the target is the same uuid, and gone uuids raise
|
|
28
|
+
* `APIError{file_not_found|folder_not_found}`.
|
|
29
|
+
* - Encryption is identity: metadata is `JSON.stringify(...)`, `decrypt()` is `JSON.parse(...)`.
|
|
30
|
+
* - Resource locks are an in-memory map; uncontended acquire resolves immediately, while a contended one
|
|
31
|
+
* retries up to `maxTries` times with `tryTimeout` between tries (blocking until released for
|
|
32
|
+
* maxTries:Infinity, which the engine always uses) before throwing — the real SDK's acquire-with-retry.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const FILE_ENCRYPTION_VERSION: FileEncryptionVersion = 2
|
|
36
|
+
const DEFAULT_UPLOAD_BUCKET = "filen-1"
|
|
37
|
+
const DEFAULT_UPLOAD_REGION = "de-1"
|
|
38
|
+
const UPLOAD_CHUNK_SIZE = 1024 * 1024
|
|
39
|
+
|
|
40
|
+
const MIME_BY_EXTENSION: Record<string, string> = {
|
|
41
|
+
".txt": "text/plain",
|
|
42
|
+
".json": "application/json",
|
|
43
|
+
".md": "text/markdown",
|
|
44
|
+
".png": "image/png",
|
|
45
|
+
".jpg": "image/jpeg",
|
|
46
|
+
".pdf": "application/pdf"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mimeForName(name: string): string {
|
|
50
|
+
return MIME_BY_EXTENSION[pathModule.posix.extname(name).toLowerCase()] ?? "application/octet-stream"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sha512Hex(data: Buffer): string {
|
|
54
|
+
return crypto.createHash("sha512").update(Uint8Array.from(data)).digest("hex")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function randomHex(bytes: number): string {
|
|
58
|
+
return crypto.randomBytes(bytes).toString("hex")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type FileNode = {
|
|
62
|
+
type: "file"
|
|
63
|
+
uuid: string
|
|
64
|
+
parent: string
|
|
65
|
+
name: string
|
|
66
|
+
size: number
|
|
67
|
+
mime: string
|
|
68
|
+
key: string
|
|
69
|
+
bucket: string
|
|
70
|
+
region: string
|
|
71
|
+
version: FileEncryptionVersion
|
|
72
|
+
chunks: number
|
|
73
|
+
lastModified: number
|
|
74
|
+
creation: number
|
|
75
|
+
hash: string
|
|
76
|
+
timestamp: number
|
|
77
|
+
favorited: boolean
|
|
78
|
+
rm: string
|
|
79
|
+
content: Buffer
|
|
80
|
+
state: "live" | "trashed"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type DirNode = {
|
|
84
|
+
type: "directory"
|
|
85
|
+
uuid: string
|
|
86
|
+
parent: string
|
|
87
|
+
name: string
|
|
88
|
+
timestamp: number
|
|
89
|
+
favorited: boolean
|
|
90
|
+
state: "live" | "trashed"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Node = FileNode | DirNode
|
|
94
|
+
|
|
95
|
+
export type DirTreeFile = [string, string, string, number, string, string, FileEncryptionVersion, number]
|
|
96
|
+
export type DirTreeFolder = [string, string, string]
|
|
97
|
+
export type DirTreeResponse = { files: DirTreeFile[]; folders: DirTreeFolder[]; raw: string }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Declarative initial remote state. Key = absolute POSIX path under the sync root
|
|
101
|
+
* (e.g. "/a.txt", "/dir/b.txt"); value = file content / spec, or `null` for a directory.
|
|
102
|
+
*/
|
|
103
|
+
export type CloudFileSpec = { content?: string; mtimeMs?: number; creationMs?: number }
|
|
104
|
+
export type CloudSpec = Record<string, string | CloudFileSpec | null>
|
|
105
|
+
|
|
106
|
+
export type RemoteSnapshotEntry = { type: "file" | "directory"; size: number; mtimeSec: number; contentHash: string }
|
|
107
|
+
export type RemoteSnapshot = Record<string, RemoteSnapshotEntry>
|
|
108
|
+
|
|
109
|
+
export type FakeCloudControls = {
|
|
110
|
+
/** The sync root's uuid (use as `remoteParentUUID`). */
|
|
111
|
+
rootUUID: string
|
|
112
|
+
/** Current full tree (bypasses the deviceId cache) — for assertions. */
|
|
113
|
+
tree(): { files: DirTreeFile[]; folders: DirTreeFolder[] }
|
|
114
|
+
/** Normalized snapshot of live nodes keyed by path — for harness assertions. */
|
|
115
|
+
snapshot(): RemoteSnapshot
|
|
116
|
+
/** Look up a live node by its path. */
|
|
117
|
+
getByPath(path: string): Node | undefined
|
|
118
|
+
/** Monotonic revision counter; bumps on every mutation. */
|
|
119
|
+
revision(): number
|
|
120
|
+
/** Make the next call to `method` (e.g. "tree", "uploadLocalFile") throw `error`. */
|
|
121
|
+
setError(method: string, error: Error): void
|
|
122
|
+
clearError(method: string): void
|
|
123
|
+
clearAllErrors(): void
|
|
124
|
+
/**
|
|
125
|
+
* Make the next `downloadFileToLocal` for the file at `path` write an INCOMPLETE (0-byte) staged file
|
|
126
|
+
* and RESOLVE (calling onError but not throwing) — reproducing the real SDK, whose aborted download
|
|
127
|
+
* ends the read stream cleanly so the pipeline reports no error. One-shot; the engine's integrity guard
|
|
128
|
+
* must discard the short file rather than commit it.
|
|
129
|
+
*/
|
|
130
|
+
simulateIncompleteDownload(path: string): void
|
|
131
|
+
/** Block lock acquisition for `resource` to simulate another device holding it. */
|
|
132
|
+
contendLock(resource: string): void
|
|
133
|
+
releaseLockContention(resource: string): void
|
|
134
|
+
/** External mutations (as if another device changed the cloud) between cycles. */
|
|
135
|
+
addDir(path: string): string
|
|
136
|
+
addFile(path: string, content: string, options?: { mtimeMs?: number; creationMs?: number }): string
|
|
137
|
+
updateFile(path: string, content: string, options?: { mtimeMs?: number }): string
|
|
138
|
+
/** Change a file's mtime WITHOUT assigning a new uuid (metadata-only touch). */
|
|
139
|
+
touchRemote(path: string, mtimeMs: number): void
|
|
140
|
+
trashPath(path: string): void
|
|
141
|
+
deletePath(path: string): void
|
|
142
|
+
movePath(fromPath: string, toPath: string): void
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type FakeCloud = {
|
|
146
|
+
sdk: FilenSDK
|
|
147
|
+
controls: FakeCloudControls
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createFakeCloud(initial: CloudSpec = {}, deps: { localFs: SyncFS; rootName?: string; rootUUID?: string }): FakeCloud {
|
|
151
|
+
const nodes = new Map<string, Node>()
|
|
152
|
+
const deviceSeen = new Map<string, number>()
|
|
153
|
+
const locks = new Map<string, string>()
|
|
154
|
+
const contendedLocks = new Set<string>()
|
|
155
|
+
const errors = new Map<string, Error>()
|
|
156
|
+
const incompleteDownloads = new Set<string>()
|
|
157
|
+
const rootUUID = deps.rootUUID ?? uuidv4()
|
|
158
|
+
let revision = 0
|
|
159
|
+
|
|
160
|
+
const now = (): number => Date.now()
|
|
161
|
+
|
|
162
|
+
nodes.set(rootUUID, {
|
|
163
|
+
type: "directory",
|
|
164
|
+
uuid: rootUUID,
|
|
165
|
+
parent: "base",
|
|
166
|
+
name: deps.rootName ?? "Sync",
|
|
167
|
+
timestamp: now(),
|
|
168
|
+
favorited: false,
|
|
169
|
+
state: "live"
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const bump = (): void => {
|
|
173
|
+
revision += 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const guard = (method: string): void => {
|
|
177
|
+
const error = errors.get(method)
|
|
178
|
+
|
|
179
|
+
if (error) {
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const liveChildren = (parentUUID: string): Node[] => {
|
|
185
|
+
const result: Node[] = []
|
|
186
|
+
|
|
187
|
+
for (const node of nodes.values()) {
|
|
188
|
+
if (node.parent === parentUUID && node.state === "live") {
|
|
189
|
+
result.push(node)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const findLiveByName = (parentUUID: string, name: string): Node | undefined => {
|
|
197
|
+
const lowercased = name.toLowerCase()
|
|
198
|
+
|
|
199
|
+
return liveChildren(parentUUID).find(node => node.name.toLowerCase() === lowercased)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const pathOf = (node: Node): string => {
|
|
203
|
+
const parts: string[] = []
|
|
204
|
+
let current: Node | undefined = node
|
|
205
|
+
|
|
206
|
+
while (current && current.uuid !== rootUUID) {
|
|
207
|
+
parts.unshift(current.name)
|
|
208
|
+
|
|
209
|
+
current = nodes.get(current.parent)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return "/" + parts.join("/")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const removeSubtree = (uuid: string): void => {
|
|
216
|
+
for (const child of [...liveChildren(uuid), ...nodes.values()].filter(n => n.parent === uuid)) {
|
|
217
|
+
if (child.type === "directory") {
|
|
218
|
+
removeSubtree(child.uuid)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
nodes.delete(child.uuid)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
nodes.delete(uuid)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const trashSubtree = (uuid: string): void => {
|
|
228
|
+
const node = nodes.get(uuid)
|
|
229
|
+
|
|
230
|
+
if (!node) {
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
node.state = "trashed"
|
|
235
|
+
|
|
236
|
+
for (const child of nodes.values()) {
|
|
237
|
+
if (child.parent === uuid) {
|
|
238
|
+
if (child.type === "directory") {
|
|
239
|
+
trashSubtree(child.uuid)
|
|
240
|
+
} else {
|
|
241
|
+
child.state = "trashed"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const encodeFileMetadata = (node: FileNode): string =>
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
name: node.name,
|
|
250
|
+
size: node.size,
|
|
251
|
+
mime: node.mime,
|
|
252
|
+
key: node.key,
|
|
253
|
+
lastModified: node.lastModified,
|
|
254
|
+
creation: node.creation,
|
|
255
|
+
hash: node.hash
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const encodeFolderMetadata = (node: DirNode): string => JSON.stringify({ name: node.name })
|
|
259
|
+
|
|
260
|
+
const buildFullTree = (): { files: DirTreeFile[]; folders: DirTreeFolder[] } => {
|
|
261
|
+
const folders: DirTreeFolder[] = []
|
|
262
|
+
const files: DirTreeFile[] = []
|
|
263
|
+
const queue: DirNode[] = []
|
|
264
|
+
const root = nodes.get(rootUUID)
|
|
265
|
+
|
|
266
|
+
if (root && root.type === "directory") {
|
|
267
|
+
queue.push(root)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Breadth-first so parents always precede children (the engine's decode relies on it).
|
|
271
|
+
while (queue.length > 0) {
|
|
272
|
+
const dir = queue.shift()!
|
|
273
|
+
|
|
274
|
+
folders.push([dir.uuid, encodeFolderMetadata(dir), dir.parent])
|
|
275
|
+
|
|
276
|
+
for (const child of liveChildren(dir.uuid)) {
|
|
277
|
+
if (child.type === "directory") {
|
|
278
|
+
queue.push(child)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const node of nodes.values()) {
|
|
284
|
+
if (node.type === "file" && node.state === "live") {
|
|
285
|
+
files.push([node.uuid, node.bucket, node.region, node.chunks, node.parent, encodeFileMetadata(node), node.version, node.timestamp])
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { files, folders }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const ensureDirByPath = (posixPath: string): string => {
|
|
293
|
+
if (posixPath === "/" || posixPath === "" || posixPath === ".") {
|
|
294
|
+
return rootUUID
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const segments = posixPath.split("/").filter(segment => segment.length > 0)
|
|
298
|
+
let parentUUID = rootUUID
|
|
299
|
+
|
|
300
|
+
for (const segment of segments) {
|
|
301
|
+
const existing = findLiveByName(parentUUID, segment)
|
|
302
|
+
|
|
303
|
+
if (existing) {
|
|
304
|
+
if (existing.type === "file") {
|
|
305
|
+
throw new Error(`Cannot create directory "${segment}": a file with that name exists.`)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
parentUUID = existing.uuid
|
|
309
|
+
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const uuid = uuidv4()
|
|
314
|
+
|
|
315
|
+
nodes.set(uuid, {
|
|
316
|
+
type: "directory",
|
|
317
|
+
uuid,
|
|
318
|
+
parent: parentUUID,
|
|
319
|
+
name: segment,
|
|
320
|
+
timestamp: now(),
|
|
321
|
+
favorited: false,
|
|
322
|
+
state: "live"
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
parentUUID = uuid
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return parentUUID
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const createFileNode = (
|
|
332
|
+
parentUUID: string,
|
|
333
|
+
name: string,
|
|
334
|
+
content: Buffer,
|
|
335
|
+
options?: { mtimeMs?: number | undefined; creationMs?: number | undefined }
|
|
336
|
+
): string => {
|
|
337
|
+
const existing = findLiveByName(parentUUID, name)
|
|
338
|
+
|
|
339
|
+
if (existing) {
|
|
340
|
+
if (existing.type === "directory") {
|
|
341
|
+
throw new Error(`Cannot create file "${name}": a directory with that name exists.`)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Same-name file → versioned: the old uuid leaves the live tree.
|
|
345
|
+
nodes.delete(existing.uuid)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const uuid = uuidv4()
|
|
349
|
+
const size = content.length
|
|
350
|
+
const stamp = now()
|
|
351
|
+
|
|
352
|
+
nodes.set(uuid, {
|
|
353
|
+
type: "file",
|
|
354
|
+
uuid,
|
|
355
|
+
parent: parentUUID,
|
|
356
|
+
name,
|
|
357
|
+
size,
|
|
358
|
+
mime: mimeForName(name),
|
|
359
|
+
key: randomHex(16),
|
|
360
|
+
bucket: DEFAULT_UPLOAD_BUCKET,
|
|
361
|
+
region: DEFAULT_UPLOAD_REGION,
|
|
362
|
+
version: FILE_ENCRYPTION_VERSION,
|
|
363
|
+
chunks: size > 0 ? Math.ceil(size / UPLOAD_CHUNK_SIZE) : 1,
|
|
364
|
+
lastModified: options?.mtimeMs ?? stamp,
|
|
365
|
+
creation: options?.creationMs ?? stamp,
|
|
366
|
+
hash: sha512Hex(content),
|
|
367
|
+
timestamp: stamp,
|
|
368
|
+
favorited: false,
|
|
369
|
+
rm: randomHex(16),
|
|
370
|
+
content,
|
|
371
|
+
state: "live"
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
return uuid
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Materialize the initial spec.
|
|
378
|
+
for (const [rawPath, value] of Object.entries(initial)) {
|
|
379
|
+
if (value === null) {
|
|
380
|
+
ensureDirByPath(rawPath)
|
|
381
|
+
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const parentUUID = ensureDirByPath(pathModule.posix.dirname(rawPath))
|
|
386
|
+
const content = typeof value === "string" ? value : value.content ?? ""
|
|
387
|
+
const options = typeof value === "string" ? undefined : { mtimeMs: value.mtimeMs, creationMs: value.creationMs }
|
|
388
|
+
|
|
389
|
+
createFileNode(parentUUID, pathModule.posix.basename(rawPath), Buffer.from(content, "utf-8"), options)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const trashFileInternal = (uuid: string): void => {
|
|
393
|
+
const node = nodes.get(uuid)
|
|
394
|
+
|
|
395
|
+
if (!node) {
|
|
396
|
+
throw new APIError({ code: "file_not_found", message: "File not found." })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
node.state = "trashed"
|
|
400
|
+
|
|
401
|
+
bump()
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const trashDirectoryInternal = (uuid: string): void => {
|
|
405
|
+
if (!nodes.get(uuid)) {
|
|
406
|
+
throw new APIError({ code: "folder_not_found", message: "Folder not found." })
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
trashSubtree(uuid)
|
|
410
|
+
|
|
411
|
+
bump()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const sdk = {
|
|
415
|
+
api: (_version: number) => ({
|
|
416
|
+
dir: () => ({
|
|
417
|
+
tree: async ({
|
|
418
|
+
uuid: _uuid,
|
|
419
|
+
deviceId,
|
|
420
|
+
skipCache = false
|
|
421
|
+
}: {
|
|
422
|
+
uuid: string
|
|
423
|
+
deviceId: string
|
|
424
|
+
skipCache?: boolean
|
|
425
|
+
includeRaw?: boolean
|
|
426
|
+
}): Promise<DirTreeResponse> => {
|
|
427
|
+
guard("tree")
|
|
428
|
+
|
|
429
|
+
if (!skipCache && deviceSeen.get(deviceId) === revision) {
|
|
430
|
+
return { files: [], folders: [], raw: "" }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
deviceSeen.set(deviceId, revision)
|
|
434
|
+
|
|
435
|
+
const { files, folders } = buildFullTree()
|
|
436
|
+
|
|
437
|
+
return { files, folders, raw: "" }
|
|
438
|
+
},
|
|
439
|
+
present: async ({ uuid }: { uuid: string }): Promise<{ present: boolean; trash: boolean }> => {
|
|
440
|
+
guard("present")
|
|
441
|
+
|
|
442
|
+
const node = nodes.get(uuid)
|
|
443
|
+
|
|
444
|
+
if (!node) {
|
|
445
|
+
return { present: false, trash: false }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return { present: true, trash: node.state === "trashed" }
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
}),
|
|
452
|
+
cloud: () => ({
|
|
453
|
+
createDirectory: async ({
|
|
454
|
+
uuid,
|
|
455
|
+
name,
|
|
456
|
+
parent,
|
|
457
|
+
renameIfExists: _renameIfExists = false
|
|
458
|
+
}: {
|
|
459
|
+
uuid?: string
|
|
460
|
+
name: string
|
|
461
|
+
parent: string
|
|
462
|
+
renameIfExists?: boolean
|
|
463
|
+
}): Promise<string> => {
|
|
464
|
+
guard("createDirectory")
|
|
465
|
+
|
|
466
|
+
const existing = findLiveByName(parent, name)
|
|
467
|
+
|
|
468
|
+
if (existing) {
|
|
469
|
+
if (existing.type === "file") {
|
|
470
|
+
throw new Error(`Cannot create directory "${name}": a file with that name exists.`)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return existing.uuid
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const newUUID = uuid ?? uuidv4()
|
|
477
|
+
|
|
478
|
+
nodes.set(newUUID, {
|
|
479
|
+
type: "directory",
|
|
480
|
+
uuid: newUUID,
|
|
481
|
+
parent,
|
|
482
|
+
name,
|
|
483
|
+
timestamp: now(),
|
|
484
|
+
favorited: false,
|
|
485
|
+
state: "live"
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
bump()
|
|
489
|
+
|
|
490
|
+
return newUUID
|
|
491
|
+
},
|
|
492
|
+
uploadLocalFile: async ({
|
|
493
|
+
source,
|
|
494
|
+
parent,
|
|
495
|
+
name,
|
|
496
|
+
abortSignal,
|
|
497
|
+
onProgress,
|
|
498
|
+
onStarted,
|
|
499
|
+
onError,
|
|
500
|
+
onUploaded,
|
|
501
|
+
onFinished,
|
|
502
|
+
uuid,
|
|
503
|
+
encryptionKey
|
|
504
|
+
}: {
|
|
505
|
+
source: string
|
|
506
|
+
parent: string
|
|
507
|
+
name?: string
|
|
508
|
+
pauseSignal?: { isPaused: () => boolean }
|
|
509
|
+
abortSignal?: AbortSignal
|
|
510
|
+
onProgress?: (bytes: number) => void
|
|
511
|
+
onStarted?: () => void
|
|
512
|
+
onError?: (error: Error) => void
|
|
513
|
+
onUploaded?: (item: CloudItem) => void | Promise<void>
|
|
514
|
+
onFinished?: () => void
|
|
515
|
+
uuid?: string
|
|
516
|
+
encryptionKey?: string
|
|
517
|
+
}): Promise<CloudItem> => {
|
|
518
|
+
try {
|
|
519
|
+
guard("uploadLocalFile")
|
|
520
|
+
|
|
521
|
+
if (abortSignal?.aborted) {
|
|
522
|
+
throw new Error("Aborted")
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (onStarted) {
|
|
526
|
+
onStarted()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const fileName = name ?? pathModule.basename(source)
|
|
530
|
+
const stats = await deps.localFs.stat(source)
|
|
531
|
+
const content = (await deps.localFs.readFile(source)) as unknown as Buffer
|
|
532
|
+
const size = stats.size
|
|
533
|
+
const lastModified = parseInt(stats.mtimeMs.toString())
|
|
534
|
+
const creation = parseInt(stats.birthtimeMs.toString())
|
|
535
|
+
|
|
536
|
+
if (onProgress) {
|
|
537
|
+
onProgress(size)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const existing = findLiveByName(parent, fileName)
|
|
541
|
+
|
|
542
|
+
if (existing && existing.type === "directory") {
|
|
543
|
+
throw new Error(`Cannot upload "${fileName}": a directory with that name exists.`)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (existing) {
|
|
547
|
+
// Same-name file → versioned: old uuid leaves the live tree.
|
|
548
|
+
nodes.delete(existing.uuid)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const newUUID = uuid ?? uuidv4()
|
|
552
|
+
const key = encryptionKey ?? randomHex(16)
|
|
553
|
+
const chunks = size > 0 ? Math.ceil(size / UPLOAD_CHUNK_SIZE) : 1
|
|
554
|
+
const stamp = now()
|
|
555
|
+
const node: FileNode = {
|
|
556
|
+
type: "file",
|
|
557
|
+
uuid: newUUID,
|
|
558
|
+
parent,
|
|
559
|
+
name: fileName,
|
|
560
|
+
size,
|
|
561
|
+
mime: mimeForName(fileName),
|
|
562
|
+
key,
|
|
563
|
+
bucket: DEFAULT_UPLOAD_BUCKET,
|
|
564
|
+
region: DEFAULT_UPLOAD_REGION,
|
|
565
|
+
version: FILE_ENCRYPTION_VERSION,
|
|
566
|
+
chunks,
|
|
567
|
+
lastModified,
|
|
568
|
+
creation,
|
|
569
|
+
hash: sha512Hex(content),
|
|
570
|
+
timestamp: stamp,
|
|
571
|
+
favorited: false,
|
|
572
|
+
rm: randomHex(16),
|
|
573
|
+
content,
|
|
574
|
+
state: "live"
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
nodes.set(newUUID, node)
|
|
578
|
+
|
|
579
|
+
bump()
|
|
580
|
+
|
|
581
|
+
const item: CloudItem = {
|
|
582
|
+
type: "file",
|
|
583
|
+
uuid: newUUID,
|
|
584
|
+
name: fileName,
|
|
585
|
+
size,
|
|
586
|
+
mime: node.mime,
|
|
587
|
+
lastModified,
|
|
588
|
+
timestamp: stamp,
|
|
589
|
+
parent,
|
|
590
|
+
rm: node.rm,
|
|
591
|
+
version: FILE_ENCRYPTION_VERSION,
|
|
592
|
+
chunks,
|
|
593
|
+
favorited: false,
|
|
594
|
+
key,
|
|
595
|
+
bucket: DEFAULT_UPLOAD_BUCKET,
|
|
596
|
+
region: DEFAULT_UPLOAD_REGION,
|
|
597
|
+
creation
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (onUploaded) {
|
|
601
|
+
await onUploaded(item)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (onFinished) {
|
|
605
|
+
onFinished()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return item
|
|
609
|
+
} catch (e) {
|
|
610
|
+
if (onError) {
|
|
611
|
+
onError(e as Error)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
throw e
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
downloadFileToLocal: async ({
|
|
618
|
+
uuid,
|
|
619
|
+
to,
|
|
620
|
+
size,
|
|
621
|
+
abortSignal,
|
|
622
|
+
onProgress,
|
|
623
|
+
onStarted,
|
|
624
|
+
onError,
|
|
625
|
+
onFinished
|
|
626
|
+
}: {
|
|
627
|
+
uuid: string
|
|
628
|
+
bucket: string
|
|
629
|
+
region: string
|
|
630
|
+
chunks: number
|
|
631
|
+
version: FileEncryptionVersion
|
|
632
|
+
key: string
|
|
633
|
+
to: string
|
|
634
|
+
size: number
|
|
635
|
+
pauseSignal?: { isPaused: () => boolean }
|
|
636
|
+
abortSignal?: AbortSignal
|
|
637
|
+
onProgress?: (bytes: number) => void
|
|
638
|
+
onStarted?: () => void
|
|
639
|
+
onError?: (error: Error) => void
|
|
640
|
+
onFinished?: () => void
|
|
641
|
+
}): Promise<string> => {
|
|
642
|
+
try {
|
|
643
|
+
guard("downloadFileToLocal")
|
|
644
|
+
|
|
645
|
+
if (abortSignal?.aborted) {
|
|
646
|
+
throw new Error("Aborted")
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const node = nodes.get(uuid)
|
|
650
|
+
|
|
651
|
+
if (!node || node.type !== "file") {
|
|
652
|
+
throw new APIError({ code: "file_not_found", message: "File not found." })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
await deps.localFs.ensureDir(pathModule.dirname(to))
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
await deps.localFs.rm(to, { force: true, recursive: true })
|
|
659
|
+
} catch {
|
|
660
|
+
// nothing to clear
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (incompleteDownloads.has(uuid)) {
|
|
664
|
+
// Reproduce the real SDK's aborted-download behavior: a 0-byte staged file, an onError
|
|
665
|
+
// notification, but a RESOLVED promise (no throw). The engine's integrity guard must catch
|
|
666
|
+
// the size mismatch and refuse to commit it. One-shot.
|
|
667
|
+
incompleteDownloads.delete(uuid)
|
|
668
|
+
|
|
669
|
+
await deps.localFs.writeFile(to, new Uint8Array(0))
|
|
670
|
+
|
|
671
|
+
if (onError) {
|
|
672
|
+
onError(new Error("Aborted"))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return to
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (size > 0) {
|
|
679
|
+
if (onStarted) {
|
|
680
|
+
onStarted()
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (onProgress) {
|
|
684
|
+
onProgress(node.content.length)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
await deps.localFs.writeFile(to, Uint8Array.from(node.content))
|
|
688
|
+
|
|
689
|
+
if (onFinished) {
|
|
690
|
+
onFinished()
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
await deps.localFs.writeFile(to, new Uint8Array(0))
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return to
|
|
697
|
+
} catch (e) {
|
|
698
|
+
if (onError) {
|
|
699
|
+
onError(e as Error)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
throw e
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
renameFile: async ({
|
|
706
|
+
uuid,
|
|
707
|
+
name,
|
|
708
|
+
overwriteIfExists = false
|
|
709
|
+
}: {
|
|
710
|
+
uuid: string
|
|
711
|
+
metadata: FileMetadata
|
|
712
|
+
name: string
|
|
713
|
+
overwriteIfExists?: boolean
|
|
714
|
+
}): Promise<void> => {
|
|
715
|
+
guard("renameFile")
|
|
716
|
+
|
|
717
|
+
const node = nodes.get(uuid)
|
|
718
|
+
|
|
719
|
+
if (!node || node.state !== "live") {
|
|
720
|
+
return
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const existing = findLiveByName(node.parent, name)
|
|
724
|
+
|
|
725
|
+
if (existing && existing.uuid !== uuid) {
|
|
726
|
+
if (overwriteIfExists) {
|
|
727
|
+
trashFileInternal(existing.uuid)
|
|
728
|
+
} else {
|
|
729
|
+
throw new Error("A file with the same name already exists in this directory.")
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
node.name = name
|
|
734
|
+
|
|
735
|
+
bump()
|
|
736
|
+
},
|
|
737
|
+
renameDirectory: async ({
|
|
738
|
+
uuid,
|
|
739
|
+
name,
|
|
740
|
+
overwriteIfExists = false
|
|
741
|
+
}: {
|
|
742
|
+
uuid: string
|
|
743
|
+
name: string
|
|
744
|
+
overwriteIfExists?: boolean
|
|
745
|
+
}): Promise<void> => {
|
|
746
|
+
guard("renameDirectory")
|
|
747
|
+
|
|
748
|
+
const node = nodes.get(uuid)
|
|
749
|
+
|
|
750
|
+
if (!node || node.state !== "live") {
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const existing = findLiveByName(node.parent, name)
|
|
755
|
+
|
|
756
|
+
if (existing && existing.uuid !== uuid) {
|
|
757
|
+
if (overwriteIfExists) {
|
|
758
|
+
trashDirectoryInternal(existing.uuid)
|
|
759
|
+
} else {
|
|
760
|
+
throw new Error("A directory with the same name already exists in this directory.")
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
node.name = name
|
|
765
|
+
|
|
766
|
+
bump()
|
|
767
|
+
},
|
|
768
|
+
moveFile: async ({
|
|
769
|
+
uuid,
|
|
770
|
+
to,
|
|
771
|
+
metadata,
|
|
772
|
+
overwriteIfExists = false
|
|
773
|
+
}: {
|
|
774
|
+
uuid: string
|
|
775
|
+
to: string
|
|
776
|
+
metadata: FileMetadata
|
|
777
|
+
overwriteIfExists?: boolean
|
|
778
|
+
}): Promise<void> => {
|
|
779
|
+
guard("moveFile")
|
|
780
|
+
|
|
781
|
+
const node = nodes.get(uuid)
|
|
782
|
+
|
|
783
|
+
if (!node || node.state !== "live") {
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const existing = findLiveByName(to, metadata.name)
|
|
788
|
+
|
|
789
|
+
if (existing) {
|
|
790
|
+
if (existing.uuid === uuid) {
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (overwriteIfExists) {
|
|
795
|
+
trashFileInternal(existing.uuid)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
node.parent = to
|
|
800
|
+
|
|
801
|
+
bump()
|
|
802
|
+
},
|
|
803
|
+
moveDirectory: async ({
|
|
804
|
+
uuid,
|
|
805
|
+
to,
|
|
806
|
+
metadata,
|
|
807
|
+
overwriteIfExists = false
|
|
808
|
+
}: {
|
|
809
|
+
uuid: string
|
|
810
|
+
to: string
|
|
811
|
+
metadata: FolderMetadata
|
|
812
|
+
overwriteIfExists?: boolean
|
|
813
|
+
}): Promise<void> => {
|
|
814
|
+
guard("moveDirectory")
|
|
815
|
+
|
|
816
|
+
const node = nodes.get(uuid)
|
|
817
|
+
|
|
818
|
+
if (!node || node.state !== "live") {
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const existing = findLiveByName(to, metadata.name)
|
|
823
|
+
|
|
824
|
+
if (existing) {
|
|
825
|
+
if (existing.uuid === uuid) {
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (overwriteIfExists) {
|
|
830
|
+
trashDirectoryInternal(existing.uuid)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
node.parent = to
|
|
835
|
+
|
|
836
|
+
bump()
|
|
837
|
+
},
|
|
838
|
+
trashFile: async ({ uuid }: { uuid: string }): Promise<void> => {
|
|
839
|
+
guard("trashFile")
|
|
840
|
+
|
|
841
|
+
trashFileInternal(uuid)
|
|
842
|
+
},
|
|
843
|
+
trashDirectory: async ({ uuid }: { uuid: string }): Promise<void> => {
|
|
844
|
+
guard("trashDirectory")
|
|
845
|
+
|
|
846
|
+
trashDirectoryInternal(uuid)
|
|
847
|
+
},
|
|
848
|
+
deleteFile: async ({ uuid }: { uuid: string }): Promise<void> => {
|
|
849
|
+
guard("deleteFile")
|
|
850
|
+
|
|
851
|
+
if (!nodes.get(uuid)) {
|
|
852
|
+
throw new APIError({ code: "file_not_found", message: "File not found." })
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
nodes.delete(uuid)
|
|
856
|
+
|
|
857
|
+
bump()
|
|
858
|
+
},
|
|
859
|
+
deleteDirectory: async ({ uuid }: { uuid: string }): Promise<void> => {
|
|
860
|
+
guard("deleteDirectory")
|
|
861
|
+
|
|
862
|
+
if (!nodes.get(uuid)) {
|
|
863
|
+
throw new APIError({ code: "folder_not_found", message: "Folder not found." })
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
removeSubtree(uuid)
|
|
867
|
+
|
|
868
|
+
bump()
|
|
869
|
+
},
|
|
870
|
+
fileExists: async ({ name, parent }: { name: string; parent: string }): Promise<{ exists: boolean; uuid?: string }> => {
|
|
871
|
+
guard("fileExists")
|
|
872
|
+
|
|
873
|
+
const existing = findLiveByName(parent, name)
|
|
874
|
+
|
|
875
|
+
if (existing && existing.type === "file") {
|
|
876
|
+
return { exists: true, uuid: existing.uuid }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return { exists: false }
|
|
880
|
+
}
|
|
881
|
+
}),
|
|
882
|
+
crypto: () => ({
|
|
883
|
+
decrypt: () => ({
|
|
884
|
+
fileMetadata: async ({ metadata }: { metadata: string; key?: string }): Promise<FileMetadata> => {
|
|
885
|
+
guard("fileMetadata")
|
|
886
|
+
|
|
887
|
+
return JSON.parse(metadata) as FileMetadata
|
|
888
|
+
},
|
|
889
|
+
folderMetadata: async ({ metadata }: { metadata: string; key?: string }): Promise<FolderMetadata> => {
|
|
890
|
+
guard("folderMetadata")
|
|
891
|
+
|
|
892
|
+
if (metadata === "default") {
|
|
893
|
+
return { name: "Default" }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return JSON.parse(metadata) as FolderMetadata
|
|
897
|
+
}
|
|
898
|
+
})
|
|
899
|
+
}),
|
|
900
|
+
user: () => ({
|
|
901
|
+
acquireResourceLock: async ({
|
|
902
|
+
resource,
|
|
903
|
+
lockUUID,
|
|
904
|
+
maxTries = 1,
|
|
905
|
+
tryTimeout = 1000
|
|
906
|
+
}: {
|
|
907
|
+
resource: string
|
|
908
|
+
lockUUID: string
|
|
909
|
+
maxTries?: number
|
|
910
|
+
tryTimeout?: number
|
|
911
|
+
}): Promise<void> => {
|
|
912
|
+
guard("acquireResourceLock")
|
|
913
|
+
|
|
914
|
+
// Faithfully model the real SDK's retry-on-contention: a lock held by ANOTHER holder (or a
|
|
915
|
+
// test-forced contention) is polled up to `maxTries` times with `tryTimeout` between tries
|
|
916
|
+
// instead of failing on the first attempt. The engine acquires with maxTries:Infinity, so a
|
|
917
|
+
// contended lock makes acquire BLOCK until the holder releases — exactly the production
|
|
918
|
+
// behavior. A finite maxTries throws once exhausted. (An injected error via guard() above is a
|
|
919
|
+
// hard, non-retryable failure and still throws immediately.)
|
|
920
|
+
const isContended = (): boolean => {
|
|
921
|
+
const holder = locks.get(resource)
|
|
922
|
+
|
|
923
|
+
return (contendedLocks.has(resource) && holder !== lockUUID) || (holder !== undefined && holder !== lockUUID)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let tries = 0
|
|
927
|
+
|
|
928
|
+
while (isContended()) {
|
|
929
|
+
tries++
|
|
930
|
+
|
|
931
|
+
if (tries >= maxTries) {
|
|
932
|
+
throw new Error(`Could not acquire lock for resource ${resource}.`)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
await new Promise<void>(resolve => setTimeout(resolve, tryTimeout))
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
locks.set(resource, lockUUID)
|
|
939
|
+
},
|
|
940
|
+
refreshResourceLock: async ({ resource, lockUUID }: { resource: string; lockUUID: string }): Promise<void> => {
|
|
941
|
+
guard("refreshResourceLock")
|
|
942
|
+
|
|
943
|
+
if (locks.get(resource) !== lockUUID) {
|
|
944
|
+
throw new Error(`Could not refresh lock for resource ${resource}.`)
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
releaseResourceLock: async ({ resource, lockUUID }: { resource: string; lockUUID: string }): Promise<void> => {
|
|
948
|
+
guard("releaseResourceLock")
|
|
949
|
+
|
|
950
|
+
if (locks.get(resource) === lockUUID) {
|
|
951
|
+
locks.delete(resource)
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const getByPath = (path: string): Node | undefined => {
|
|
958
|
+
for (const node of nodes.values()) {
|
|
959
|
+
if (node.state === "live" && node.uuid !== rootUUID && pathOf(node) === path) {
|
|
960
|
+
return node
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return undefined
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const controls: FakeCloudControls = {
|
|
968
|
+
rootUUID,
|
|
969
|
+
tree: () => buildFullTree(),
|
|
970
|
+
snapshot: (): RemoteSnapshot => {
|
|
971
|
+
const result: RemoteSnapshot = {}
|
|
972
|
+
|
|
973
|
+
for (const node of nodes.values()) {
|
|
974
|
+
if (node.state !== "live" || node.uuid === rootUUID) {
|
|
975
|
+
continue
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const path = pathOf(node)
|
|
979
|
+
|
|
980
|
+
if (node.type === "file") {
|
|
981
|
+
result[path] = {
|
|
982
|
+
type: "file",
|
|
983
|
+
size: node.size,
|
|
984
|
+
mtimeSec: Math.floor(node.lastModified / 1000),
|
|
985
|
+
contentHash: sha512Hex(node.content)
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
result[path] = { type: "directory", size: 0, mtimeSec: 0, contentHash: "" }
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return result
|
|
993
|
+
},
|
|
994
|
+
getByPath,
|
|
995
|
+
revision: () => revision,
|
|
996
|
+
setError: (method, error) => errors.set(method, error),
|
|
997
|
+
clearError: method => errors.delete(method),
|
|
998
|
+
clearAllErrors: () => errors.clear(),
|
|
999
|
+
simulateIncompleteDownload: (path: string): void => {
|
|
1000
|
+
const node = getByPath(path)
|
|
1001
|
+
|
|
1002
|
+
if (!node) {
|
|
1003
|
+
throw new Error(`simulateIncompleteDownload: no node at ${path}`)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
incompleteDownloads.add(node.uuid)
|
|
1007
|
+
},
|
|
1008
|
+
contendLock: resource => contendedLocks.add(resource),
|
|
1009
|
+
releaseLockContention: resource => contendedLocks.delete(resource),
|
|
1010
|
+
addDir: (path: string): string => {
|
|
1011
|
+
const uuid = ensureDirByPath(path)
|
|
1012
|
+
|
|
1013
|
+
bump()
|
|
1014
|
+
|
|
1015
|
+
return uuid
|
|
1016
|
+
},
|
|
1017
|
+
addFile: (path, content, options): string => {
|
|
1018
|
+
const parentUUID = ensureDirByPath(pathModule.posix.dirname(path))
|
|
1019
|
+
const uuid = createFileNode(parentUUID, pathModule.posix.basename(path), Buffer.from(content, "utf-8"), options)
|
|
1020
|
+
|
|
1021
|
+
bump()
|
|
1022
|
+
|
|
1023
|
+
return uuid
|
|
1024
|
+
},
|
|
1025
|
+
updateFile: (path, content, options): string => {
|
|
1026
|
+
const parentUUID = ensureDirByPath(pathModule.posix.dirname(path))
|
|
1027
|
+
const uuid = createFileNode(parentUUID, pathModule.posix.basename(path), Buffer.from(content, "utf-8"), options)
|
|
1028
|
+
|
|
1029
|
+
bump()
|
|
1030
|
+
|
|
1031
|
+
return uuid
|
|
1032
|
+
},
|
|
1033
|
+
touchRemote: (path: string, mtimeMs: number): void => {
|
|
1034
|
+
const node = getByPath(path)
|
|
1035
|
+
|
|
1036
|
+
if (!node || node.type !== "file") {
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
node.lastModified = mtimeMs
|
|
1041
|
+
|
|
1042
|
+
bump()
|
|
1043
|
+
},
|
|
1044
|
+
trashPath: (path: string): void => {
|
|
1045
|
+
const node = getByPath(path)
|
|
1046
|
+
|
|
1047
|
+
if (!node) {
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (node.type === "directory") {
|
|
1052
|
+
trashSubtree(node.uuid)
|
|
1053
|
+
} else {
|
|
1054
|
+
node.state = "trashed"
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
bump()
|
|
1058
|
+
},
|
|
1059
|
+
deletePath: (path: string): void => {
|
|
1060
|
+
const node = getByPath(path)
|
|
1061
|
+
|
|
1062
|
+
if (!node) {
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (node.type === "directory") {
|
|
1067
|
+
removeSubtree(node.uuid)
|
|
1068
|
+
} else {
|
|
1069
|
+
nodes.delete(node.uuid)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
bump()
|
|
1073
|
+
},
|
|
1074
|
+
movePath: (fromPath: string, toPath: string): void => {
|
|
1075
|
+
const node = getByPath(fromPath)
|
|
1076
|
+
|
|
1077
|
+
if (!node) {
|
|
1078
|
+
return
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const parentUUID = ensureDirByPath(pathModule.posix.dirname(toPath))
|
|
1082
|
+
|
|
1083
|
+
node.parent = parentUUID
|
|
1084
|
+
node.name = pathModule.posix.basename(toPath)
|
|
1085
|
+
|
|
1086
|
+
bump()
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
sdk: sdk as unknown as FilenSDK,
|
|
1092
|
+
controls
|
|
1093
|
+
}
|
|
1094
|
+
}
|