@timber-js/app 0.2.0-alpha.186 → 0.2.0-alpha.188

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 (331) hide show
  1. package/dist/_chunks/{actions-C-Rw9vPc.js → actions-35jnMdeJ.js} +4 -3
  2. package/dist/_chunks/{actions-C-Rw9vPc.js.map → actions-35jnMdeJ.js.map} +1 -1
  3. package/dist/_chunks/als-registry-C6kcfprT.js +41 -0
  4. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -0
  5. package/dist/_chunks/{als-slots-mFweg276.js → als-slots-BEEIPKYm.js} +3 -4
  6. package/dist/_chunks/{als-slots-mFweg276.js.map → als-slots-BEEIPKYm.js.map} +1 -1
  7. package/dist/_chunks/{cache-api-Cd0VZ_Pd.js → cache-api-DjNrIWRR.js} +7 -13
  8. package/dist/_chunks/cache-api-DjNrIWRR.js.map +1 -0
  9. package/dist/_chunks/cli-check-CpmN7Nh-.js +256 -0
  10. package/dist/_chunks/cli-check-CpmN7Nh-.js.map +1 -0
  11. package/dist/_chunks/cli-schema-sync-CGMp_Psg.js +298 -0
  12. package/dist/_chunks/cli-schema-sync-CGMp_Psg.js.map +1 -0
  13. package/dist/_chunks/{cloudflare-Cs0uZXea.js → cloudflare-CGP6BZKO.js} +4 -3
  14. package/dist/_chunks/{cloudflare-Cs0uZXea.js.map → cloudflare-CGP6BZKO.js.map} +1 -1
  15. package/dist/_chunks/convention-lint-kXsgc_-7.js +784 -0
  16. package/dist/_chunks/convention-lint-kXsgc_-7.js.map +1 -0
  17. package/dist/_chunks/{error-boundary-DpYRI_I1.js → error-boundary-D-ODYX41.js} +25 -2
  18. package/dist/_chunks/error-boundary-D-ODYX41.js.map +1 -0
  19. package/dist/_chunks/file-cache-DmX7OqZP.js +454 -0
  20. package/dist/_chunks/file-cache-DmX7OqZP.js.map +1 -0
  21. package/dist/_chunks/{logger-N7e5auP0.js → logger-D8xJZXIN.js} +27 -40
  22. package/dist/_chunks/logger-D8xJZXIN.js.map +1 -0
  23. package/dist/_chunks/mdx-file-C005ay-P.js +25 -0
  24. package/dist/_chunks/mdx-file-C005ay-P.js.map +1 -0
  25. package/dist/_chunks/{plugin-context-DEGLSJs3.js → plugin-context-rCinWLiE.js} +7 -2
  26. package/dist/_chunks/{plugin-context-DEGLSJs3.js.map → plugin-context-rCinWLiE.js.map} +1 -1
  27. package/dist/_chunks/purge-store-Byr8XOjU.js +14 -0
  28. package/dist/_chunks/purge-store-Byr8XOjU.js.map +1 -0
  29. package/dist/_chunks/{resolve-schema-Dz3fcFUo.js → resolve-schema-5ma5pp1b.js} +2 -2
  30. package/dist/_chunks/{resolve-schema-Dz3fcFUo.js.map → resolve-schema-5ma5pp1b.js.map} +1 -1
  31. package/dist/_chunks/rsc-media-type-DRqE_lD_.js +46 -0
  32. package/dist/_chunks/rsc-media-type-DRqE_lD_.js.map +1 -0
  33. package/dist/_chunks/{cli-schema-sync-CKgHC2MB.js → scanner-C8b0Gcw3.js} +5 -298
  34. package/dist/_chunks/scanner-C8b0Gcw3.js.map +1 -0
  35. package/dist/_chunks/schema-bridge-Cc2Gngu1.js +199 -0
  36. package/dist/_chunks/schema-bridge-Cc2Gngu1.js.map +1 -0
  37. package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
  38. package/dist/_chunks/{param-value-C8TNYchQ.js → segment-context-CjOlyB8Y.js} +33 -2
  39. package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -0
  40. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
  41. package/dist/_chunks/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
  42. package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
  43. package/dist/_chunks/{navigation-root-B29qg0_T.js → use-segment-params-C4r4BD9T.js} +129 -5
  44. package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
  45. package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
  46. package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
  47. package/dist/adapters/cloudflare-dev.js +1 -1
  48. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  49. package/dist/adapters/cloudflare.d.ts.map +1 -1
  50. package/dist/adapters/cloudflare.js +1 -1
  51. package/dist/adapters/compress-module.d.ts +12 -0
  52. package/dist/adapters/compress-module.d.ts.map +1 -1
  53. package/dist/adapters/nitro.js +54 -2
  54. package/dist/adapters/nitro.js.map +1 -1
  55. package/dist/cache/index.js +1 -1
  56. package/dist/cdn/cloudflare-purge.js +30 -0
  57. package/dist/cdn/cloudflare-purge.js.map +1 -0
  58. package/dist/cdn/fastly-purge.js +33 -0
  59. package/dist/cdn/fastly-purge.js.map +1 -0
  60. package/dist/cdn/index.js +94 -0
  61. package/dist/cdn/index.js.map +1 -0
  62. package/dist/cdn/workers-cache-purge.js +35 -0
  63. package/dist/cdn/workers-cache-purge.js.map +1 -0
  64. package/dist/cli-check.d.ts +153 -0
  65. package/dist/cli-check.d.ts.map +1 -0
  66. package/dist/cli.d.ts +34 -8
  67. package/dist/cli.d.ts.map +1 -1
  68. package/dist/cli.js +46 -21
  69. package/dist/cli.js.map +1 -1
  70. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  71. package/dist/client/browser-entry/index.d.ts +1 -1
  72. package/dist/client/browser-entry/index.d.ts.map +1 -1
  73. package/dist/client/error-boundary.d.ts +6 -0
  74. package/dist/client/error-boundary.d.ts.map +1 -1
  75. package/dist/client/error-boundary.js +1 -1
  76. package/dist/client/index.d.ts +1 -0
  77. package/dist/client/index.d.ts.map +1 -1
  78. package/dist/client/index.js +27 -33
  79. package/dist/client/index.js.map +1 -1
  80. package/dist/client/internal.js +33 -17
  81. package/dist/client/internal.js.map +1 -1
  82. package/dist/client/link.d.ts +1 -7
  83. package/dist/client/link.d.ts.map +1 -1
  84. package/dist/client/navigation-commit.d.ts +16 -0
  85. package/dist/client/navigation-commit.d.ts.map +1 -1
  86. package/dist/client/router-pipeline.d.ts.map +1 -1
  87. package/dist/client/router.d.ts.map +1 -1
  88. package/dist/client/rsc-fetch.d.ts +9 -1
  89. package/dist/client/rsc-fetch.d.ts.map +1 -1
  90. package/dist/client/segment-cache.d.ts +14 -0
  91. package/dist/client/segment-cache.d.ts.map +1 -1
  92. package/dist/client/use-query-states.d.ts +9 -3
  93. package/dist/client/use-query-states.d.ts.map +1 -1
  94. package/dist/codec.js +1 -1
  95. package/dist/cookies/define-cookie.d.ts.map +1 -1
  96. package/dist/cookies/index.js +2 -2
  97. package/dist/cookies/index.js.map +1 -1
  98. package/dist/index.d.ts +4 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +142 -477
  101. package/dist/index.js.map +1 -1
  102. package/dist/params/index.js +1 -1
  103. package/dist/plugin-context.d.ts +15 -0
  104. package/dist/plugin-context.d.ts.map +1 -1
  105. package/dist/plugins/routing.d.ts +0 -9
  106. package/dist/plugins/routing.d.ts.map +1 -1
  107. package/dist/plugins/shims.d.ts.map +1 -1
  108. package/dist/plugins/static-build.d.ts +24 -0
  109. package/dist/plugins/static-build.d.ts.map +1 -1
  110. package/dist/routing/codegen-shared.d.ts +3 -44
  111. package/dist/routing/codegen-shared.d.ts.map +1 -1
  112. package/dist/routing/codegen-types.d.ts +10 -31
  113. package/dist/routing/codegen-types.d.ts.map +1 -1
  114. package/dist/routing/codegen-write.d.ts +51 -0
  115. package/dist/routing/codegen-write.d.ts.map +1 -0
  116. package/dist/routing/codegen.d.ts.map +1 -1
  117. package/dist/routing/convention-lint.d.ts +18 -4
  118. package/dist/routing/convention-lint.d.ts.map +1 -1
  119. package/dist/routing/export-detect.d.ts +16 -0
  120. package/dist/routing/export-detect.d.ts.map +1 -1
  121. package/dist/routing/index.js +3 -2
  122. package/dist/routing/link-codegen.d.ts +19 -4
  123. package/dist/routing/link-codegen.d.ts.map +1 -1
  124. package/dist/routing/manifest-codegen.d.ts +1 -7
  125. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  126. package/dist/routing/segment-keys.d.ts +22 -0
  127. package/dist/routing/segment-keys.d.ts.map +1 -1
  128. package/dist/routing/types.d.ts +0 -6
  129. package/dist/routing/types.d.ts.map +1 -1
  130. package/dist/schema-bridge.d.ts +60 -9
  131. package/dist/schema-bridge.d.ts.map +1 -1
  132. package/dist/search-params/define.d.ts +62 -8
  133. package/dist/search-params/define.d.ts.map +1 -1
  134. package/dist/search-params/index.d.ts +0 -1
  135. package/dist/search-params/index.d.ts.map +1 -1
  136. package/dist/search-params/index.js +66 -29
  137. package/dist/search-params/index.js.map +1 -1
  138. package/dist/search-params/parse-total.d.ts +70 -0
  139. package/dist/search-params/parse-total.d.ts.map +1 -0
  140. package/dist/search-params/wrappers.d.ts +26 -3
  141. package/dist/search-params/wrappers.d.ts.map +1 -1
  142. package/dist/server/access-gate.d.ts +19 -8
  143. package/dist/server/access-gate.d.ts.map +1 -1
  144. package/dist/server/action-handler.d.ts.map +1 -1
  145. package/dist/server/als-registry.d.ts +16 -0
  146. package/dist/server/als-registry.d.ts.map +1 -1
  147. package/dist/server/chain-url-parts.d.ts +2 -3
  148. package/dist/server/chain-url-parts.d.ts.map +1 -1
  149. package/dist/server/compress.d.ts.map +1 -1
  150. package/dist/server/deny-boundary.d.ts +148 -15
  151. package/dist/server/deny-boundary.d.ts.map +1 -1
  152. package/dist/server/deny-renderer.d.ts +2 -2
  153. package/dist/server/deny-renderer.d.ts.map +1 -1
  154. package/dist/server/error-boundary-wrapper.d.ts +85 -15
  155. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  156. package/dist/server/index.d.ts +0 -1
  157. package/dist/server/index.d.ts.map +1 -1
  158. package/dist/server/index.js +3 -2
  159. package/dist/server/index.js.map +1 -1
  160. package/dist/server/internal.d.ts +3 -1
  161. package/dist/server/internal.d.ts.map +1 -1
  162. package/dist/server/internal.js +344 -237
  163. package/dist/server/internal.js.map +1 -1
  164. package/dist/server/metadata-collector.d.ts +52 -0
  165. package/dist/server/metadata-collector.d.ts.map +1 -0
  166. package/dist/server/param-coercion.d.ts +12 -5
  167. package/dist/server/param-coercion.d.ts.map +1 -1
  168. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  169. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  170. package/dist/server/pipeline-phases.d.ts.map +1 -1
  171. package/dist/server/primitives.d.ts +23 -0
  172. package/dist/server/primitives.d.ts.map +1 -1
  173. package/dist/server/route-element-builder.d.ts +1 -12
  174. package/dist/server/route-element-builder.d.ts.map +1 -1
  175. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
  177. package/dist/server/rsc-entry/error-renderer.d.ts +1 -1
  178. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  179. package/dist/server/rsc-entry/helpers.d.ts +0 -7
  180. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  181. package/dist/server/rsc-entry/index.d.ts +0 -1
  182. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  183. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  184. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  185. package/dist/server/rsc-entry/rsc-payload.d.ts +1 -3
  186. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  187. package/dist/server/rsc-entry/rsc-stream.d.ts +12 -0
  188. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  189. package/dist/server/rsc-entry/ssr-renderer.d.ts +0 -2
  190. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  191. package/dist/server/skippable-prefix.d.ts +3 -0
  192. package/dist/server/skippable-prefix.d.ts.map +1 -1
  193. package/dist/server/slot-resolver.d.ts +18 -19
  194. package/dist/server/slot-resolver.d.ts.map +1 -1
  195. package/dist/server/ssr-bridge-types.d.ts +11 -0
  196. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  197. package/dist/server/ssr-entry.d.ts +0 -1
  198. package/dist/server/ssr-entry.d.ts.map +1 -1
  199. package/dist/server/state-tree-diff.d.ts +9 -16
  200. package/dist/server/state-tree-diff.d.ts.map +1 -1
  201. package/dist/server/static-generator.d.ts.map +1 -1
  202. package/dist/server/status-code-resolver.d.ts +8 -1
  203. package/dist/server/status-code-resolver.d.ts.map +1 -1
  204. package/dist/server/tree-builder.d.ts +28 -43
  205. package/dist/server/tree-builder.d.ts.map +1 -1
  206. package/dist/server/types.d.ts +12 -7
  207. package/dist/server/types.d.ts.map +1 -1
  208. package/dist/server/utils/element-type.d.ts +40 -0
  209. package/dist/server/utils/element-type.d.ts.map +1 -0
  210. package/dist/shared/rsc-media-type.d.ts +40 -0
  211. package/dist/shared/rsc-media-type.d.ts.map +1 -0
  212. package/dist/shared/segment-info.d.ts +7 -0
  213. package/dist/shared/segment-info.d.ts.map +1 -1
  214. package/docs/api/30-api-server.mdx +1 -1
  215. package/docs/api/33-api-search-params.mdx +38 -16
  216. package/docs/api/35-api-typescript.mdx +3 -3
  217. package/docs/api/36-cli.mdx +34 -7
  218. package/docs/learn/00-introduction.mdx +1 -1
  219. package/docs/learn/02-pages-and-layouts.mdx +1 -1
  220. package/docs/learn/05-typed-params.mdx +8 -8
  221. package/docs/learn/07-typed-routes.mdx +12 -7
  222. package/docs/learn/11-error-handling.mdx +16 -0
  223. package/docs/more/01-advanced-routing.mdx +1 -1
  224. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  225. package/docs/more/50-ai-agent-instructions.mdx +6 -4
  226. package/package.json +8 -5
  227. package/src/adapters/cloudflare.ts +4 -1
  228. package/src/adapters/compress-module.ts +79 -1
  229. package/src/cli-check.ts +458 -0
  230. package/src/cli.ts +59 -24
  231. package/src/client/browser-entry/action-dispatch.ts +2 -1
  232. package/src/client/browser-entry/index.ts +0 -5
  233. package/src/client/error-boundary.tsx +65 -1
  234. package/src/client/index.ts +14 -3
  235. package/src/client/link.tsx +65 -64
  236. package/src/client/navigation-commit.ts +27 -4
  237. package/src/client/params-context.ts +4 -4
  238. package/src/client/router-pipeline.ts +4 -0
  239. package/src/client/router.ts +1 -0
  240. package/src/client/rsc-fetch.ts +14 -4
  241. package/src/client/segment-cache.ts +26 -5
  242. package/src/client/use-query-states.ts +102 -39
  243. package/src/cookies/define-cookie.ts +6 -1
  244. package/src/index.ts +20 -3
  245. package/src/plugin-context.ts +26 -0
  246. package/src/plugins/routing.ts +84 -146
  247. package/src/plugins/shims.ts +0 -1
  248. package/src/plugins/static-build.ts +78 -24
  249. package/src/routing/codegen-shared.ts +3 -79
  250. package/src/routing/codegen-types.ts +10 -31
  251. package/src/routing/codegen-write.ts +139 -0
  252. package/src/routing/codegen.ts +56 -182
  253. package/src/routing/convention-lint.ts +139 -40
  254. package/src/routing/export-detect.ts +151 -7
  255. package/src/routing/link-codegen.ts +32 -65
  256. package/src/routing/manifest-codegen.ts +1 -59
  257. package/src/routing/scanner.ts +3 -3
  258. package/src/routing/segment-keys.ts +37 -0
  259. package/src/routing/types.ts +0 -6
  260. package/src/schema-bridge.ts +180 -58
  261. package/src/search-params/define.ts +102 -37
  262. package/src/search-params/index.ts +0 -1
  263. package/src/search-params/parse-total.ts +78 -0
  264. package/src/search-params/wrappers.ts +60 -11
  265. package/src/server/access-gate.tsx +61 -68
  266. package/src/server/action-handler.ts +1 -4
  267. package/src/server/als-registry.ts +16 -0
  268. package/src/server/chain-url-parts.ts +2 -3
  269. package/src/server/compress.ts +9 -1
  270. package/src/server/deny-boundary.ts +269 -41
  271. package/src/server/deny-renderer.ts +32 -21
  272. package/src/server/error-boundary-wrapper.ts +166 -79
  273. package/src/server/index.ts +1 -3
  274. package/src/server/internal.ts +2 -2
  275. package/src/server/metadata-collector.ts +115 -0
  276. package/src/server/param-coercion.ts +13 -61
  277. package/src/server/pipeline-helpers.ts +2 -2
  278. package/src/server/pipeline-outcome.ts +2 -1
  279. package/src/server/pipeline-phases.ts +9 -9
  280. package/src/server/primitives.ts +25 -0
  281. package/src/server/route-element-builder.ts +169 -194
  282. package/src/server/rsc-cache-key-guard.ts +2 -8
  283. package/src/server/rsc-entry/deny-fallback.ts +3 -2
  284. package/src/server/rsc-entry/error-renderer.ts +31 -11
  285. package/src/server/rsc-entry/helpers.ts +3 -12
  286. package/src/server/rsc-entry/index.ts +0 -5
  287. package/src/server/rsc-entry/render-route.ts +14 -11
  288. package/src/server/rsc-entry/revalidate-renderer.ts +2 -1
  289. package/src/server/rsc-entry/rsc-payload.ts +104 -20
  290. package/src/server/rsc-entry/rsc-stream.ts +25 -2
  291. package/src/server/rsc-entry/ssr-renderer.ts +28 -16
  292. package/src/server/skippable-prefix.ts +21 -39
  293. package/src/server/slot-resolver.ts +69 -189
  294. package/src/server/ssr-bridge-types.ts +12 -0
  295. package/src/server/ssr-entry.ts +8 -6
  296. package/src/server/state-tree-diff.ts +11 -62
  297. package/src/server/static-generator.ts +3 -2
  298. package/src/server/status-code-resolver.ts +28 -11
  299. package/src/server/tree-builder.ts +35 -228
  300. package/src/server/types.ts +12 -7
  301. package/src/server/utils/element-type.ts +72 -0
  302. package/src/shared/rsc-media-type.ts +43 -0
  303. package/src/shared/segment-info.ts +7 -0
  304. package/dist/_chunks/cache-api-Cd0VZ_Pd.js.map +0 -1
  305. package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +0 -1
  306. package/dist/_chunks/error-boundary-DpYRI_I1.js.map +0 -1
  307. package/dist/_chunks/logger-N7e5auP0.js.map +0 -1
  308. package/dist/_chunks/navigation-root-B29qg0_T.js.map +0 -1
  309. package/dist/_chunks/param-value-C8TNYchQ.js.map +0 -1
  310. package/dist/_chunks/registry-DbJPKoBp.js +0 -20
  311. package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
  312. package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
  313. package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
  314. package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
  315. package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
  316. package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
  317. package/dist/_chunks/use-segment-params-ClyUNq4d.js +0 -128
  318. package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +0 -1
  319. package/dist/_chunks/walkers-BhhwI9TD.js +0 -936
  320. package/dist/_chunks/walkers-BhhwI9TD.js.map +0 -1
  321. package/dist/search-params/registry.d.ts +0 -20
  322. package/dist/search-params/registry.d.ts.map +0 -1
  323. package/dist/segment-params/define.d.ts +0 -83
  324. package/dist/segment-params/define.d.ts.map +0 -1
  325. package/dist/segment-params/index.d.ts +0 -3
  326. package/dist/segment-params/index.d.ts.map +0 -1
  327. package/dist/segment-params/index.js +0 -70
  328. package/dist/segment-params/index.js.map +0 -1
  329. package/src/search-params/registry.ts +0 -31
  330. package/src/segment-params/define.ts +0 -226
  331. package/src/segment-params/index.ts +0 -9
