@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,1084 @@
1
+ /**
2
+ * Dropbox emulator API: the route table (evidence, request-shape checks, and stateful handlers),
3
+ * the fixture error envelopes, metadata rendering, and list and search cursors (internal;
4
+ * re-exported by `src/dropbox.ts`).
5
+ *
6
+ * Only the RPC and upload routes the eight Dropbox conformance cases (and their cleanup) send are
7
+ * emulated, with the wire shapes of their synthetic fixtures. Every route is a `POST` under `/2`: the RPC routes on `api.dropboxapi.com` with a JSON body, the
8
+ * upload on `content.dropboxapi.com` with an `application/octet-stream` body and a
9
+ * `Dropbox-API-Arg` header, each only on the origin its fixtures record. Route errors are the
10
+ * fixtures' HTTP 409 `error_summary` envelopes, copied byte for byte. Every route plans first
11
+ * (reading the state, writing nothing) and commits only an eligible request: anything no fixture
12
+ * shows is not emulated (400, nothing written, no fault used up).
13
+ *
14
+ * @experimental
15
+ */
16
+ import { Predicate } from 'effect'
17
+ import type * as Schema from 'effect/Schema'
18
+ import {
19
+ exactObject,
20
+ integerIn,
21
+ isNotEmulated,
22
+ jsonResponse,
23
+ mediaType,
24
+ notEmulated,
25
+ parseJsonText,
26
+ statefulRoute,
27
+ type Commit,
28
+ type EmulatedRequest,
29
+ type NotEmulated,
30
+ type StatefulRoute
31
+ } from '../stateful-emulator.ts'
32
+ import {
33
+ syntheticContentHash,
34
+ type DropboxEmulatorDeleted,
35
+ type DropboxEmulatorEntry,
36
+ type DropboxEmulatorFile,
37
+ type DropboxEmulatorState
38
+ } from './state.ts'
39
+
40
+ /** API version prefix of every Dropbox route. */
41
+ export const dropboxEmulatorBasePath = '/2'
42
+
43
+ /**
44
+ * The fixture error envelopes, byte for byte (`not-found-envelope.ts`, `create-folder-conflict.ts`,
45
+ * and `upload-rev-precondition.ts`), each answered with HTTP 409.
46
+ */
47
+ export const dropboxEmulatorErrorBodies = {
48
+ notFound:
49
+ '{"error_summary": "path/not_found/.", "error": {".tag": "path", "path": {".tag": "not_found"}}}',
50
+ folderConflict:
51
+ '{"error_summary": "path/conflict/folder/..", "error": {".tag": "path", "path": {".tag": "conflict", "conflict": {".tag": "folder"}}}}',
52
+ uploadConflict:
53
+ '{"error_summary": "path/conflict/file/..", "error": {".tag": "path", "path": {"reason": {".tag": "conflict", "conflict": {".tag": "file"}}}}}'
54
+ } as const
55
+
56
+ // Drill-only envelopes (tests only): each disagrees with one claim.
57
+ const drillErrorBodies = {
58
+ notFoundAsPathLookup:
59
+ '{"error_summary": "path_lookup/not_found/.", "error": {".tag": "path_lookup", "path_lookup": {".tag": "not_found"}}}',
60
+ folderConflictAsFile:
61
+ '{"error_summary": "path/conflict/file/..", "error": {".tag": "path", "path": {".tag": "conflict", "conflict": {".tag": "file"}}}}'
62
+ } as const
63
+
64
+ /** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
65
+ export type DropboxEmulatorDrills = {
66
+ /** `list_folder` ignores `limit`: one page with every entry and `has_more: false`. */
67
+ readonly listFolderSinglePage?: boolean
68
+ /** `get_metadata` matches path components case-sensitively. */
69
+ readonly getMetadataCaseSensitive?: boolean
70
+ /** `search/continue_v2` answers the earlier pages' matches again before the new ones. */
71
+ readonly searchRepeatsMatches?: boolean
72
+ /** A missing path answers a `path_lookup/not_found` envelope (still 409). */
73
+ readonly notFoundAsPathLookup?: boolean
74
+ /** A folder create conflict answers `path/conflict/file` instead of `path/conflict/folder`. */
75
+ readonly folderConflictAsFile?: boolean
76
+ /** `delete_v2` keeps no deleted-entry record, so `include_deleted` is not emulated. */
77
+ readonly deleteLeavesNoTombstone?: boolean
78
+ /** `move_v2` gives the moved file a new id. */
79
+ readonly moveMintsNewId?: boolean
80
+ /** An `update` upload naming a stale rev overwrites instead of conflicting. */
81
+ readonly uploadIgnoresRev?: boolean
82
+ }
83
+
84
+ export const dropboxEmulatorDrillKnobs: ReadonlyArray<keyof DropboxEmulatorDrills> = [
85
+ 'listFolderSinglePage',
86
+ 'getMetadataCaseSensitive',
87
+ 'searchRepeatsMatches',
88
+ 'notFoundAsPathLookup',
89
+ 'folderConflictAsFile',
90
+ 'deleteLeavesNoTombstone',
91
+ 'moveMintsNewId',
92
+ 'uploadIgnoresRev'
93
+ ]
94
+
95
+ /** A `list_folder` cursor (runtime data, not state). */
96
+ export type DropboxListCursor = {
97
+ readonly kind: 'list'
98
+ readonly folderId: string
99
+ /** The whole listing as rendered when the cursor was issued; any change is not emulated. */
100
+ readonly fingerprint: string
101
+ readonly offset: number
102
+ readonly limit: number
103
+ }
104
+
105
+ /** A `search_v2` cursor (runtime data, not state). */
106
+ export type DropboxSearchCursor = {
107
+ readonly kind: 'search'
108
+ readonly query: string
109
+ /** Every match as rendered when the cursor was issued; any change is not emulated. */
110
+ readonly fingerprint: string
111
+ readonly offset: number
112
+ readonly maxResults: number
113
+ }
114
+
115
+ export type DropboxCursor = DropboxListCursor | DropboxSearchCursor
116
+
117
+ export type DropboxApiEnv = {
118
+ /** Clock in epoch milliseconds (upload timestamps only). */
119
+ readonly now: () => number
120
+ readonly drills: Readonly<Record<keyof DropboxEmulatorDrills, boolean>>
121
+ /** Issued cursors by value (runtime-only; cleared by reset and seed). */
122
+ readonly cursors: Map<string, DropboxCursor>
123
+ /** Cursor numbers; never reset, so a cursor issued before a reset is never issued again. */
124
+ readonly cursorCounters: { list: number; search: number }
125
+ }
126
+
127
+ type Route = StatefulRoute<DropboxEmulatorState, DropboxApiEnv>
128
+
129
+ /** Origin of the RPC routes. */
130
+ export const dropboxEmulatorApiOrigin = 'https://api.dropboxapi.com'
131
+
132
+ /** Origin of the upload route. */
133
+ export const dropboxEmulatorContentOrigin = 'https://content.dropboxapi.com'
134
+
135
+ const pagingCase = 'dropbox.files.list-folder-cursor-paging'
136
+
137
+ const pathLowerCase = 'dropbox.files.path-lower-lookup'
138
+
139
+ const searchCase = 'dropbox.files.search-continue'
140
+
141
+ const notFoundCase = 'dropbox.errors.not-found-409-envelope'
142
+
143
+ const conflictCase = 'dropbox.files.create-folder-conflict'
144
+
145
+ const deleteCase = 'dropbox.files.delete-then-not-found'
146
+
147
+ const copyCase = 'dropbox.files.copy-move-metadata'
148
+
149
+ const uploadCase = 'dropbox.files.upload-rev-precondition'
150
+
151
+ const evidence = (
152
+ path: string,
153
+ write: boolean,
154
+ caseIds: ReadonlyArray<string>,
155
+ origin: string = dropboxEmulatorApiOrigin
156
+ ) => ({
157
+ method: 'POST',
158
+ path: `${dropboxEmulatorBasePath}${path}`,
159
+ kind: 'connector' as const,
160
+ write,
161
+ caseIds,
162
+ evidence: 'unverified' as const,
163
+ origin
164
+ })
165
+
166
+ // Responses.
167
+
168
+ const json = (status: number, text: string): Response =>
169
+ new Response(text, { status, headers: { 'content-type': 'application/json' } })
170
+
171
+ const ok = (body: Schema.Json): Response => jsonResponse(200, body)
172
+
173
+ const notFound = (env: DropboxApiEnv): Response =>
174
+ json(
175
+ 409,
176
+ env.drills.notFoundAsPathLookup
177
+ ? drillErrorBodies.notFoundAsPathLookup
178
+ : dropboxEmulatorErrorBodies.notFound
179
+ )
180
+
181
+ // Paths.
182
+
183
+ /** A parsed absolute path: its components as sent. */
184
+ type DropboxPath = { readonly components: ReadonlyArray<string> }
185
+
186
+ const pathPattern = /^(?:\/[^/]+)+$/
187
+
188
+ const parsePath = (value: Schema.Json | undefined, label: string): DropboxPath | NotEmulated => {
189
+ if (!Predicate.isString(value) || !pathPattern.test(value)) {
190
+ return notEmulated(
191
+ `${label} must be an absolute /path (the root, ids, and revs are not emulated)`
192
+ )
193
+ }
194
+
195
+ const components = value.split('/').slice(1)
196
+
197
+ return components.some(component => component === '.' || component === '..')
198
+ ? notEmulated(`${label} with . or .. components is not emulated`)
199
+ : { components }
200
+ }
201
+
202
+ const idPattern = /^id:\S+$/
203
+
204
+ const childrenOf = (state: DropboxEmulatorState, folderId: string | null) =>
205
+ state.entries.filter(entry => entry.parentId === folderId)
206
+
207
+ const sameName = (left: string, right: string, caseSensitive: boolean): boolean =>
208
+ caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase()
209
+
210
+ /** The entry at `components` (case-insensitive, like Dropbox, unless `caseSensitive`). */
211
+ const lookup = (
212
+ state: DropboxEmulatorState,
213
+ components: ReadonlyArray<string>,
214
+ caseSensitive = false
215
+ ): DropboxEmulatorEntry | undefined => {
216
+ let current: DropboxEmulatorEntry | undefined
217
+ let parentId: string | null = null
218
+
219
+ for (const component of components) {
220
+ current = childrenOf(state, parentId).find(entry =>
221
+ sameName(entry.name, component, caseSensitive)
222
+ )
223
+
224
+ if (current === undefined) return undefined
225
+
226
+ parentId = current.id
227
+ }
228
+
229
+ return current
230
+ }
231
+
232
+ /**
233
+ * The folder holding `components`: `null` for the root, the folder, or not emulated when it is
234
+ * missing or a file (every fixture names a path whose parent folder exists).
235
+ */
236
+ const parentFolder = (
237
+ state: DropboxEmulatorState,
238
+ components: ReadonlyArray<string>,
239
+ label: string
240
+ ): DropboxEmulatorEntry | null | NotEmulated => {
241
+ if (components.length === 1) return null
242
+
243
+ const parent = lookup(state, components.slice(0, -1))
244
+
245
+ return parent !== undefined && parent.file === null
246
+ ? parent
247
+ : notEmulated(`${label} under a missing parent folder (or a file) is not emulated`)
248
+ }
249
+
250
+ /** Stored names from the root down to the entry. */
251
+ const storedNames = (
252
+ state: DropboxEmulatorState,
253
+ entry: DropboxEmulatorEntry
254
+ ): ReadonlyArray<string> => {
255
+ const names: Array<string> = []
256
+ let current: DropboxEmulatorEntry | undefined = entry
257
+
258
+ while (current !== undefined) {
259
+ names.unshift(current.name)
260
+
261
+ const parentId: string | null = current.parentId
262
+
263
+ current =
264
+ parentId === null ? undefined : state.entries.find(candidate => candidate.id === parentId)
265
+ }
266
+
267
+ return names
268
+ }
269
+
270
+ const pathOf = (names: ReadonlyArray<string>): string => names.map(name => `/${name}`).join('')
271
+
272
+ // Metadata, in the fixture key order.
273
+
274
+ const fileMetadata = (
275
+ entry: DropboxEmulatorEntry,
276
+ file: DropboxEmulatorFile,
277
+ display: string
278
+ ): Schema.JsonObject => ({
279
+ '.tag': 'file',
280
+ name: entry.name,
281
+ path_lower: display.toLowerCase(),
282
+ path_display: display,
283
+ id: entry.id,
284
+ client_modified: file.clientModified,
285
+ server_modified: file.serverModified,
286
+ rev: file.rev,
287
+ size: file.size,
288
+ is_downloadable: true,
289
+ content_hash: file.contentHash
290
+ })
291
+
292
+ /** Folder metadata; `create_folder_v2` answers it without `.tag`, as its fixtures record. */
293
+ const folderMetadata = (
294
+ entry: DropboxEmulatorEntry,
295
+ display: string,
296
+ tagged: boolean
297
+ ): Schema.JsonObject => {
298
+ const body = {
299
+ name: entry.name,
300
+ path_lower: display.toLowerCase(),
301
+ path_display: display,
302
+ id: entry.id
303
+ }
304
+
305
+ return tagged ? { '.tag': 'folder', ...body } : body
306
+ }
307
+
308
+ const impliedRefusal = (): NotEmulated =>
309
+ notEmulated(
310
+ 'an answer showing a folder no fixture shows (an implied seed folder) is not emulated'
311
+ )
312
+
313
+ /** Metadata at the stored display path, or not emulated for an implied entry. */
314
+ const metadataOf = (
315
+ state: DropboxEmulatorState,
316
+ entry: DropboxEmulatorEntry
317
+ ): Schema.JsonObject | NotEmulated => {
318
+ if (entry.implied) return impliedRefusal()
319
+
320
+ const display = pathOf(storedNames(state, entry))
321
+
322
+ return entry.file === null
323
+ ? folderMetadata(entry, display, true)
324
+ : fileMetadata(entry, entry.file, display)
325
+ }
326
+
327
+ /** Metadata of every entry, or not emulated when any is implied. */
328
+ const metadataOfAll = (
329
+ state: DropboxEmulatorState,
330
+ entries: ReadonlyArray<DropboxEmulatorEntry>
331
+ ): ReadonlyArray<Schema.JsonObject> | NotEmulated => {
332
+ const rendered: Array<Schema.JsonObject> = []
333
+
334
+ for (const entry of entries) {
335
+ const metadata = metadataOf(state, entry)
336
+
337
+ if (isNotEmulated(metadata)) return metadata
338
+
339
+ rendered.push(metadata)
340
+ }
341
+
342
+ return rendered
343
+ }
344
+
345
+ const deletedMetadata = (deleted: DropboxEmulatorDeleted): Schema.JsonObject => ({
346
+ '.tag': 'deleted',
347
+ name: deleted.name,
348
+ path_lower: deleted.pathLower,
349
+ path_display: deleted.pathDisplay
350
+ })
351
+
352
+ // Counters and timestamps (minted values).
353
+
354
+ const pad = (value: number, width: number): string => String(value).padStart(width, '0')
355
+
356
+ const nextId = (state: DropboxEmulatorState): string => {
357
+ const number = state.counters.nextIdNumber
358
+
359
+ state.counters = { ...state.counters, nextIdNumber: number + 1 }
360
+
361
+ return `id:SyntheticEntry${pad(number, 8)}`
362
+ }
363
+
364
+ const nextRev = (state: DropboxEmulatorState): string => {
365
+ const number = state.counters.nextRevNumber
366
+
367
+ state.counters = { ...state.counters, nextRevNumber: number + 1 }
368
+
369
+ return `a1b2c3d4e5f6${pad(number, 4)}`
370
+ }
371
+
372
+ /** A Dropbox timestamp (`2026-09-29T14:00:00Z`) from the emulator clock. */
373
+ const nowTimestamp = (env: DropboxApiEnv): string =>
374
+ new Date(Math.floor(env.now() / 1000) * 1000).toISOString().replace('.000Z', 'Z')
375
+
376
+ /** Issue a cursor (in a commit): a value never issued before, registered until reset or seed. */
377
+ const issueCursor = (env: DropboxApiEnv, cursor: DropboxCursor): string => {
378
+ const number = env.cursorCounters[cursor.kind]
379
+
380
+ env.cursorCounters[cursor.kind] = number + 1
381
+
382
+ const value = `AAHsynthetic${cursor.kind === 'list' ? 'List' : 'Search'}Cursor${pad(number, 4)}`
383
+
384
+ env.cursors.set(value, cursor)
385
+
386
+ return value
387
+ }
388
+
389
+ // Request-shape checks shared by the routes.
390
+
391
+ const withoutQuery = (request: EmulatedRequest): NotEmulated | undefined =>
392
+ request.query.size > 0
393
+ ? notEmulated('query parameters are not emulated on Dropbox routes')
394
+ : undefined
395
+
396
+ const rpcBody = (
397
+ request: EmulatedRequest,
398
+ route: string,
399
+ required: ReadonlyArray<string>,
400
+ optional: ReadonlyArray<string> = []
401
+ ): Schema.JsonObject | NotEmulated =>
402
+ withoutQuery(request) ?? exactObject(request.json, `the ${route} body`, required, optional)
403
+
404
+ const falseFlag = (value: Schema.Json | undefined, label: string): NotEmulated | undefined =>
405
+ value === false ? undefined : notEmulated(`${label} other than false is not emulated`)
406
+
407
+ const cursorValue = (request: EmulatedRequest, route: string): string | NotEmulated => {
408
+ const body = rpcBody(request, route, ['cursor'])
409
+
410
+ if (isNotEmulated(body)) return body
411
+
412
+ return Predicate.isString(body.cursor) && body.cursor.length > 0
413
+ ? body.cursor
414
+ : notEmulated('cursor must be a non-empty string')
415
+ }
416
+
417
+ /** A read-only commit. */
418
+ const answer =
419
+ (response: () => Response): Commit =>
420
+ () =>
421
+ response()
422
+
423
+ // Routes.
424
+
425
+ type GetMetadataInput = { readonly path: DropboxPath; readonly includeDeleted: boolean }
426
+
427
+ const getMetadata: Route = statefulRoute(
428
+ evidence('/files/get_metadata', false, [
429
+ pathLowerCase,
430
+ notFoundCase,
431
+ conflictCase,
432
+ deleteCase,
433
+ copyCase,
434
+ uploadCase
435
+ ]),
436
+ 'json',
437
+ (request): GetMetadataInput | NotEmulated => {
438
+ const body = rpcBody(request, 'get_metadata', ['path'], ['include_deleted'])
439
+
440
+ if (isNotEmulated(body)) return body
441
+
442
+ if (body.include_deleted !== undefined && body.include_deleted !== true) {
443
+ return notEmulated('include_deleted other than true is not emulated')
444
+ }
445
+
446
+ const path = parsePath(body.path, 'path')
447
+
448
+ return isNotEmulated(path) ? path : { path, includeDeleted: body.include_deleted === true }
449
+ },
450
+ (state, input, { env }) => {
451
+ const entry = lookup(state, input.path.components, env.drills.getMetadataCaseSensitive)
452
+
453
+ if (entry === undefined) {
454
+ const parent = parentFolder(state, input.path.components, 'get_metadata of a path')
455
+
456
+ if (isNotEmulated(parent)) return parent
457
+
458
+ if (!input.includeDeleted) return answer(() => notFound(env))
459
+
460
+ const lower = pathOf(input.path.components).toLowerCase()
461
+ const deleted = state.deleted.find(candidate => candidate.pathLower === lower)
462
+
463
+ return deleted === undefined
464
+ ? notEmulated('include_deleted for a path without a recorded delete is not emulated')
465
+ : answer(() => ok(deletedMetadata(deleted)))
466
+ }
467
+
468
+ if (input.includeDeleted) {
469
+ return notEmulated('include_deleted for an existing entry is not emulated')
470
+ }
471
+
472
+ if (entry.file === null) {
473
+ return notEmulated('get_metadata of a folder is not emulated (no fixture records it)')
474
+ }
475
+
476
+ if (entry.implied) return impliedRefusal()
477
+
478
+ // Only the last component keeps the stored casing; the rest echo the request.
479
+ const display = pathOf([...input.path.components.slice(0, -1), entry.name])
480
+ const file = entry.file
481
+
482
+ return answer(() => ok(fileMetadata(entry, file, display)))
483
+ }
484
+ )
485
+
486
+ type ListInput = { readonly path: DropboxPath; readonly limit: number }
487
+
488
+ /** The whole listing of a folder, rendered, or not emulated (an implied child). */
489
+ const listingOf = (
490
+ state: DropboxEmulatorState,
491
+ folderId: string
492
+ ): ReadonlyArray<Schema.JsonObject> | NotEmulated =>
493
+ metadataOfAll(state, childrenOf(state, folderId))
494
+
495
+ /** A commit answering one listing page and issuing its cursor. */
496
+ const listPage =
497
+ (
498
+ env: DropboxApiEnv,
499
+ folderId: string,
500
+ listing: ReadonlyArray<Schema.JsonObject>,
501
+ offset: number,
502
+ limit: number
503
+ ): Commit =>
504
+ () => {
505
+ const page = listing.slice(offset, offset + limit)
506
+ const end = offset + page.length
507
+
508
+ const cursor = issueCursor(env, {
509
+ kind: 'list',
510
+ folderId,
511
+ fingerprint: JSON.stringify(listing),
512
+ offset: end,
513
+ limit
514
+ })
515
+
516
+ return ok({ entries: [...page], cursor, has_more: end < listing.length })
517
+ }
518
+
519
+ const listFolder: Route = statefulRoute(
520
+ evidence('/files/list_folder', false, [pagingCase]),
521
+ 'json',
522
+ (request): ListInput | NotEmulated => {
523
+ const body = rpcBody(request, 'list_folder', ['path', 'limit'])
524
+
525
+ if (isNotEmulated(body)) return body
526
+
527
+ const path = parsePath(body.path, 'path')
528
+
529
+ if (isNotEmulated(path)) return path
530
+
531
+ const limit = integerIn(body.limit, 'limit', 1, 2000)
532
+
533
+ return isNotEmulated(limit) ? limit : { path, limit }
534
+ },
535
+ (state, input, { env }) => {
536
+ const folder = lookup(state, input.path.components)
537
+
538
+ if (folder === undefined || folder.file !== null) {
539
+ return notEmulated('list_folder of a missing path or a file is not emulated')
540
+ }
541
+
542
+ const listing = listingOf(state, folder.id)
543
+
544
+ if (isNotEmulated(listing)) return listing
545
+
546
+ // Every recorded listing has entries; an empty one (for example the leftover lookup's empty
547
+ // work folder) is no fixture's answer.
548
+ if (listing.length === 0) {
549
+ return notEmulated('a listing of an empty folder is not emulated (no fixture records one)')
550
+ }
551
+
552
+ const limit = env.drills.listFolderSinglePage ? Math.max(listing.length, 1) : input.limit
553
+
554
+ return listPage(env, folder.id, listing, 0, limit)
555
+ }
556
+ )
557
+
558
+ const listFolderContinue: Route = statefulRoute(
559
+ evidence('/files/list_folder/continue', false, [pagingCase]),
560
+ 'json',
561
+ request => cursorValue(request, 'list_folder/continue'),
562
+ (state, value, { env }) => {
563
+ const cursor = env.cursors.get(value)
564
+
565
+ if (cursor === undefined || cursor.kind !== 'list') {
566
+ return notEmulated(
567
+ 'a list cursor this emulator did not issue since the last reset is not emulated'
568
+ )
569
+ }
570
+
571
+ const folder = state.entries.find(entry => entry.id === cursor.folderId)
572
+ const listing = folder === undefined ? undefined : listingOf(state, folder.id)
573
+
574
+ if (
575
+ folder === undefined ||
576
+ listing === undefined ||
577
+ isNotEmulated(listing) ||
578
+ JSON.stringify(listing) !== cursor.fingerprint
579
+ ) {
580
+ return notEmulated(
581
+ 'continuing a listing whose folder changed since its cursor is not emulated'
582
+ )
583
+ }
584
+
585
+ if (cursor.offset >= listing.length) {
586
+ return notEmulated('continuing a finished listing (changes since it) is not emulated')
587
+ }
588
+
589
+ return listPage(env, folder.id, listing, cursor.offset, cursor.limit)
590
+ }
591
+ )
592
+
593
+ type SearchInput = { readonly query: string; readonly maxResults: number }
594
+
595
+ /** Every match of `query`, rendered as the fixture's match objects, or not emulated. */
596
+ const searchMatches = (
597
+ state: DropboxEmulatorState,
598
+ query: string
599
+ ): ReadonlyArray<Schema.JsonObject> | NotEmulated => {
600
+ const lower = query.toLowerCase()
601
+ const matches = state.entries.filter(entry => entry.name.toLowerCase().includes(lower))
602
+
603
+ if (matches.length === 0) return notEmulated('a search without matches is not emulated')
604
+
605
+ if (matches.some(entry => entry.file === null)) {
606
+ return notEmulated('a search matching a folder is not emulated (the fixture matches files)')
607
+ }
608
+
609
+ const rendered = metadataOfAll(state, matches)
610
+
611
+ return isNotEmulated(rendered)
612
+ ? rendered
613
+ : rendered.map(metadata => ({
614
+ match_type: { '.tag': 'filename' },
615
+ metadata: { '.tag': 'metadata', metadata }
616
+ }))
617
+ }
618
+
619
+ /** A commit answering one search page (the fixtures' last page carries no cursor). */
620
+ const searchPage =
621
+ (
622
+ env: DropboxApiEnv,
623
+ query: string,
624
+ matches: ReadonlyArray<Schema.JsonObject>,
625
+ start: number,
626
+ offset: number,
627
+ maxResults: number
628
+ ): Commit =>
629
+ () => {
630
+ const end = Math.min(offset + maxResults, matches.length)
631
+ const hasMore = end < matches.length
632
+ const body = { matches: matches.slice(start, end), has_more: hasMore }
633
+
634
+ if (!hasMore) return ok(body)
635
+
636
+ const cursor = issueCursor(env, {
637
+ kind: 'search',
638
+ query,
639
+ fingerprint: JSON.stringify(matches),
640
+ offset: end,
641
+ maxResults
642
+ })
643
+
644
+ return ok({ ...body, cursor })
645
+ }
646
+
647
+ const search: Route = statefulRoute(
648
+ evidence('/files/search_v2', false, [searchCase]),
649
+ 'json',
650
+ (request): SearchInput | NotEmulated => {
651
+ const body = rpcBody(request, 'search_v2', ['query', 'options'])
652
+
653
+ if (isNotEmulated(body)) return body
654
+
655
+ if (!Predicate.isString(body.query) || body.query.trim().length === 0) {
656
+ return notEmulated('query must be a non-empty string')
657
+ }
658
+
659
+ const options = exactObject(body.options, 'options', ['max_results', 'filename_only'])
660
+
661
+ if (isNotEmulated(options)) return options
662
+
663
+ if (options.filename_only !== true) {
664
+ return notEmulated('options.filename_only other than true is not emulated')
665
+ }
666
+
667
+ const maxResults = integerIn(options.max_results, 'options.max_results', 1, 1000)
668
+
669
+ return isNotEmulated(maxResults) ? maxResults : { query: body.query, maxResults }
670
+ },
671
+ (state, input, { env }) => {
672
+ const matches = searchMatches(state, input.query)
673
+
674
+ return isNotEmulated(matches)
675
+ ? matches
676
+ : searchPage(env, input.query, matches, 0, 0, input.maxResults)
677
+ }
678
+ )
679
+
680
+ const searchContinue: Route = statefulRoute(
681
+ evidence('/files/search/continue_v2', false, [searchCase]),
682
+ 'json',
683
+ request => cursorValue(request, 'search/continue_v2'),
684
+ (state, value, { env }) => {
685
+ const cursor = env.cursors.get(value)
686
+
687
+ if (cursor === undefined || cursor.kind !== 'search') {
688
+ return notEmulated(
689
+ 'a search cursor this emulator did not issue since the last reset is not emulated'
690
+ )
691
+ }
692
+
693
+ const matches = searchMatches(state, cursor.query)
694
+
695
+ if (isNotEmulated(matches) || JSON.stringify(matches) !== cursor.fingerprint) {
696
+ return notEmulated(
697
+ 'continuing a search whose matches changed since its cursor is not emulated'
698
+ )
699
+ }
700
+
701
+ const start = env.drills.searchRepeatsMatches ? 0 : cursor.offset
702
+
703
+ return searchPage(env, cursor.query, matches, start, cursor.offset, cursor.maxResults)
704
+ }
705
+ )
706
+
707
+ const createFolder: Route = statefulRoute(
708
+ evidence('/files/create_folder_v2', true, [conflictCase, deleteCase, copyCase, uploadCase]),
709
+ 'json',
710
+ (request): DropboxPath | NotEmulated => {
711
+ const body = rpcBody(request, 'create_folder_v2', ['path', 'autorename'])
712
+
713
+ if (isNotEmulated(body)) return body
714
+
715
+ return falseFlag(body.autorename, 'autorename') ?? parsePath(body.path, 'path')
716
+ },
717
+ (state, path, { env }) => {
718
+ const parent = parentFolder(state, path.components, 'create_folder_v2')
719
+
720
+ if (isNotEmulated(parent)) return parent
721
+
722
+ const existing = lookup(state, path.components)
723
+
724
+ if (existing !== undefined) {
725
+ if (existing.file !== null) {
726
+ return notEmulated('create_folder_v2 over a file is not emulated')
727
+ }
728
+
729
+ return answer(() =>
730
+ json(
731
+ 409,
732
+ env.drills.folderConflictAsFile
733
+ ? drillErrorBodies.folderConflictAsFile
734
+ : dropboxEmulatorErrorBodies.folderConflict
735
+ )
736
+ )
737
+ }
738
+
739
+ return () => {
740
+ const created: DropboxEmulatorEntry = {
741
+ id: nextId(state),
742
+ parentId: parent?.id ?? null,
743
+ name: path.components.at(-1) ?? '',
744
+ file: null,
745
+ implied: false
746
+ }
747
+
748
+ state.entries = [...state.entries, created]
749
+
750
+ return ok({ metadata: folderMetadata(created, pathOf(storedNames(state, created)), false) })
751
+ }
752
+ }
753
+ )
754
+
755
+ /** The entry and everything under it. */
756
+ const subtreeOf = (
757
+ state: DropboxEmulatorState,
758
+ root: DropboxEmulatorEntry
759
+ ): ReadonlyArray<DropboxEmulatorEntry> => [
760
+ root,
761
+ ...childrenOf(state, root.id).flatMap(child => subtreeOf(state, child))
762
+ ]
763
+
764
+ type Target = { readonly id: string } | { readonly path: DropboxPath }
765
+
766
+ const deleteEntry: Route = statefulRoute(
767
+ evidence('/files/delete_v2', true, [conflictCase, deleteCase, copyCase, uploadCase]),
768
+ 'json',
769
+ (request): Target | NotEmulated => {
770
+ const body = rpcBody(request, 'delete_v2', ['path'])
771
+
772
+ if (isNotEmulated(body)) return body
773
+
774
+ if (Predicate.isString(body.path) && idPattern.test(body.path)) return { id: body.path }
775
+
776
+ const path = parsePath(body.path, 'path')
777
+
778
+ return isNotEmulated(path) ? path : { path }
779
+ },
780
+ (state, target, { env }) => {
781
+ const entry =
782
+ 'id' in target
783
+ ? state.entries.find(candidate => candidate.id === target.id)
784
+ : lookup(state, target.path.components)
785
+
786
+ if (entry === undefined) {
787
+ return notEmulated('delete_v2 of a missing entry is not emulated (no fixture records it)')
788
+ }
789
+
790
+ if (entry.file !== null) {
791
+ return notEmulated('delete_v2 of a file is not emulated (the fixtures delete folders)')
792
+ }
793
+
794
+ const removed = subtreeOf(state, entry)
795
+
796
+ if (removed.some(candidate => candidate.implied)) return impliedRefusal()
797
+
798
+ const metadata = metadataOf(state, entry)
799
+
800
+ if (isNotEmulated(metadata)) return metadata
801
+
802
+ return () => {
803
+ const removedIds = new Set(removed.map(candidate => candidate.id))
804
+
805
+ // Only an empty folder's delete has a recorded `include_deleted` answer.
806
+ if (removed.length === 1 && !env.drills.deleteLeavesNoTombstone) {
807
+ const display = pathOf(storedNames(state, entry))
808
+ const lower = display.toLowerCase()
809
+
810
+ state.deleted = [
811
+ ...state.deleted.filter(record => record.pathLower !== lower),
812
+ { name: entry.name, pathLower: lower, pathDisplay: display }
813
+ ]
814
+ }
815
+
816
+ state.entries = state.entries.filter(candidate => !removedIds.has(candidate.id))
817
+
818
+ return ok({ metadata })
819
+ }
820
+ }
821
+ )
822
+
823
+ type RelocationInput = { readonly from: DropboxPath; readonly to: DropboxPath }
824
+
825
+ const relocationInput = (
826
+ request: EmulatedRequest,
827
+ route: string
828
+ ): RelocationInput | NotEmulated => {
829
+ const body = rpcBody(request, route, ['from_path', 'to_path', 'autorename'])
830
+
831
+ if (isNotEmulated(body)) return body
832
+
833
+ const autorename = falseFlag(body.autorename, 'autorename')
834
+
835
+ if (autorename !== undefined) return autorename
836
+
837
+ const from = parsePath(body.from_path, 'from_path')
838
+
839
+ if (isNotEmulated(from)) return from
840
+
841
+ const to = parsePath(body.to_path, 'to_path')
842
+
843
+ return isNotEmulated(to) ? to : { from, to }
844
+ }
845
+
846
+ type Relocation = {
847
+ readonly source: DropboxEmulatorEntry
848
+ readonly file: DropboxEmulatorFile
849
+ readonly parent: DropboxEmulatorEntry | null
850
+ }
851
+
852
+ /** The source file and destination folder of a copy or move, or not emulated. */
853
+ const relocation = (
854
+ state: DropboxEmulatorState,
855
+ input: RelocationInput,
856
+ route: string
857
+ ): Relocation | NotEmulated => {
858
+ const source = lookup(state, input.from.components)
859
+
860
+ if (source === undefined || source.file === null || source.implied) {
861
+ return notEmulated(`${route} of a missing entry or a folder is not emulated (files only)`)
862
+ }
863
+
864
+ const parent = parentFolder(state, input.to.components, route)
865
+
866
+ if (isNotEmulated(parent)) return parent
867
+
868
+ return lookup(state, input.to.components) === undefined
869
+ ? { source, file: source.file, parent }
870
+ : notEmulated(`${route} onto an existing entry is not emulated`)
871
+ }
872
+
873
+ const copy: Route = statefulRoute(
874
+ evidence('/files/copy_v2', true, [copyCase]),
875
+ 'json',
876
+ request => relocationInput(request, 'copy_v2'),
877
+ (state, input) => {
878
+ const found = relocation(state, input, 'copy_v2')
879
+
880
+ if (isNotEmulated(found)) return found
881
+
882
+ // The copy keeps the source's size, content hash, and timestamps, as the fixture records.
883
+ return () => {
884
+ const file = { ...found.file, rev: nextRev(state) }
885
+
886
+ const created: DropboxEmulatorEntry = {
887
+ id: nextId(state),
888
+ parentId: found.parent?.id ?? null,
889
+ name: input.to.components.at(-1) ?? '',
890
+ file,
891
+ implied: false
892
+ }
893
+
894
+ state.entries = [...state.entries, created]
895
+
896
+ return ok({ metadata: fileMetadata(created, file, pathOf(storedNames(state, created))) })
897
+ }
898
+ }
899
+ )
900
+
901
+ const move: Route = statefulRoute(
902
+ evidence('/files/move_v2', true, [copyCase]),
903
+ 'json',
904
+ request => relocationInput(request, 'move_v2'),
905
+ (state, input, { env }) => {
906
+ const found = relocation(state, input, 'move_v2')
907
+
908
+ if (isNotEmulated(found)) return found
909
+
910
+ // The moved file keeps its id and timestamps and gets a new rev, as the fixture records.
911
+ return () => {
912
+ const file = { ...found.file, rev: nextRev(state) }
913
+
914
+ const moved: DropboxEmulatorEntry = {
915
+ id: env.drills.moveMintsNewId ? nextId(state) : found.source.id,
916
+ parentId: found.parent?.id ?? null,
917
+ name: input.to.components.at(-1) ?? '',
918
+ file,
919
+ implied: false
920
+ }
921
+
922
+ state.entries = state.entries.map(entry => (entry.id === found.source.id ? moved : entry))
923
+
924
+ return ok({ metadata: fileMetadata(moved, file, pathOf(storedNames(state, moved))) })
925
+ }
926
+ }
927
+ )
928
+
929
+ type UploadInput =
930
+ | { readonly mode: 'add'; readonly path: DropboxPath; readonly bytes: Uint8Array }
931
+ | {
932
+ readonly mode: 'update'
933
+ readonly id: string
934
+ readonly rev: string
935
+ readonly bytes: Uint8Array
936
+ }
937
+
938
+ const revPattern = /^[0-9a-f]{9,}$/
939
+
940
+ /** A file written by an upload (in a commit): a new rev, its size, hash, and clock timestamps. */
941
+ const uploadedFile = (
942
+ state: DropboxEmulatorState,
943
+ at: string,
944
+ bytes: Uint8Array
945
+ ): DropboxEmulatorFile => {
946
+ const rev = nextRev(state)
947
+
948
+ return {
949
+ rev,
950
+ size: bytes.byteLength,
951
+ contentHash: syntheticContentHash(rev.slice(-2)),
952
+ clientModified: at,
953
+ serverModified: at
954
+ }
955
+ }
956
+
957
+ const upload: Route = statefulRoute(
958
+ evidence('/files/upload', true, [uploadCase], dropboxEmulatorContentOrigin),
959
+ 'bytes',
960
+ (request): UploadInput | NotEmulated => {
961
+ const noQuery = withoutQuery(request)
962
+
963
+ if (noQuery !== undefined) return noQuery
964
+
965
+ if (mediaType(request.header('content-type')) !== 'application/octet-stream') {
966
+ return notEmulated('the upload takes a content-type: application/octet-stream body')
967
+ }
968
+
969
+ const header = request.header('dropbox-api-arg')
970
+ const arg = header === undefined ? undefined : parseJsonText(header)
971
+
972
+ if (arg === undefined) return notEmulated('the upload needs a JSON Dropbox-API-Arg header')
973
+
974
+ const fields = exactObject(arg, 'Dropbox-API-Arg', [
975
+ 'path',
976
+ 'mode',
977
+ 'autorename',
978
+ 'strict_conflict'
979
+ ])
980
+
981
+ if (isNotEmulated(fields)) return fields
982
+
983
+ const autorename = falseFlag(fields.autorename, 'autorename')
984
+
985
+ if (autorename !== undefined) return autorename
986
+
987
+ if (fields.strict_conflict !== true) {
988
+ return notEmulated('strict_conflict other than true is not emulated')
989
+ }
990
+
991
+ const bytes = request.bytes ?? new Uint8Array()
992
+
993
+ if (fields.mode === 'add') {
994
+ const path = parsePath(fields.path, 'path')
995
+
996
+ return isNotEmulated(path) ? path : { mode: 'add', path, bytes }
997
+ }
998
+
999
+ const mode = exactObject(fields.mode, 'mode', ['.tag', 'update'])
1000
+
1001
+ if (isNotEmulated(mode)) return mode
1002
+
1003
+ if (
1004
+ mode['.tag'] !== 'update' ||
1005
+ !Predicate.isString(mode.update) ||
1006
+ !revPattern.test(mode.update)
1007
+ ) {
1008
+ return notEmulated(
1009
+ 'mode other than add or { ".tag": "update", update: <rev> } is not emulated'
1010
+ )
1011
+ }
1012
+
1013
+ return Predicate.isString(fields.path) && idPattern.test(fields.path)
1014
+ ? { mode: 'update', id: fields.path, rev: mode.update, bytes }
1015
+ : notEmulated('an update upload addresses the file by its id: form only')
1016
+ },
1017
+ (state, input, { env }) => {
1018
+ if (input.mode === 'add') {
1019
+ const parent = parentFolder(state, input.path.components, 'the upload')
1020
+
1021
+ if (isNotEmulated(parent)) return parent
1022
+
1023
+ const existing = lookup(state, input.path.components)
1024
+
1025
+ if (existing !== undefined) {
1026
+ return existing.file === null
1027
+ ? notEmulated('an add upload onto a folder is not emulated')
1028
+ : answer(() => json(409, dropboxEmulatorErrorBodies.uploadConflict))
1029
+ }
1030
+
1031
+ return () => {
1032
+ // Read the clock before any write: a failing clock writes nothing.
1033
+ const at = nowTimestamp(env)
1034
+ const file = uploadedFile(state, at, input.bytes)
1035
+
1036
+ const created: DropboxEmulatorEntry = {
1037
+ id: nextId(state),
1038
+ parentId: parent?.id ?? null,
1039
+ name: input.path.components.at(-1) ?? '',
1040
+ file,
1041
+ implied: false
1042
+ }
1043
+
1044
+ state.entries = [...state.entries, created]
1045
+
1046
+ return ok(fileMetadata(created, file, pathOf(storedNames(state, created))))
1047
+ }
1048
+ }
1049
+
1050
+ const existing = state.entries.find(entry => entry.id === input.id)
1051
+
1052
+ if (existing === undefined || existing.file === null || existing.implied) {
1053
+ return notEmulated('an update upload of a missing file or a folder is not emulated')
1054
+ }
1055
+
1056
+ if (existing.file.rev !== input.rev && !env.drills.uploadIgnoresRev) {
1057
+ return answer(() => json(409, dropboxEmulatorErrorBodies.uploadConflict))
1058
+ }
1059
+
1060
+ return () => {
1061
+ const at = nowTimestamp(env)
1062
+ const file = uploadedFile(state, at, input.bytes)
1063
+ const updated: DropboxEmulatorEntry = { ...existing, file }
1064
+
1065
+ state.entries = state.entries.map(entry => (entry.id === existing.id ? updated : entry))
1066
+
1067
+ return ok(fileMetadata(updated, file, pathOf(storedNames(state, updated))))
1068
+ }
1069
+ }
1070
+ )
1071
+
1072
+ /** The route table: evidence plus handlers. `dropboxEmulatorRoutes` is its evidence part. */
1073
+ export const dropboxApiRoutes: ReadonlyArray<Route> = [
1074
+ listFolder,
1075
+ listFolderContinue,
1076
+ getMetadata,
1077
+ search,
1078
+ searchContinue,
1079
+ createFolder,
1080
+ deleteEntry,
1081
+ copy,
1082
+ move,
1083
+ upload
1084
+ ]