@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,976 @@
1
+ /**
2
+ * Notion emulator API: the route table (evidence, request-shape checks, and stateful handlers),
3
+ * the fixture error envelopes, object rendering, and cursor paging (internal; re-exported by
4
+ * `src/notion.ts`).
5
+ *
6
+ * Only the `/v1` routes the eight Notion conformance cases (and their cleanup) send are emulated,
7
+ * at API version 2025-09-03, with the wire shapes of their synthetic fixtures:
8
+ * search, the bot user, page read, create, and archive, block children, paginated property items,
9
+ * and the database / data source split (the database lists its data sources; the data source
10
+ * holds the schema and the rows). List cursors are the next result's id, as the search, block, and
11
+ * query fixtures show; property item cursors are opaque. A cursor is accepted only when this
12
+ * emulator issued it for the same list since the last reset and the list is unchanged. Every
13
+ * route plans first (reading the state, writing nothing) and commits only an eligible request:
14
+ * anything no fixture shows is not emulated (400, nothing written, no fault used up).
15
+ *
16
+ * @experimental
17
+ */
18
+ import { Predicate } from 'effect'
19
+ import type * as Schema from 'effect/Schema'
20
+ import {
21
+ exactObject,
22
+ integerIn,
23
+ isJsonObject,
24
+ isNotEmulated,
25
+ notEmulated,
26
+ statefulRoute,
27
+ type Commit,
28
+ type EmulatedRequest,
29
+ type NotEmulated,
30
+ type StatefulRoute
31
+ } from '../stateful-emulator.ts'
32
+ import {
33
+ plainRichText,
34
+ titleProperty,
35
+ type NotionEmulatorPage,
36
+ type NotionEmulatorState
37
+ } from './state.ts'
38
+
39
+ /** API version prefix of every Notion route. */
40
+ export const notionEmulatorBasePath = '/v1'
41
+
42
+ /** The only `Notion-Version` the emulator answers (every fixture sends it). */
43
+ export const notionEmulatorVersion = '2025-09-03'
44
+
45
+ /** Content type of every Notion response, as the fixtures record it. */
46
+ const notionContentType = 'application/json; charset=utf-8'
47
+
48
+ /** The only page title a fixture records a create with, and the URL slug it records for it. */
49
+ const recordedCreateTitle = 'yolk-conformance page: safe to delete'
50
+
51
+ const recordedCreateSlug = 'yolk-conformance-page-safe-to-delete'
52
+
53
+ /** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
54
+ export type NotionEmulatorDrills = {
55
+ /** A later search page starts one result early, repeating the previous page's last result. */
56
+ readonly searchRepeatsResults?: boolean
57
+ /** `GET /v1/users/me` answers a person instead of the bot user. */
58
+ readonly botUserAsPerson?: boolean
59
+ /** Error envelopes carry a `status` other than the HTTP status. */
60
+ readonly envelopeStatusMismatch?: boolean
61
+ /** Page reads answer title rich text without `plain_text`. */
62
+ readonly omitTitlePlainText?: boolean
63
+ /** A later block children page starts one block early, repeating a block. */
64
+ readonly blockCursorRepeats?: boolean
65
+ /** The property id path segment is decoded twice, so a re-encoded id is not found. */
66
+ readonly rejectDoubleEncodedPropertyId?: boolean
67
+ /** Data source rows answer a `database_id` parent instead of their data source. */
68
+ readonly rowParentAsDatabase?: boolean
69
+ /** A trashed page reads back as 404 `object_not_found`. */
70
+ readonly trashedPageNotFound?: boolean
71
+ }
72
+
73
+ export const notionEmulatorDrillKnobs: ReadonlyArray<keyof NotionEmulatorDrills> = [
74
+ 'searchRepeatsResults',
75
+ 'botUserAsPerson',
76
+ 'envelopeStatusMismatch',
77
+ 'omitTitlePlainText',
78
+ 'blockCursorRepeats',
79
+ 'rejectDoubleEncodedPropertyId',
80
+ 'rowParentAsDatabase',
81
+ 'trashedPageNotFound'
82
+ ]
83
+
84
+ /** A cursor this emulator issued: where its list continues, and the list it was issued for. */
85
+ export type NotionIssuedCursor = {
86
+ readonly offset: number
87
+ /** The whole list as rendered when the cursor was issued; any change is not emulated. */
88
+ readonly fingerprint: string
89
+ }
90
+
91
+ export type NotionApiEnv = {
92
+ /** Clock in epoch milliseconds (created page timestamps only). */
93
+ readonly now: () => number
94
+ readonly drills: Readonly<Record<keyof NotionEmulatorDrills, boolean>>
95
+ /** Issued cursors by list and value (runtime-only; cleared by reset and seed). */
96
+ readonly cursors: Map<string, NotionIssuedCursor>
97
+ /** Property cursor numbers; never reset, so a cursor issued before a reset is never reissued. */
98
+ readonly propertyCursorCounter: { next: number }
99
+ /**
100
+ * The cursor generation: advanced by every reset and seed (never rewound). Search and block
101
+ * cursors are the next result's id only in the generation that first issued that id for that
102
+ * list; a later generation issues a distinct value, so a cursor from before a reset or seed is
103
+ * never accepted again, even after the same first-page request.
104
+ */
105
+ readonly generation: { current: number }
106
+ /** The generation that first issued each list's raw id cursor (never cleared). */
107
+ readonly firstIssued: Map<string, number>
108
+ }
109
+
110
+ /** The origin every Notion fixture records; another origin is not emulated. */
111
+ export const notionEmulatorOrigin = 'https://api.notion.com'
112
+
113
+ type Route = StatefulRoute<NotionEmulatorState, NotionApiEnv>
114
+
115
+ const searchCase = 'notion.search.cursor-paging'
116
+
117
+ const versionCase = 'notion.api.pinned-version-accepted'
118
+
119
+ const errorCase = 'notion.errors.error-envelope'
120
+
121
+ const titleCase = 'notion.pages.title-plain-text'
122
+
123
+ const blocksCase = 'notion.blocks.children-cursor-paging'
124
+
125
+ const propertyCase = 'notion.pages.property-item-paging'
126
+
127
+ const dataSourceCase = 'notion.data-sources.database-split'
128
+
129
+ const archiveCase = 'notion.pages.archive-in-trash'
130
+
131
+ const evidence = (
132
+ method: string,
133
+ path: string,
134
+ write: boolean,
135
+ caseIds: ReadonlyArray<string>
136
+ ) => ({
137
+ method,
138
+ path: `${notionEmulatorBasePath}${path}`,
139
+ kind: 'connector' as const,
140
+ write,
141
+ caseIds,
142
+ evidence: 'unverified' as const,
143
+ origin: notionEmulatorOrigin
144
+ })
145
+
146
+ // Responses.
147
+
148
+ const ok = (body: Schema.JsonObject): Response =>
149
+ new Response(JSON.stringify(body), {
150
+ status: 200,
151
+ headers: { 'content-type': notionContentType }
152
+ })
153
+
154
+ /** A read-only commit. */
155
+ const answer =
156
+ (response: () => Response): Commit =>
157
+ () =>
158
+ response()
159
+
160
+ const pad = (value: number, width: number): string => String(value).padStart(width, '0')
161
+
162
+ /** A synthetic request id for ledger sequence `seq`. */
163
+ const requestId = (seq: number): string =>
164
+ `00000000-0000-4000-8000-${pad(seq % 1_000_000_000_000, 12)}`
165
+
166
+ /** The fixture error envelope `{ object: "error", status, code, message, request_id }`. */
167
+ const errorEnvelope = (
168
+ env: NotionApiEnv,
169
+ seq: number,
170
+ status: number,
171
+ code: string,
172
+ message: string
173
+ ): Response =>
174
+ new Response(
175
+ JSON.stringify({
176
+ object: 'error',
177
+ status: env.drills.envelopeStatusMismatch ? 500 : status,
178
+ code,
179
+ message,
180
+ request_id: requestId(seq)
181
+ }),
182
+ { status, headers: { 'content-type': notionContentType } }
183
+ )
184
+
185
+ /** The error-envelope fixture's 404 for a well-formed id that addresses no page. */
186
+ const pageNotFound = (env: NotionApiEnv, seq: number, id: string): Response =>
187
+ errorEnvelope(
188
+ env,
189
+ seq,
190
+ 404,
191
+ 'object_not_found',
192
+ `Could not find page with ID: ${id}. Make sure the relevant pages and databases are shared with your integration.`
193
+ )
194
+
195
+ /** The error-envelope fixture's 400 for a malformed page id. */
196
+ const malformedPageId = (env: NotionApiEnv, seq: number, raw: string): Response =>
197
+ errorEnvelope(
198
+ env,
199
+ seq,
200
+ 400,
201
+ 'validation_error',
202
+ `path failed validation: path.page_id should be a valid uuid, instead was \`${JSON.stringify(raw)}\`.`
203
+ )
204
+
205
+ // Ids, queries, and bodies.
206
+
207
+ const compactIdPattern = /^[0-9a-f]{32}$/i
208
+
209
+ const dashedIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
210
+
211
+ /** A Notion id with or without dashes, in any case, as stored (dashed, lower case). */
212
+ const normalizeId = (raw: string): string | undefined => {
213
+ if (dashedIdPattern.test(raw)) return raw.toLowerCase()
214
+
215
+ if (!compactIdPattern.test(raw)) return undefined
216
+
217
+ const hex = raw.toLowerCase()
218
+
219
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
220
+ }
221
+
222
+ const idParam = (request: EmulatedRequest, name: string): string | NotEmulated =>
223
+ normalizeId(request.params[name] ?? '') ??
224
+ notEmulated(`a malformed ${name} is not emulated on this route`)
225
+
226
+ const withoutQuery = (request: EmulatedRequest): NotEmulated | undefined =>
227
+ request.query.size > 0
228
+ ? notEmulated('query parameters are not emulated on this route')
229
+ : undefined
230
+
231
+ type ListQuery = { readonly pageSize: number; readonly startCursor: string | undefined }
232
+
233
+ /** `page_size` (required, 1-100) and an optional `start_cursor`, as the list fixtures send them. */
234
+ const listQuery = (request: EmulatedRequest): ListQuery | NotEmulated => {
235
+ const keys = [...request.query.keys()]
236
+ const unknown = keys.find(key => key !== 'page_size' && key !== 'start_cursor')
237
+
238
+ if (unknown !== undefined) return notEmulated(`query parameter ${unknown} is not emulated`)
239
+
240
+ if (keys.length !== new Set(keys).size) {
241
+ return notEmulated('repeated query parameters are not emulated')
242
+ }
243
+
244
+ const raw = request.query.get('page_size')
245
+
246
+ if (raw === null) return notEmulated('requests without page_size are not emulated')
247
+
248
+ const pageSize = integerIn(/^\d+$/.test(raw) ? Number(raw) : null, 'page_size', 1, 100)
249
+
250
+ if (isNotEmulated(pageSize)) return pageSize
251
+
252
+ const startCursor = request.query.get('start_cursor') ?? undefined
253
+
254
+ return startCursor === ''
255
+ ? notEmulated('an empty start_cursor is not emulated')
256
+ : { pageSize, startCursor }
257
+ }
258
+
259
+ const jsonBody = (
260
+ request: EmulatedRequest,
261
+ label: string,
262
+ required: ReadonlyArray<string>,
263
+ optional: ReadonlyArray<string> = []
264
+ ): Schema.JsonObject | NotEmulated =>
265
+ withoutQuery(request) ?? exactObject(request.json, label, required, optional)
266
+
267
+ // Rendering, in the fixture key order.
268
+
269
+ /** The plain text of a page's `title` property (empty without one). */
270
+ const pageTitle = (page: NotionEmulatorPage): string => {
271
+ const title = Object.values(page.properties).find(
272
+ property => isJsonObject(property) && property.type === 'title'
273
+ )
274
+
275
+ const items = isJsonObject(title) && Array.isArray(title.title) ? title.title : []
276
+
277
+ return items
278
+ .map(item => (isJsonObject(item) && Predicate.isString(item.plain_text) ? item.plain_text : ''))
279
+ .join('')
280
+ }
281
+
282
+ /** Title items without `plain_text` (the `omitTitlePlainText` drill). */
283
+ const withoutTitlePlainText = (properties: Schema.JsonObject): Schema.JsonObject =>
284
+ Object.fromEntries(
285
+ Object.entries(properties).map(([name, property]) => [
286
+ name,
287
+ isJsonObject(property) && property.type === 'title' && Array.isArray(property.title)
288
+ ? {
289
+ ...property,
290
+ title: property.title.map(item =>
291
+ isJsonObject(item)
292
+ ? Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'plain_text'))
293
+ : item
294
+ )
295
+ }
296
+ : property
297
+ ])
298
+ )
299
+
300
+ const renderPage = (
301
+ env: NotionApiEnv,
302
+ page: NotionEmulatorPage,
303
+ read: 'page' | 'list'
304
+ ): Schema.JsonObject => {
305
+ const parent: Schema.JsonObject =
306
+ page.parent.type === 'data_source_id' && env.drills.rowParentAsDatabase
307
+ ? { type: 'database_id', database_id: page.parent.database_id }
308
+ : page.parent
309
+
310
+ const properties =
311
+ read === 'page' && env.drills.omitTitlePlainText
312
+ ? withoutTitlePlainText(page.properties)
313
+ : page.properties
314
+
315
+ const head = { object: 'page', id: page.id }
316
+
317
+ const tail = {
318
+ parent,
319
+ archived: page.archived,
320
+ in_trash: page.inTrash,
321
+ properties,
322
+ url: page.url
323
+ }
324
+
325
+ // In fixture key order; data source rows carry no timestamps, as their fixture records.
326
+ return page.times === null
327
+ ? { ...head, ...tail }
328
+ : {
329
+ ...head,
330
+ created_time: page.times.created,
331
+ last_edited_time: page.times.lastEdited,
332
+ ...tail
333
+ }
334
+ }
335
+
336
+ const isTrashed = (page: NotionEmulatorPage): boolean => page.archived || page.inTrash
337
+
338
+ const isImplied = (state: NotionEmulatorState, id: string): boolean =>
339
+ state.impliedPages.some(page => page.id === id)
340
+
341
+ const impliedRefusal = (): NotEmulated =>
342
+ notEmulated('an answer showing a page a fixture only names by id is not emulated')
343
+
344
+ const list = (
345
+ results: ReadonlyArray<Schema.Json>,
346
+ nextCursor: string | null,
347
+ type: 'page_or_data_source' | 'block'
348
+ ): Schema.JsonObject => ({
349
+ object: 'list',
350
+ results: [...results],
351
+ next_cursor: nextCursor,
352
+ has_more: nextCursor !== null,
353
+ type,
354
+ [type]: {}
355
+ })
356
+
357
+ /** Where a page of a list starts: 0, or an issued cursor's offset; not emulated otherwise. */
358
+ const startOf = (
359
+ env: NotionApiEnv,
360
+ listKey: string,
361
+ fingerprint: string,
362
+ cursor: string | undefined
363
+ ): number | NotEmulated => {
364
+ if (cursor === undefined) return 0
365
+
366
+ const issued = env.cursors.get(`${listKey}\u0000${cursor}`)
367
+
368
+ if (issued === undefined) {
369
+ return notEmulated(
370
+ 'a start_cursor this emulator did not issue for this list since the last reset is not emulated'
371
+ )
372
+ }
373
+
374
+ return issued.fingerprint === fingerprint
375
+ ? issued.offset
376
+ : notEmulated('continuing a list that changed since its cursor was issued is not emulated')
377
+ }
378
+
379
+ /** Register a cursor (in a commit). */
380
+ const issue = (
381
+ env: NotionApiEnv,
382
+ listKey: string,
383
+ cursor: string,
384
+ offset: number,
385
+ fingerprint: string
386
+ ): void => {
387
+ env.cursors.set(`${listKey}\u0000${cursor}`, { offset, fingerprint })
388
+ }
389
+
390
+ /**
391
+ * The cursor value for the next item `id` of a list (in a commit): the id itself, as the fixtures
392
+ * record, in the generation that first issued it for this list; a distinct value in any later
393
+ * generation (after a reset or seed), so no cursor value crosses a reset or seed.
394
+ */
395
+ const idCursor = (env: NotionApiEnv, listKey: string, id: string): string => {
396
+ const key = `${listKey}\u0000${id}`
397
+ const first = env.firstIssued.get(key)
398
+ const generation = env.generation.current
399
+
400
+ if (first === undefined) env.firstIssued.set(key, generation)
401
+
402
+ return first === undefined || first === generation ? id : `${id}.g${generation}`
403
+ }
404
+
405
+ /**
406
+ * The commit answering one page of an id-cursor list (search results, blocks): the next cursor is
407
+ * the next item's id, as the fixtures record (see `idCursor`), issued for this list.
408
+ */
409
+ const idListPage = (
410
+ env: NotionApiEnv,
411
+ listKey: string,
412
+ items: ReadonlyArray<{ readonly id: string; readonly rendered: Schema.JsonObject }>,
413
+ query: ListQuery,
414
+ repeats: boolean,
415
+ type: 'page_or_data_source' | 'block'
416
+ ): Commit | NotEmulated => {
417
+ const fingerprint = JSON.stringify(items.map(item => item.rendered))
418
+ const offset = startOf(env, listKey, fingerprint, query.startCursor)
419
+
420
+ if (isNotEmulated(offset)) return offset
421
+
422
+ const start = repeats && offset > 0 ? offset - 1 : offset
423
+ const end = start + query.pageSize
424
+ const next = items[end]
425
+
426
+ return () => {
427
+ const cursor = next === undefined ? null : idCursor(env, listKey, next.id)
428
+
429
+ if (cursor !== null) issue(env, listKey, cursor, end, fingerprint)
430
+
431
+ return ok(
432
+ list(
433
+ items.slice(start, end).map(item => item.rendered),
434
+ cursor,
435
+ type
436
+ )
437
+ )
438
+ }
439
+ }
440
+
441
+ // Routes.
442
+
443
+ type SearchInput = { readonly query: string; readonly list: ListQuery }
444
+
445
+ const search: Route = statefulRoute(
446
+ evidence('POST', '/search', false, [searchCase]),
447
+ 'json',
448
+ (request): SearchInput | NotEmulated => {
449
+ const body = jsonBody(
450
+ request,
451
+ 'the search body',
452
+ ['query', 'filter', 'page_size'],
453
+ ['start_cursor']
454
+ )
455
+
456
+ if (isNotEmulated(body)) return body
457
+
458
+ if (!Predicate.isString(body.query)) return notEmulated('query must be a string')
459
+
460
+ const filter = exactObject(body.filter, 'filter', ['property', 'value'])
461
+
462
+ if (isNotEmulated(filter)) return filter
463
+
464
+ if (filter.property !== 'object' || filter.value !== 'page') {
465
+ return notEmulated(
466
+ 'filters other than { property: "object", value: "page" } are not emulated'
467
+ )
468
+ }
469
+
470
+ const pageSize = integerIn(body.page_size, 'page_size', 1, 100)
471
+
472
+ if (isNotEmulated(pageSize)) return pageSize
473
+
474
+ if (
475
+ body.start_cursor !== undefined &&
476
+ (!Predicate.isString(body.start_cursor) || body.start_cursor.length === 0)
477
+ ) {
478
+ return notEmulated('start_cursor must be a non-empty string')
479
+ }
480
+
481
+ return {
482
+ query: body.query,
483
+ list: {
484
+ pageSize,
485
+ startCursor: Predicate.isString(body.start_cursor) ? body.start_cursor : undefined
486
+ }
487
+ }
488
+ },
489
+ (state, input, { env }) => {
490
+ const query = input.query.toLowerCase()
491
+ const matches = state.pages.filter(page => pageTitle(page).toLowerCase().includes(query))
492
+
493
+ // Every recorded search answer has results; an empty one (for example the leftover lookup's
494
+ // search of a clean workspace) is no fixture's answer.
495
+ if (matches.length === 0) {
496
+ return notEmulated('a search without matches is not emulated (no fixture records one)')
497
+ }
498
+
499
+ if (matches.some(isTrashed)) {
500
+ return notEmulated(
501
+ 'a search matching a trashed page is not emulated (no fixture records one)'
502
+ )
503
+ }
504
+
505
+ // The search fixture's results all carry timestamps; a page shown only as a query row has
506
+ // none, so no fixture records it as a search result.
507
+ if (matches.some(page => page.times === null)) {
508
+ return notEmulated(
509
+ 'a search matching a page shown only as a query row is not emulated (no fixture records one)'
510
+ )
511
+ }
512
+
513
+ return idListPage(
514
+ env,
515
+ `search\u0000${input.query}`,
516
+ matches.map(page => ({ id: page.id, rendered: renderPage(env, page, 'list') })),
517
+ input.list,
518
+ env.drills.searchRepeatsResults,
519
+ 'page_or_data_source'
520
+ )
521
+ }
522
+ )
523
+
524
+ const botUser: Route = statefulRoute(
525
+ evidence('GET', '/users/me', false, [versionCase]),
526
+ 'none',
527
+ request => withoutQuery(request) ?? {},
528
+ (state, _input, { env, seq }) =>
529
+ answer(() => {
530
+ const user = state.botUser
531
+
532
+ const kind: Schema.JsonObject = env.drills.botUserAsPerson
533
+ ? { type: 'person', person: {} }
534
+ : {
535
+ type: 'bot',
536
+ bot: {
537
+ owner: { type: 'workspace', workspace: true },
538
+ workspace_name: user.workspaceName
539
+ }
540
+ }
541
+
542
+ return ok({
543
+ object: 'user',
544
+ id: user.id,
545
+ name: user.name,
546
+ avatar_url: user.avatarUrl,
547
+ ...kind,
548
+ request_id: requestId(seq)
549
+ })
550
+ })
551
+ )
552
+
553
+ type PageRead = { readonly raw: string; readonly id: string | undefined }
554
+
555
+ const getPage: Route = statefulRoute(
556
+ evidence('GET', '/pages/{pageId}', false, [errorCase, titleCase, propertyCase, archiveCase]),
557
+ 'none',
558
+ (request): PageRead | NotEmulated => {
559
+ const raw = request.params.pageId ?? ''
560
+
561
+ return withoutQuery(request) ?? { raw, id: normalizeId(raw) }
562
+ },
563
+ (state, input, { env, seq }) => {
564
+ const id = input.id
565
+
566
+ if (id === undefined) return answer(() => malformedPageId(env, seq, input.raw))
567
+
568
+ if (isImplied(state, id)) return impliedRefusal()
569
+
570
+ const page = state.pages.find(candidate => candidate.id === id)
571
+
572
+ if (page === undefined || (env.drills.trashedPageNotFound && isTrashed(page))) {
573
+ return answer(() => pageNotFound(env, seq, id))
574
+ }
575
+
576
+ if (page.times === null) {
577
+ return notEmulated(
578
+ 'a page read of a page the fixtures show only as a query row is not emulated'
579
+ )
580
+ }
581
+
582
+ return answer(() => ok(renderPage(env, page, 'page')))
583
+ }
584
+ )
585
+
586
+ const createPage: Route = statefulRoute(
587
+ evidence('POST', '/pages', true, [archiveCase]),
588
+ 'json',
589
+ (request): string | NotEmulated => {
590
+ const body = jsonBody(request, 'the create page body', ['parent', 'properties'])
591
+
592
+ if (isNotEmulated(body)) return body
593
+
594
+ const parent = exactObject(body.parent, 'parent', ['page_id'])
595
+
596
+ if (isNotEmulated(parent)) return parent
597
+
598
+ const parentId = Predicate.isString(parent.page_id) ? normalizeId(parent.page_id) : undefined
599
+
600
+ if (parentId === undefined) return notEmulated('parent.page_id must be a Notion id')
601
+
602
+ const properties = exactObject(body.properties, 'properties', ['title'])
603
+
604
+ if (isNotEmulated(properties)) return properties
605
+
606
+ const title = exactObject(properties.title, 'properties.title', ['title'])
607
+
608
+ if (isNotEmulated(title)) return title
609
+
610
+ const items = title.title
611
+
612
+ if (!Array.isArray(items) || items.length !== 1) {
613
+ return notEmulated('a title of other than one text item is not emulated')
614
+ }
615
+
616
+ const item = exactObject(items[0], 'the title item', ['text'])
617
+
618
+ if (isNotEmulated(item)) return item
619
+
620
+ const text = exactObject(item.text, 'the title item text', ['content'])
621
+
622
+ if (isNotEmulated(text)) return text
623
+
624
+ // The page URL derives from the title; only the recorded title's URL is recorded.
625
+ return text.content === recordedCreateTitle
626
+ ? parentId
627
+ : notEmulated(`a page title other than "${recordedCreateTitle}" is not emulated`)
628
+ },
629
+ (state, parentId, { env }) => {
630
+ const parent = state.pages.find(page => page.id === parentId)
631
+
632
+ if (!isImplied(state, parentId) && (parent === undefined || isTrashed(parent))) {
633
+ return notEmulated('creating a page under a missing or trashed page is not emulated')
634
+ }
635
+
636
+ return () => {
637
+ // Read the clock before any write: a failing clock writes nothing.
638
+ const at = new Date(env.now()).toISOString()
639
+ const number = state.counters.nextPageNumber
640
+ const id = `1f0000e0-0000-4000-8000-${pad(number, 12)}`
641
+
642
+ const page: NotionEmulatorPage = {
643
+ id,
644
+ times: { created: at, lastEdited: at },
645
+ parent: { type: 'page_id', page_id: parentId },
646
+ archived: false,
647
+ inTrash: false,
648
+ properties: { title: titleProperty([plainRichText(recordedCreateTitle)]) },
649
+ url: `https://www.notion.so/${recordedCreateSlug}-${id.replaceAll('-', '')}`
650
+ }
651
+
652
+ state.counters = { ...state.counters, nextPageNumber: number + 1 }
653
+ state.pages = [...state.pages, page]
654
+
655
+ return ok(renderPage(env, page, 'page'))
656
+ }
657
+ }
658
+ )
659
+
660
+ const archivePage: Route = statefulRoute(
661
+ evidence('PATCH', '/pages/{pageId}', true, [archiveCase]),
662
+ 'json',
663
+ (request): string | NotEmulated => {
664
+ const id = idParam(request, 'pageId')
665
+
666
+ if (isNotEmulated(id)) return id
667
+
668
+ const body = jsonBody(request, 'the update page body', ['archived'])
669
+
670
+ if (isNotEmulated(body)) return body
671
+
672
+ return body.archived === true
673
+ ? id
674
+ : notEmulated('page updates other than { archived: true } are not emulated')
675
+ },
676
+ (state, id, { env }) => {
677
+ if (isImplied(state, id)) return impliedRefusal()
678
+
679
+ const page = state.pages.find(candidate => candidate.id === id)
680
+
681
+ if (page === undefined || isTrashed(page) || page.times === null) {
682
+ return notEmulated('archiving a missing, already trashed, or row page is not emulated')
683
+ }
684
+
685
+ return () => {
686
+ // The archive fixture keeps `last_edited_time` unchanged.
687
+ const archived: NotionEmulatorPage = { ...page, archived: true, inTrash: true }
688
+
689
+ state.pages = state.pages.map(candidate => (candidate.id === id ? archived : candidate))
690
+
691
+ return ok(renderPage(env, archived, 'page'))
692
+ }
693
+ }
694
+ )
695
+
696
+ type ChildrenInput = { readonly blockId: string; readonly list: ListQuery }
697
+
698
+ const blockChildren: Route = statefulRoute(
699
+ evidence('GET', '/blocks/{blockId}/children', false, [blocksCase]),
700
+ 'none',
701
+ (request): ChildrenInput | NotEmulated => {
702
+ const blockId = idParam(request, 'blockId')
703
+
704
+ if (isNotEmulated(blockId)) return blockId
705
+
706
+ const query = listQuery(request)
707
+
708
+ return isNotEmulated(query) ? query : { blockId, list: query }
709
+ },
710
+ (state, input, { env }) => {
711
+ if (!isImplied(state, input.blockId) && !state.pages.some(page => page.id === input.blockId)) {
712
+ return notEmulated('children of anything but a page are not emulated')
713
+ }
714
+
715
+ const blocks = state.blocks.filter(block => block.pageId === input.blockId)
716
+
717
+ // The block fixture records only nonempty pages of children.
718
+ if (blocks.length === 0) {
719
+ return notEmulated('a page without recorded child blocks is not emulated')
720
+ }
721
+
722
+ return idListPage(
723
+ env,
724
+ `blocks\u0000${input.blockId}`,
725
+ blocks.map(block => ({
726
+ id: block.id,
727
+ rendered: {
728
+ object: 'block',
729
+ id: block.id,
730
+ parent: { type: 'page_id', page_id: block.pageId },
731
+ created_time: block.createdTime,
732
+ last_edited_time: block.lastEditedTime,
733
+ has_children: block.hasChildren,
734
+ archived: block.archived,
735
+ in_trash: block.inTrash,
736
+ type: block.type,
737
+ [block.type]: block.value
738
+ }
739
+ })),
740
+ input.list,
741
+ env.drills.blockCursorRepeats,
742
+ 'block'
743
+ )
744
+ }
745
+ )
746
+
747
+ // Property item cursors: opaque, minted from a counter that never resets (the first one is the
748
+ // value the fixture records), and accepted only for the list they were issued for.
749
+
750
+ const base64url = (text: string): string =>
751
+ btoa(text).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
752
+
753
+ const mintPropertyCursor = (env: NotionApiEnv): string => {
754
+ const number = env.propertyCursorCounter.next
755
+
756
+ env.propertyCursorCounter.next = number + 1
757
+
758
+ return base64url(
759
+ number === 1 ? 'synthetic-property-cursor' : `synthetic-property-cursor-${number}`
760
+ )
761
+ }
762
+
763
+ type PropertyInput = {
764
+ readonly pageId: string
765
+ readonly propertyId: string
766
+ readonly list: ListQuery
767
+ }
768
+
769
+ const decodeAgain = (value: string): string | undefined => {
770
+ try {
771
+ return decodeURIComponent(value)
772
+ } catch {
773
+ return undefined
774
+ }
775
+ }
776
+
777
+ const propertyItems: Route = statefulRoute(
778
+ evidence('GET', '/pages/{pageId}/properties/{propertyId}', false, [propertyCase]),
779
+ 'none',
780
+ (request, env): PropertyInput | NotEmulated => {
781
+ const pageId = idParam(request, 'pageId')
782
+
783
+ if (isNotEmulated(pageId)) return pageId
784
+
785
+ const once = request.params.propertyId ?? ''
786
+ const propertyId = env.drills.rejectDoubleEncodedPropertyId ? decodeAgain(once) : once
787
+
788
+ if (propertyId === undefined) return notEmulated('a malformed property id is not emulated')
789
+
790
+ const query = listQuery(request)
791
+
792
+ return isNotEmulated(query) ? query : { pageId, propertyId, list: query }
793
+ },
794
+ (state, input, { env }) => {
795
+ const items = state.propertyItems.find(
796
+ candidate => candidate.pageId === input.pageId && candidate.propertyId === input.propertyId
797
+ )
798
+
799
+ if (items === undefined || !state.pages.some(page => page.id === input.pageId)) {
800
+ return notEmulated('items of a property with no seeded item list are not emulated')
801
+ }
802
+
803
+ const listKey = `property\u0000${items.pageId}\u0000${items.propertyId}`
804
+ const fingerprint = JSON.stringify(items.values)
805
+ const offset = startOf(env, listKey, fingerprint, input.list.startCursor)
806
+
807
+ if (isNotEmulated(offset)) return offset
808
+
809
+ return () => {
810
+ const end = Math.min(offset + input.list.pageSize, items.values.length)
811
+ const nextCursor = end < items.values.length ? mintPropertyCursor(env) : null
812
+
813
+ if (nextCursor !== null) issue(env, listKey, nextCursor, end, fingerprint)
814
+
815
+ return ok({
816
+ object: 'list',
817
+ results: items.values.slice(offset, end).map(value => ({
818
+ object: 'property_item',
819
+ id: items.propertyId,
820
+ type: items.type,
821
+ [items.type]: value
822
+ })),
823
+ next_cursor: nextCursor,
824
+ has_more: nextCursor !== null,
825
+ type: 'property_item',
826
+ property_item: {
827
+ id: items.propertyId,
828
+ // The fixture's next_url names the property id as the page object returns it.
829
+ next_url:
830
+ nextCursor === null
831
+ ? null
832
+ : `${notionEmulatorOrigin}${notionEmulatorBasePath}/pages/${items.pageId}/properties/${items.propertyId}?start_cursor=${nextCursor}`,
833
+ type: items.type,
834
+ [items.type]: {}
835
+ }
836
+ })
837
+ }
838
+ }
839
+ )
840
+
841
+ const getDatabase: Route = statefulRoute(
842
+ evidence('GET', '/databases/{databaseId}', false, [dataSourceCase]),
843
+ 'none',
844
+ request => withoutQuery(request) ?? idParam(request, 'databaseId'),
845
+ (state, id) => {
846
+ const database = state.databases.find(candidate => candidate.id === id)
847
+
848
+ if (database === undefined) return notEmulated('a missing database is not emulated')
849
+
850
+ return answer(() =>
851
+ ok({
852
+ object: 'database',
853
+ id: database.id,
854
+ title: [...database.title],
855
+ description: [...database.description],
856
+ parent: database.parent,
857
+ is_inline: database.isInline,
858
+ in_trash: database.inTrash,
859
+ archived: database.archived,
860
+ created_time: database.createdTime,
861
+ last_edited_time: database.lastEditedTime,
862
+ data_sources: state.dataSources
863
+ .filter(source => source.databaseId === database.id)
864
+ .map(source => ({
865
+ id: source.id,
866
+ name: source.title
867
+ .map(item =>
868
+ isJsonObject(item) && Predicate.isString(item.plain_text) ? item.plain_text : ''
869
+ )
870
+ .join('')
871
+ })),
872
+ url: database.url
873
+ })
874
+ )
875
+ }
876
+ )
877
+
878
+ const getDataSource: Route = statefulRoute(
879
+ evidence('GET', '/data_sources/{dataSourceId}', false, [dataSourceCase]),
880
+ 'none',
881
+ request => withoutQuery(request) ?? idParam(request, 'dataSourceId'),
882
+ (state, id) => {
883
+ const source = state.dataSources.find(candidate => candidate.id === id)
884
+ const database = state.databases.find(candidate => candidate.id === source?.databaseId)
885
+
886
+ if (source === undefined || database === undefined) {
887
+ return notEmulated('a missing data source is not emulated')
888
+ }
889
+
890
+ return answer(() =>
891
+ ok({
892
+ object: 'data_source',
893
+ id: source.id,
894
+ parent: { type: 'database_id', database_id: database.id },
895
+ database_parent: database.parent,
896
+ title: [...source.title],
897
+ properties: source.properties,
898
+ archived: source.archived,
899
+ in_trash: source.inTrash
900
+ })
901
+ )
902
+ }
903
+ )
904
+
905
+ type QueryInput = { readonly dataSourceId: string; readonly pageSize: number }
906
+
907
+ const queryDataSource: Route = statefulRoute(
908
+ evidence('POST', '/data_sources/{dataSourceId}/query', false, [dataSourceCase]),
909
+ 'json',
910
+ (request): QueryInput | NotEmulated => {
911
+ const dataSourceId = idParam(request, 'dataSourceId')
912
+
913
+ if (isNotEmulated(dataSourceId)) return dataSourceId
914
+
915
+ const body = jsonBody(request, 'the query body', ['page_size'])
916
+
917
+ if (isNotEmulated(body)) return body
918
+
919
+ const pageSize = integerIn(body.page_size, 'page_size', 1, 100)
920
+
921
+ return isNotEmulated(pageSize) ? pageSize : { dataSourceId, pageSize }
922
+ },
923
+ (state, input, { env }) => {
924
+ if (!state.dataSources.some(source => source.id === input.dataSourceId)) {
925
+ return notEmulated('a missing data source is not emulated')
926
+ }
927
+
928
+ const inSource = (parent: NotionEmulatorPage['parent'] | null): boolean =>
929
+ parent?.type === 'data_source_id' && parent.data_source_id === input.dataSourceId
930
+
931
+ const rows = state.pages.filter(page => inSource(page.parent))
932
+
933
+ if (rows.some(isTrashed)) {
934
+ return notEmulated('a query of a data source with a trashed row is not emulated')
935
+ }
936
+
937
+ const implied = state.impliedPages.filter(row => inSource(row.parent)).map(row => row.id)
938
+ const next = [...rows.map(row => row.id), ...implied][input.pageSize]
939
+
940
+ // The fixture records a first page whose next row it only names: a page that would show an
941
+ // implied row, or a last page, is not recorded.
942
+ if (rows.length < input.pageSize) {
943
+ return notEmulated('a query page showing a row no fixture shows is not emulated')
944
+ }
945
+
946
+ const page = rows.slice(0, input.pageSize)
947
+
948
+ if (next === undefined) {
949
+ return notEmulated('a last query page (no further row) is not emulated')
950
+ }
951
+
952
+ return answer(() =>
953
+ ok(
954
+ list(
955
+ page.map(row => renderPage(env, row, 'list')),
956
+ next,
957
+ 'page_or_data_source'
958
+ )
959
+ )
960
+ )
961
+ }
962
+ )
963
+
964
+ /** The route table: evidence plus handlers. `notionEmulatorRoutes` is its evidence part. */
965
+ export const notionApiRoutes: ReadonlyArray<Route> = [
966
+ search,
967
+ botUser,
968
+ getPage,
969
+ createPage,
970
+ archivePage,
971
+ blockChildren,
972
+ propertyItems,
973
+ getDatabase,
974
+ getDataSource,
975
+ queryDataSource
976
+ ]