@@ -5,8 +5,17 @@
5
5
  * This module is the single source of truth for:
6
6
  * - StandardSchemaV1 interface (subset of the Standard Schema spec)
7
7
  * - validateSync() helper
8
- * - fromSchema() — bridge from Standard Schema to Codec<T>
9
- * - fromArraySchema() — bridge for array-valued codecs
8
+ * - fromSchema() — search-param bridge; scalar shape first, array second
9
+ * - fromArraySchema() — same loop, array shape first
10
+ * - fromCookieSchema() — scalar shape only; a cookie has no array shape
11
+ * - fromParamSchema() — route params; throws on failure (invalid param → 404)
12
+ *
13
+ * One parse loop, four candidate orders. Which shapes a bridge offers, and
14
+ * in what order, is the ONLY thing that differs — see §"Shape tolerance"
15
+ * below and in design/23-search-params.md. `serialize` is deliberately NOT
16
+ * shared: `null` means "omit the key" for a search param and "delete the
17
+ * cookie" for a cookie, so unifying it silently changed what
18
+ * `cookie.set([])` did (TIM-1352).
10
19
  *
11
20
  * These are re-exported from @timber-js/app/search-params, @timber-js/app/segment-params,
12
21
  * and @timber-js/app/cookies for convenience. The canonical import is
