@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,736 @@
1
+ /**
2
+ * Todoist emulator API: the route table (evidence, query allowlist, handler) and route matching
3
+ * (internal; re-exported by `src/todoist.ts`, which runs the table on the shared stateful wrapper,
4
+ * `src/stateful-emulator.ts`, in its resolved mode).
5
+ *
6
+ * Only the routes the seven Todoist conformance cases (and their cleanup) need are emulated, with
7
+ * wire shapes copied from the synthetic fixtures; everything else answers the wrapper's ledgered
8
+ * 400 not-emulated. Each route lists the cases whose claims it follows. The project listing that
9
+ * the read-only leftover lookup (`findTodoistConformanceLeftovers`) sends has no fixture, so it is
10
+ * not emulated: the lookup fails, and runners print their lookup-failed WARN.
11
+ *
12
+ * @experimental
13
+ */
14
+ import { Predicate } from 'effect'
15
+ import type * as Schema from 'effect/Schema'
16
+ import {
17
+ isNotEmulated,
18
+ notEmulated,
19
+ statefulRoute,
20
+ type Commit,
21
+ type EmulatedRequest,
22
+ type NotEmulated,
23
+ type StatefulRoute
24
+ } from '../stateful-emulator.ts'
25
+ import {
26
+ isTodoistMintedId,
27
+ makeTodoistProject,
28
+ makeTodoistTask,
29
+ todoistIdPattern,
30
+ todoistMintedProjectPrefix,
31
+ todoistMintedTaskPrefix,
32
+ todoistPagingProjectId,
33
+ type TodoistEmulatorDue,
34
+ type TodoistEmulatorProject,
35
+ type TodoistEmulatorState,
36
+ type TodoistEmulatorTask
37
+ } from './state.ts'
38
+
39
+ /** API v1 prefix of every Todoist route. */
40
+ export const todoistEmulatorBasePath = '/api/v1'
41
+
42
+ /** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
43
+ export type TodoistEmulatorDrills = {
44
+ /** `true`: a task-listing cursor leads back to the first page (tasks repeat across pages). */
45
+ readonly cursorRestarts?: boolean
46
+ /** `true`: 404 bodies omit the `error` message. */
47
+ readonly notFoundWithoutError?: boolean
48
+ /** `true`: tasks answer their labels as label ids instead of names. */
49
+ readonly taskLabelsAsIds?: boolean
50
+ /** `true`: task listings include closed tasks. */
51
+ readonly listIncludesClosed?: boolean
52
+ /** `true`: `due_date` / `due_datetime` are accepted but not applied (`due` stays `null`). */
53
+ readonly ignoreDue?: boolean
54
+ /** `true`: a project create answers `parent_id: null` (the project still has its parent). */
55
+ readonly createOmitsParent?: boolean
56
+ /** `true`: deleting a project keeps its tasks. */
57
+ readonly deleteKeepsTasks?: boolean
58
+ }
59
+
60
+ /** Where a task-listing cursor leads (runtime data, like the ledger; not in the state). */
61
+ export type TodoistCursor = {
62
+ readonly id: string
63
+ readonly projectId: string
64
+ readonly limit: string
65
+ readonly offset: number
66
+ }
67
+
68
+ export type TodoistApiEnv = {
69
+ /** Clock in epoch milliseconds (timestamps of created and closed items). */
70
+ readonly now: () => number
71
+ readonly drills: Required<TodoistEmulatorDrills>
72
+ /** Task-listing cursors by id (runtime-only; cleared by reset and seed). */
73
+ readonly cursors: Map<string, TodoistCursor>
74
+ readonly cursorCounter: { next: number }
75
+ }
76
+
77
+ type RouteRequest = {
78
+ /** Path parameters (Todoist ids, matched raw). */
79
+ readonly params: Readonly<Record<string, string>>
80
+ readonly query: URLSearchParams
81
+ /** Parsed JSON body; `undefined` when absent. */
82
+ readonly body: Schema.Json | undefined
83
+ /** The request carried a non-empty body. */
84
+ readonly hasBody: boolean
85
+ readonly contentType: string | null
86
+ }
87
+
88
+ /**
89
+ * A route handler (the wrapper's plan): it validates the request and the state without writing
90
+ * (not emulated), then returns the commit that performs every write.
91
+ */
92
+ type RouteHandler = (
93
+ state: TodoistEmulatorState,
94
+ request: RouteRequest,
95
+ env: TodoistApiEnv
96
+ ) => Commit | NotEmulated
97
+
98
+ type TodoistApiRoute = {
99
+ readonly route: StatefulRoute<TodoistEmulatorState, TodoistApiEnv>
100
+ /** Query parameters the route emulates; any other (or a repeated) key is not emulated. */
101
+ readonly queryKeys: ReadonlyArray<string>
102
+ }
103
+
104
+ const pagingCase = 'todoist.tasks.list-cursor-paging'
105
+
106
+ const notFoundCase = 'todoist.errors.not-found-envelope'
107
+
108
+ const labelsCase = 'todoist.labels.task-labels-are-names'
109
+
110
+ const lifecycleCase = 'todoist.tasks.lifecycle-close'
111
+
112
+ const dueCase = 'todoist.tasks.due-dates'
113
+
114
+ const parentCase = 'todoist.projects.parent-id'
115
+
116
+ const deleteCase = 'todoist.projects.delete-then-not-found'
117
+
118
+ /** Every write case creates, reads, and deletes its own project. */
119
+ const projectCases = [lifecycleCase, dueCase, parentCase, deleteCase]
120
+
121
+ /** The task listing page size the fixtures send (the only one emulated), and the largest
122
+ * unlimited listing they record. */
123
+ const taskLimit = 2
124
+
125
+ /** The `limit` the label listing fixture sends (the largest one emulated). */
126
+ const maxListLimit = 200
127
+
128
+ /** The only due values the due-dates fixture records, with the due objects it answers. */
129
+ const recordedDueDate = '2030-01-15'
130
+
131
+ const recordedDueDatetime = '2030-01-15T12:00:00Z'
132
+
133
+ const dueOfDate: TodoistEmulatorDue = {
134
+ date: recordedDueDate,
135
+ timezone: null,
136
+ string: 'Jan 15 2030',
137
+ lang: 'en',
138
+ is_recurring: false
139
+ }
140
+
141
+ const dueOfDatetime: TodoistEmulatorDue = {
142
+ date: recordedDueDatetime,
143
+ timezone: 'UTC',
144
+ string: 'Jan 15 2030 12:00',
145
+ lang: 'en',
146
+ is_recurring: false
147
+ }
148
+
149
+ /**
150
+ * Case project names: `yolk-conformance-<runId>-<suffix>` with a conformance run id (`run-` then
151
+ * lower-case letters, digits, and inner hyphens, at most 40 characters) and a recorded suffix.
152
+ */
153
+ const caseProjectNamePattern =
154
+ /^yolk-conformance-(run-[a-z0-9]+(?:-[a-z0-9]+)*)-(lifecycle|due|parent|delete)$/
155
+
156
+ const json = (status: number, body: unknown): Response =>
157
+ new Response(JSON.stringify(body), {
158
+ status,
159
+ headers: { 'content-type': 'application/json' }
160
+ })
161
+
162
+ const noContent = (): Response => new Response(null, { status: 204 })
163
+
164
+ const padded = (value: number, width: number): string => String(value).padStart(width, '0')
165
+
166
+ /** An API v1 timestamp (`2026-09-30T12:00:00.000000Z`) from the clock; throws on a bad clock. */
167
+ const timestamp = (env: TodoistApiEnv): string => {
168
+ const value = env.now()
169
+
170
+ if (!Number.isFinite(value)) throw new Error('the emulator clock is not a finite instant')
171
+
172
+ return `${new Date(value).toISOString().slice(0, 23)}000Z`
173
+ }
174
+
175
+ /** The 404 error body of the fixtures, with the next synthetic `event_id`. */
176
+ const notFound = (state: TodoistEmulatorState, env: TodoistApiEnv, error: string): Response => {
177
+ const number = state.counters.nextEventNumber
178
+
179
+ state.counters = { ...state.counters, nextEventNumber: number + 1 }
180
+
181
+ const rest = {
182
+ error_code: 478,
183
+ error_extra: { event_id: padded(number, 32) },
184
+ error_tag: 'NOT_FOUND',
185
+ http_code: 404
186
+ }
187
+
188
+ return json(404, env.drills.notFoundWithoutError ? rest : { error, ...rest })
189
+ }
190
+
191
+ const projectWire = (project: TodoistEmulatorProject) =>
192
+ makeTodoistProject({
193
+ id: project.id,
194
+ name: project.name,
195
+ parentId: project.parent_id,
196
+ childOrder: project.child_order,
197
+ createdAt: project.created_at,
198
+ updatedAt: project.updated_at
199
+ })
200
+
201
+ const taskWire = (state: TodoistEmulatorState, env: TodoistApiEnv, task: TodoistEmulatorTask) => ({
202
+ id: task.id,
203
+ user_id: task.user_id,
204
+ project_id: task.project_id,
205
+ section_id: task.section_id,
206
+ parent_id: task.parent_id,
207
+ added_by_uid: task.added_by_uid,
208
+ assigned_by_uid: task.assigned_by_uid,
209
+ responsible_uid: task.responsible_uid,
210
+ labels: env.drills.taskLabelsAsIds
211
+ ? task.labels.map(name => state.labels.find(item => item.name === name)?.id ?? name)
212
+ : task.labels,
213
+ deadline: task.deadline,
214
+ duration: task.duration,
215
+ checked: task.checked,
216
+ is_deleted: task.is_deleted,
217
+ added_at: task.added_at,
218
+ completed_at: task.completed_at,
219
+ updated_at: task.updated_at,
220
+ due: task.due,
221
+ priority: task.priority,
222
+ child_order: task.child_order,
223
+ content: task.content,
224
+ description: task.description,
225
+ note_count: task.note_count,
226
+ day_order: task.day_order,
227
+ is_collapsed: task.is_collapsed
228
+ })
229
+
230
+ const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
231
+ value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
232
+
233
+ const mediaType = (contentType: string | null): string | undefined =>
234
+ contentType?.split(';', 1)[0]?.trim().toLowerCase()
235
+
236
+ /**
237
+ * A write body: `application/json`, a JSON object with only `allowed` keys and every `required`
238
+ * key, or a refusal.
239
+ */
240
+ const bodyObject = (
241
+ request: RouteRequest,
242
+ allowed: ReadonlyArray<string>,
243
+ required: ReadonlyArray<string>
244
+ ): Schema.JsonObject | NotEmulated => {
245
+ if (mediaType(request.contentType) !== 'application/json') {
246
+ return notEmulated('write bodies other than application/json are not emulated')
247
+ }
248
+
249
+ if (!isJsonObject(request.body)) {
250
+ return notEmulated('a request body that is not a JSON object is not emulated')
251
+ }
252
+
253
+ const keys = Object.keys(request.body)
254
+ const unknown = keys.find(key => !allowed.includes(key))
255
+
256
+ if (unknown !== undefined) {
257
+ return notEmulated(`the body field ${unknown} is not emulated on this route`)
258
+ }
259
+
260
+ const missing = required.find(key => !keys.includes(key))
261
+
262
+ return missing === undefined
263
+ ? request.body
264
+ : notEmulated(`requests without the body field ${missing} are not emulated on this route`)
265
+ }
266
+
267
+ const noBody = (request: RouteRequest): NotEmulated | undefined =>
268
+ request.hasBody ? notEmulated('a request body is not emulated on this route') : undefined
269
+
270
+ /** A positive integer query value: `undefined` when absent, a refusal when not in `allowed`. */
271
+ const limitOf = (
272
+ request: RouteRequest,
273
+ allowed: (value: number) => boolean,
274
+ what: string
275
+ ): number | undefined | NotEmulated => {
276
+ const raw = request.query.get('limit')
277
+
278
+ if (raw === null) return undefined
279
+
280
+ const value = Number(raw)
281
+
282
+ return /^[1-9][0-9]{0,3}$/.test(raw) && allowed(value)
283
+ ? value
284
+ : notEmulated(`limit must be ${what} (as the fixtures send it)`)
285
+ }
286
+
287
+ /** A task created through the recorded create flow (never a seeded one), still active. */
288
+ const createdActiveTask = (state: TodoistEmulatorState, id: string) =>
289
+ isTodoistMintedId(id, todoistMintedTaskPrefix)
290
+ ? state.tasks.find(task => task.id === id && !task.checked)
291
+ : undefined
292
+
293
+ /** A case project created through the recorded create flow (never a seeded one). */
294
+ const createdProject = (state: TodoistEmulatorState, id: string) =>
295
+ isTodoistMintedId(id, todoistMintedProjectPrefix)
296
+ ? state.projects.find(project => project.id === id)
297
+ : undefined
298
+
299
+ const byOrder = (left: TodoistEmulatorTask, right: TodoistEmulatorTask): number =>
300
+ left.child_order - right.child_order || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
301
+
302
+ // Handlers: validate (not emulated) first, then return the commit that writes.
303
+
304
+ const listTasks: RouteHandler = (state, request, env) => {
305
+ const projectId = request.query.get('project_id')
306
+
307
+ if (projectId === null) return notEmulated('task listings without project_id are not emulated')
308
+
309
+ if (!state.projects.some(project => project.id === projectId)) {
310
+ return notEmulated('a task listing of an unknown project is not emulated')
311
+ }
312
+
313
+ const limit = limitOf(request, value => value === taskLimit, String(taskLimit))
314
+
315
+ if (isNotEmulated(limit)) return limit
316
+
317
+ // The fixtures record two listings only: the paging project with `limit=2`, and a case project
318
+ // created here without `limit`.
319
+ const recorded =
320
+ limit === undefined
321
+ ? createdProject(state, projectId) !== undefined
322
+ : projectId === todoistPagingProjectId
323
+
324
+ if (!recorded) {
325
+ return notEmulated(
326
+ 'task listings other than the paging project with limit=2 or a case project created here ' +
327
+ 'without limit are not emulated'
328
+ )
329
+ }
330
+
331
+ const tasks = state.tasks
332
+ .filter(
333
+ task => task.project_id === projectId && (env.drills.listIncludesClosed || !task.checked)
334
+ )
335
+ .sort(byOrder)
336
+
337
+ const rawCursor = request.query.get('cursor')
338
+ const cursor = rawCursor === null ? undefined : env.cursors.get(rawCursor)
339
+
340
+ // Cursors are valid only as issued by this emulator since its last reset or seed.
341
+ if (rawCursor !== null && cursor === undefined) {
342
+ return notEmulated('a cursor this emulator did not issue is not emulated')
343
+ }
344
+
345
+ if (
346
+ cursor !== undefined &&
347
+ (cursor.projectId !== projectId || cursor.limit !== request.query.get('limit'))
348
+ ) {
349
+ return notEmulated('a cursor sent with another project_id or limit is not emulated')
350
+ }
351
+
352
+ // A case project holds at most one task, so its unlimited listing is always one page.
353
+ if (limit === undefined) {
354
+ return () =>
355
+ json(200, { results: tasks.map(task => taskWire(state, env, task)), next_cursor: null })
356
+ }
357
+
358
+ const offset = cursor === undefined || env.drills.cursorRestarts ? 0 : cursor.offset
359
+ const end = offset + limit
360
+
361
+ return () => {
362
+ let next: string | null = null
363
+
364
+ if (end < tasks.length) {
365
+ next = `SyntheticTaskCursor${padded(env.cursorCounter.next++, 4)}`
366
+ env.cursors.set(next, { id: next, projectId, limit: String(limit), offset: end })
367
+ }
368
+
369
+ return json(200, {
370
+ results: tasks.slice(offset, end).map(task => taskWire(state, env, task)),
371
+ next_cursor: next
372
+ })
373
+ }
374
+ }
375
+
376
+ const getTask: RouteHandler = (state, request, env) => {
377
+ const id = request.params.taskId ?? ''
378
+ const task = state.tasks.find(candidate => candidate.id === id)
379
+
380
+ if (task === undefined) return () => notFound(state, env, 'Task not found')
381
+
382
+ return task.checked
383
+ ? notEmulated('reading a closed task is not emulated')
384
+ : () => json(200, taskWire(state, env, task))
385
+ }
386
+
387
+ const createTask: RouteHandler = (state, request, env) => {
388
+ const body = bodyObject(request, ['content', 'project_id', 'due_date'], ['content', 'project_id'])
389
+
390
+ if (isNotEmulated(body)) return body
391
+
392
+ const { content, project_id: projectId, due_date: dueDate } = body
393
+
394
+ if (!Predicate.isString(content) || content === '') {
395
+ return notEmulated('content must be a non-empty string')
396
+ }
397
+
398
+ if (!Predicate.isString(projectId) || createdProject(state, projectId) === undefined) {
399
+ return notEmulated('a task outside a case project created here is not emulated')
400
+ }
401
+
402
+ // The fixtures record one task per case project (`child_order: 1`).
403
+ if (state.tasks.some(item => item.project_id === projectId)) {
404
+ return notEmulated('a second task in one case project is not emulated')
405
+ }
406
+
407
+ if (dueDate !== undefined && dueDate !== recordedDueDate) {
408
+ return notEmulated(
409
+ `due_date other than ${recordedDueDate} (the recorded value) is not emulated`
410
+ )
411
+ }
412
+
413
+ return () => {
414
+ const now = timestamp(env)
415
+ const number = state.counters.nextTaskNumber
416
+
417
+ const task = makeTodoistTask({
418
+ id: `${todoistMintedTaskPrefix}${padded(number, 7)}`,
419
+ userId: state.userId,
420
+ projectId,
421
+ content,
422
+ labels: [],
423
+ addedAt: now,
424
+ updatedAt: now,
425
+ due: dueDate === undefined || env.drills.ignoreDue ? null : dueOfDate,
426
+ childOrder: 1
427
+ })
428
+
429
+ state.counters = { ...state.counters, nextTaskNumber: number + 1 }
430
+ state.tasks = [...state.tasks, task]
431
+
432
+ return json(200, taskWire(state, env, task))
433
+ }
434
+ }
435
+
436
+ const updateTask: RouteHandler = (state, request, env) => {
437
+ const task = createdActiveTask(state, request.params.taskId ?? '')
438
+
439
+ if (task === undefined) {
440
+ return notEmulated('updating a seeded, unknown, or closed task is not emulated')
441
+ }
442
+
443
+ const body = bodyObject(request, ['content', 'due_datetime'], [])
444
+
445
+ if (isNotEmulated(body)) return body
446
+
447
+ const { content, due_datetime: dueDatetime } = body
448
+
449
+ if (content === undefined && dueDatetime === undefined) {
450
+ return notEmulated('an update without content or due_datetime is not emulated')
451
+ }
452
+
453
+ if (content !== undefined && (!Predicate.isString(content) || content === '')) {
454
+ return notEmulated('content must be a non-empty string')
455
+ }
456
+
457
+ if (dueDatetime !== undefined && dueDatetime !== recordedDueDatetime) {
458
+ return notEmulated(
459
+ `due_datetime other than ${recordedDueDatetime} (the recorded value) is not emulated`
460
+ )
461
+ }
462
+
463
+ // The fixtures answer an update with the task's unchanged `updated_at`.
464
+ const updated: TodoistEmulatorTask = {
465
+ ...task,
466
+ content: Predicate.isString(content) ? content : task.content,
467
+ due: dueDatetime === undefined || env.drills.ignoreDue ? task.due : dueOfDatetime
468
+ }
469
+
470
+ return () => {
471
+ state.tasks = state.tasks.map(item => (item.id === task.id ? updated : item))
472
+
473
+ return json(200, taskWire(state, env, updated))
474
+ }
475
+ }
476
+
477
+ const closeTask: RouteHandler = (state, request, env) => {
478
+ const refused = noBody(request)
479
+
480
+ if (refused !== undefined) return refused
481
+
482
+ const task = createdActiveTask(state, request.params.taskId ?? '')
483
+
484
+ if (task === undefined) {
485
+ return notEmulated('closing a seeded, unknown, or closed task is not emulated')
486
+ }
487
+
488
+ return () => {
489
+ const closed: TodoistEmulatorTask = { ...task, checked: true, completed_at: timestamp(env) }
490
+
491
+ state.tasks = state.tasks.map(item => (item.id === task.id ? closed : item))
492
+
493
+ return noContent()
494
+ }
495
+ }
496
+
497
+ /** The label listing, one page (its fixture never pages): `limit` required; every label in it. */
498
+ const listLabels: RouteHandler = (state, request) => {
499
+ const limit = limitOf(request, value => value <= maxListLimit, `from 1 to ${maxListLimit}`)
500
+
501
+ if (isNotEmulated(limit)) return limit
502
+
503
+ if (limit === undefined) return notEmulated('label listings without limit are not emulated')
504
+
505
+ return state.labels.length > limit
506
+ ? notEmulated(`more labels than limit (${limit}): paging labels is not emulated`)
507
+ : () => json(200, { results: state.labels, next_cursor: null })
508
+ }
509
+
510
+ const createProject: RouteHandler = (state, request, env) => {
511
+ const body = bodyObject(request, ['name', 'parent_id'], ['name', 'parent_id'])
512
+
513
+ if (isNotEmulated(body)) return body
514
+
515
+ const { name, parent_id: parentId } = body
516
+ const runId = Predicate.isString(name) ? caseProjectNamePattern.exec(name)?.[1] : undefined
517
+
518
+ if (!Predicate.isString(name) || runId === undefined || runId.length > 40) {
519
+ return notEmulated(
520
+ 'project names other than yolk-conformance-<runId>-<lifecycle|due|parent|delete> ' +
521
+ 'are not emulated'
522
+ )
523
+ }
524
+
525
+ // The fixtures create case projects under the seeded work project, never under a case project.
526
+ if (
527
+ !Predicate.isString(parentId) ||
528
+ isTodoistMintedId(parentId, todoistMintedProjectPrefix) ||
529
+ !state.projects.some(item => item.id === parentId)
530
+ ) {
531
+ return notEmulated('a project under anything but an existing seeded project is not emulated')
532
+ }
533
+
534
+ // The fixtures record a first sub-project (`child_order: 1`) only.
535
+ if (state.projects.some(item => item.parent_id === parentId)) {
536
+ return notEmulated('a second sub-project under one parent is not emulated')
537
+ }
538
+
539
+ return () => {
540
+ const now = timestamp(env)
541
+ const number = state.counters.nextProjectNumber
542
+
543
+ const project = makeTodoistProject({
544
+ id: `${todoistMintedProjectPrefix}${padded(number, 4)}`,
545
+ name,
546
+ parentId,
547
+ childOrder: 1,
548
+ createdAt: now,
549
+ updatedAt: now
550
+ })
551
+
552
+ state.counters = { ...state.counters, nextProjectNumber: number + 1 }
553
+ state.projects = [...state.projects, project]
554
+
555
+ return json(
556
+ 200,
557
+ env.drills.createOmitsParent
558
+ ? { ...projectWire(project), parent_id: null }
559
+ : projectWire(project)
560
+ )
561
+ }
562
+ }
563
+
564
+ const getProject: RouteHandler = (state, request, env) => {
565
+ const id = request.params.projectId ?? ''
566
+
567
+ if (!state.projects.some(item => item.id === id)) {
568
+ return () => notFound(state, env, 'Project not found')
569
+ }
570
+
571
+ // Only projects created here answer: no fixture records a seeded project's object.
572
+ const project = createdProject(state, id)
573
+
574
+ return project === undefined
575
+ ? notEmulated('reading a seeded project is not emulated (no fixture records its answer)')
576
+ : () => json(200, projectWire(project))
577
+ }
578
+
579
+ const deleteProject: RouteHandler = (state, request, env) => {
580
+ const refused = noBody(request)
581
+
582
+ if (refused !== undefined) return refused
583
+
584
+ const project = createdProject(state, request.params.projectId ?? '')
585
+
586
+ // A created project never has sub-projects: projects are created under seeded parents only.
587
+ if (project === undefined) {
588
+ return notEmulated('deleting a seeded or unknown project is not emulated')
589
+ }
590
+
591
+ return () => {
592
+ state.projects = state.projects.filter(item => item.id !== project.id)
593
+
594
+ if (!env.drills.deleteKeepsTasks) {
595
+ state.tasks = state.tasks.filter(task => task.project_id !== project.id)
596
+ }
597
+
598
+ return noContent()
599
+ }
600
+ }
601
+
602
+ /** The route's request, as the handlers see it (the wrapper checked and parsed the body). */
603
+ const routeRequest = (request: EmulatedRequest): RouteRequest => ({
604
+ params: request.params,
605
+ query: request.query,
606
+ body: request.json,
607
+ // A non-empty body is valid JSON by now (`json-or-empty`), so it always parses.
608
+ hasBody: request.json !== undefined,
609
+ contentType: request.header('content-type') ?? null
610
+ })
611
+
612
+ const route = (
613
+ method: string,
614
+ path: string,
615
+ write: boolean,
616
+ caseIds: ReadonlyArray<string>,
617
+ handler: RouteHandler,
618
+ queryKeys: ReadonlyArray<string> = []
619
+ ): TodoistApiRoute => ({
620
+ route: statefulRoute(
621
+ {
622
+ method,
623
+ path: `${todoistEmulatorBasePath}${path}`,
624
+ kind: 'connector',
625
+ write,
626
+ caseIds,
627
+ evidence: 'unverified'
628
+ },
629
+ 'json-or-empty',
630
+ routeRequest,
631
+ (state, request, context) => handler(state, request, context.env)
632
+ ),
633
+ queryKeys
634
+ })
635
+
636
+ /** The route table: evidence plus handler. `todoistEmulatorRoutes` is its evidence part. */
637
+ export const todoistApiRoutes: ReadonlyArray<TodoistApiRoute> = [
638
+ route('GET', '/tasks', false, [pagingCase, lifecycleCase], listTasks, [
639
+ 'project_id',
640
+ 'cursor',
641
+ 'limit'
642
+ ]),
643
+ route('POST', '/tasks', true, [lifecycleCase, dueCase, deleteCase], createTask),
644
+ route(
645
+ 'GET',
646
+ '/tasks/{taskId}',
647
+ false,
648
+ [notFoundCase, labelsCase, lifecycleCase, deleteCase],
649
+ getTask
650
+ ),
651
+ route('POST', '/tasks/{taskId}', true, [lifecycleCase, dueCase], updateTask),
652
+ route('POST', '/tasks/{taskId}/close', true, [lifecycleCase], closeTask),
653
+ route('GET', '/labels', false, [labelsCase], listLabels, ['limit']),
654
+ route('POST', '/projects', true, projectCases, createProject),
655
+ route('GET', '/projects/{projectId}', false, projectCases, getProject),
656
+ route('DELETE', '/projects/{projectId}', true, projectCases, deleteProject)
657
+ ]
658
+
659
+ const compiledRoutes = todoistApiRoutes.map(candidate => ({
660
+ route: candidate,
661
+ pattern: new RegExp(`^${candidate.route.path.replace(/\{[A-Za-z]+\}/g, '([^/]+)')}$`),
662
+ names: [...candidate.route.path.matchAll(/\{([A-Za-z]+)\}/g)].map(match => match[1] ?? '')
663
+ }))
664
+
665
+ export type TodoistMatchedRoute = {
666
+ readonly route: TodoistApiRoute
667
+ /** Path parameters (Todoist ids, matched raw: never percent-encoded). */
668
+ readonly params: Readonly<Record<string, string>>
669
+ }
670
+
671
+ /**
672
+ * Path words of other Todoist endpoints (`/tasks/filter`, `/tasks/completed`, `/projects/archived`,
673
+ * ...): never an id, so those endpoints stay unknown routes instead of reading as a missing item.
674
+ */
675
+ const reservedSegments: ReadonlySet<string> = new Set([
676
+ 'filter',
677
+ 'completed',
678
+ 'quick',
679
+ 'archived',
680
+ 'search'
681
+ ])
682
+
683
+ /**
684
+ * The route answering `method` + raw `path` with its id parameters, or `undefined` (no route, a
685
+ * reserved endpoint word, or a raw segment that is not a Todoist id: percent-encoding is never
686
+ * recognised).
687
+ */
688
+ export const matchTodoistRoute = (
689
+ method: string,
690
+ path: string
691
+ ): TodoistMatchedRoute | undefined => {
692
+ for (const candidate of compiledRoutes) {
693
+ if (candidate.route.route.method !== method.toUpperCase()) continue
694
+
695
+ const match = candidate.pattern.exec(path)
696
+
697
+ if (match === null) continue
698
+
699
+ const params: Record<string, string> = {}
700
+
701
+ for (const [index, name] of candidate.names.entries()) {
702
+ // The raw segment itself must be a Todoist id: a percent-encoded id is not recognised.
703
+ const value = match[index + 1] ?? ''
704
+
705
+ if (!todoistIdPattern.test(value) || reservedSegments.has(value)) {
706
+ return undefined
707
+ }
708
+
709
+ params[name] = value
710
+ }
711
+
712
+ return { route: candidate.route, params }
713
+ }
714
+
715
+ return undefined
716
+ }
717
+
718
+ /**
719
+ * Why the query is not emulated on `route` (a key it does not emulate, or a repeated key), or
720
+ * `undefined`.
721
+ */
722
+ export const todoistQueryProblem = (
723
+ route: TodoistApiRoute,
724
+ query: URLSearchParams
725
+ ): string | undefined => {
726
+ const keys = [...query.keys()]
727
+ const unknown = keys.find(key => !route.queryKeys.includes(key))
728
+
729
+ if (unknown !== undefined) return `the query parameter ${unknown} is not emulated on this route`
730
+
731
+ const repeated = keys.find((key, index) => keys.indexOf(key) !== index)
732
+
733
+ return repeated === undefined
734
+ ? undefined
735
+ : `the repeated query parameter ${repeated} is not emulated`
736
+ }