@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.
Files changed (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. 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
+ }