@@ -122,18 +131,21 @@ export function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T
122
131
  * @param fieldName - used in error messages
123
132
  * @param value - the codec or schema to resolve
124
133
  * @param mode - 'param' uses fromParamSchema (throws on parse failure),
125
- * 'search' uses fromSchema (falls back to default on failure)
134
+ * 'search' uses fromSchema (falls back to default on failure,
135
+ * tolerant of the repeated-key array shape),
136
+ * 'cookie' uses fromCookieSchema (same, minus the array shape,
137
+ * which a cookie value cannot have)
126
138
  */
127
139
  export function resolveCodecOrSchema(
128
140
  fieldName: string,
129
141
  value: unknown,
130
- mode: 'param' | 'search' = 'search'
142
+ mode: 'param' | 'search' | 'cookie' = 'search'
131
143
  ): Codec<unknown> {
132
144
  if (isCodec(value)) return value;
133
145
  if (isStandardSchema(value)) {
134
- return mode === 'param'
135
- ? fromParamSchema(fieldName, value)
136
- : (fromSchema(value) as Codec<unknown>);
146
+ if (mode === 'param') return fromParamSchema(fieldName, value);
147
+ if (mode === 'cookie') return fromCookieSchema(value) as Codec<unknown>;
148
+ return fromSchema(value) as Codec<unknown>;
137
149
  }
138
150
  throw new Error(
139
151
  `[timber] Field '${fieldName}' is not a valid codec or Standard Schema. ` +
@@ -146,51 +158,169 @@ export function resolveCodecOrSchema(
146
158
  // fromSchema — bridge from Standard Schema to Codec<T>
147
159
  // ---------------------------------------------------------------------------
148
160
 
161
+ // ---------------------------------------------------------------------------
162
+ // Shape tolerance — the shared parse loop for both bridges
163
+ //
164
+ // A URL value is `string | string[] | undefined`, but a schema is written
165
+ // against ONE of those: `z.string()` wants `'a'`, `z.array(z.string())`
166
+ // wants `['a']`. Nothing in Standard Schema says which — `~standard.types`
167
+ // exists at the type level only, and probing at runtime is not reliable
168
+ // (design/23-search-params.md §"Shape tolerance"). So a bridge tries BOTH
169
+ // shapes rather than committing to one and discarding the value when it
170
+ // guessed wrong.
171
+ //
172
+ // Each bridge only chooses WHICH candidates, and in what order. The first
173
+ // candidate is always the shape that bridge already passed, so nothing that
174
+ // parses today changes meaning; a later one is reached only where the old
175
+ // code fell through to the default. `fromCookieSchema` offers ONE candidate,
176
+ // because a cookie has no array shape at all.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * The scalar shape, and only it. A value that cannot be repeated — a cookie —
181
+ * has no array shape to tolerate, so its bridge stays exactly where it was.
182
+ */
183
+ function scalarOnly(value: string | string[] | undefined): unknown[] {
184
+ return [Array.isArray(value) ? value[0] : value];
185
+ }
186
+
187
+ /** Candidate inputs for the scalar bridge: scalar first, array second. */
188
+ function scalarFirst(value: string | string[] | undefined): unknown[] {
189
+ if (value === undefined) return [undefined];
190
+ if (typeof value === 'string') return [value, [value]];
191
+ // Repeated keys: `value[0]` matches URLSearchParams.get().
192
+ //
193
+ // A raw `[]` carries no value at all. `value[0]` is `undefined`, which is
194
+ // the shape the scalar bridge has always passed for it, so `undefined`
195
+ // stays FIRST — an empty array must keep meaning "absent" and land on the
196
+ // schema's default. Only `defineSearchParams().parse({ tags: [] })`, the
197
+ // record form, can produce one: URLSearchParams never yields an empty list.
198
+ return value.length > 0 ? [value[0], value] : [undefined, value];
199
+ }
200
+
201
+ /** Candidate inputs for the array bridge: array first, scalar second. */
202
+ function arrayFirst(value: string | string[] | undefined): unknown[] {
203
+ if (value === undefined) return [undefined];
204
+ if (typeof value === 'string') return [[value], value];
205
+ // `[]` first here for the same reason, inverted: the array bridge has
206
+ // always passed the empty array straight through.
207
+ return value.length > 0 ? [value, value[0]] : [value, undefined];
208
+ }
209
+
210
+ /**
211
+ * Validate `value` against `schema` in each candidate shape, then fall back
212
+ * to the schema's default. Shared by every bridge — they differ only in
213
+ * which candidates they offer, and in what order.
214
+ */
215
+ function parseThroughSchema<T>(
216
+ schema: StandardSchemaV1<T>,
217
+ candidates: (value: string | string[] | undefined) => unknown[],
218
+ value: string | string[] | undefined
219
+ ): T {
220
+ const inputs = candidates(value);
221
+ for (let i = 0; i < inputs.length; i++) {
222
+ // A throw from the FIRST candidate propagates, unchanged: that is the
223
+ // shape the schema was written against, and a throw from it is the
224
+ // deliberate signal design/23 protects — an app schema may `redirect()`
225
+ // or `notFound()` on a value it refuses, and `validateSync` throws on
226
+ // an async schema.
227
+ //
228
+ // A throw from a LATER candidate is ours, not the app's. We invented
229
+ // that shape; the schema never agreed to receive it. A scalar-only
230
+ // hand-written validator doing `value.toUpperCase()`, or a schema that
231
+ // is async for the array shape only, would turn a field that used to
232
+ // fall back to its default into a render-phase 500. Treat it as "this
233
+ // shape does not fit" and keep going.
234
+ let result: StandardSchemaResult<T>;
235
+ try {
236
+ result = validateSync(schema, inputs[i]);
237
+ } catch (error) {
238
+ if (i === 0) throw error;
239
+ continue;
240
+ }
241
+ if (!result.issues) {
242
+ return result.value;
243
+ }
244
+ }
245
+
246
+ // No shape validated — try parsing undefined to get the default.
247
+ // Re-validate each time so factory defaults (e.g. .default(() => []))
248
+ // produce fresh values.
249
+ const defaultResult = validateSync(schema, undefined);
250
+ if (!defaultResult.issues) {
251
+ return defaultResult.value;
252
+ }
253
+
254
+ // No default available — the field is implicitly optional. Return
255
+ // undefined; defineSearchParams widens the field's inferred type to
256
+ // T | undefined via InferField so this doesn't lie.
257
+ // design/23-search-params.md §"Implicit Optionality"
258
+ return undefined as T;
259
+ }
260
+
149
261
  /**
150
262
  * Bridge a Standard Schema-compatible schema (Zod, Valibot, ArkType) to a
151
263
  * Codec<T>.
152
264
  *
153
- * Parse: coerces the raw string through the schema. On validation failure,
154
- * parses `undefined` to get the schema's default value (the schema should have
155
- * a `.default()` call). If that also fails, returns `undefined`.
265
+ * Parse: coerces the raw URL value through the schema, trying the scalar
266
+ * shape and then the array shape (see `scalarFirst`), so an array schema
267
+ * works bare: `z.array(z.string()).default([])` yields `[]` when absent,
268
+ * `['a']` for `?tags=a`, and `['a','b']` for `?tags=a&tags=b`. When no
269
+ * shape validates, parses `undefined` to get the schema's default (the
270
+ * schema should have a `.default()` call). If that also fails, returns
271
+ * `undefined`.
156
272
  *
157
- * Serialize: uses `String()` for primitives, `null` for null/undefined.
273
+ * Serialize: `String()` for primitives, `null` for null/undefined. Note
274
+ * that `String(['a','b'])` is `'a,b'` — the same string `fromArraySchema`
275
+ * writes — so a bare array schema round-trips exactly as design/09
276
+ * §"Array params" describes.
277
+ *
278
+ * This is the SEARCH-PARAM bridge. Cookies use `fromCookieSchema`; a
279
+ * cookie has no array shape (see below).
158
280
  *
159
281
  * ```ts
160
- * import { fromSchema } from '@timber-js/app/codec'
161
282
  * import { z } from 'zod/v4'
162
283
  *
163
284
  * const pageCodec = fromSchema(z.coerce.number().int().min(1).default(1))
285
+ * const tagsCodec = fromSchema(z.array(z.string()).default([]))
164
286
  * ```
165
287
  */
166
288
  export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
167
289
  return {
168
- parse(value: string | string[] | undefined): T {
169
- // For array inputs (duplicate query keys), use the first value.
170
- // Browsers and URLSearchParams.get() return the first occurrence.
171
- const input = Array.isArray(value) ? value[0] : value;
172
-
173
- // Try parsing the raw value
174
- const result = validateSync(schema, input);
175
- if (!result.issues) {
176
- return result.value;
177
- }
178
-
179
- // On failure, try parsing undefined to get the default.
180
- // Re-validate each time so factory defaults (e.g. .default(() => []))
181
- // produce fresh values.
182
- const defaultResult = validateSync(schema, undefined);
183
- if (!defaultResult.issues) {
184
- return defaultResult.value;
290
+ parse: (value: string | string[] | undefined): T =>
291
+ parseThroughSchema(schema, scalarFirst, value),
292
+ serialize(value: T): string | null {
293
+ if (value === null || value === undefined) {
294
+ return null;
185
295
  }
186
-
187
- // No default available — the field is implicitly optional. Return
188
- // undefined; defineSearchParams widens the field's inferred type to
189
- // T | undefined via InferField so this doesn't lie.
190
- // design/23-search-params.md §"Implicit Optionality"
191
- return undefined as T;
296
+ return String(value);
192
297
  },
298
+ };
299
+ }
193
300
 
301
+ // ---------------------------------------------------------------------------
302
+ // fromCookieSchema — bridge for cookie values
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Bridge a Standard Schema for a COOKIE value.
307
+ *
308
+ * Identical to `fromSchema` minus the array shape, because a cookie cannot
309
+ * carry one: `Cookie:` headers and `document.cookie` yield a single string
310
+ * per name, and there is no repeated-key concept to be tolerant of. Sharing
311
+ * the search-param bridge here meant a cookie written by this very codec —
312
+ * `serialize(['a','b'])` → `a,b` — read back as the one-element array
313
+ * `['a,b']` instead of failing to its default: no repeated key in sight,
314
+ * just a delimiter reinterpreted as data.
315
+ *
316
+ * The `serialize` contract also differs by domain and must not be unified:
317
+ * for a search param `null` means "omit the key", for a cookie it means
318
+ * **delete the cookie** (design/29-cookies.md).
319
+ */
320
+ export function fromCookieSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
321
+ return {
322
+ parse: (value: string | string[] | undefined): T =>
323
+ parseThroughSchema(schema, scalarOnly, value),
194
324
  serialize(value: T): string | null {
195
325
  if (value === null || value === undefined) {
196
326
  return null;
@@ -214,32 +344,24 @@ export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
214
344
  *
215
345
  * const tagsCodec = fromArraySchema(z.array(z.string()).default([]))
216
346
  * ```
347
+ *
348
+ * Since TIM-1352 a bare array schema works through `fromSchema` too, so
349
+ * this is no longer required to make an array field parse. Two cases still
350
+ * want it, both listed in design/23 §"Shape tolerance":
351
+ *
352
+ * 1. A **permissive** schema meant as an array — one that accepts a string
353
+ * as readily as an array (`z.any()`, a hand-written coercing validator)
354
+ * — where scalar-first ordering would settle on the scalar shape.
355
+ * 2. An array schema wrapped in `.catch([])`, which swallows the failure
356
+ * that would otherwise trigger the array attempt.
357
+ *
358
+ * It also serializes an empty array to `null` (omitting the key) where
359
+ * `fromSchema` writes `''`.
217
360
  */
218
361
  export function fromArraySchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
219
362
  return {
220
- parse(value: string | string[] | undefined): T {
221
- // Coerce single string to array for array schemas
222
- let input: unknown = value;
223
- if (typeof value === 'string') {
224
- input = [value];
225
- } else if (value === undefined) {
226
- input = undefined;
227
- }
228
-
229
- const result = validateSync(schema, input);
230
- if (!result.issues) {
231
- return result.value;
232
- }
233
-
234
- // On failure, try undefined for default
235
- const defaultResult = validateSync(schema, undefined);
236
- if (!defaultResult.issues) {
237
- return defaultResult.value;
238
- }
239
-
240
- return undefined as T;
241
- },
242
-
363
+ parse: (value: string | string[] | undefined): T =>
364
+ parseThroughSchema(schema, arrayFirst, value),
243
365
  serialize(value: T): string | null {
244
366
  if (value === null || value === undefined) {
245
367
  return null;
@@ -20,6 +20,7 @@ import type { Codec } from '../codec.js';
20
20
  // can register the getter without importing this file, which would drag
21
21
  // `nuqs` into every server entry (TIM-1298). See shared/als-slots.ts.
22
22
  import { getSearchParamsFromAls } from '../shared/als-slots.js';
23
+ import { parseTotal } from './parse-total.js';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Types
@@ -28,13 +29,43 @@ import { getSearchParamsFromAls } from '../shared/als-slots.js';
28
29
  /**
29
30
  * A codec that converts between URL string values and typed values.
30
31
  *
31
- * nuqs parsers implement this interface natively — no adapter needed.
32
+ * `parse` receives the RAW url value: `string | string[] | undefined`.
33
+ * A codec must be TOTAL over that domain — return a default rather than
34
+ * throwing on an absent or repeated param.
35
+ *
36
+ * A codec whose own `parse` covers only a PRESENT, SINGLE-VALUED param
37
+ * may publish `parseServerSide` instead, and timber calls that. nuqs
38
+ * parsers do exactly this, which is how they become total here: absent →
39
+ * `null` (or their `withDefault` value), repeated → the first value, and
40
+ * a throw from the inner parse → `null`. See
41
+ * design/23-search-params.md §'nuqs parsers, made total',
42
+ * `parse-total.ts`, and tests/nuqs-codec-boundary.test.ts (TIM-1350).
43
+ *
32
44
  * Standard Schema objects (Zod, Valibot, ArkType) are auto-detected
33
- * by defineSearchParams and wrapped via fromSchema.
45
+ * by defineSearchParams and wrapped via fromSchema; those ARE total
46
+ * through `parse` and are called that way.
34
47
  */
35
48
  export interface SearchParamCodec<T> extends Codec<T> {
36
49
  /** Optional URL key alias, set by withUrlKey(). */
37
50
  urlKey?: string;
51
+ /**
52
+ * Optional TOTAL entry point over `string | string[] | undefined`,
53
+ * preferred over `parse` wherever timber invokes a codec. Declared here
54
+ * so the protocol is typed rather than duck-checked at each wrapper:
55
+ * anything that reconstructs a codec has to carry it, and a wrapper
56
+ * cannot carry a property the interface does not admit.
57
+ *
58
+ * It returns `T`, not `T | null`. This is the entry point timber calls,
59
+ * so whatever it returns IS the field's type — admitting a `null` the
60
+ * field type did not carry would let `SearchParamCodec<string>` produce
61
+ * `null` for an absent param under a non-nullable annotation. A codec
62
+ * whose absent-case answer is `null` declares that in `T`, exactly as a
63
+ * bare nuqs parser does: `parseAsString` is a `SearchParamCodec<string |
64
+ * null>` here, never a `SearchParamCodec<string>`.
65
+ *
66
+ * nuqs parser builders satisfy this. See parse-total.ts.
67
+ */
68
+ parseServerSide?(value: string | string[] | undefined): T;
38
69
  }
39
70
 
40
71
  /** A codec with a URL key alias attached via withUrlKey(). */
@@ -93,7 +124,7 @@ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
93
124
  *
94
125
  * ```tsx
95
126
  * // app/products/page.tsx
96
- * import { searchParams } from './params'
127
+ * import { searchParams } from './search-params'
97
128
  * export default function Page() {
98
129
  * const { page, category } = searchParams.get()
99
130
  * }
@@ -112,15 +143,21 @@ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
112
143
  /** Pick a subset of keys. Preserves codecs and aliases. */
113
144
  pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>>;
114
145
 
115
- /** Serialize values to a query string (no leading '?'), omitting defaults. */
116
- serialize(values: Partial<T>): string;
146
+ /**
147
+ * Serialize values to a query string (no leading '?'), omitting defaults
148
+ * and applying `withUrlKey` aliases. This is the value to pass to
149
+ * `<Link searchParams={...}>`.
150
+ *
151
+ * Returns a **string**, not a `URLSearchParams`. A `<Link>` prop crosses
152
+ * the RSC Flight boundary, and `URLSearchParams` is iterable — React
153
+ * serializes it as an entries array, which arrives as `[['pg','2'], …]`
154
+ * and renders as `?0=pg&0=2`. A string survives intact.
155
+ */
156
+ buildSearchParams(values: Partial<T>): string;
117
157
 
118
158
  /** Build a full path with query string, omitting defaults. */
119
159
  href(pathname: string, values: Partial<T>): string;
120
160
 
121
- /** Build a URLSearchParams instance, omitting defaults. */
122
- toSearchParams(values: Partial<T>): URLSearchParams;
123
-
124
161
  /** Read-only codec map for spreading into .extend(). */
125
162
  codecs: { [K in keyof T]: SearchParamCodec<T[K]> };
126
163
 
@@ -159,6 +196,21 @@ type InferSchemaInput<V> = V extends { '~standard': { types?: infer TS } }
159
196
  /**
160
197
  * Infer the output type from either a SearchParamCodec or a StandardSchemaV1.
161
198
  *
199
+ * A codec publishing `parseServerSide` is read through THAT signature, not
200
+ * through `parse` — it is the entry point timber actually calls, and it is
201
+ * the one that tells the truth about absent input. A bare `parseAsString`
202
+ * declares `parse(value: string): string` but answers `null` for a missing
203
+ * param, so the field is `string | null`; `parseAsInteger.withDefault(1)`
204
+ * narrows its own `parseServerSide` to `NonNullable<number>` and the field
205
+ * stays `number`. Reading `parse` instead produced a non-nullable type for
206
+ * a nullable field (TIM-1350).
207
+ *
208
+ * The match is structural, not nuqs-specific: any codec declaring that
209
+ * signature opts into being read through it, which is exactly the contract
210
+ * `parseTotal` applies at runtime. The two must stay in step — a type
211
+ * inferred from `parse` while the runtime calls `parseServerSide` is the
212
+ * lie this branch exists to remove.
213
+ *
162
214
  * Schemas whose input type rejects `undefined` (e.g. bare `z.string()`, whose
163
215
  * input is `string`) are implicitly optional: the URL might not contain the
164
216
  * param, and fromSchema returns `undefined` when the schema rejects absent
@@ -171,8 +223,11 @@ type InferSchemaInput<V> = V extends { '~standard': { types?: infer TS } }
171
223
  * keeps its narrow output type even though absent input yields `undefined`
172
224
  * at runtime. Add `.default()` to coerce schemas for accurate types.
173
225
  */
174
- export type InferField<V> =
175
- V extends SearchParamCodec<infer T>
226
+ export type InferField<V> = V extends {
227
+ parseServerSide(value: string | string[] | undefined): infer R;
228
+ }
229
+ ? R
230
+ : V extends SearchParamCodec<infer T>
176
231
  ? T
177
232
  : V extends StandardSchemaV1<infer T>
178
233
  ? undefined extends InferSchemaInput<V>
@@ -210,13 +265,36 @@ function normalizeRaw(
210
265
  * default-omission: when serialize(value) === serialize(parse(undefined)),
211
266
  * the field is omitted from the URL.
212
267
  *
268
+ * Goes through `parseTotal` for the same reason request-time parsing does:
269
+ * a nuqs parser's own `parse` throws on absent input, and this is the
270
+ * absent case by construction.
271
+ *
272
+ * **A codec with no value for an absent param has no default to omit**,
273
+ * and `null` is returned rather than `serialize(null)`. Serializing it
274
+ * makes the "no value" case collide with a real one: `parseAsInteger`
275
+ * serializes `null` as the string `'null'`, so `buildSearchParams({ q:
276
+ * 'null' })` would silently drop a legitimate value (before TIM-1350 the
277
+ * absent parse was `undefined` and the swallowed input was the string
278
+ * `'undefined'` — same defect, a less likely input). Nothing is lost:
279
+ * `buildSearchParams` already skips a field whose `serialize` returns
280
+ * `null`, so a codec that encodes "no value" as an omission behaves
281
+ * identically, and one that encodes it as a real query value now writes
282
+ * it instead of dropping it.
283
+ *
213
284
  * Codecs are documented to return a default rather than throw, but a
214
285
  * hand-written codec that throws on absent input must not turn definition
215
- * into a crash — treat its default as null (nothing to omit).
286
+ * into a crash — treat its default as null (nothing to omit). `serialize`
287
+ * is inside the try for the same reason.
288
+ *
289
+ * Typed `SearchParamCodec<unknown>` rather than generic on purpose: the
290
+ * absent-input value is whatever the codec's total entry point returns,
291
+ * which for a nuqs parser is `T | null` while its `serialize` declares
292
+ * `T`. Widening to `unknown` states that honestly instead of casting.
216
293
  */
217
- function getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {
294
+ function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | null {
218
295
  try {
219
- return codec.serialize(codec.parse(undefined));
296
+ const absent = parseTotal(codec, undefined);
297
+ return absent === null || absent === undefined ? null : codec.serialize(absent);
220
298
  } catch {
221
299
  return null;
222
300
  }
@@ -382,7 +460,7 @@ function buildDefinition<T extends Record<string, unknown>>(
382
460
  for (const prop of Object.keys(codecMap)) {
383
461
  const urlKey = getUrlKey(prop);
384
462
  const rawValue = normalized[urlKey];
385
- result[prop] = (codecMap[prop as keyof T] as SearchParamCodec<unknown>).parse(rawValue);
463
+ result[prop] = parseTotal(codecMap[prop as keyof T] as SearchParamCodec<unknown>, rawValue);
386
464
  }
387
465
 
388
466
  return result as T;
@@ -406,8 +484,14 @@ function buildDefinition<T extends Record<string, unknown>>(
406
484
  return parseSync(raw);
407
485
  }
408
486
 
409
- // ---- serialize ----
410
- function serialize(values: Partial<T>): string {
487
+ // ---- buildSearchParams ----
488
+ //
489
+ // Returns a query string. It used to have a URLSearchParams-returning
490
+ // sibling (`toSearchParams`) that `<Link>` consumed; that shape cannot
491
+ // cross the RSC Flight boundary (see the interface docstring), and having
492
+ // two methods produce the same query two ways was a drift waiting to
493
+ // happen. One method now.
494
+ function buildSearchParams(values: Partial<T>): string {
411
495
  const parts: string[] = [];
412
496
 
413
497
  for (const prop of Object.keys(codecMap)) {
@@ -427,28 +511,10 @@ function buildDefinition<T extends Record<string, unknown>>(
427
511
 
428
512
  // ---- href ----
429
513
  function href(pathname: string, values: Partial<T>): string {
430
- const qs = serialize(values);
514
+ const qs = buildSearchParams(values);
431
515
  return qs ? `${pathname}?${qs}` : pathname;
432
516
  }
433
517
 
434
- // ---- toSearchParams ----
435
- function toSearchParams(values: Partial<T>): URLSearchParams {
436
- const usp = new URLSearchParams();
437
-
438
- for (const prop of Object.keys(codecMap)) {
439
- if (!(prop in values)) continue;
440
- const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;
441
- const serialized = codec.serialize(values[prop as keyof T] as unknown);
442
-
443
- if (serialized === defaultSerialized[prop]) continue;
444
- if (serialized === null) continue;
445
-
446
- usp.set(getUrlKey(prop), serialized);
447
- }
448
-
449
- return usp;
450
- }
451
-
452
518
  // ---- extend ----
453
519
  function extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(
454
520
  newCodecs: U
@@ -542,9 +608,8 @@ function buildDefinition<T extends Record<string, unknown>>(
542
608
  useQueryStates,
543
609
  extend,
544
610
  pick,
545
- serialize,
546
611
  href,
547
- toSearchParams,
612
+ buildSearchParams,
548
613
  codecs: codecMap,
549
614
  urlKeys: Object.freeze({ ...urlKeys }),
550
615
  };
@@ -25,4 +25,3 @@ export { defineSearchParams } from './define.js';
25
25
  export { withDefault, withUrlKey } from './wrappers.js';
26
26
 
27
27
  // Runtime registry (route-scoped useQueryStates)
28
- export { registerSearchParams, getSearchParamsDefinition } from './registry.js';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * parseTotal — invoke a codec over the FULL raw domain.
3
+ *
4
+ * `SearchParamCodec.parse` is documented to be total over
5
+ * `string | string[] | undefined`, because that is exactly what a URL
6
+ * hands it: a param can be absent (`undefined`) or repeated (`string[]`).
7
+ * Timber's own codecs and the Standard Schema bridges honour that.
8
+ *
9
+ * nuqs parsers do not. Their `parse` expects a **present scalar string** —
10
+ * nuqs checks presence itself before ever calling it — so `parseAsBoolean`
11
+ * and `parseAsIsoDate` threw a render-phase 500 on an absent param and
12
+ * `parseAsString` returned an array on a repeated one (TIM-1350).
13
+ *
14
+ * nuqs ships the missing adapter: every parser builder exposes
15
+ * `parseServerSide(value: string | string[] | undefined)`, which maps
16
+ * absent → `null` (or the parser's `withDefault` value), takes the FIRST
17
+ * entry of a repeated param (matching `URLSearchParams.get()`), and wraps
18
+ * the inner `parse` so a throw becomes `null`. That is precisely timber's
19
+ * domain, so we call it in preference to `parse` rather than hand-rolling
20
+ * a second normalization that could disagree with the client hook.
21
+ *
22
+ * Feature detection, not an instanceof check: any codec MAY publish
23
+ * `parseServerSide` to declare "this is my total entry point" — it is an
24
+ * optional member of `SearchParamCodec` — and a codec that does not is
25
+ * assumed already total and called through `parse`. Timber codecs and
26
+ * schema bridges take the second branch untouched; several of them rely on
27
+ * `parse(undefined)` to produce their default.
28
+ *
29
+ * **Search params only.** Segment params (`server/param-coercion.ts`) call
30
+ * `codec.parse` directly and must keep doing so: their domain is a value
31
+ * the router matched, never absent, and a codec that REJECTS one is how a
32
+ * route produces a 404. Routing a rejection through nuqs's `safeParse`
33
+ * would turn that 404 into a silent `null` param. The two domains differ
34
+ * in what "no value" means, not just in plumbing. Cookies are a third
35
+ * domain (`cookies/define-cookie.ts`) and are likewise untouched.
36
+ *
37
+ * `parseServerSide` carries a `@deprecated` tag in nuqs (it steers users to
38
+ * loaders, which timber does not use). It remains public, typed and
39
+ * exercised; `tests/nuqs-codec-boundary.test.ts` asserts totality for every
40
+ * parser through timber's own API, so a nuqs release that drops it fails
41
+ * loudly rather than silently reinstating the 500s.
42
+ *
43
+ * Design doc: design/23-search-params.md §"nuqs parsers, made total"
44
+ */
45
+
46
+ import type { Codec } from '../codec.js';
47
+
48
+ /**
49
+ * A codec that publishes a total entry point over the raw URL domain.
50
+ *
51
+ * The return is `T`, not `T | null`: this is the entry point timber calls,
52
+ * so whatever it answers IS the field's type. A `null` for an absent param
53
+ * belongs in `T` — a bare nuqs parser is a codec of `string | null`, and
54
+ * `parseAsInteger.withDefault(1)` is a codec of `number`, because nuqs
55
+ * narrows its own `parseServerSide` return to `NonNullable<T>`. Declaring
56
+ * `T | null` here would let a codec annotated `SearchParamCodec<string>`
57
+ * hand back `null` under a non-nullable type (TIM-1350 review).
58
+ */
59
+ export interface TotalCodec<T> {
60
+ parseServerSide(value: string | string[] | undefined): T;
61
+ }
62
+
63
+ function hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<T> {
64
+ return typeof (codec as Partial<TotalCodec<T>>).parseServerSide === 'function';
65
+ }
66
+
67
+ /**
68
+ * Parse a raw URL value through a codec, using the codec's total entry
69
+ * point when it publishes one.
70
+ *
71
+ * Returns `T`, from both branches. A `null` for an absent param is part of
72
+ * the codec's own `T` — see TotalCodec above — so this signature does not
73
+ * widen it, and a caller that must handle "no value" (`withDefault`) sees
74
+ * it because `T` carries it.
75
+ */
76
+ export function parseTotal<T>(codec: Codec<T>, raw: string | string[] | undefined): T {
77
+ return hasParseServerSide(codec) ? codec.parseServerSide(raw) : codec.parse(raw);
78
+ }