emdash 0.32.0 → 0.34.0

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 (946) hide show
  1. package/dist/{adapters-C0ONzkUP.d.mts → adapters-34EN2eXO.d.mts} +34 -2
  2. package/dist/adapters-34EN2eXO.d.mts.map +1 -0
  3. package/dist/after-CBU2vkoO.mjs +96 -0
  4. package/dist/after-CBU2vkoO.mjs.map +1 -0
  5. package/dist/{allowed-origins-CCEi9bPI.mjs → allowed-origins-DW7sYyqg.mjs} +2 -2
  6. package/dist/{allowed-origins-CCEi9bPI.mjs.map → allowed-origins-DW7sYyqg.mjs.map} +1 -1
  7. package/dist/api/route-utils.d.mts +3 -4
  8. package/dist/api/route-utils.d.mts.map +1 -1
  9. package/dist/api/route-utils.mjs +26 -25
  10. package/dist/api/route-utils.mjs.map +1 -1
  11. package/dist/api/schemas/index.d.mts +2 -2
  12. package/dist/api/schemas/index.mjs +6 -5
  13. package/dist/{api-DgPbMlng.mjs → api-DdAsirBe.mjs} +321 -428
  14. package/dist/api-DdAsirBe.mjs.map +1 -0
  15. package/dist/{api-tokens-p-iMzvXR.mjs → api-tokens-CFXu8F_2.mjs} +2 -2
  16. package/dist/{api-tokens-p-iMzvXR.mjs.map → api-tokens-CFXu8F_2.mjs.map} +1 -1
  17. package/dist/{apply-1_6ra7NP.mjs → apply-D5zfeD09.mjs} +139 -102
  18. package/dist/apply-D5zfeD09.mjs.map +1 -0
  19. package/dist/astro/index.d.mts +12 -12
  20. package/dist/astro/index.d.mts.map +1 -1
  21. package/dist/astro/index.mjs +103 -35
  22. package/dist/astro/index.mjs.map +1 -1
  23. package/dist/astro/middleware/auth.d.mts +10 -10
  24. package/dist/astro/middleware/auth.mjs +9 -9
  25. package/dist/astro/middleware/media-usage-write-fence.d.mts +7 -0
  26. package/dist/astro/middleware/media-usage-write-fence.d.mts.map +1 -0
  27. package/dist/astro/middleware/media-usage-write-fence.mjs +35 -0
  28. package/dist/astro/middleware/media-usage-write-fence.mjs.map +1 -0
  29. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  30. package/dist/astro/middleware/redirect.mjs +13 -10
  31. package/dist/astro/middleware/redirect.mjs.map +1 -1
  32. package/dist/astro/middleware/request-context.mjs +5 -5
  33. package/dist/astro/middleware/setup.mjs +1 -1
  34. package/dist/astro/middleware.d.mts +12 -11
  35. package/dist/astro/middleware.d.mts.map +1 -1
  36. package/dist/astro/middleware.mjs +1915 -223
  37. package/dist/astro/middleware.mjs.map +1 -1
  38. package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +9 -8
  39. package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs.map +1 -1
  40. package/dist/astro/routes/api/admin/allowed-domains/index.mjs +9 -8
  41. package/dist/astro/routes/api/admin/allowed-domains/index.mjs.map +1 -1
  42. package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +5 -5
  43. package/dist/astro/routes/api/admin/api-tokens/index.mjs +6 -6
  44. package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +6 -6
  45. package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +12 -11
  46. package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs.map +1 -1
  47. package/dist/astro/routes/api/admin/byline-fields/index.mjs +12 -11
  48. package/dist/astro/routes/api/admin/byline-fields/index.mjs.map +1 -1
  49. package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +12 -11
  50. package/dist/astro/routes/api/admin/byline-fields/reorder.mjs.map +1 -1
  51. package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +20 -20
  52. package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +20 -20
  53. package/dist/astro/routes/api/admin/bylines/index.mjs +20 -20
  54. package/dist/astro/routes/api/admin/comments/_id_/status.mjs +18 -17
  55. package/dist/astro/routes/api/admin/comments/_id_/status.mjs.map +1 -1
  56. package/dist/astro/routes/api/admin/comments/_id_.mjs +8 -8
  57. package/dist/astro/routes/api/admin/comments/bulk.mjs +14 -13
  58. package/dist/astro/routes/api/admin/comments/bulk.mjs.map +1 -1
  59. package/dist/astro/routes/api/admin/comments/counts.mjs +8 -8
  60. package/dist/astro/routes/api/admin/comments/index.mjs +14 -13
  61. package/dist/astro/routes/api/admin/comments/index.mjs.map +1 -1
  62. package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +6 -6
  63. package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +5 -5
  64. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.d.mts +8 -0
  65. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.d.mts.map +1 -0
  66. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.mjs +36 -0
  67. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.mjs.map +1 -0
  68. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.d.mts +8 -0
  69. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.d.mts.map +1 -0
  70. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.mjs +36 -0
  71. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.mjs.map +1 -0
  72. package/dist/astro/routes/api/admin/media-usage/repair.mjs +15 -14
  73. package/dist/astro/routes/api/admin/media-usage/repair.mjs.map +1 -1
  74. package/dist/astro/routes/api/admin/media-usage/work/index.d.mts +8 -0
  75. package/dist/astro/routes/api/admin/media-usage/work/index.d.mts.map +1 -0
  76. package/dist/astro/routes/api/admin/media-usage/work/index.mjs +37 -0
  77. package/dist/astro/routes/api/admin/media-usage/work/index.mjs.map +1 -0
  78. package/dist/astro/routes/api/admin/media-usage/work/retry.d.mts +8 -0
  79. package/dist/astro/routes/api/admin/media-usage/work/retry.d.mts.map +1 -0
  80. package/dist/astro/routes/api/admin/media-usage/work/retry.mjs +37 -0
  81. package/dist/astro/routes/api/admin/media-usage/work/retry.mjs.map +1 -0
  82. package/dist/astro/routes/api/admin/oauth-clients/_id_.mjs +5 -5
  83. package/dist/astro/routes/api/admin/oauth-clients/index.mjs +5 -5
  84. package/dist/astro/routes/api/admin/plugins/_id_/disable.d.mts.map +1 -1
  85. package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs +56 -47
  86. package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs.map +1 -1
  87. package/dist/astro/routes/api/admin/plugins/_id_/enable.d.mts.map +1 -1
  88. package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +56 -47
  89. package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs.map +1 -1
  90. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +52 -46
  91. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs.map +1 -1
  92. package/dist/astro/routes/api/admin/plugins/_id_/mcp.mjs +6 -6
  93. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs +52 -46
  94. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs.map +1 -1
  95. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.d.mts.map +1 -1
  96. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +55 -46
  97. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs.map +1 -1
  98. package/dist/astro/routes/api/admin/plugins/_id_/update.d.mts.map +1 -1
  99. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +55 -46
  100. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs.map +1 -1
  101. package/dist/astro/routes/api/admin/plugins/index.mjs +52 -46
  102. package/dist/astro/routes/api/admin/plugins/index.mjs.map +1 -1
  103. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +4 -4
  104. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +52 -46
  105. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs.map +1 -1
  106. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.d.mts.map +1 -1
  107. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +55 -46
  108. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs.map +1 -1
  109. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +52 -46
  110. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs.map +1 -1
  111. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.d.mts.map +1 -1
  112. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +55 -46
  113. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs.map +1 -1
  114. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.d.mts.map +1 -1
  115. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +56 -47
  116. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs.map +1 -1
  117. package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +52 -46
  118. package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs.map +1 -1
  119. package/dist/astro/routes/api/admin/plugins/registry/install.d.mts.map +1 -1
  120. package/dist/astro/routes/api/admin/plugins/registry/install.mjs +56 -47
  121. package/dist/astro/routes/api/admin/plugins/registry/install.mjs.map +1 -1
  122. package/dist/astro/routes/api/admin/plugins/updates.mjs +52 -46
  123. package/dist/astro/routes/api/admin/plugins/updates.mjs.map +1 -1
  124. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +52 -46
  125. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs.map +1 -1
  126. package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +4 -4
  127. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +52 -46
  128. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs.map +1 -1
  129. package/dist/astro/routes/api/admin/users/_id_/disable.mjs +4 -4
  130. package/dist/astro/routes/api/admin/users/_id_/enable.mjs +3 -3
  131. package/dist/astro/routes/api/admin/users/_id_/index.mjs +10 -9
  132. package/dist/astro/routes/api/admin/users/_id_/index.mjs.map +1 -1
  133. package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +6 -6
  134. package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs.map +1 -1
  135. package/dist/astro/routes/api/admin/users/index.mjs +9 -8
  136. package/dist/astro/routes/api/admin/users/index.mjs.map +1 -1
  137. package/dist/astro/routes/api/auth/dev-bypass.mjs +8 -8
  138. package/dist/astro/routes/api/auth/invite/accept.mjs +3 -3
  139. package/dist/astro/routes/api/auth/invite/complete.mjs +14 -13
  140. package/dist/astro/routes/api/auth/invite/complete.mjs.map +1 -1
  141. package/dist/astro/routes/api/auth/invite/index.mjs +12 -11
  142. package/dist/astro/routes/api/auth/invite/index.mjs.map +1 -1
  143. package/dist/astro/routes/api/auth/invite/register-options.mjs +13 -12
  144. package/dist/astro/routes/api/auth/invite/register-options.mjs.map +1 -1
  145. package/dist/astro/routes/api/auth/logout.mjs +4 -4
  146. package/dist/astro/routes/api/auth/magic-link/send.mjs +14 -13
  147. package/dist/astro/routes/api/auth/magic-link/send.mjs.map +1 -1
  148. package/dist/astro/routes/api/auth/magic-link/verify.mjs +4 -4
  149. package/dist/astro/routes/api/auth/me.mjs +10 -9
  150. package/dist/astro/routes/api/auth/me.mjs.map +1 -1
  151. package/dist/astro/routes/api/auth/mode.mjs +4 -4
  152. package/dist/astro/routes/api/auth/oauth/_provider_/callback.mjs +4 -4
  153. package/dist/astro/routes/api/auth/oauth/_provider_.mjs +2 -2
  154. package/dist/astro/routes/api/auth/passkey/_id_.mjs +9 -8
  155. package/dist/astro/routes/api/auth/passkey/_id_.mjs.map +1 -1
  156. package/dist/astro/routes/api/auth/passkey/index.mjs +3 -3
  157. package/dist/astro/routes/api/auth/passkey/options.mjs +15 -14
  158. package/dist/astro/routes/api/auth/passkey/options.mjs.map +1 -1
  159. package/dist/astro/routes/api/auth/passkey/register/options.mjs +13 -12
  160. package/dist/astro/routes/api/auth/passkey/register/options.mjs.map +1 -1
  161. package/dist/astro/routes/api/auth/passkey/register/verify.mjs +14 -13
  162. package/dist/astro/routes/api/auth/passkey/register/verify.mjs.map +1 -1
  163. package/dist/astro/routes/api/auth/passkey/verify.mjs +14 -13
  164. package/dist/astro/routes/api/auth/passkey/verify.mjs.map +1 -1
  165. package/dist/astro/routes/api/auth/signup/complete.mjs +14 -13
  166. package/dist/astro/routes/api/auth/signup/complete.mjs.map +1 -1
  167. package/dist/astro/routes/api/auth/signup/request.mjs +14 -13
  168. package/dist/astro/routes/api/auth/signup/request.mjs.map +1 -1
  169. package/dist/astro/routes/api/auth/signup/verify.mjs +3 -3
  170. package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +20 -19
  171. package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs.map +1 -1
  172. package/dist/astro/routes/api/comments/_collection_/_contentId_/reactions.mjs +17 -16
  173. package/dist/astro/routes/api/comments/_collection_/_contentId_/reactions.mjs.map +1 -1
  174. package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +4 -4
  175. package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +5 -5
  176. package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +5 -5
  177. package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +4 -4
  178. package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +15 -15
  179. package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +11 -10
  180. package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs.map +1 -1
  181. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/children.mjs +21 -20
  182. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/children.mjs.map +1 -1
  183. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/parents.mjs +21 -20
  184. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/parents.mjs.map +1 -1
  185. package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +5 -5
  186. package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +4 -4
  187. package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +11 -10
  188. package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs.map +1 -1
  189. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.d.mts.map +1 -1
  190. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +47 -34
  191. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs.map +1 -1
  192. package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +4 -4
  193. package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +5 -5
  194. package/dist/astro/routes/api/content/_collection_/_id_.mjs +11 -10
  195. package/dist/astro/routes/api/content/_collection_/_id_.mjs.map +1 -1
  196. package/dist/astro/routes/api/content/_collection_/authors.mjs +4 -4
  197. package/dist/astro/routes/api/content/_collection_/index.mjs +11 -10
  198. package/dist/astro/routes/api/content/_collection_/index.mjs.map +1 -1
  199. package/dist/astro/routes/api/content/_collection_/trash.mjs +10 -9
  200. package/dist/astro/routes/api/content/_collection_/trash.mjs.map +1 -1
  201. package/dist/astro/routes/api/dashboard.mjs +12 -10
  202. package/dist/astro/routes/api/dashboard.mjs.map +1 -1
  203. package/dist/astro/routes/api/dev/emails.mjs +4 -4
  204. package/dist/astro/routes/api/import/probe.d.mts +3 -3
  205. package/dist/astro/routes/api/import/probe.mjs +22 -20
  206. package/dist/astro/routes/api/import/probe.mjs.map +1 -1
  207. package/dist/astro/routes/api/import/wordpress/analyze.mjs +5 -5
  208. package/dist/astro/routes/api/import/wordpress/execute.d.mts +11 -11
  209. package/dist/astro/routes/api/import/wordpress/execute.mjs +18 -18
  210. package/dist/astro/routes/api/import/wordpress/execute.mjs.map +1 -1
  211. package/dist/astro/routes/api/import/wordpress/media.d.mts +10 -10
  212. package/dist/astro/routes/api/import/wordpress/media.mjs +14 -12
  213. package/dist/astro/routes/api/import/wordpress/media.mjs.map +1 -1
  214. package/dist/astro/routes/api/import/wordpress/prepare.mjs +13 -12
  215. package/dist/astro/routes/api/import/wordpress/prepare.mjs.map +1 -1
  216. package/dist/astro/routes/api/import/wordpress/rewrite-urls.d.mts +11 -11
  217. package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +15 -15
  218. package/dist/astro/routes/api/import/wordpress-plugin/analyze.d.mts +1 -1
  219. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +22 -20
  220. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs.map +1 -1
  221. package/dist/astro/routes/api/import/wordpress-plugin/callback.mjs +1 -1
  222. package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +11 -11
  223. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +32 -31
  224. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs.map +1 -1
  225. package/dist/astro/routes/api/manifest.mjs +6 -6
  226. package/dist/astro/routes/api/mcp.d.mts.map +1 -1
  227. package/dist/astro/routes/api/mcp.mjs +174 -72
  228. package/dist/astro/routes/api/mcp.mjs.map +1 -1
  229. package/dist/astro/routes/api/media/_id_/confirm.mjs +14 -12
  230. package/dist/astro/routes/api/media/_id_/confirm.mjs.map +1 -1
  231. package/dist/astro/routes/api/media/_id_/upload.mjs +7 -7
  232. package/dist/astro/routes/api/media/_id_/usage.mjs +17 -16
  233. package/dist/astro/routes/api/media/_id_/usage.mjs.map +1 -1
  234. package/dist/astro/routes/api/media/_id_.mjs +16 -15
  235. package/dist/astro/routes/api/media/_id_.mjs.map +1 -1
  236. package/dist/astro/routes/api/media/file/_...key_.mjs +3 -3
  237. package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +4 -4
  238. package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +4 -4
  239. package/dist/astro/routes/api/media/providers/index.mjs +4 -4
  240. package/dist/astro/routes/api/media/upload-url.mjs +11 -10
  241. package/dist/astro/routes/api/media/upload-url.mjs.map +1 -1
  242. package/dist/astro/routes/api/media.mjs +19 -16
  243. package/dist/astro/routes/api/media.mjs.map +1 -1
  244. package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +13 -13
  245. package/dist/astro/routes/api/menus/_name_/items.mjs +13 -13
  246. package/dist/astro/routes/api/menus/_name_/reorder.mjs +13 -13
  247. package/dist/astro/routes/api/menus/_name_/translations.mjs +13 -13
  248. package/dist/astro/routes/api/menus/_name_.mjs +13 -13
  249. package/dist/astro/routes/api/menus/index.mjs +13 -13
  250. package/dist/astro/routes/api/oauth/authorize.mjs +6 -6
  251. package/dist/astro/routes/api/oauth/device/authorize.mjs +7 -7
  252. package/dist/astro/routes/api/oauth/device/code.mjs +10 -10
  253. package/dist/astro/routes/api/oauth/device/token.mjs +9 -9
  254. package/dist/astro/routes/api/oauth/register.mjs +4 -4
  255. package/dist/astro/routes/api/oauth/token/refresh.mjs +7 -7
  256. package/dist/astro/routes/api/oauth/token/revoke.mjs +7 -7
  257. package/dist/astro/routes/api/oauth/token.mjs +7 -7
  258. package/dist/astro/routes/api/openapi.json.mjs +183 -6
  259. package/dist/astro/routes/api/openapi.json.mjs.map +1 -1
  260. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.d.mts.map +1 -1
  261. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +8 -7
  262. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs.map +1 -1
  263. package/dist/astro/routes/api/redirects/404s/index.mjs +14 -13
  264. package/dist/astro/routes/api/redirects/404s/index.mjs.map +1 -1
  265. package/dist/astro/routes/api/redirects/404s/summary.mjs +14 -13
  266. package/dist/astro/routes/api/redirects/404s/summary.mjs.map +1 -1
  267. package/dist/astro/routes/api/redirects/_id_.mjs +16 -14
  268. package/dist/astro/routes/api/redirects/_id_.mjs.map +1 -1
  269. package/dist/astro/routes/api/redirects/index.mjs +16 -14
  270. package/dist/astro/routes/api/redirects/index.mjs.map +1 -1
  271. package/dist/astro/routes/api/relations/_id_/index.mjs +21 -20
  272. package/dist/astro/routes/api/relations/_id_/index.mjs.map +1 -1
  273. package/dist/astro/routes/api/relations/_id_/translations.mjs +16 -15
  274. package/dist/astro/routes/api/relations/_id_/translations.mjs.map +1 -1
  275. package/dist/astro/routes/api/relations/index.mjs +21 -20
  276. package/dist/astro/routes/api/relations/index.mjs.map +1 -1
  277. package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +4 -4
  278. package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +5 -5
  279. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +52 -46
  280. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs.map +1 -1
  281. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +52 -46
  282. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs.map +1 -1
  283. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +52 -46
  284. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs.map +1 -1
  285. package/dist/astro/routes/api/schema/collections/_slug_/index.d.mts.map +1 -1
  286. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +52 -46
  287. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs.map +1 -1
  288. package/dist/astro/routes/api/schema/collections/index.mjs +52 -46
  289. package/dist/astro/routes/api/schema/collections/index.mjs.map +1 -1
  290. package/dist/astro/routes/api/schema/collections/reorder.d.mts +8 -0
  291. package/dist/astro/routes/api/schema/collections/reorder.d.mts.map +1 -0
  292. package/dist/astro/routes/api/schema/collections/reorder.mjs +71 -0
  293. package/dist/astro/routes/api/schema/collections/reorder.mjs.map +1 -0
  294. package/dist/astro/routes/api/schema/index.mjs +14 -11
  295. package/dist/astro/routes/api/schema/index.mjs.map +1 -1
  296. package/dist/astro/routes/api/schema/orphans/_slug_.mjs +52 -46
  297. package/dist/astro/routes/api/schema/orphans/_slug_.mjs.map +1 -1
  298. package/dist/astro/routes/api/schema/orphans/index.mjs +52 -46
  299. package/dist/astro/routes/api/schema/orphans/index.mjs.map +1 -1
  300. package/dist/astro/routes/api/search/enable.d.mts +1 -0
  301. package/dist/astro/routes/api/search/enable.d.mts.map +1 -1
  302. package/dist/astro/routes/api/search/enable.mjs +19 -15
  303. package/dist/astro/routes/api/search/enable.mjs.map +1 -1
  304. package/dist/astro/routes/api/search/index.mjs +13 -13
  305. package/dist/astro/routes/api/search/rebuild.mjs +15 -15
  306. package/dist/astro/routes/api/search/rebuild.mjs.map +1 -1
  307. package/dist/astro/routes/api/search/stats.mjs +9 -9
  308. package/dist/astro/routes/api/search/suggest.mjs +13 -13
  309. package/dist/astro/routes/api/sections/_slug_.mjs +13 -12
  310. package/dist/astro/routes/api/sections/_slug_.mjs.map +1 -1
  311. package/dist/astro/routes/api/sections/index.mjs +13 -12
  312. package/dist/astro/routes/api/sections/index.mjs.map +1 -1
  313. package/dist/astro/routes/api/settings/backups/archives/_name_.mjs +8 -8
  314. package/dist/astro/routes/api/settings/backups/archives/index.mjs +7 -7
  315. package/dist/astro/routes/api/settings/backups/export.mjs +8 -8
  316. package/dist/astro/routes/api/settings/backups/index.mjs +9 -9
  317. package/dist/astro/routes/api/settings/email.mjs +6 -6
  318. package/dist/astro/routes/api/settings.mjs +18 -17
  319. package/dist/astro/routes/api/settings.mjs.map +1 -1
  320. package/dist/astro/routes/api/setup/admin-verify.mjs +15 -14
  321. package/dist/astro/routes/api/setup/admin-verify.mjs.map +1 -1
  322. package/dist/astro/routes/api/setup/admin.mjs +14 -13
  323. package/dist/astro/routes/api/setup/admin.mjs.map +1 -1
  324. package/dist/astro/routes/api/setup/dev-bypass.mjs +34 -32
  325. package/dist/astro/routes/api/setup/dev-bypass.mjs.map +1 -1
  326. package/dist/astro/routes/api/setup/dev-reset.mjs +4 -4
  327. package/dist/astro/routes/api/setup/index.mjs +36 -34
  328. package/dist/astro/routes/api/setup/index.mjs.map +1 -1
  329. package/dist/astro/routes/api/setup/status.mjs +5 -5
  330. package/dist/astro/routes/api/snapshot.mjs +10 -10
  331. package/dist/astro/routes/api/taxonomies/_name_/reorder.d.mts +8 -0
  332. package/dist/astro/routes/api/taxonomies/_name_/reorder.d.mts.map +1 -0
  333. package/dist/astro/routes/api/taxonomies/_name_/reorder.mjs +44 -0
  334. package/dist/astro/routes/api/taxonomies/_name_/reorder.mjs.map +1 -0
  335. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +19 -18
  336. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs.map +1 -1
  337. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +19 -18
  338. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs.map +1 -1
  339. package/dist/astro/routes/api/taxonomies/_name_/terms/index.d.mts.map +1 -1
  340. package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +21 -19
  341. package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs.map +1 -1
  342. package/dist/astro/routes/api/taxonomies/_name_/translations.d.mts +8 -0
  343. package/dist/astro/routes/api/taxonomies/_name_/translations.d.mts.map +1 -0
  344. package/dist/astro/routes/api/taxonomies/_name_/translations.mjs +46 -0
  345. package/dist/astro/routes/api/taxonomies/_name_/translations.mjs.map +1 -0
  346. package/dist/astro/routes/api/taxonomies/_name_.d.mts +19 -0
  347. package/dist/astro/routes/api/taxonomies/_name_.d.mts.map +1 -0
  348. package/dist/astro/routes/api/taxonomies/_name_.mjs +88 -0
  349. package/dist/astro/routes/api/taxonomies/_name_.mjs.map +1 -0
  350. package/dist/astro/routes/api/taxonomies/index.mjs +19 -18
  351. package/dist/astro/routes/api/taxonomies/index.mjs.map +1 -1
  352. package/dist/astro/routes/api/themes/preview.mjs +8 -8
  353. package/dist/astro/routes/api/typegen.mjs +6 -6
  354. package/dist/astro/routes/api/well-known/auth.mjs +2 -2
  355. package/dist/astro/routes/api/well-known/oauth-authorization-server.mjs +2 -2
  356. package/dist/astro/routes/api/well-known/oauth-protected-resource.mjs +2 -2
  357. package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +10 -9
  358. package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs.map +1 -1
  359. package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +13 -12
  360. package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs.map +1 -1
  361. package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +13 -12
  362. package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs.map +1 -1
  363. package/dist/astro/routes/api/widget-areas/_name_.mjs +8 -7
  364. package/dist/astro/routes/api/widget-areas/_name_.mjs.map +1 -1
  365. package/dist/astro/routes/api/widget-areas/index.mjs +13 -12
  366. package/dist/astro/routes/api/widget-areas/index.mjs.map +1 -1
  367. package/dist/astro/routes/api/widget-components.mjs +4 -4
  368. package/dist/astro/routes/robots.txt.mjs +11 -10
  369. package/dist/astro/routes/robots.txt.mjs.map +1 -1
  370. package/dist/astro/routes/sitemap-_collection_.xml.mjs +14 -14
  371. package/dist/astro/routes/sitemap.xml.mjs +12 -11
  372. package/dist/astro/routes/sitemap.xml.mjs.map +1 -1
  373. package/dist/astro/types.d.mts +41 -17
  374. package/dist/astro/types.d.mts.map +1 -1
  375. package/dist/auth/providers/github.d.mts +1 -1
  376. package/dist/auth/providers/google.d.mts +1 -1
  377. package/dist/{authorize-R5iYYrpa.mjs → authorize-1iFJzPaZ.mjs} +2 -2
  378. package/dist/{authorize-R5iYYrpa.mjs.map → authorize-1iFJzPaZ.mjs.map} +1 -1
  379. package/dist/{backup-LpQ1V_sR.mjs → backup-LHjIAVNP.mjs} +5 -5
  380. package/dist/{backup-LpQ1V_sR.mjs.map → backup-LHjIAVNP.mjs.map} +1 -1
  381. package/dist/{base64-B-PsqheR.mjs → base64-CvLhUKQv.mjs} +1 -1
  382. package/dist/{base64-B-PsqheR.mjs.map → base64-CvLhUKQv.mjs.map} +1 -1
  383. package/dist/bundle-CBEXrBnb.mjs +663 -0
  384. package/dist/bundle-CBEXrBnb.mjs.map +1 -0
  385. package/dist/{byline-fields-T9ZLZMxJ.mjs → byline-fields-C5S8wygO.mjs} +2 -2
  386. package/dist/{byline-fields-T9ZLZMxJ.mjs.map → byline-fields-C5S8wygO.mjs.map} +1 -1
  387. package/dist/{byline-C5TAqs8N.mjs → byline-pEcoDps8.mjs} +9 -9
  388. package/dist/{byline-C5TAqs8N.mjs.map → byline-pEcoDps8.mjs.map} +1 -1
  389. package/dist/{byline-registry-BCuOp4UF.mjs → byline-registry-DDun8sxh.mjs} +4 -4
  390. package/dist/{byline-registry-BCuOp4UF.mjs.map → byline-registry-DDun8sxh.mjs.map} +1 -1
  391. package/dist/{bylines-B8-WGdja.mjs → bylines-BWemdX2k.mjs} +8 -8
  392. package/dist/{bylines-B8-WGdja.mjs.map → bylines-BWemdX2k.mjs.map} +1 -1
  393. package/dist/{bylines-DGuqBLjV.mjs → bylines-DJfSfXCL.mjs} +5 -5
  394. package/dist/{bylines-DGuqBLjV.mjs.map → bylines-DJfSfXCL.mjs.map} +1 -1
  395. package/dist/cache-CBJaDy_E.mjs +108 -0
  396. package/dist/cache-CBJaDy_E.mjs.map +1 -0
  397. package/dist/{challenge-store-BFzgFRog.mjs → challenge-store-CZyFVaad.mjs} +1 -1
  398. package/dist/{challenge-store-BFzgFRog.mjs.map → challenge-store-CZyFVaad.mjs.map} +1 -1
  399. package/dist/{chunks-D5dlPeRb.mjs → chunks-DfPax1I0.mjs} +2 -2
  400. package/dist/{chunks-D5dlPeRb.mjs.map → chunks-DfPax1I0.mjs.map} +1 -1
  401. package/dist/cli/index.mjs +193 -707
  402. package/dist/cli/index.mjs.map +1 -1
  403. package/dist/client/cf-access.d.mts +1 -1
  404. package/dist/client/index.d.mts +74 -4
  405. package/dist/client/index.d.mts.map +1 -1
  406. package/dist/client/index.mjs +25 -2
  407. package/dist/client/index.mjs.map +1 -1
  408. package/dist/collection-deletion-C8BugSGz.mjs +779 -0
  409. package/dist/collection-deletion-C8BugSGz.mjs.map +1 -0
  410. package/dist/{comment-DnTxxVHv.mjs → comment-Dxlep6cE.mjs} +3 -3
  411. package/dist/{comment-DnTxxVHv.mjs.map → comment-Dxlep6cE.mjs.map} +1 -1
  412. package/dist/{comment-reaction-BUlUz2bh.mjs → comment-reaction-D6qfHKBc.mjs} +2 -2
  413. package/dist/{comment-reaction-BUlUz2bh.mjs.map → comment-reaction-D6qfHKBc.mjs.map} +1 -1
  414. package/dist/{comments-QPKgK3nk.mjs → comments-CTOuhTSX.mjs} +3 -3
  415. package/dist/{comments-QPKgK3nk.mjs.map → comments-CTOuhTSX.mjs.map} +1 -1
  416. package/dist/{components-DZkL4Fx0.mjs → components-CQy3lh9x.mjs} +1 -1
  417. package/dist/{components-DZkL4Fx0.mjs.map → components-CQy3lh9x.mjs.map} +1 -1
  418. package/dist/{config-DyF3OKg_.mjs → config-C3EBstHi.mjs} +18 -2
  419. package/dist/config-C3EBstHi.mjs.map +1 -0
  420. package/dist/{content-CpfKV9QE.mjs → content-BNfwh7xX.mjs} +667 -183
  421. package/dist/content-BNfwh7xX.mjs.map +1 -0
  422. package/dist/content-list-query-DhQ84eVV.d.mts +26 -0
  423. package/dist/content-list-query-DhQ84eVV.d.mts.map +1 -0
  424. package/dist/content-refresh-DNERP4-f.mjs +2560 -0
  425. package/dist/content-refresh-DNERP4-f.mjs.map +1 -0
  426. package/dist/{context-kO4YZB03.mjs → context-DRgOvSnW.mjs} +28 -20
  427. package/dist/context-DRgOvSnW.mjs.map +1 -0
  428. package/dist/{cron-BlKIMD_e.mjs → cron-Y9aLtgrA.mjs} +1 -1
  429. package/dist/{cron-BlKIMD_e.mjs.map → cron-Y9aLtgrA.mjs.map} +1 -1
  430. package/dist/{dashboard-DoBwPnON.mjs → dashboard-CB8J89rJ.mjs} +45 -11
  431. package/dist/dashboard-CB8J89rJ.mjs.map +1 -0
  432. package/dist/db/index.d.mts +4 -4
  433. package/dist/db/index.mjs +3 -3
  434. package/dist/db/index.mjs.map +1 -1
  435. package/dist/db/libsql.d.mts +1 -1
  436. package/dist/db/postgres.d.mts +1 -1
  437. package/dist/db/sqlite.d.mts +1 -1
  438. package/dist/{db-errors-CcWLaRiR.mjs → db-errors-Cl-sKYTm.mjs} +1 -1
  439. package/dist/{db-errors-CcWLaRiR.mjs.map → db-errors-Cl-sKYTm.mjs.map} +1 -1
  440. package/dist/{default-C4v9KP56.mjs → default-DdDl3fxv.mjs} +1 -1
  441. package/dist/{default-C4v9KP56.mjs.map → default-DdDl3fxv.mjs.map} +1 -1
  442. package/dist/deferred-tasks-BMsa9F64.d.mts +9 -0
  443. package/dist/deferred-tasks-BMsa9F64.d.mts.map +1 -0
  444. package/dist/{device-flow-DhUsyJU6.mjs → device-flow-CaioYGK3.mjs} +5 -5
  445. package/dist/{device-flow-DhUsyJU6.mjs.map → device-flow-CaioYGK3.mjs.map} +1 -1
  446. package/dist/{dialect-helpers-DFwyWjTM.mjs → dialect-helpers-BSbfaO3F.mjs} +24 -16
  447. package/dist/dialect-helpers-BSbfaO3F.mjs.map +1 -0
  448. package/dist/dist-D_6CKR8z.mjs +810 -0
  449. package/dist/dist-D_6CKR8z.mjs.map +1 -0
  450. package/dist/{email-console-C-9Ng8DM.mjs → email-console-BHNjIwiB.mjs} +1 -1
  451. package/dist/{email-console-C-9Ng8DM.mjs.map → email-console-BHNjIwiB.mjs.map} +1 -1
  452. package/dist/{enrich-CFJJgxs_.mjs → enrich-_ySMEvEE.mjs} +2 -2
  453. package/dist/{enrich-CFJJgxs_.mjs.map → enrich-_ySMEvEE.mjs.map} +1 -1
  454. package/dist/{error-DmmN74gW.mjs → error-DupyJZE2.mjs} +3 -3
  455. package/dist/{error-DmmN74gW.mjs.map → error-DupyJZE2.mjs.map} +1 -1
  456. package/dist/{errors-DtEXIQQV.mjs → errors-CLUPm7aD.mjs} +15 -2
  457. package/dist/errors-CLUPm7aD.mjs.map +1 -0
  458. package/dist/{escape-CdW31WsG.mjs → escape-C_aLKP3H.mjs} +1 -1
  459. package/dist/{escape-CdW31WsG.mjs.map → escape-C_aLKP3H.mjs.map} +1 -1
  460. package/dist/{field-defs-cache-QMVnzTH6.mjs → field-defs-cache-hg6QkjV9.mjs} +5 -5
  461. package/dist/{field-defs-cache-QMVnzTH6.mjs.map → field-defs-cache-hg6QkjV9.mjs.map} +1 -1
  462. package/dist/{fts-manager-vncsZIJm.mjs → fts-manager-CZ-5dVwJ.mjs} +111 -61
  463. package/dist/fts-manager-CZ-5dVwJ.mjs.map +1 -0
  464. package/dist/{hash-Cb7U1b5M.mjs → hash-Cg0TXJPV.mjs} +1 -1
  465. package/dist/{hash-Cb7U1b5M.mjs.map → hash-Cg0TXJPV.mjs.map} +1 -1
  466. package/dist/{hreflang-CZbSx6Bg.d.mts → hreflang-D3Uehfv2.d.mts} +2 -2
  467. package/dist/{hreflang-CZbSx6Bg.d.mts.map → hreflang-D3Uehfv2.d.mts.map} +1 -1
  468. package/dist/{hreflang-Bg-zMpkr.mjs → hreflang-DNswoYsi.mjs} +7 -7
  469. package/dist/{hreflang-Bg-zMpkr.mjs.map → hreflang-DNswoYsi.mjs.map} +1 -1
  470. package/dist/{import-PKhLeXvn.mjs → import-B41u8Rot.mjs} +7 -7
  471. package/dist/{import-PKhLeXvn.mjs.map → import-B41u8Rot.mjs.map} +1 -1
  472. package/dist/{index-j9rTh3cp.d.mts → index-C3Y-rsyN.d.mts} +286 -93
  473. package/dist/index-C3Y-rsyN.d.mts.map +1 -0
  474. package/dist/{index-BeNto_Vs.d.mts → index-Cof4-Fab.d.mts} +5 -10
  475. package/dist/index-Cof4-Fab.d.mts.map +1 -0
  476. package/dist/index.d.mts +20 -19
  477. package/dist/index.mjs +79 -71
  478. package/dist/{init-lock-DJkX6Hto.mjs → init-lock-6b309ZrF.mjs} +1 -1
  479. package/dist/{init-lock-DJkX6Hto.mjs.map → init-lock-6b309ZrF.mjs.map} +1 -1
  480. package/dist/{load-BwTdWE8B.mjs → load-DmvJrtxS.mjs} +2 -2
  481. package/dist/{load-BwTdWE8B.mjs.map → load-DmvJrtxS.mjs.map} +1 -1
  482. package/dist/{loader-C1XOLV5b.mjs → loader-DLBqRiGP.mjs} +120 -64
  483. package/dist/loader-DLBqRiGP.mjs.map +1 -0
  484. package/dist/{manifest-schema-bCq54i7F.mjs → manifest-schema-BXbUAHod.mjs} +1 -1
  485. package/dist/{manifest-schema-bCq54i7F.mjs.map → manifest-schema-BXbUAHod.mjs.map} +1 -1
  486. package/dist/media/image-endpoint.mjs +1 -1
  487. package/dist/media/index.d.mts +1 -1
  488. package/dist/media/index.mjs +3 -2
  489. package/dist/media/index.mjs.map +1 -1
  490. package/dist/media/local-runtime.d.mts +12 -12
  491. package/dist/media/local-runtime.mjs +10 -9
  492. package/dist/media/local-runtime.mjs.map +1 -1
  493. package/dist/{media-DWIzoVFL.mjs → media-BWWWb8kg.mjs} +92 -3
  494. package/dist/media-BWWWb8kg.mjs.map +1 -0
  495. package/dist/{media-tQDZEdu7.mjs → media-CzhAn2--.mjs} +2 -2
  496. package/dist/{media-tQDZEdu7.mjs.map → media-CzhAn2--.mjs.map} +1 -1
  497. package/dist/{media-allowlist-B7TPD6aq.mjs → media-allowlist-BD6HhgXA.mjs} +14 -3
  498. package/dist/media-allowlist-BD6HhgXA.mjs.map +1 -0
  499. package/dist/{media-upload-Bc9Nc5Zf.mjs → media-upload-9yas9zGH.mjs} +11 -9
  500. package/dist/{media-upload-Bc9Nc5Zf.mjs.map → media-upload-9yas9zGH.mjs.map} +1 -1
  501. package/dist/{media-url-BCm5vBn6.mjs → media-url-BvoX88FA.mjs} +1 -1
  502. package/dist/{media-url-BCm5vBn6.mjs.map → media-url-BvoX88FA.mjs.map} +1 -1
  503. package/dist/{media-usage-DPJcNaAq.mjs → media-usage-De3qvwjc.mjs} +138 -61
  504. package/dist/media-usage-De3qvwjc.mjs.map +1 -0
  505. package/dist/media-usage-work-B9Bksh_b.mjs +152 -0
  506. package/dist/media-usage-work-B9Bksh_b.mjs.map +1 -0
  507. package/dist/media-usage-work-DrCbqBcl.mjs +424 -0
  508. package/dist/media-usage-work-DrCbqBcl.mjs.map +1 -0
  509. package/dist/media-usage-write-fence-aHEgN1zB.mjs +42 -0
  510. package/dist/media-usage-write-fence-aHEgN1zB.mjs.map +1 -0
  511. package/dist/{menus-C6qhqP4X.mjs → menus-CC-yn1F9.mjs} +554 -121
  512. package/dist/menus-CC-yn1F9.mjs.map +1 -0
  513. package/dist/{menus-BxPLFfIc.mjs → menus-fxvEDYE6.mjs} +5 -5
  514. package/dist/{menus-BxPLFfIc.mjs.map → menus-fxvEDYE6.mjs.map} +1 -1
  515. package/dist/{mode-fiXRMfeA.mjs → mode-Bpe-tNNy.mjs} +1 -1
  516. package/dist/{mode-fiXRMfeA.mjs.map → mode-Bpe-tNNy.mjs.map} +1 -1
  517. package/dist/{normalize-C-SHXmra.mjs → normalize-Bc-s_r-a.mjs} +1 -1
  518. package/dist/{normalize-C-SHXmra.mjs.map → normalize-Bc-s_r-a.mjs.map} +1 -1
  519. package/dist/{oauth-authorization-C9tFinly.mjs → oauth-authorization-BXO1MKej.mjs} +5 -5
  520. package/dist/{oauth-authorization-C9tFinly.mjs.map → oauth-authorization-BXO1MKej.mjs.map} +1 -1
  521. package/dist/{oauth-clients-BwTxE1aE.mjs → oauth-clients-C_DNqtRu.mjs} +1 -1
  522. package/dist/{oauth-clients-BwTxE1aE.mjs.map → oauth-clients-C_DNqtRu.mjs.map} +1 -1
  523. package/dist/{oauth-state-store-DaRUwPWO.mjs → oauth-state-store-DwAxMtk4.mjs} +1 -1
  524. package/dist/{oauth-state-store-DaRUwPWO.mjs.map → oauth-state-store-DwAxMtk4.mjs.map} +1 -1
  525. package/dist/{oauth-user-lookup-CzuzOSlF.mjs → oauth-user-lookup-BxfZizdk.mjs} +1 -1
  526. package/dist/{oauth-user-lookup-CzuzOSlF.mjs.map → oauth-user-lookup-BxfZizdk.mjs.map} +1 -1
  527. package/dist/object-cache/memory.d.mts +1 -1
  528. package/dist/{object-cache-Bok5j2ae.mjs → object-cache-71tPfV4A.mjs} +106 -5
  529. package/dist/object-cache-71tPfV4A.mjs.map +1 -0
  530. package/dist/{options-BlmBHTvX.mjs → options-CBXIHUoT.mjs} +1 -1
  531. package/dist/{options-BlmBHTvX.mjs.map → options-CBXIHUoT.mjs.map} +1 -1
  532. package/dist/{options-aeIs8WFu.d.mts → options-DRe1pIBL.d.mts} +4 -3
  533. package/dist/options-DRe1pIBL.d.mts.map +1 -0
  534. package/dist/page/index.d.mts +2 -2
  535. package/dist/{parse-BL49yb9D.mjs → parse-DFbuc-Fh.mjs} +2 -2
  536. package/dist/{parse-BL49yb9D.mjs.map → parse-DFbuc-Fh.mjs.map} +1 -1
  537. package/dist/{passkey-config-KMhgFjdQ.mjs → passkey-config-_2GOwwK8.mjs} +1 -1
  538. package/dist/{passkey-config-KMhgFjdQ.mjs.map → passkey-config-_2GOwwK8.mjs.map} +1 -1
  539. package/dist/{placeholder-Dxo5Zt58.d.mts → placeholder-CzW7Lshs.d.mts} +1 -1
  540. package/dist/{placeholder-Dxo5Zt58.d.mts.map → placeholder-CzW7Lshs.d.mts.map} +1 -1
  541. package/dist/plugin-types.d.mts +8 -1
  542. package/dist/plugin-types.d.mts.map +1 -1
  543. package/dist/plugin-utils.d.mts +10 -10
  544. package/dist/plugins/adapt-sandbox-entry.d.mts +10 -10
  545. package/dist/plugins/adapt-sandbox-entry.mjs +7 -4
  546. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  547. package/dist/{portable-text-CNahFfj6.mjs → portable-text-DhKbDAGr.mjs} +1 -1
  548. package/dist/{portable-text-CNahFfj6.mjs.map → portable-text-DhKbDAGr.mjs.map} +1 -1
  549. package/dist/{preview-D4Jnbfx7.mjs → preview-PW-nOnq4.mjs} +2 -2
  550. package/dist/{preview-D4Jnbfx7.mjs.map → preview-PW-nOnq4.mjs.map} +1 -1
  551. package/dist/{public-url-DSGTnJFw.mjs → public-url-CWoFhQT8.mjs} +14 -3
  552. package/dist/public-url-CWoFhQT8.mjs.map +1 -0
  553. package/dist/{query-DCiXI7OZ.mjs → query-BgjfiLUQ.mjs} +38 -77
  554. package/dist/query-BgjfiLUQ.mjs.map +1 -0
  555. package/dist/{query-1xOcEy18.mjs → query-vgZNwIhh.mjs} +4 -4
  556. package/dist/{query-1xOcEy18.mjs.map → query-vgZNwIhh.mjs.map} +1 -1
  557. package/dist/{rate-limit-Vkp9gQ1Y.mjs → rate-limit-L5anykgo.mjs} +2 -2
  558. package/dist/{rate-limit-Vkp9gQ1Y.mjs.map → rate-limit-L5anykgo.mjs.map} +1 -1
  559. package/dist/{redirect-BJ6d1yHh.mjs → redirect-COsS6SWJ.mjs} +3 -3
  560. package/dist/{redirect-BJ6d1yHh.mjs.map → redirect-COsS6SWJ.mjs.map} +1 -1
  561. package/dist/{redirect-CDgfv9vh.mjs → redirect-KBNE3UEs.mjs} +1 -1
  562. package/dist/{redirect-CDgfv9vh.mjs.map → redirect-KBNE3UEs.mjs.map} +1 -1
  563. package/dist/{redirects-Cwkq2H-G.mjs → redirects-CufRQt0Q.mjs} +5 -5
  564. package/dist/{redirects-Cwkq2H-G.mjs.map → redirects-CufRQt0Q.mjs.map} +1 -1
  565. package/dist/{redirects-BOn2DIUs.mjs → redirects-cV9g7V-0.mjs} +149 -16
  566. package/dist/redirects-cV9g7V-0.mjs.map +1 -0
  567. package/dist/registry-C581V4TP.mjs +1275 -0
  568. package/dist/registry-C581V4TP.mjs.map +1 -0
  569. package/dist/{relation-DJ5DZh7B.mjs → relation-DtmAQH7s.mjs} +3 -3
  570. package/dist/{relation-DJ5DZh7B.mjs.map → relation-DtmAQH7s.mjs.map} +1 -1
  571. package/dist/{relations-C4duJnwI.mjs → relations-BBApI4e2.mjs} +3 -3
  572. package/dist/{relations-C4duJnwI.mjs.map → relations-BBApI4e2.mjs.map} +1 -1
  573. package/dist/{relations-DpTjftCl.mjs → relations-DICSyfsh.mjs} +6 -6
  574. package/dist/{relations-DpTjftCl.mjs.map → relations-DICSyfsh.mjs.map} +1 -1
  575. package/dist/{relations-DHFQGX0h.d.mts → relations-DItI3MN9.d.mts} +401 -40
  576. package/dist/relations-DItI3MN9.d.mts.map +1 -0
  577. package/dist/request-context.d.mts +3 -0
  578. package/dist/request-context.d.mts.map +1 -1
  579. package/dist/request-context.mjs.map +1 -1
  580. package/dist/{request-meta-DzXYYI-n.mjs → request-meta-DgZJh3zG.mjs} +2 -2
  581. package/dist/{request-meta-DzXYYI-n.mjs.map → request-meta-DgZJh3zG.mjs.map} +1 -1
  582. package/dist/{resolve-BUvFE0Lr.mjs → resolve-DOhVEvty.mjs} +2 -2
  583. package/dist/{resolve-BUvFE0Lr.mjs.map → resolve-DOhVEvty.mjs.map} +1 -1
  584. package/dist/{runner-BsI18UgP.mjs → runner-BiDvq5dv.mjs} +1404 -235
  585. package/dist/runner-BiDvq5dv.mjs.map +1 -0
  586. package/dist/{runner-CHdkERbt.d.mts → runner-CgQbELYC.d.mts} +2 -2
  587. package/dist/{runner-CHdkERbt.d.mts.map → runner-CgQbELYC.d.mts.map} +1 -1
  588. package/dist/runtime.d.mts +11 -11
  589. package/dist/runtime.mjs +5 -4
  590. package/dist/runtime.mjs.map +1 -1
  591. package/dist/{schema-eqZhkR0S.mjs → schema-BFyTg38T.mjs} +3 -3
  592. package/dist/{schema-eqZhkR0S.mjs.map → schema-BFyTg38T.mjs.map} +1 -1
  593. package/dist/schema-CeQSNwB3.mjs +390 -0
  594. package/dist/schema-CeQSNwB3.mjs.map +1 -0
  595. package/dist/{scopes-Be2d8bSb.mjs → scopes-BVeuhGBE.mjs} +3 -3
  596. package/dist/{scopes-Be2d8bSb.mjs.map → scopes-BVeuhGBE.mjs.map} +1 -1
  597. package/dist/{search-CeO678cp.mjs → search-BpuPKZb0.mjs} +26 -16
  598. package/dist/search-BpuPKZb0.mjs.map +1 -0
  599. package/dist/{secrets-870d-7yA.mjs → secrets-dtYs2hDo.mjs} +5 -5
  600. package/dist/{secrets-870d-7yA.mjs.map → secrets-dtYs2hDo.mjs.map} +1 -1
  601. package/dist/{sections-DSuEfirn.mjs → sections-COctk6hf.mjs} +3 -3
  602. package/dist/{sections-DSuEfirn.mjs.map → sections-COctk6hf.mjs.map} +1 -1
  603. package/dist/seed/index.d.mts +2 -2
  604. package/dist/seed/index.mjs +28 -26
  605. package/dist/seo/index.d.mts +3 -3
  606. package/dist/seo/index.mjs +20 -19
  607. package/dist/seo/index.mjs.map +1 -1
  608. package/dist/{seo-C3GDfT0V.mjs → seo-AFpVvTqr.mjs} +6 -4
  609. package/dist/seo-AFpVvTqr.mjs.map +1 -0
  610. package/dist/{seo-BAPKiED8.mjs → seo-CvDxfvFb.mjs} +3 -3
  611. package/dist/{seo-BAPKiED8.mjs.map → seo-CvDxfvFb.mjs.map} +1 -1
  612. package/dist/{service-DyFT-k-R.mjs → service-xMiMSTsR.mjs} +4 -4
  613. package/dist/{service-DyFT-k-R.mjs.map → service-xMiMSTsR.mjs.map} +1 -1
  614. package/dist/{session-user-BrK2zz7S.mjs → session-user-BzVemLkw.mjs} +2 -2
  615. package/dist/{session-user-BrK2zz7S.mjs.map → session-user-BzVemLkw.mjs.map} +1 -1
  616. package/dist/{settings-DgKouY2S.mjs → settings-B372MV0p.mjs} +7 -7
  617. package/dist/{settings-DgKouY2S.mjs.map → settings-B372MV0p.mjs.map} +1 -1
  618. package/dist/{settings-CREKyj8I.mjs → settings-BE9aqn_9.mjs} +3 -3
  619. package/dist/{settings-CREKyj8I.mjs.map → settings-BE9aqn_9.mjs.map} +1 -1
  620. package/dist/{setup-complete-L7bMK2VI.mjs → setup-complete-DF7TAfD5.mjs} +2 -2
  621. package/dist/{setup-complete-L7bMK2VI.mjs.map → setup-complete-DF7TAfD5.mjs.map} +1 -1
  622. package/dist/{setup-nonce-Ct5tjWcq.mjs → setup-nonce-Do-U_cg9.mjs} +1 -1
  623. package/dist/{setup-nonce-Ct5tjWcq.mjs.map → setup-nonce-Do-U_cg9.mjs.map} +1 -1
  624. package/dist/{single-flight-cache-C2exrGAi.mjs → single-flight-cache-B0eLKS6K.mjs} +2 -2
  625. package/dist/{single-flight-cache-C2exrGAi.mjs.map → single-flight-cache-B0eLKS6K.mjs.map} +1 -1
  626. package/dist/site-url-Bowsi9T-.mjs +16 -0
  627. package/dist/site-url-Bowsi9T-.mjs.map +1 -0
  628. package/dist/slugify-B994zkXC.mjs +17 -0
  629. package/dist/slugify-B994zkXC.mjs.map +1 -0
  630. package/dist/{snapshot-BdpUJKD-.mjs → snapshot-B5h6aPMh.mjs} +2 -2
  631. package/dist/{snapshot-BdpUJKD-.mjs.map → snapshot-B5h6aPMh.mjs.map} +1 -1
  632. package/dist/{ssrf-CviKqWmq.mjs → ssrf-Cpvg_n3A.mjs} +1 -1
  633. package/dist/{ssrf-CviKqWmq.mjs.map → ssrf-Cpvg_n3A.mjs.map} +1 -1
  634. package/dist/{state-xxv6ZTMv.mjs → state-DYwPue40.mjs} +1 -1
  635. package/dist/{state-xxv6ZTMv.mjs.map → state-DYwPue40.mjs.map} +1 -1
  636. package/dist/{status-COfycGIU.mjs → status-CWVYpbD8.mjs} +1 -1
  637. package/dist/{status-COfycGIU.mjs.map → status-CWVYpbD8.mjs.map} +1 -1
  638. package/dist/storage/local.d.mts +1 -1
  639. package/dist/storage/local.mjs +1 -1
  640. package/dist/storage/s3.d.mts +1 -1
  641. package/dist/storage/s3.mjs +1 -1
  642. package/dist/{taxonomies-B61CRSha.mjs → taxonomies-B0o9ExNV.mjs} +85 -75
  643. package/dist/taxonomies-B0o9ExNV.mjs.map +1 -0
  644. package/dist/{taxonomies-Dbdki3Un.mjs → taxonomies-Dk3MAklL.mjs} +360 -56
  645. package/dist/taxonomies-Dk3MAklL.mjs.map +1 -0
  646. package/dist/taxonomy-CPeM8d2o.mjs +484 -0
  647. package/dist/taxonomy-CPeM8d2o.mjs.map +1 -0
  648. package/dist/{tokens-DVltpO2D.mjs → tokens-C39klX8R.mjs} +2 -2
  649. package/dist/{tokens-DVltpO2D.mjs.map → tokens-C39klX8R.mjs.map} +1 -1
  650. package/dist/{transaction-D0FOsb3X.mjs → transaction-NQj4VJ7Z.mjs} +1 -1
  651. package/dist/{transaction-D0FOsb3X.mjs.map → transaction-NQj4VJ7Z.mjs.map} +1 -1
  652. package/dist/{transport-BWQo0Foy.mjs → transport--CfOflrp.mjs} +1 -1
  653. package/dist/{transport-BWQo0Foy.mjs.map → transport--CfOflrp.mjs.map} +1 -1
  654. package/dist/{transport-DokJOScS.d.mts → transport-B_rfeU-a.d.mts} +1 -1
  655. package/dist/{transport-DokJOScS.d.mts.map → transport-B_rfeU-a.d.mts.map} +1 -1
  656. package/dist/{trusted-proxy-CwjQj0YG.mjs → trusted-proxy-CEVekpby.mjs} +1 -1
  657. package/dist/{trusted-proxy-CwjQj0YG.mjs.map → trusted-proxy-CEVekpby.mjs.map} +1 -1
  658. package/dist/{types-o7xo7VgH.mjs → types-75KIqzL9.mjs} +20 -3
  659. package/dist/types-75KIqzL9.mjs.map +1 -0
  660. package/dist/{types-C9P3ysxq.d.mts → types-BPuloofb.d.mts} +1 -1
  661. package/dist/{types-C9P3ysxq.d.mts.map → types-BPuloofb.d.mts.map} +1 -1
  662. package/dist/{types-BD40g7zz.mjs → types-BW1syrlw.mjs} +1 -1
  663. package/dist/{types-BD40g7zz.mjs.map → types-BW1syrlw.mjs.map} +1 -1
  664. package/dist/{types-jISEalTX.d.mts → types-C3_AbkDP.d.mts} +53 -10
  665. package/dist/types-C3_AbkDP.d.mts.map +1 -0
  666. package/dist/{types-84Bque3-.d.mts → types-CAGdePf3.d.mts} +133 -1
  667. package/dist/types-CAGdePf3.d.mts.map +1 -0
  668. package/dist/{types-CcSZCcF1.d.mts → types-CFUfyt1s.d.mts} +1 -1
  669. package/dist/{types-CcSZCcF1.d.mts.map → types-CFUfyt1s.d.mts.map} +1 -1
  670. package/dist/{types-DQLZ58GV.d.mts → types-CPx2lIGo.d.mts} +1 -1
  671. package/dist/{types-DQLZ58GV.d.mts.map → types-CPx2lIGo.d.mts.map} +1 -1
  672. package/dist/{types-XrQQ-Aex.mjs → types-CQPWhPxE.mjs} +15 -11
  673. package/dist/types-CQPWhPxE.mjs.map +1 -0
  674. package/dist/{types-BQY7VYw6.d.mts → types-Cs-TFLNf.d.mts} +28 -8
  675. package/dist/types-Cs-TFLNf.d.mts.map +1 -0
  676. package/dist/types-CtzytVXn.mjs +18 -0
  677. package/dist/types-CtzytVXn.mjs.map +1 -0
  678. package/dist/{types-DqZVPILm.d.mts → types-D4_1Ga4L.d.mts} +44 -5
  679. package/dist/types-D4_1Ga4L.d.mts.map +1 -0
  680. package/dist/{types-Dr2GLlwu.d.mts → types-DefV3OiO.d.mts} +1 -1
  681. package/dist/{types-Dr2GLlwu.d.mts.map → types-DefV3OiO.d.mts.map} +1 -1
  682. package/dist/{types-DsAMyD_o.d.mts → types-X0MThr7x.d.mts} +1 -1
  683. package/dist/{types-DsAMyD_o.d.mts.map → types-X0MThr7x.d.mts.map} +1 -1
  684. package/dist/{upload-attempts-C5yd6Gae.mjs → upload-attempts-hlne3iA1.mjs} +1 -1
  685. package/dist/{upload-attempts-C5yd6Gae.mjs.map → upload-attempts-hlne3iA1.mjs.map} +1 -1
  686. package/dist/url-pattern-BHS0V5Ng.mjs +33 -0
  687. package/dist/url-pattern-BHS0V5Ng.mjs.map +1 -0
  688. package/dist/{user-BAumEmpA.mjs → user-sHgJ62SP.mjs} +3 -3
  689. package/dist/{user-BAumEmpA.mjs.map → user-sHgJ62SP.mjs.map} +1 -1
  690. package/dist/{utils-BbUgmeZG.mjs → utils-D4vD_Y98.mjs} +3 -3
  691. package/dist/{utils-BbUgmeZG.mjs.map → utils-D4vD_Y98.mjs.map} +1 -1
  692. package/dist/{validate-Bs_wT2ul.mjs → validate-BkqIG_Uj.mjs} +24 -5
  693. package/dist/validate-BkqIG_Uj.mjs.map +1 -0
  694. package/dist/{validate-VPnKoIzW.mjs → validate-BpwoDOgt.mjs} +25 -2
  695. package/dist/validate-BpwoDOgt.mjs.map +1 -0
  696. package/dist/{validate-CdYcY-Ln.d.mts → validate-DeGtBN84.d.mts} +25 -7
  697. package/dist/validate-DeGtBN84.d.mts.map +1 -0
  698. package/dist/{validation-BngR0nit.mjs → validation-CiJb_X5o.mjs} +14 -13
  699. package/dist/{validation-BngR0nit.mjs.map → validation-CiJb_X5o.mjs.map} +1 -1
  700. package/dist/version-x0NjTScG.mjs +7 -0
  701. package/dist/{version-Bdfb34kk.mjs.map → version-x0NjTScG.mjs.map} +1 -1
  702. package/dist/{widgets-L-VndTKD.mjs → widgets-Cwzvww0W.mjs} +3 -3
  703. package/dist/{widgets-L-VndTKD.mjs.map → widgets-Cwzvww0W.mjs.map} +1 -1
  704. package/dist/{wxr-taxonomies-DueaLFd9.d.mts → wxr-taxonomies-CmC0ZsDr.d.mts} +1 -1
  705. package/dist/{wxr-taxonomies-DueaLFd9.d.mts.map → wxr-taxonomies-CmC0ZsDr.d.mts.map} +1 -1
  706. package/dist/{wxr-taxonomies-BexjtNid.mjs → wxr-taxonomies-QZjWxjyk.mjs} +13 -32
  707. package/dist/{wxr-taxonomies-BexjtNid.mjs.map → wxr-taxonomies-QZjWxjyk.mjs.map} +1 -1
  708. package/dist/{zod-generator-DMzfga3g.mjs → zod-generator-D11MhxG4.mjs} +53 -6
  709. package/dist/zod-generator-D11MhxG4.mjs.map +1 -0
  710. package/package.json +18 -7
  711. package/src/after.ts +6 -1
  712. package/src/api/errors.ts +13 -0
  713. package/src/api/handlers/content.ts +172 -33
  714. package/src/api/handlers/dashboard.ts +12 -2
  715. package/src/api/handlers/index.ts +16 -0
  716. package/src/api/handlers/manifest.ts +2 -0
  717. package/src/api/handlers/marketplace.ts +14 -0
  718. package/src/api/handlers/media-allowlist.ts +13 -2
  719. package/src/api/handlers/media-usage-work.ts +188 -0
  720. package/src/api/handlers/media-usage.ts +5 -3
  721. package/src/api/handlers/registry.ts +14 -0
  722. package/src/api/handlers/revision.ts +19 -5
  723. package/src/api/handlers/schema.ts +46 -3
  724. package/src/api/handlers/seo.ts +5 -2
  725. package/src/api/handlers/snapshot.ts +1 -1
  726. package/src/api/handlers/taxonomies.ts +446 -83
  727. package/src/api/media-usage-write-fence.ts +64 -0
  728. package/src/api/openapi/document.ts +247 -0
  729. package/src/api/public-url.ts +14 -2
  730. package/src/api/schemas/common.ts +3 -1
  731. package/src/api/schemas/content.ts +122 -1
  732. package/src/api/schemas/media-usage.ts +133 -0
  733. package/src/api/schemas/schema.ts +91 -3
  734. package/src/api/schemas/search.ts +2 -0
  735. package/src/api/schemas/taxonomies.ts +58 -8
  736. package/src/api/site-url.ts +16 -6
  737. package/src/api/types.ts +1 -0
  738. package/src/astro/integration/index.ts +55 -42
  739. package/src/astro/integration/routes.ts +48 -4
  740. package/src/astro/integration/runtime.ts +28 -0
  741. package/src/astro/integration/virtual-modules.ts +27 -1
  742. package/src/astro/integration/vite-config.ts +16 -1
  743. package/src/astro/middleware/media-usage-write-fence.ts +30 -0
  744. package/src/astro/middleware/redirect.ts +14 -12
  745. package/src/astro/middleware/scoped-db.ts +35 -4
  746. package/src/astro/middleware.ts +131 -30
  747. package/src/astro/routes/api/admin/comments/[id]/status.ts +1 -1
  748. package/src/astro/routes/api/admin/media-usage/collection-deletions/index.ts +22 -0
  749. package/src/astro/routes/api/admin/media-usage/collection-deletions/retry.ts +22 -0
  750. package/src/astro/routes/api/admin/media-usage/work/index.ts +26 -0
  751. package/src/astro/routes/api/admin/media-usage/work/retry.ts +26 -0
  752. package/src/astro/routes/api/admin/plugins/[id]/disable.ts +4 -0
  753. package/src/astro/routes/api/admin/plugins/[id]/enable.ts +4 -0
  754. package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +4 -0
  755. package/src/astro/routes/api/admin/plugins/[id]/update.ts +4 -0
  756. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +4 -0
  757. package/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts +4 -0
  758. package/src/astro/routes/api/admin/plugins/registry/[id]/update.ts +4 -0
  759. package/src/astro/routes/api/admin/plugins/registry/install.ts +4 -0
  760. package/src/astro/routes/api/admin/users/[id]/send-recovery.ts +2 -2
  761. package/src/astro/routes/api/auth/invite/index.ts +2 -2
  762. package/src/astro/routes/api/auth/magic-link/send.ts +2 -2
  763. package/src/astro/routes/api/auth/signup/request.ts +2 -2
  764. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +1 -1
  765. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +67 -31
  766. package/src/astro/routes/api/import/wordpress/execute.ts +3 -10
  767. package/src/astro/routes/api/mcp.ts +1 -0
  768. package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +7 -1
  769. package/src/astro/routes/api/schema/collections/[slug]/index.ts +1 -7
  770. package/src/astro/routes/api/schema/collections/reorder.ts +31 -0
  771. package/src/astro/routes/api/schema/index.ts +2 -0
  772. package/src/astro/routes/api/search/enable.ts +5 -1
  773. package/src/astro/routes/api/search/rebuild.ts +6 -1
  774. package/src/astro/routes/api/taxonomies/[name]/reorder.ts +41 -0
  775. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +1 -0
  776. package/src/astro/routes/api/taxonomies/[name]/translations.ts +48 -0
  777. package/src/astro/routes/api/taxonomies/[name].ts +102 -0
  778. package/src/astro/types.ts +31 -4
  779. package/src/cleanup.ts +25 -26
  780. package/src/cli/commands/dev.ts +13 -3
  781. package/src/cli/commands/doctor.ts +193 -6
  782. package/src/cli/commands/export-seed.ts +6 -1
  783. package/src/cli/index.ts +1 -1
  784. package/src/client/index.ts +119 -0
  785. package/src/components/Block.astro +12 -0
  786. package/src/components/CommentForm.astro +1 -1
  787. package/src/components/Comments.astro +1 -1
  788. package/src/components/InlinePortableTextEditor.tsx +260 -32
  789. package/src/components/OrderedList.astro +14 -0
  790. package/src/components/PortableText.astro +26 -4
  791. package/src/components/index.ts +12 -1
  792. package/src/components/ordered-list.ts +549 -0
  793. package/src/content/converters/mark-safety.ts +120 -0
  794. package/src/content/converters/numbered-list.ts +148 -0
  795. package/src/content/converters/portable-text-to-prosemirror.ts +84 -25
  796. package/src/content/converters/prosemirror-to-portable-text.ts +35 -14
  797. package/src/content/converters/types.ts +2 -0
  798. package/src/content/portable-text-lists.ts +205 -0
  799. package/src/content-list-query.ts +29 -0
  800. package/src/database/dialect-helpers.ts +3 -22
  801. package/src/database/migrations/055_content_translation_group_locale_index.ts +67 -0
  802. package/src/database/migrations/056_taxonomy_term_sort_order.ts +189 -0
  803. package/src/database/migrations/057_collection_hidden.ts +24 -0
  804. package/src/database/migrations/058_collection_sort_order.ts +20 -0
  805. package/src/database/migrations/059_revision_prune_queue.ts +36 -0
  806. package/src/database/migrations/060_collection_admin_config.ts +16 -0
  807. package/src/database/migrations/061_media_usage_cleanup.ts +77 -0
  808. package/src/database/migrations/062_media_usage_cleanup_fence.ts +295 -0
  809. package/src/database/migrations/063_media_usage_incremental_work.ts +397 -0
  810. package/src/database/migrations/064_fts_plain_text.ts +253 -0
  811. package/src/database/migrations/065_media_usage_collection_deletion.ts +85 -0
  812. package/src/database/migrations/066_media_usage_reconciliation.ts +115 -0
  813. package/src/database/migrations/067_indexed_content_fields.ts +39 -0
  814. package/src/database/migrations/068_content_taxonomy_entry_groups.ts +61 -0
  815. package/src/database/migrations/069_collection_title_date_fields.ts +45 -0
  816. package/src/database/migrations/070_collection_routable.ts +18 -0
  817. package/src/database/migrations/runner.ts +32 -0
  818. package/src/database/repositories/content.ts +1051 -273
  819. package/src/database/repositories/media-usage-work.ts +848 -0
  820. package/src/database/repositories/media-usage.ts +1502 -138
  821. package/src/database/repositories/revision.ts +98 -22
  822. package/src/database/repositories/taxonomy.ts +429 -103
  823. package/src/database/repositories/types.ts +58 -2
  824. package/src/database/types.ts +147 -17
  825. package/src/database/validate.ts +39 -0
  826. package/src/db/adapters.ts +36 -0
  827. package/src/db/index.ts +3 -0
  828. package/src/deferred-tasks.ts +71 -0
  829. package/src/emdash-runtime.ts +493 -110
  830. package/src/fields/file.ts +7 -4
  831. package/src/fields/image.ts +8 -1
  832. package/src/fields/types.ts +18 -5
  833. package/src/i18n/config.ts +32 -0
  834. package/src/i18n/repair-locale-casing.ts +0 -14
  835. package/src/i18n/taxonomy-locale-diagnostic.ts +47 -0
  836. package/src/import/wxr-taxonomies.ts +7 -47
  837. package/src/index.ts +27 -2
  838. package/src/loader.ts +165 -89
  839. package/src/mcp/server.ts +242 -34
  840. package/src/media/usage/activation.ts +626 -0
  841. package/src/media/usage/capture-triggers.ts +543 -0
  842. package/src/media/usage/cleanup.ts +305 -0
  843. package/src/media/usage/collection-deletion-processor.ts +356 -0
  844. package/src/media/usage/collection-deletion.ts +836 -0
  845. package/src/media/usage/content-fields.ts +40 -3
  846. package/src/media/usage/content-refresh.ts +356 -47
  847. package/src/media/usage/content-repair.ts +180 -61
  848. package/src/media/usage/content-snapshots.ts +85 -105
  849. package/src/media/usage/gc.ts +50 -0
  850. package/src/media/usage/projection-fingerprint.ts +129 -0
  851. package/src/media/usage/reconciliation-processor.ts +372 -0
  852. package/src/media/usage/reconciliation.ts +1117 -0
  853. package/src/media/usage/source-key.ts +4 -0
  854. package/src/media/usage/types.ts +2 -0
  855. package/src/media/usage/work-processor.ts +248 -0
  856. package/src/menus/index.ts +246 -141
  857. package/src/object-cache/index.ts +160 -3
  858. package/src/plugin-types.ts +8 -0
  859. package/src/plugins/adapt-sandbox-entry.ts +24 -5
  860. package/src/plugins/context.ts +24 -7
  861. package/src/plugins/index.ts +9 -0
  862. package/src/plugins/routes.ts +77 -1
  863. package/src/plugins/sandbox/index.ts +10 -1
  864. package/src/plugins/sandbox/types.ts +78 -1
  865. package/src/plugins/scheduler/node.ts +13 -1
  866. package/src/plugins/scheduler/types.ts +2 -0
  867. package/src/plugins/storage-indexes.ts +50 -11
  868. package/src/plugins/types.ts +39 -2
  869. package/src/query.ts +50 -79
  870. package/src/redirects/cache.ts +79 -19
  871. package/src/request-context.ts +3 -0
  872. package/src/scheduled-publish.ts +6 -1
  873. package/src/scheduler-health.ts +46 -0
  874. package/src/schema/registry.ts +863 -249
  875. package/src/schema/types.ts +74 -8
  876. package/src/schema/url-pattern.ts +33 -0
  877. package/src/schema/zod-generator.ts +77 -5
  878. package/src/search/fts-manager.ts +167 -86
  879. package/src/search/index.ts +2 -0
  880. package/src/search/query.ts +32 -11
  881. package/src/search/types.ts +8 -0
  882. package/src/seed/apply.ts +89 -16
  883. package/src/seed/types.ts +21 -3
  884. package/src/seed/validate.ts +52 -2
  885. package/src/taxonomies/index.ts +88 -82
  886. package/src/taxonomies/term-counts.ts +23 -12
  887. package/src/ui-comments.ts +19 -0
  888. package/src/ui.ts +11 -3
  889. package/src/utils/slugify.ts +1 -29
  890. package/src/virtual-modules.d.ts +24 -0
  891. package/dist/adapters-C0ONzkUP.d.mts.map +0 -1
  892. package/dist/after-B1IIdH3Y.mjs +0 -29
  893. package/dist/after-B1IIdH3Y.mjs.map +0 -1
  894. package/dist/api-DgPbMlng.mjs.map +0 -1
  895. package/dist/apply-1_6ra7NP.mjs.map +0 -1
  896. package/dist/cache-B26cufFd.mjs +0 -65
  897. package/dist/cache-B26cufFd.mjs.map +0 -1
  898. package/dist/config-DyF3OKg_.mjs.map +0 -1
  899. package/dist/content-CpfKV9QE.mjs.map +0 -1
  900. package/dist/content-refresh-DgqUBeTv.mjs +0 -1629
  901. package/dist/content-refresh-DgqUBeTv.mjs.map +0 -1
  902. package/dist/context-kO4YZB03.mjs.map +0 -1
  903. package/dist/dashboard-DoBwPnON.mjs.map +0 -1
  904. package/dist/dialect-helpers-DFwyWjTM.mjs.map +0 -1
  905. package/dist/errors-DtEXIQQV.mjs.map +0 -1
  906. package/dist/fts-manager-vncsZIJm.mjs.map +0 -1
  907. package/dist/index-BeNto_Vs.d.mts.map +0 -1
  908. package/dist/index-j9rTh3cp.d.mts.map +0 -1
  909. package/dist/loader-C1XOLV5b.mjs.map +0 -1
  910. package/dist/media-DWIzoVFL.mjs.map +0 -1
  911. package/dist/media-allowlist-B7TPD6aq.mjs.map +0 -1
  912. package/dist/media-usage-DPJcNaAq.mjs.map +0 -1
  913. package/dist/menus-C6qhqP4X.mjs.map +0 -1
  914. package/dist/object-cache-Bok5j2ae.mjs.map +0 -1
  915. package/dist/options-aeIs8WFu.d.mts.map +0 -1
  916. package/dist/public-url-DSGTnJFw.mjs.map +0 -1
  917. package/dist/query-DCiXI7OZ.mjs.map +0 -1
  918. package/dist/redirects-BOn2DIUs.mjs.map +0 -1
  919. package/dist/registry-BP1JK2xh.mjs +0 -795
  920. package/dist/registry-BP1JK2xh.mjs.map +0 -1
  921. package/dist/relations-DHFQGX0h.d.mts.map +0 -1
  922. package/dist/runner-BsI18UgP.mjs.map +0 -1
  923. package/dist/search-CeO678cp.mjs.map +0 -1
  924. package/dist/seo-C3GDfT0V.mjs.map +0 -1
  925. package/dist/site-url-8N3kSyVZ.mjs +0 -13
  926. package/dist/site-url-8N3kSyVZ.mjs.map +0 -1
  927. package/dist/slugify-C_tqlU4G.mjs +0 -30
  928. package/dist/slugify-C_tqlU4G.mjs.map +0 -1
  929. package/dist/taxonomies-B61CRSha.mjs.map +0 -1
  930. package/dist/taxonomies-Dbdki3Un.mjs.map +0 -1
  931. package/dist/taxonomy-DvwWAPvA.mjs +0 -304
  932. package/dist/taxonomy-DvwWAPvA.mjs.map +0 -1
  933. package/dist/types-84Bque3-.d.mts.map +0 -1
  934. package/dist/types-BQY7VYw6.d.mts.map +0 -1
  935. package/dist/types-DqZVPILm.d.mts.map +0 -1
  936. package/dist/types-XrQQ-Aex.mjs.map +0 -1
  937. package/dist/types-jISEalTX.d.mts.map +0 -1
  938. package/dist/types-o7xo7VgH.mjs.map +0 -1
  939. package/dist/validate-Bs_wT2ul.mjs.map +0 -1
  940. package/dist/validate-CdYcY-Ln.d.mts.map +0 -1
  941. package/dist/validate-VPnKoIzW.mjs.map +0 -1
  942. package/dist/version-Bdfb34kk.mjs +0 -7
  943. package/dist/zod-generator-DMzfga3g.mjs.map +0 -1
  944. /package/dist/{api-tokens-CEsW_jCg.mjs → api-tokens-CyTsLszZ.mjs} +0 -0
  945. /package/dist/{ssrf-BB__xfNM.mjs → ssrf-DpXgKsna.mjs} +0 -0
  946. /package/dist/{types-CK0LxPUQ.mjs → types-CP1CflWf.mjs} +0 -0
@@ -1,7 +1,9 @@
1
1
  import { sql, type Kysely } from "kysely";
2
2
  import { ulid } from "ulidx";
3
3
 
4
+ import type { ContentFieldFilterValue, ContentFieldFilters } from "../../content-list-query.js";
4
5
  import { invalidateCollectionCache } from "../../object-cache/index.js";
6
+ import { isIndexableFieldType, type FieldType } from "../../schema/types.js";
5
7
  import { buildFtsPrefixMatch, buildSlugGlobPrefix } from "../../search/match.js";
6
8
  import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
7
9
  import { isMissingTableError } from "../../utils/db-errors.js";
@@ -16,9 +18,13 @@ import type {
16
18
  FindManyResult,
17
19
  ContentItem,
18
20
  ContentDateField,
21
+ ContentBylineFilter,
19
22
  } from "./types.js";
20
23
  import {
24
+ ContentCollectionNotFoundError,
25
+ ContentMutationConflictError,
21
26
  EmDashValidationError,
27
+ InvalidCursorError,
22
28
  ScheduledNotDueError,
23
29
  encodeCursor,
24
30
  decodeCursor,
@@ -26,9 +32,149 @@ import {
26
32
 
27
33
  // Regex pattern for ULID validation
28
34
  const ULID_PATTERN = /^[0-9A-Z]{26}$/;
35
+ const SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/;
36
+ const MAX_DRAFT_STAGE_ATTEMPTS = 32;
29
37
 
30
38
  // LIKE wildcards that must be escaped so user search input is matched literally.
31
39
  const LIKE_WILDCARD_RE = /[\\%_]/g;
40
+ const MAX_INDEXED_FIELD_FILTERS = 20;
41
+ const MAX_IN_FILTER_VALUES = SQL_BATCH_SIZE;
42
+ const MAX_FILTER_STRING_LENGTH = 2048;
43
+
44
+ type NormalizedFilterScalar = string | number;
45
+
46
+ type ResolvedFieldFilter =
47
+ | { column: string; kind: "null" }
48
+ | { column: string; kind: "exact"; value: NormalizedFilterScalar }
49
+ | { column: string; kind: "in"; values: NormalizedFilterScalar[] }
50
+ | {
51
+ column: string;
52
+ kind: "range";
53
+ bounds: Partial<Record<"gt" | "gte" | "lt" | "lte", NormalizedFilterScalar>>;
54
+ };
55
+
56
+ function nullableColumnMatch(column: string, value: string | null): ReturnType<typeof sql> {
57
+ validateIdentifier(column, "content column");
58
+ return value === null ? sql`${sql.ref(column)} IS NULL` : sql`${sql.ref(column)} = ${value}`;
59
+ }
60
+
61
+ function sameStoredValue(left: unknown, right: unknown): boolean {
62
+ return Object.is(serializeValue(left), serializeValue(right));
63
+ }
64
+
65
+ function matchesPublication(
66
+ observed: ContentItem,
67
+ existing: ContentItem,
68
+ revision: { data: Record<string, unknown> },
69
+ revisionId: string,
70
+ slug: string | null,
71
+ publishedAt: string,
72
+ updatedAt: string,
73
+ ): boolean {
74
+ if (
75
+ observed.version !== existing.version + 1 ||
76
+ observed.status !== "published" ||
77
+ observed.slug !== slug ||
78
+ observed.liveRevisionId !== revisionId ||
79
+ observed.draftRevisionId !== null ||
80
+ observed.scheduledAt !== null ||
81
+ observed.publishedAt !== publishedAt ||
82
+ observed.updatedAt !== updatedAt
83
+ ) {
84
+ return false;
85
+ }
86
+
87
+ return Object.entries(revision.data).every(
88
+ ([key, value]) =>
89
+ SYSTEM_COLUMNS.has(key) || key.startsWith("_") || sameStoredValue(observed.data[key], value),
90
+ );
91
+ }
92
+
93
+ function matchesLifecyclePublication(
94
+ observed: ContentItem,
95
+ existing: ContentItem,
96
+ liveRevisionId: string,
97
+ publishedAt: string,
98
+ updatedAt: string,
99
+ ): boolean {
100
+ if (
101
+ observed.version !== existing.version + 1 ||
102
+ observed.status !== "published" ||
103
+ observed.slug !== existing.slug ||
104
+ observed.liveRevisionId !== liveRevisionId ||
105
+ observed.draftRevisionId !== null ||
106
+ observed.scheduledAt !== null ||
107
+ observed.publishedAt !== publishedAt ||
108
+ observed.updatedAt !== updatedAt
109
+ ) {
110
+ return false;
111
+ }
112
+
113
+ return Object.entries(existing.data).every(([key, value]) =>
114
+ sameStoredValue(observed.data[key], value),
115
+ );
116
+ }
117
+
118
+ function matchesPublicationFence(observed: ContentItem, existing: ContentItem): boolean {
119
+ return (
120
+ observed.version === existing.version &&
121
+ observed.status === existing.status &&
122
+ observed.liveRevisionId === existing.liveRevisionId &&
123
+ observed.draftRevisionId === existing.draftRevisionId &&
124
+ observed.scheduledAt === existing.scheduledAt
125
+ );
126
+ }
127
+
128
+ function isConfirmedStatementFailure(error: unknown): boolean {
129
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
130
+ const code = (error as { code?: unknown }).code;
131
+ return typeof code === "string" && (code.startsWith("SQLITE_") || SQLSTATE_PATTERN.test(code));
132
+ }
133
+
134
+ interface ResolvedOrderField {
135
+ column: string;
136
+ indexedCustomField: boolean;
137
+ }
138
+
139
+ type IndexedOrderValue = string | number | null;
140
+
141
+ interface IndexedFieldCursorPayload {
142
+ version: 1;
143
+ field: string;
144
+ value: IndexedOrderValue;
145
+ }
146
+
147
+ function encodeIndexedFieldCursor(field: string, value: IndexedOrderValue, id: string): string {
148
+ const payload: IndexedFieldCursorPayload = { version: 1, field, value };
149
+ return encodeCursor(JSON.stringify(payload), id);
150
+ }
151
+
152
+ function decodeIndexedFieldCursor(
153
+ cursor: string,
154
+ field: string,
155
+ ): { value: IndexedOrderValue; id: string } {
156
+ const { orderValue, id } = decodeCursor(cursor);
157
+ let payload: unknown;
158
+ try {
159
+ payload = JSON.parse(orderValue);
160
+ } catch {
161
+ throw new InvalidCursorError(cursor);
162
+ }
163
+
164
+ if (payload === null || typeof payload !== "object") {
165
+ throw new InvalidCursorError(cursor);
166
+ }
167
+ const candidate = payload as Partial<IndexedFieldCursorPayload>;
168
+ const validValue =
169
+ candidate.value === null ||
170
+ typeof candidate.value === "string" ||
171
+ typeof candidate.value === "number";
172
+ if (candidate.version !== 1 || candidate.field !== field || !validValue) {
173
+ throw new InvalidCursorError(cursor);
174
+ }
175
+
176
+ return { value: candidate.value as IndexedOrderValue, id };
177
+ }
32
178
 
33
179
  /**
34
180
  * Whitelist mapping a public date-filter field to its physical column. Keeping
@@ -42,6 +188,29 @@ const DATE_FILTER_COLUMNS: Record<ContentDateField, "created_at" | "updated_at"
42
188
  publishedAt: "published_at",
43
189
  };
44
190
 
191
+ /**
192
+ * Built-in sort fields → their physical columns. A closed set that blocks
193
+ * sorting by arbitrary columns; per-collection fields are allowed
194
+ * separately via `mapOrderField`'s `sortableExtras`.
195
+ */
196
+ const ORDER_FIELD_COLUMNS: Record<string, string> = {
197
+ createdAt: "created_at",
198
+ updatedAt: "updated_at",
199
+ publishedAt: "published_at",
200
+ scheduledAt: "scheduled_at",
201
+ deletedAt: "deleted_at",
202
+ title: "title",
203
+ name: "name",
204
+ slug: "slug",
205
+ status: "status",
206
+ locale: "locale",
207
+ };
208
+
209
+ /** True when `field` maps to a system column and needs no per-collection resolution. */
210
+ export function isSystemOrderField(field: string): boolean {
211
+ return field in ORDER_FIELD_COLUMNS;
212
+ }
213
+
45
214
  /**
46
215
  * System columns that exist in every ec_* table
47
216
  */
@@ -89,6 +258,16 @@ function serializeValue(value: unknown): unknown {
89
258
  return value;
90
259
  }
91
260
 
261
+ function writableContentData(data: Record<string, unknown>): Record<string, unknown> {
262
+ const writable: Record<string, unknown> = {};
263
+ for (const [key, value] of Object.entries(data)) {
264
+ if (SYSTEM_COLUMNS.has(key)) continue;
265
+ validateIdentifier(key, "content field name");
266
+ writable[key] = value;
267
+ }
268
+ return writable;
269
+ }
270
+
92
271
  /**
93
272
  * Deserialize a value from database storage
94
273
  * Attempts to parse JSON strings that look like objects/arrays
@@ -130,7 +309,7 @@ export class ContentRepository {
130
309
  * Create a new content item
131
310
  */
132
311
  async create(input: CreateContentInput): Promise<ContentItem> {
133
- const id = ulid();
312
+ const id = input.id ?? ulid();
134
313
  const now = new Date().toISOString();
135
314
 
136
315
  const {
@@ -228,7 +407,7 @@ export class ContentRepository {
228
407
  * (optionally scoped to a locale) and appends a numeric suffix (`-1`,
229
408
  * `-2`, etc.) on collision to guarantee uniqueness.
230
409
  *
231
- * Returns `null` if `baseSlug` is empty after slugification.
410
+ * Returns null when slug normalization cannot produce a value.
232
411
  */
233
412
  async generateUniqueSlug(type: string, text: string, locale?: string): Promise<string | null> {
234
413
  const baseSlug = slugify(text);
@@ -502,7 +681,13 @@ export class ContentRepository {
502
681
  // Determine ordering
503
682
  const orderField = options.orderBy?.field || "createdAt";
504
683
  const orderDirection = options.orderBy?.direction || "desc";
505
- const dbField = this.mapOrderField(orderField);
684
+ const resolvedOrderField = await this.resolveOrderField(
685
+ type,
686
+ orderField,
687
+ options.sortableExtras,
688
+ );
689
+ const dbField = resolvedOrderField.column;
690
+ const resolvedFieldFilters = await this.resolveFieldFilters(type, options.where?.fieldFilters);
506
691
 
507
692
  // Validate order direction to prevent injection
508
693
  const safeOrderDirection = orderDirection.toLowerCase() === "asc" ? "ASC" : "DESC";
@@ -529,40 +714,90 @@ export class ContentRepository {
529
714
 
530
715
  query = this.applySearchFilter(query, options.where, type);
531
716
  query = this.applyDateFilter(query, options.where);
717
+ query = this.applyBylineFilter(query, options.where, type);
718
+ query = this.applyFieldFilters(query, resolvedFieldFilters);
532
719
 
533
720
  // Handle cursor pagination — decodeCursor throws InvalidCursorError
534
721
  // on malformed input; let it propagate so handlers surface a
535
722
  // structured INVALID_CURSOR rather than silently returning page 1.
536
723
  if (options.cursor) {
537
- const { orderValue, id: cursorId } = decodeCursor(options.cursor);
538
-
539
- if (safeOrderDirection === "DESC") {
540
- query = query.where((eb) =>
541
- eb.or([
542
- eb(dbField as any, "<", orderValue),
543
- eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
544
- ]),
545
- );
724
+ if (resolvedOrderField.indexedCustomField) {
725
+ const { value, id: cursorId } = decodeIndexedFieldCursor(options.cursor, orderField);
726
+ const isPresent = sql<boolean>`${sql.ref(dbField)} IS NOT NULL`;
727
+ const falseLiteral = sql<boolean>`FALSE`;
728
+ const trueLiteral = sql<boolean>`TRUE`;
729
+ if (safeOrderDirection === "ASC" && value === null) {
730
+ query = query.where(sql<boolean>`
731
+ (${isPresent}) > ${falseLiteral}
732
+ OR ((${isPresent}) = ${falseLiteral} AND ${sql.ref("id")} > ${cursorId})
733
+ `);
734
+ } else if (safeOrderDirection === "DESC" && value === null) {
735
+ query = query.where(sql<boolean>`
736
+ (${isPresent}) = ${falseLiteral} AND ${sql.ref("id")} < ${cursorId}
737
+ `);
738
+ } else if (safeOrderDirection === "ASC") {
739
+ query = query.where(sql<boolean>`
740
+ (${isPresent}, ${sql.ref(dbField)}, ${sql.ref("id")})
741
+ > (${trueLiteral}, ${value}, ${cursorId})
742
+ `);
743
+ } else {
744
+ query = query.where(sql<boolean>`
745
+ (${isPresent}, ${sql.ref(dbField)}, ${sql.ref("id")})
746
+ < (${trueLiteral}, ${value}, ${cursorId})
747
+ `);
748
+ }
546
749
  } else {
547
- query = query.where((eb) =>
548
- eb.or([
549
- eb(dbField as any, ">", orderValue),
550
- eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
551
- ]),
552
- );
750
+ const { orderValue, id: cursorId } = decodeCursor(options.cursor);
751
+
752
+ if (safeOrderDirection === "DESC") {
753
+ query = query.where((eb) =>
754
+ eb.or([
755
+ eb(dbField as any, "<", orderValue),
756
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
757
+ ]),
758
+ );
759
+ } else {
760
+ query = query.where((eb) =>
761
+ eb.or([
762
+ eb(dbField as any, ">", orderValue),
763
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
764
+ ]),
765
+ );
766
+ }
553
767
  }
554
768
  }
555
769
 
556
770
  // Apply ordering and limit
557
- query = query
558
- .orderBy(dbField as any, safeOrderDirection === "ASC" ? "asc" : "desc")
559
- .orderBy("id", safeOrderDirection === "ASC" ? "asc" : "desc")
560
- .limit(limit + 1);
771
+ const indexedOrderFilter = resolvedOrderField.indexedCustomField
772
+ ? resolvedFieldFilters.find((filter) => filter.column === dbField)
773
+ : undefined;
774
+ if (resolvedOrderField.indexedCustomField && !indexedOrderFilter) {
775
+ query = query.orderBy(
776
+ sql<boolean>`${sql.ref(dbField)} IS NOT NULL`,
777
+ safeOrderDirection === "ASC" ? "asc" : "desc",
778
+ );
779
+ }
780
+ if (indexedOrderFilter?.kind !== "null") {
781
+ query = query.orderBy(dbField as any, safeOrderDirection === "ASC" ? "asc" : "desc");
782
+ }
783
+ query = query.orderBy("id", safeOrderDirection === "ASC" ? "asc" : "desc").limit(limit + 1);
561
784
 
562
785
  // Run the page fetch and the unbounded count together — the UI needs
563
786
  // both to render a stable denominator (kept on every page intentionally),
564
787
  // and issuing them in parallel on SQLite is essentially free.
565
- const [rows, total] = await Promise.all([query.execute(), this.count(type, options.where)]);
788
+ //
789
+ // Settled rather than raced: a collection whose table is missing rejects
790
+ // both, and `Promise.all` returns on the first. The loser stays in flight
791
+ // holding a pooled connection, so a Postgres pool destroyed in that window
792
+ // never finishes closing.
793
+ const [rowsResult, countResult] = await Promise.allSettled([
794
+ query.execute(),
795
+ this.countWithResolvedFilters(type, options.where, resolvedFieldFilters),
796
+ ]);
797
+ if (rowsResult.status === "rejected") throw rowsResult.reason;
798
+ if (countResult.status === "rejected") throw countResult.reason;
799
+ const rows = rowsResult.value;
800
+ const total = countResult.value;
566
801
  const hasMore = rows.length > limit;
567
802
  const items = rows.slice(0, limit);
568
803
 
@@ -574,11 +809,26 @@ export class ContentRepository {
574
809
  if (hasMore && items.length > 0) {
575
810
  const lastRow = items.at(-1) as Record<string, unknown>;
576
811
  const lastOrderValue = lastRow[dbField];
577
- const orderStr =
578
- typeof lastOrderValue === "string" || typeof lastOrderValue === "number"
579
- ? String(lastOrderValue)
580
- : "";
581
- mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id));
812
+ if (resolvedOrderField.indexedCustomField) {
813
+ if (
814
+ lastOrderValue !== null &&
815
+ typeof lastOrderValue !== "string" &&
816
+ typeof lastOrderValue !== "number"
817
+ ) {
818
+ throw new EmDashValidationError(`Invalid indexed value for order field: ${orderField}`);
819
+ }
820
+ mappedResult.nextCursor = encodeIndexedFieldCursor(
821
+ orderField,
822
+ lastOrderValue,
823
+ String(lastRow.id),
824
+ );
825
+ } else {
826
+ const orderStr =
827
+ typeof lastOrderValue === "string" || typeof lastOrderValue === "number"
828
+ ? String(lastOrderValue)
829
+ : "";
830
+ mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id));
831
+ }
582
832
  }
583
833
 
584
834
  return mappedResult;
@@ -591,15 +841,8 @@ export class ContentRepository {
591
841
  const tableName = getTableName(type);
592
842
  const now = new Date().toISOString();
593
843
 
594
- // Build update object with parameterized values. `version` bumps on
595
- // every update so the _rev optimistic-concurrency token
596
- // (version:updated_at) always moves — concurrent editors still get a
597
- // 409 on stale saves. `updated_at` is stamped only when the request
598
- // actually carries column writes: a draft-only save on a
599
- // revision-supporting collection resolves to a column no-op here (all
600
- // data went into draft storage), and stamping it anyway would register
601
- // a phantom modification for public "last modified" consumers like
602
- // sitemap <lastmod> and JSON-LD dateModified (#2143).
844
+ // Every update advances the optimistic-concurrency version. updated_at
845
+ // advances only when a content-row column actually changes.
603
846
  const updates: Record<string, unknown> = {};
604
847
 
605
848
  if (input.status !== undefined) {
@@ -628,11 +871,8 @@ export class ContentRepository {
628
871
 
629
872
  // Update data fields (skip system columns to prevent injection via data)
630
873
  if (input.data !== undefined && typeof input.data === "object") {
631
- for (const [key, value] of Object.entries(input.data)) {
632
- if (!SYSTEM_COLUMNS.has(key)) {
633
- validateIdentifier(key, "content field name");
634
- updates[key] = serializeValue(value);
635
- }
874
+ for (const [key, value] of Object.entries(writableContentData(input.data))) {
875
+ updates[key] = serializeValue(value);
636
876
  }
637
877
  }
638
878
 
@@ -649,18 +889,6 @@ export class ContentRepository {
649
889
  .where("deleted_at" as never, "is", null)
650
890
  .execute();
651
891
 
652
- // Re-stamp the taxonomy pivot only when a denormalized column actually
653
- // moved (status/publishedAt/scheduledAt). A plain content edit bumps
654
- // `updated_at` — which is not denormalized — so it needs no pivot write,
655
- // keeping the common edit path free of taxonomy write amplification.
656
- if (
657
- input.status !== undefined ||
658
- input.publishedAt !== undefined ||
659
- input.scheduledAt !== undefined
660
- ) {
661
- await this.restampEntryPivot(type, id);
662
- }
663
-
664
892
  if (hasColumnWrites) invalidateCollectionCache(type);
665
893
 
666
894
  const updated = await this.findById(type, id);
@@ -671,6 +899,159 @@ export class ContentRepository {
671
899
  return updated;
672
900
  }
673
901
 
902
+ /**
903
+ * Update plugin-authored fields without letting content columns diverge
904
+ * from the revision pointers that publication promotes.
905
+ */
906
+ async updateDraftAware(
907
+ type: string,
908
+ id: string,
909
+ input: UpdateContentInput,
910
+ ): Promise<ContentItem> {
911
+ const data = input.data ? writableContentData(input.data) : {};
912
+ const stagedSlug = typeof input.slug === "string" ? input.slug : undefined;
913
+ const hasDraftUpdate = Object.keys(data).length > 0 || stagedSlug !== undefined;
914
+
915
+ if (!hasDraftUpdate) {
916
+ return this.update(type, id, { ...input, data });
917
+ }
918
+
919
+ const collectionRows = await this.db
920
+ .selectFrom("_emdash_collections as collection")
921
+ .leftJoin("_emdash_fields as field", "field.collection_id", "collection.id")
922
+ .select(["collection.supports", "field.slug as fieldSlug"])
923
+ .where("collection.slug", "=", type)
924
+ .execute();
925
+ const supportsRaw = collectionRows[0]?.supports;
926
+ const supports: unknown = supportsRaw ? JSON.parse(supportsRaw) : [];
927
+ if (!Array.isArray(supports) || !supports.includes("revisions")) {
928
+ return this.update(type, id, { ...input, data });
929
+ }
930
+
931
+ const fieldSlugs = new Set(collectionRows.map((row) => row.fieldSlug).filter(Boolean));
932
+ for (const field of Object.keys(data)) {
933
+ if (!fieldSlugs.has(field)) {
934
+ throw new EmDashValidationError(`Unknown field '${field}' in collection '${type}'`);
935
+ }
936
+ }
937
+
938
+ const revisionRepo = new RevisionRepository(this.db);
939
+ let existing = await this.findById(type, id);
940
+
941
+ for (let attempt = 0; existing && attempt < MAX_DRAFT_STAGE_ATTEMPTS; attempt++) {
942
+ let baseData = existing.data;
943
+ if (existing.draftRevisionId) {
944
+ const draft = await revisionRepo.findById(existing.draftRevisionId);
945
+ if (draft) baseData = draft.data;
946
+ }
947
+
948
+ const mergedData = { ...baseData, ...data };
949
+ if (stagedSlug !== undefined) mergedData._slug = stagedSlug;
950
+ const revision = await revisionRepo.create({
951
+ collection: type,
952
+ entryId: id,
953
+ data: mergedData,
954
+ ...(input.authorId ? { authorId: input.authorId } : {}),
955
+ });
956
+
957
+ let staged: boolean;
958
+ try {
959
+ staged = await this.replaceDraftRevisionForUpdate(type, id, revision.id, existing, input);
960
+ } catch (error) {
961
+ await this.deleteUnstagedRevision(revisionRepo, type, id, revision.id);
962
+ throw error;
963
+ }
964
+
965
+ if (staged) {
966
+ const updated = await this.findById(type, id);
967
+ if (!updated) throw new Error("Content not found");
968
+ const draftData: Record<string, unknown> = {};
969
+ for (const [key, value] of Object.entries(mergedData)) {
970
+ if (!key.startsWith("_")) draftData[key] = value;
971
+ }
972
+ return { ...updated, data: { ...updated.data, ...draftData } };
973
+ }
974
+
975
+ await this.deleteUnstagedRevision(revisionRepo, type, id, revision.id);
976
+ existing = await this.findById(type, id);
977
+ }
978
+
979
+ if (!existing) throw new Error("Content not found");
980
+ throw new ContentMutationConflictError();
981
+ }
982
+
983
+ private async deleteUnstagedRevision(
984
+ revisionRepo: RevisionRepository,
985
+ type: string,
986
+ id: string,
987
+ revisionId: string,
988
+ ): Promise<void> {
989
+ try {
990
+ await revisionRepo.deleteIfUnreferenced(type, id, revisionId);
991
+ } catch (error) {
992
+ console.error(`[content] Failed to clean up unstaged revision ${revisionId}:`, error);
993
+ }
994
+ }
995
+
996
+ private async replaceDraftRevisionForUpdate(
997
+ type: string,
998
+ id: string,
999
+ revisionId: string,
1000
+ expected: ContentItem,
1001
+ input: UpdateContentInput,
1002
+ ): Promise<boolean> {
1003
+ const tableName = getTableName(type);
1004
+ const assignments = [sql`draft_revision_id = ${revisionId}`];
1005
+ let liveMetadataChanged = false;
1006
+
1007
+ if (input.status !== undefined) {
1008
+ assignments.push(sql`status = ${input.status}`);
1009
+ liveMetadataChanged = true;
1010
+ }
1011
+ if (input.slug === null) {
1012
+ assignments.push(sql`slug = NULL`);
1013
+ liveMetadataChanged = true;
1014
+ }
1015
+ if (input.publishedAt !== undefined) {
1016
+ assignments.push(sql`published_at = ${input.publishedAt}`);
1017
+ liveMetadataChanged = true;
1018
+ }
1019
+ if (input.scheduledAt !== undefined) {
1020
+ assignments.push(sql`scheduled_at = ${input.scheduledAt}`);
1021
+ liveMetadataChanged = true;
1022
+ }
1023
+ if (input.authorId !== undefined) {
1024
+ assignments.push(sql`author_id = ${input.authorId}`);
1025
+ liveMetadataChanged = true;
1026
+ }
1027
+ if (input.primaryBylineId !== undefined) {
1028
+ assignments.push(sql`primary_byline_id = ${input.primaryBylineId}`);
1029
+ liveMetadataChanged = true;
1030
+ }
1031
+ if (liveMetadataChanged) assignments.push(sql`updated_at = ${new Date().toISOString()}`);
1032
+ assignments.push(sql`version = version + 1`);
1033
+
1034
+ const result = await sql`
1035
+ UPDATE ${sql.ref(tableName)}
1036
+ SET ${sql.join(assignments, sql`, `)}
1037
+ WHERE id = ${id}
1038
+ AND deleted_at IS NULL
1039
+ AND version = ${expected.version}
1040
+ AND ${nullableColumnMatch("live_revision_id", expected.liveRevisionId)}
1041
+ AND ${nullableColumnMatch("draft_revision_id", expected.draftRevisionId)}
1042
+ AND EXISTS (
1043
+ SELECT 1 FROM revisions
1044
+ WHERE revisions.id = ${revisionId}
1045
+ AND revisions.collection = ${type}
1046
+ AND revisions.entry_id = ${id}
1047
+ )
1048
+ `.execute(this.db);
1049
+
1050
+ const changed = (result.numAffectedRows ?? 0n) > 0n;
1051
+ if (changed && liveMetadataChanged) invalidateCollectionCache(type);
1052
+ return changed;
1053
+ }
1054
+
674
1055
  /**
675
1056
  * Delete content (soft delete - moves to trash)
676
1057
  */
@@ -687,7 +1068,6 @@ export class ContentRepository {
687
1068
 
688
1069
  const changed = (result.numAffectedRows ?? 0n) > 0n;
689
1070
  if (changed) {
690
- await this.restampEntryPivot(type, id);
691
1071
  invalidateCollectionCache(type);
692
1072
  }
693
1073
  return changed;
@@ -710,36 +1090,10 @@ export class ContentRepository {
710
1090
  const restored = result.rows[0];
711
1091
  if (!restored) return null;
712
1092
 
713
- await this.restampEntryPivot(type, id);
714
1093
  invalidateCollectionCache(type);
715
1094
  return this.mapRow(type, restored);
716
1095
  }
717
1096
 
718
- /**
719
- * Re-stamp the denormalized filter + sort columns on every
720
- * `content_taxonomies` pivot row for an entry from its authoritative `ec_*`
721
- * row (migration 051). Called after any mutation that moves one of those
722
- * columns so a taxonomy-filtered listing can seek the entry directly.
723
- *
724
- * A single correlated `UPDATE` reads the post-mutation values from `ec_*`, so
725
- * the pivot converges to the authoritative row. This is NOT atomic with the
726
- * `ec_*` mutation on D1 (no transactions), which is why the read path
727
- * re-checks the real predicates on the joined `ec_*` row. Untagged entries
728
- * have no pivot rows, so the statement is a cheap no-op for them.
729
- */
730
- private async restampEntryPivot(type: string, id: string): Promise<void> {
731
- const tableName = getTableName(type);
732
- await sql`
733
- UPDATE content_taxonomies
734
- SET (status, scheduled_at, deleted_at, locale, published_at, created_at) = (
735
- SELECT status, scheduled_at, deleted_at, locale, published_at, created_at
736
- FROM ${sql.ref(tableName)}
737
- WHERE ${sql.ref(tableName)}.id = ${id}
738
- )
739
- WHERE collection = ${type} AND entry_id = ${id}
740
- `.execute(this.db);
741
- }
742
-
743
1097
  /**
744
1098
  * Permanently delete content (cannot be undone)
745
1099
  */
@@ -905,7 +1259,7 @@ export class ContentRepository {
905
1259
  eb.or(
906
1260
  columns.map((col) => {
907
1261
  validateIdentifier(col, "search column");
908
- return eb(sql`lower(${sql.ref(col)})`, "like", sql`lower(${pattern}) escape '\\'`);
1262
+ return sql<boolean>`lower(CAST(${sql.ref(col)} AS TEXT)) LIKE lower(${pattern}) ESCAPE '\\'`;
909
1263
  }),
910
1264
  ),
911
1265
  );
@@ -936,10 +1290,128 @@ export class ContentRepository {
936
1290
  return next;
937
1291
  }
938
1292
 
1293
+ /**
1294
+ * Apply the optional byline filter as a correlated (NOT) EXISTS against
1295
+ * `_emdash_content_bylines`.
1296
+ *
1297
+ * Correlating from the content table preserves the outer sort index so
1298
+ * `LIMIT` can short-circuit. `mode: "none"` tests the junction rather than
1299
+ * `primary_byline_id` because the two are written in the same call but
1300
+ * are not atomically consistent, so the junction is authoritative.
1301
+ *
1302
+ * Whether a credit *renders* is locale-scoped; whether one *exists* is
1303
+ * not. Both are needed: the first decides what the filter matches, the
1304
+ * second decides whether the author fallback applies at all.
1305
+ */
1306
+ private applyBylineFilter<QB extends { where: (cb: (eb: any) => unknown) => QB }>(
1307
+ query: QB,
1308
+ where: { bylineFilter?: ContentBylineFilter } | undefined,
1309
+ type: string,
1310
+ ): QB {
1311
+ const filter = where?.bylineFilter;
1312
+ if (!filter) return query;
1313
+ const tableName = getTableName(type);
1314
+ const idColumn = `${tableName}.id`;
1315
+ const authorColumn = `${tableName}.author_id`;
1316
+ const localeColumn = `${tableName}.locale`;
1317
+
1318
+ // An explicit credit that actually renders — optionally within a given
1319
+ // set of translation groups. The junction stores a group, but a credit
1320
+ // resolves only where that group has a byline row at the locale the
1321
+ // list is scoped to, so this repeats the join `getContentBylinesMany`
1322
+ // makes. `locale` falls back to each entry's own when the list spans
1323
+ // locales.
1324
+ const creditRenders = (eb: any, bylineIds?: string[]) => {
1325
+ let sub = eb
1326
+ .selectFrom("_emdash_content_bylines as cb")
1327
+ .innerJoin("_emdash_bylines as b", "b.translation_group", "cb.byline_id")
1328
+ .select("cb.id")
1329
+ .where("cb.collection_slug", "=", type)
1330
+ .whereRef("cb.content_id", "=", idColumn);
1331
+ sub = filter.locale
1332
+ ? sub.where("b.locale", "=", filter.locale)
1333
+ : sub.whereRef("b.locale", "=", localeColumn);
1334
+ if (bylineIds) sub = sub.where("cb.byline_id", "in", bylineIds);
1335
+ return eb.exists(sub);
1336
+ };
1337
+
1338
+ // Whether the entry carries an explicit credit at all, at any locale.
1339
+ // Deliberately not locale-scoped: the author fallback is suppressed by
1340
+ // the presence of a junction row, even one that renders nothing here
1341
+ // (`hydrateBylinesMany` gates inference on `primaryBylineId`).
1342
+ const hasExplicitCredit = (eb: any) =>
1343
+ eb.exists(
1344
+ eb
1345
+ .selectFrom("_emdash_content_bylines as cb")
1346
+ .select("cb.id")
1347
+ .where("cb.collection_slug", "=", type)
1348
+ .whereRef("cb.content_id", "=", idColumn),
1349
+ );
1350
+
1351
+ // The entry's author owns a byline row — optionally within a given set
1352
+ // of translation groups — at the locale the list is scoped to. Matching
1353
+ // the locale is what keeps the filter agreeing with the list: an
1354
+ // inferred credit renders only when the author's byline has a row at
1355
+ // that locale (`hydrateBylinesMany` -> `findByUserIds`), and byline
1356
+ // translations start life with a null `user_id`, so a group translated
1357
+ // into the locale but not re-linked resolves to no credit. `locale`
1358
+ // falls back to each entry's own when the list spans locales.
1359
+ const authorHasByline = (eb: any, bylineIds?: string[]) => {
1360
+ let sub = eb
1361
+ .selectFrom("_emdash_bylines as b")
1362
+ .select("b.id")
1363
+ .whereRef("b.user_id", "=", authorColumn);
1364
+ sub = filter.locale
1365
+ ? sub.where("b.locale", "=", filter.locale)
1366
+ : sub.whereRef("b.locale", "=", localeColumn);
1367
+ if (bylineIds) sub = sub.where("b.translation_group", "in", bylineIds);
1368
+ return eb.exists(sub);
1369
+ };
1370
+
1371
+ if (filter.mode === "none") {
1372
+ return query.where((eb: any) => {
1373
+ const uncredited = eb.not(creditRenders(eb));
1374
+ // With inference on, "no byline" means none is rendered, so an
1375
+ // entry that falls through to an author byline is excluded too.
1376
+ return filter.includeInferred
1377
+ ? eb.and([uncredited, eb.or([hasExplicitCredit(eb), eb.not(authorHasByline(eb))])])
1378
+ : uncredited;
1379
+ });
1380
+ }
1381
+
1382
+ const bylineIds = filter.bylineIds ?? [];
1383
+ if (bylineIds.length === 0) {
1384
+ // A filter that resolved to no ids must match nothing rather than
1385
+ // silently degrade to "no filter" and return the whole collection.
1386
+ // `1 = 0` rather than a bound `false`: better-sqlite3 refuses to
1387
+ // bind JS booleans.
1388
+ return query.where(() => sql<boolean>`1 = 0`);
1389
+ }
1390
+
1391
+ return query.where((eb: any) => {
1392
+ if (!filter.includeInferred) return creditRenders(eb, bylineIds);
1393
+ // Inference applies only where no explicit credit exists, so an
1394
+ // entry credited to someone else never matches on its author.
1395
+ return eb.or([
1396
+ creditRenders(eb, bylineIds),
1397
+ eb.and([eb.not(hasExplicitCredit(eb)), authorHasByline(eb, bylineIds)]),
1398
+ ]);
1399
+ });
1400
+ }
1401
+
939
1402
  /**
940
1403
  * Count content items
941
1404
  */
942
1405
  async count(type: string, where?: FindManyOptions["where"]): Promise<number> {
1406
+ const resolvedFieldFilters = await this.resolveFieldFilters(type, where?.fieldFilters);
1407
+ return this.countWithResolvedFilters(type, where, resolvedFieldFilters);
1408
+ }
1409
+
1410
+ private async countWithResolvedFilters(
1411
+ type: string,
1412
+ where: FindManyOptions["where"] | undefined,
1413
+ resolvedFieldFilters: ResolvedFieldFilter[],
1414
+ ): Promise<number> {
943
1415
  const tableName = getTableName(type);
944
1416
 
945
1417
  let query = this.db
@@ -961,6 +1433,8 @@ export class ContentRepository {
961
1433
 
962
1434
  query = this.applySearchFilter(query, where, type);
963
1435
  query = this.applyDateFilter(query, where);
1436
+ query = this.applyBylineFilter(query, where, type);
1437
+ query = this.applyFieldFilters(query, resolvedFieldFilters);
964
1438
 
965
1439
  const result = await query.executeTakeFirst();
966
1440
  return Number(result?.count || 0);
@@ -989,8 +1463,16 @@ export class ContentRepository {
989
1463
  // get overall statistics for a content type in a single query
990
1464
  async getStats(
991
1465
  type: string,
992
- ): Promise<{ total: number; published: number; draft: number; scheduled: number }> {
1466
+ now = new Date(),
1467
+ ): Promise<{
1468
+ total: number;
1469
+ published: number;
1470
+ draft: number;
1471
+ scheduled: number;
1472
+ overdueScheduled: number;
1473
+ }> {
993
1474
  const tableName = getTableName(type);
1475
+ const nowIso = now.toISOString();
994
1476
 
995
1477
  const result = await this.db
996
1478
  .selectFrom(tableName as keyof Database)
@@ -999,6 +1481,9 @@ export class ContentRepository {
999
1481
  eb.fn.sum(eb.case().when("status", "=", "published").then(1).else(0).end()).as("published"),
1000
1482
  eb.fn.sum(eb.case().when("status", "=", "draft").then(1).else(0).end()).as("draft"),
1001
1483
  sql<number>`SUM(CASE WHEN scheduled_at IS NOT NULL THEN 1 ELSE 0 END)`.as("scheduled"),
1484
+ sql<number>`SUM(CASE WHEN scheduled_at IS NOT NULL AND scheduled_at <= ${nowIso} THEN 1 ELSE 0 END)`.as(
1485
+ "overdue_scheduled",
1486
+ ),
1002
1487
  ])
1003
1488
  .where("deleted_at" as never, "is", null)
1004
1489
  .executeTakeFirst();
@@ -1008,6 +1493,7 @@ export class ContentRepository {
1008
1493
  published: Number(result?.published || 0),
1009
1494
  draft: Number(result?.draft || 0),
1010
1495
  scheduled: Number(result?.scheduled || 0),
1496
+ overdueScheduled: Number(result?.overdue_scheduled || 0),
1011
1497
  };
1012
1498
  }
1013
1499
 
@@ -1049,7 +1535,6 @@ export class ContentRepository {
1049
1535
  AND deleted_at IS NULL
1050
1536
  `.execute(this.db);
1051
1537
 
1052
- await this.restampEntryPivot(type, id);
1053
1538
  invalidateCollectionCache(type);
1054
1539
 
1055
1540
  const updated = await this.findById(type, id);
@@ -1089,7 +1574,6 @@ export class ContentRepository {
1089
1574
  AND deleted_at IS NULL
1090
1575
  `.execute(this.db);
1091
1576
 
1092
- await this.restampEntryPivot(type, id);
1093
1577
  invalidateCollectionCache(type);
1094
1578
 
1095
1579
  const updated = await this.findById(type, id);
@@ -1158,6 +1642,10 @@ export class ContentRepository {
1158
1642
  * Lets callers resolve many edge groups without an N+1 per group. The caller
1159
1643
  * groups the flat result by `translationGroup` itself.
1160
1644
  *
1645
+ * `translation_group` leads the sort so the ordering follows
1646
+ * `idx_{table}_del_tg_locale` past its `deleted_at` equality; callers group by
1647
+ * `translationGroup`, so the per-group locale order they rely on is preserved.
1648
+ *
1161
1649
  * `publishedOnly` restricts the result to `status = 'published'` — reference
1162
1650
  * reads pass this for callers without `content:read_drafts` so draft/scheduled
1163
1651
  * entries never leak through an edge traversal.
@@ -1184,7 +1672,7 @@ export class ContentRepository {
1184
1672
  WHERE translation_group IN (${sql.join(chunk)})
1185
1673
  AND deleted_at IS NULL
1186
1674
  ${publishedFilter}
1187
- ORDER BY locale ASC
1675
+ ORDER BY translation_group ASC, locale ASC
1188
1676
  `.execute(this.db);
1189
1677
  for (const row of result.rows) items.push(this.mapRow(type, row));
1190
1678
  }
@@ -1267,6 +1755,8 @@ export class ContentRepository {
1267
1755
  * Syncs the draft revision's data into the content table columns so the
1268
1756
  * content table always reflects the published version.
1269
1757
  * If no draft revision exists, creates one from current data and publishes it.
1758
+ * When `promoteRevision` is false, publishes the current content-table data
1759
+ * by changing lifecycle metadata only.
1270
1760
  *
1271
1761
  * `publishedAt` (optional) overrides the publication timestamp. If omitted,
1272
1762
  * the existing `published_at` is preserved (idempotent re-publish keeps the
@@ -1274,19 +1764,18 @@ export class ContentRepository {
1274
1764
  * an explicit value to backdate a publish (e.g. when migrating content from
1275
1765
  * another CMS).
1276
1766
  *
1277
- * `requireDue` (optional) gates the publish on the row still being due:
1278
- * `scheduled_at` non-null and in the past. Used by the scheduled-publishing
1279
- * sweep to avoid publishing content an editor unscheduled or rescheduled
1280
- * between selection and publish. It claims the row with a single conditional
1281
- * UPDATE (clearing `scheduled_at`) before any other write, so it is atomic
1282
- * even on D1 (no multi-statement transactions) and serialises against
1283
- * `unschedule()` and concurrent sweeps — no TOCTOU and no double publish.
1767
+ * `requireDue` gates the final update on the row still being due.
1768
+ * `expectedScheduledAt` additionally fences changes made after a sweep
1769
+ * selected the row but before publication preparation began.
1284
1770
  */
1285
1771
  async publish(
1286
1772
  type: string,
1287
1773
  id: string,
1288
1774
  publishedAt?: string,
1289
1775
  requireDue = false,
1776
+ expectedScheduledAt?: string,
1777
+ promoteRevision = true,
1778
+ requireSlug = true,
1290
1779
  ): Promise<ContentItem> {
1291
1780
  const tableName = getTableName(type);
1292
1781
  const now = new Date().toISOString();
@@ -1295,167 +1784,239 @@ export class ContentRepository {
1295
1784
  if (!existing) {
1296
1785
  throw new EmDashValidationError("Content item not found");
1297
1786
  }
1787
+ if (
1788
+ requireDue &&
1789
+ expectedScheduledAt !== undefined &&
1790
+ existing.scheduledAt !== expectedScheduledAt
1791
+ ) {
1792
+ throw new ScheduledNotDueError();
1793
+ }
1794
+ if (!promoteRevision && requireSlug && !existing.slug?.trim()) {
1795
+ throw new EmDashValidationError("Cannot publish routable content without a slug");
1796
+ }
1797
+
1798
+ if (!promoteRevision) {
1799
+ const revisionRepo = new RevisionRepository(this.db);
1800
+ let provisionalRevisionId: string | null = null;
1801
+ try {
1802
+ let liveRevisionId = existing.liveRevisionId;
1803
+ if (!liveRevisionId) {
1804
+ const revision = await revisionRepo.create({
1805
+ collection: type,
1806
+ entryId: id,
1807
+ data: existing.data,
1808
+ });
1809
+ liveRevisionId = revision.id;
1810
+ provisionalRevisionId = revision.id;
1811
+ }
1298
1812
 
1299
- // Scheduled sweep: atomically claim the row before any other write. A
1300
- // single conditional UPDATE is atomic per-statement on every dialect
1301
- // (it doesn't depend on a wrapping transaction, which D1 lacks). If the
1302
- // schedule was cleared or pushed to the future (unschedule/reschedule)
1303
- // or another sweep already claimed it, this affects 0 rows and we bail
1304
- // before promoting any revision — so the row can't be double-published.
1305
- let claimedScheduledAt: string | null = null;
1306
- let claimedUpdatedAt: string | null = null;
1307
- if (requireDue) {
1308
- const claim = await sql`
1309
- UPDATE ${sql.ref(tableName)}
1310
- SET scheduled_at = NULL,
1311
- updated_at = ${now}
1312
- WHERE id = ${id}
1313
- AND scheduled_at IS NOT NULL
1314
- AND scheduled_at <= ${now}
1315
- AND deleted_at IS NULL
1316
- `.execute(this.db);
1317
- if ((claim.numAffectedRows ?? 0n) === 0n) {
1318
- throw new ScheduledNotDueError();
1813
+ const intendedPublishedAt = publishedAt ?? existing.publishedAt ?? now;
1814
+ const duePredicate = requireDue
1815
+ ? sql`AND scheduled_at IS NOT NULL AND scheduled_at <= ${now}`
1816
+ : sql``;
1817
+ let published = false;
1818
+ try {
1819
+ const result = await sql`
1820
+ UPDATE ${sql.ref(tableName)}
1821
+ SET live_revision_id = ${liveRevisionId},
1822
+ draft_revision_id = NULL,
1823
+ status = 'published',
1824
+ scheduled_at = NULL,
1825
+ published_at = ${intendedPublishedAt},
1826
+ updated_at = ${now},
1827
+ version = version + 1
1828
+ WHERE id = ${id}
1829
+ AND deleted_at IS NULL
1830
+ AND version = ${existing.version}
1831
+ AND status = ${existing.status}
1832
+ AND ${nullableColumnMatch("live_revision_id", existing.liveRevisionId)}
1833
+ AND ${nullableColumnMatch("draft_revision_id", existing.draftRevisionId)}
1834
+ AND ${nullableColumnMatch("scheduled_at", existing.scheduledAt)}
1835
+ ${duePredicate}
1836
+ `.execute(this.db);
1837
+ published = (result.numAffectedRows ?? 0n) > 0n;
1838
+ } catch (error) {
1839
+ if (isConfirmedStatementFailure(error)) throw error;
1840
+ let observed: ContentItem | null;
1841
+ try {
1842
+ observed = await this.findById(type, id);
1843
+ } catch (reconciliationError) {
1844
+ throw new Error("Unable to confirm whether content publication completed", {
1845
+ cause: reconciliationError,
1846
+ });
1847
+ }
1848
+ if (!observed) {
1849
+ throw new Error("Unable to confirm whether content publication completed", {
1850
+ cause: error,
1851
+ });
1852
+ }
1853
+ published = matchesLifecyclePublication(
1854
+ observed,
1855
+ existing,
1856
+ liveRevisionId,
1857
+ intendedPublishedAt,
1858
+ now,
1859
+ );
1860
+ if (!published && matchesPublicationFence(observed, existing)) throw error;
1861
+ if (!published) {
1862
+ throw new Error("Unable to confirm whether content publication completed", {
1863
+ cause: error,
1864
+ });
1865
+ }
1866
+ }
1867
+
1868
+ if (!published) {
1869
+ throw requireDue ? new ScheduledNotDueError() : new ContentMutationConflictError();
1870
+ }
1871
+
1872
+ invalidateCollectionCache(type);
1873
+ const updated = await this.findById(type, id);
1874
+ if (!updated) throw new Error("Content not found");
1875
+ return updated;
1876
+ } catch (error) {
1877
+ if (provisionalRevisionId) {
1878
+ try {
1879
+ await revisionRepo.deleteIfUnreferenced(type, id, provisionalRevisionId);
1880
+ } catch (cleanupError) {
1881
+ console.error(
1882
+ `[content] Failed to clean up provisional revision ${provisionalRevisionId}:`,
1883
+ cleanupError,
1884
+ );
1885
+ }
1886
+ }
1887
+ throw error;
1319
1888
  }
1320
- // Remember what we cleared so we can put it back if the publish work
1321
- // below fails on a driver without transactions (see catch). Both
1322
- // values come from the pre-claim snapshot: if a concurrent
1323
- // reschedule-to-a-different-past-time landed between findById and the
1324
- // claim, the restore writes the snapshot value rather than the one the
1325
- // claim actually cleared. That window is tiny and the restore is
1326
- // best-effort retry bookkeeping, so the imprecision is acceptable.
1327
- claimedScheduledAt = existing.scheduledAt;
1328
- claimedUpdatedAt = existing.updatedAt;
1329
- }
1330
-
1331
- // Track whether the final publish write committed. On D1 the claim above
1332
- // is already durable (withTransaction is a no-op there), so if a later
1333
- // step throws we must restore the schedule — otherwise the row is left
1334
- // `scheduled` with `scheduled_at = NULL` and no sweep ever retries it.
1335
- let publishCommitted = false;
1889
+ }
1890
+
1891
+ const revisionRepo = new RevisionRepository(this.db);
1892
+ let provisionalRevisionId: string | null = null;
1336
1893
  try {
1337
- const revisionRepo = new RevisionRepository(this.db);
1338
1894
  let revisionToPublish = existing.draftRevisionId || existing.liveRevisionId;
1339
1895
 
1340
1896
  if (!revisionToPublish) {
1341
- // No revision exists - create one from current data
1342
1897
  const revision = await revisionRepo.create({
1343
1898
  collection: type,
1344
1899
  entryId: id,
1345
1900
  data: existing.data,
1346
1901
  });
1347
1902
  revisionToPublish = revision.id;
1903
+ provisionalRevisionId = revision.id;
1348
1904
  }
1349
1905
 
1350
- // Sync the revision's data into the content table columns
1351
- // so the content table always holds the published version
1352
1906
  const revision = await revisionRepo.findById(revisionToPublish);
1353
- if (revision) {
1354
- // A staged slug lands on the live column only here — it never
1355
- // passes through update(), so its uniqueness was never checked.
1356
- // Validate before any write: the `(slug, locale)` unique index
1357
- // covers every row (drafts and trashed entries included), and
1358
- // letting the constraint fire mid-publish would surface as an
1359
- // opaque 500 after draft data already hit the live columns.
1360
- // A NULL locale never collides (unique indexes treat NULLs as
1361
- // distinct), so the check only applies to localized rows.
1362
- const stagedSlug = typeof revision.data._slug === "string" ? revision.data._slug : null;
1363
- if (stagedSlug !== null && stagedSlug !== existing.slug && existing.locale !== null) {
1364
- const conflict = await this.findBySlugIncludingTrashed(type, stagedSlug, existing.locale);
1365
- if (conflict && conflict.id !== id) {
1366
- throw new EmDashValidationError(
1367
- `Cannot publish: slug '${stagedSlug}' is already used by another entry` +
1368
- ` in this collection (id: ${conflict.id}). Choose a different slug.`,
1369
- { code: "SLUG_CONFLICT" },
1370
- );
1371
- }
1372
- }
1907
+ if (!revision || revision.collection !== type || revision.entryId !== id) {
1908
+ throw new EmDashValidationError("Revision does not belong to the specified content item");
1909
+ }
1373
1910
 
1374
- // Write the slug before the data columns: the slug UPDATE is the
1375
- // only statement here that can hit the unique constraint (if a
1376
- // concurrent write took the slug after the check above), and on
1377
- // D1 there is no transaction to roll back earlier statements.
1378
- if (stagedSlug !== null) {
1379
- await sql`
1380
- UPDATE ${sql.ref(tableName)}
1381
- SET slug = ${stagedSlug}
1382
- WHERE id = ${id}
1383
- `.execute(this.db);
1911
+ const stagedSlug = typeof revision.data._slug === "string" ? revision.data._slug : null;
1912
+ const intendedSlug = stagedSlug ?? existing.slug;
1913
+ if (requireSlug && !intendedSlug?.trim()) {
1914
+ throw new EmDashValidationError("Cannot publish routable content without a slug");
1915
+ }
1916
+ const intendedPublishedAt = publishedAt ?? existing.publishedAt ?? now;
1917
+ if (stagedSlug !== null && stagedSlug !== existing.slug && existing.locale !== null) {
1918
+ const conflict = await this.findBySlugIncludingTrashed(type, stagedSlug, existing.locale);
1919
+ if (conflict && conflict.id !== id) {
1920
+ throw new EmDashValidationError(
1921
+ `Cannot publish: slug '${stagedSlug}' is already used by another entry` +
1922
+ ` in this collection (id: ${conflict.id}). Choose a different slug.`,
1923
+ { code: "SLUG_CONFLICT" },
1924
+ );
1384
1925
  }
1385
-
1386
- await this.syncDataColumns(type, id, revision.data);
1387
1926
  }
1388
1927
 
1389
- if (publishedAt !== undefined) {
1390
- // Caller supplied an explicit timestamp, so we overwrite published_at
1391
- // directly (used to backdate a publish, e.g. for content migrations).
1392
- await sql`
1393
- UPDATE ${sql.ref(tableName)}
1394
- SET live_revision_id = ${revisionToPublish},
1395
- draft_revision_id = NULL,
1396
- status = 'published',
1397
- scheduled_at = NULL,
1398
- published_at = ${publishedAt},
1399
- updated_at = ${now}
1400
- WHERE id = ${id}
1401
- AND deleted_at IS NULL
1402
- `.execute(this.db);
1403
- } else {
1404
- // No timestamp supplied — preserve existing published_at on
1405
- // idempotent re-publish, fall back to `now` on first publish.
1406
- await sql`
1928
+ const assignments: ReturnType<typeof sql>[] = [];
1929
+ if (stagedSlug !== null) assignments.push(sql`slug = ${stagedSlug}`);
1930
+ for (const [key, value] of Object.entries(revision.data)) {
1931
+ if (SYSTEM_COLUMNS.has(key) || key.startsWith("_")) continue;
1932
+ validateIdentifier(key, "content field name");
1933
+ assignments.push(sql`${sql.ref(key)} = ${serializeValue(value)}`);
1934
+ }
1935
+ assignments.push(
1936
+ sql`live_revision_id = ${revisionToPublish}`,
1937
+ sql`draft_revision_id = NULL`,
1938
+ sql`status = 'published'`,
1939
+ sql`scheduled_at = NULL`,
1940
+ sql`published_at = ${intendedPublishedAt}`,
1941
+ sql`updated_at = ${now}`,
1942
+ sql`version = version + 1`,
1943
+ );
1944
+
1945
+ const duePredicate = requireDue
1946
+ ? sql`AND scheduled_at IS NOT NULL AND scheduled_at <= ${now}`
1947
+ : sql``;
1948
+ let promoted = false;
1949
+ try {
1950
+ const result = await sql`
1407
1951
  UPDATE ${sql.ref(tableName)}
1408
- SET live_revision_id = ${revisionToPublish},
1409
- draft_revision_id = NULL,
1410
- status = 'published',
1411
- scheduled_at = NULL,
1412
- published_at = COALESCE(published_at, ${now}),
1413
- updated_at = ${now}
1952
+ SET ${sql.join(assignments, sql`, `)}
1414
1953
  WHERE id = ${id}
1415
1954
  AND deleted_at IS NULL
1955
+ AND version = ${existing.version}
1956
+ AND status = ${existing.status}
1957
+ AND ${nullableColumnMatch("live_revision_id", existing.liveRevisionId)}
1958
+ AND ${nullableColumnMatch("draft_revision_id", existing.draftRevisionId)}
1959
+ AND ${nullableColumnMatch("scheduled_at", existing.scheduledAt)}
1960
+ ${duePredicate}
1961
+ AND EXISTS (
1962
+ SELECT 1 FROM revisions
1963
+ WHERE revisions.id = ${revisionToPublish}
1964
+ AND revisions.collection = ${type}
1965
+ AND revisions.entry_id = ${id}
1966
+ )
1416
1967
  `.execute(this.db);
1968
+ promoted = (result.numAffectedRows ?? 0n) > 0n;
1969
+ } catch (error) {
1970
+ if (isConfirmedStatementFailure(error)) throw error;
1971
+ let observed: ContentItem | null;
1972
+ try {
1973
+ observed = await this.findById(type, id);
1974
+ } catch (reconciliationError) {
1975
+ throw new Error("Unable to confirm whether content publication completed", {
1976
+ cause: reconciliationError,
1977
+ });
1978
+ }
1979
+ if (!observed) {
1980
+ throw new Error("Unable to confirm whether content publication completed", {
1981
+ cause: error,
1982
+ });
1983
+ }
1984
+ promoted = matchesPublication(
1985
+ observed,
1986
+ existing,
1987
+ revision,
1988
+ revisionToPublish,
1989
+ intendedSlug,
1990
+ intendedPublishedAt,
1991
+ now,
1992
+ );
1993
+ if (!promoted && matchesPublicationFence(observed, existing)) throw error;
1994
+ if (!promoted) {
1995
+ throw new Error("Unable to confirm whether content publication completed", {
1996
+ cause: error,
1997
+ });
1998
+ }
1417
1999
  }
1418
- publishCommitted = true;
1419
2000
 
1420
- await this.restampEntryPivot(type, id);
2001
+ if (!promoted) {
2002
+ throw requireDue ? new ScheduledNotDueError() : new ContentMutationConflictError();
2003
+ }
1421
2004
 
2005
+ invalidateCollectionCache(type);
1422
2006
  const updated = await this.findById(type, id);
1423
2007
  if (!updated) {
1424
2008
  throw new Error("Content not found");
1425
2009
  }
1426
2010
 
1427
- invalidateCollectionCache(type);
1428
2011
  return updated;
1429
2012
  } catch (error) {
1430
- // Best-effort schedule restore for the no-transaction (D1) case so a
1431
- // failed publish stays retryable. Skipped when the publish actually
1432
- // committed (the failure was afterwards). On SQLite/Postgres the
1433
- // enclosing transaction rolls the claim back, so this restore also
1434
- // rolls back — a harmless no-op. Never mask the original error.
1435
- if (requireDue && claimedScheduledAt && !publishCommitted) {
2013
+ if (provisionalRevisionId) {
1436
2014
  try {
1437
- // Only restore if the row still has pending work: either it's not
1438
- // published, or it's a published row that still has a draft change
1439
- // queued. This avoids re-adding a stale schedule (and triggering a
1440
- // redundant republish) when another actor fully published the row
1441
- // in the failure window — that publish clears draft_revision_id.
1442
- // Restore updated_at to its pre-claim value too — the claim bumped
1443
- // it to `now`, and a failed publish made no real change, so leaving
1444
- // it advanced would be a phantom modification for "changed since"
1445
- // consumers (sync, ETags, incremental indexers).
1446
- await sql`
1447
- UPDATE ${sql.ref(tableName)}
1448
- SET scheduled_at = ${claimedScheduledAt},
1449
- updated_at = ${claimedUpdatedAt ?? now}
1450
- WHERE id = ${id}
1451
- AND scheduled_at IS NULL
1452
- AND deleted_at IS NULL
1453
- AND (status != 'published' OR draft_revision_id IS NOT NULL)
1454
- `.execute(this.db);
1455
- } catch (restoreError) {
2015
+ await revisionRepo.deleteIfUnreferenced(type, id, provisionalRevisionId);
2016
+ } catch (cleanupError) {
1456
2017
  console.error(
1457
- `[content] Failed to restore schedule for ${type}/${id} after publish failure:`,
1458
- restoreError,
2018
+ `[content] Failed to clean up provisional revision ${provisionalRevisionId}:`,
2019
+ cleanupError,
1459
2020
  );
1460
2021
  }
1461
2022
  }
@@ -1507,7 +2068,6 @@ export class ContentRepository {
1507
2068
  AND deleted_at IS NULL
1508
2069
  `.execute(this.db);
1509
2070
 
1510
- await this.restampEntryPivot(type, id);
1511
2071
  invalidateCollectionCache(type);
1512
2072
 
1513
2073
  const updated = await this.findById(type, id);
@@ -1530,8 +2090,6 @@ export class ContentRepository {
1530
2090
  * content row pointing at a missing or unrelated revision.
1531
2091
  */
1532
2092
  async setDraftRevision(type: string, id: string, revisionId: string): Promise<void> {
1533
- const tableName = getTableName(type);
1534
-
1535
2093
  const existing = await this.findById(type, id);
1536
2094
  if (!existing) {
1537
2095
  throw new EmDashValidationError("Content item not found");
@@ -1547,15 +2105,35 @@ export class ContentRepository {
1547
2105
  throw new EmDashValidationError("Revision does not belong to the specified content item");
1548
2106
  }
1549
2107
 
1550
- // Draft staging leaves live content untouched, so it must not bump
1551
- // updated_at — public "content last modified" consumers (sitemap
1552
- // <lastmod>, dateModified) would see a phantom modification (#2143).
1553
- await sql`
2108
+ if (!(await this.replaceDraftRevision(type, id, revisionId, existing))) {
2109
+ throw new ContentMutationConflictError();
2110
+ }
2111
+ }
2112
+
2113
+ async replaceDraftRevision(
2114
+ type: string,
2115
+ id: string,
2116
+ revisionId: string,
2117
+ expected: Pick<ContentItem, "version" | "liveRevisionId" | "draftRevisionId">,
2118
+ ): Promise<boolean> {
2119
+ const tableName = getTableName(type);
2120
+ const result = await sql`
1554
2121
  UPDATE ${sql.ref(tableName)}
1555
- SET draft_revision_id = ${revisionId}
2122
+ SET draft_revision_id = ${revisionId},
2123
+ version = version + 1
1556
2124
  WHERE id = ${id}
1557
2125
  AND deleted_at IS NULL
2126
+ AND version = ${expected.version}
2127
+ AND ${nullableColumnMatch("live_revision_id", expected.liveRevisionId)}
2128
+ AND ${nullableColumnMatch("draft_revision_id", expected.draftRevisionId)}
2129
+ AND EXISTS (
2130
+ SELECT 1 FROM revisions
2131
+ WHERE revisions.id = ${revisionId}
2132
+ AND revisions.collection = ${type}
2133
+ AND revisions.entry_id = ${id}
2134
+ )
1558
2135
  `.execute(this.db);
2136
+ return (result.numAffectedRows ?? 0n) > 0n;
1559
2137
  }
1560
2138
 
1561
2139
  /**
@@ -1595,35 +2173,6 @@ export class ContentRepository {
1595
2173
  return updated;
1596
2174
  }
1597
2175
 
1598
- /**
1599
- * Sync data columns in the content table from a data object.
1600
- * Used to promote revision data into the content table on publish.
1601
- * Keys starting with _ are revision metadata (e.g. _slug) and are skipped.
1602
- */
1603
- private async syncDataColumns(
1604
- type: string,
1605
- id: string,
1606
- data: Record<string, unknown>,
1607
- ): Promise<void> {
1608
- const tableName = getTableName(type);
1609
- const updates: Record<string, unknown> = {};
1610
-
1611
- for (const [key, value] of Object.entries(data)) {
1612
- if (SYSTEM_COLUMNS.has(key)) continue;
1613
- if (key.startsWith("_")) continue; // revision metadata
1614
- validateIdentifier(key, "content field name");
1615
- updates[key] = serializeValue(value);
1616
- }
1617
-
1618
- if (Object.keys(updates).length === 0) return;
1619
-
1620
- await this.db
1621
- .updateTable(tableName as keyof Database)
1622
- .set(updates)
1623
- .where("id", "=", id)
1624
- .execute();
1625
- }
1626
-
1627
2176
  /**
1628
2177
  * Count content items with a pending schedule.
1629
2178
  * Includes both draft-scheduled (status='scheduled') and published
@@ -1675,28 +2224,257 @@ export class ContentRepository {
1675
2224
  };
1676
2225
  }
1677
2226
 
2227
+ private normalizeFilterScalar(
2228
+ field: string,
2229
+ type: FieldType,
2230
+ value: unknown,
2231
+ ): NormalizedFilterScalar {
2232
+ if (type === "number" || type === "integer") {
2233
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2234
+ throw new EmDashValidationError(`Filter for field "${field}" must use a finite number`);
2235
+ }
2236
+ if (type === "integer" && !Number.isInteger(value)) {
2237
+ throw new EmDashValidationError(`Filter for field "${field}" must use an integer`);
2238
+ }
2239
+ return value;
2240
+ }
2241
+
2242
+ if (type === "boolean") {
2243
+ if (typeof value !== "boolean") {
2244
+ throw new EmDashValidationError(`Filter for field "${field}" must use a boolean`);
2245
+ }
2246
+ return value ? 1 : 0;
2247
+ }
2248
+
2249
+ if (typeof value !== "string") {
2250
+ throw new EmDashValidationError(`Filter for field "${field}" must use a string`);
2251
+ }
2252
+ if (value.length > MAX_FILTER_STRING_LENGTH) {
2253
+ throw new EmDashValidationError(
2254
+ `Filter value for field "${field}" exceeds ${MAX_FILTER_STRING_LENGTH} characters`,
2255
+ );
2256
+ }
2257
+ return value;
2258
+ }
2259
+
2260
+ private normalizeFieldFilter(
2261
+ field: string,
2262
+ type: FieldType,
2263
+ value: ContentFieldFilterValue,
2264
+ ): ResolvedFieldFilter {
2265
+ if (value === null) return { column: field, kind: "null" };
2266
+ if (typeof value !== "object") {
2267
+ return {
2268
+ column: field,
2269
+ kind: "exact",
2270
+ value: this.normalizeFilterScalar(field, type, value),
2271
+ };
2272
+ }
2273
+ if (Array.isArray(value)) {
2274
+ throw new EmDashValidationError(`Invalid filter for field "${field}"`);
2275
+ }
2276
+
2277
+ const record = value as Record<string, unknown>;
2278
+ const keys = Object.keys(record);
2279
+ if (keys.length === 1 && keys[0] === "in") {
2280
+ if (!Array.isArray(record.in) || record.in.length === 0) {
2281
+ throw new EmDashValidationError(`IN filter for field "${field}" must not be empty`);
2282
+ }
2283
+ if (record.in.length > MAX_IN_FILTER_VALUES) {
2284
+ throw new EmDashValidationError(
2285
+ `IN filter for field "${field}" exceeds ${MAX_IN_FILTER_VALUES} values`,
2286
+ );
2287
+ }
2288
+ return {
2289
+ column: field,
2290
+ kind: "in",
2291
+ values: record.in.map((entry) => this.normalizeFilterScalar(field, type, entry)),
2292
+ };
2293
+ }
2294
+
2295
+ const rangeKeys = new Set(["gt", "gte", "lt", "lte"]);
2296
+ if (keys.length === 0 || keys.some((key) => !rangeKeys.has(key))) {
2297
+ throw new EmDashValidationError(`Invalid filter operator for field "${field}"`);
2298
+ }
2299
+ if (type === "boolean") {
2300
+ throw new EmDashValidationError(`Boolean field "${field}" does not support range filters`);
2301
+ }
2302
+
2303
+ const bounds: Partial<Record<"gt" | "gte" | "lt" | "lte", NormalizedFilterScalar>> = {};
2304
+ for (const key of keys as Array<"gt" | "gte" | "lt" | "lte">) {
2305
+ if (record[key] === undefined) continue;
2306
+ bounds[key] = this.normalizeFilterScalar(field, type, record[key]);
2307
+ }
2308
+ if (Object.keys(bounds).length === 0) {
2309
+ throw new EmDashValidationError(`Range filter for field "${field}" has no bounds`);
2310
+ }
2311
+ return { column: field, kind: "range", bounds };
2312
+ }
2313
+
2314
+ private async collectionExists(type: string): Promise<boolean> {
2315
+ const collection = await this.db
2316
+ .selectFrom("_emdash_collections")
2317
+ .where("slug", "=", type)
2318
+ .select("id")
2319
+ .executeTakeFirst();
2320
+ return collection !== undefined;
2321
+ }
2322
+
2323
+ private async resolveFieldFilters(
2324
+ type: string,
2325
+ filters: ContentFieldFilters | undefined,
2326
+ ): Promise<ResolvedFieldFilter[]> {
2327
+ const resolvedFilters = filters ?? {};
2328
+ const fields = Object.keys(resolvedFilters);
2329
+ if (fields.length === 0) return [];
2330
+ if (fields.length > MAX_INDEXED_FIELD_FILTERS) {
2331
+ if (!(await this.collectionExists(type))) return [];
2332
+ throw new EmDashValidationError(
2333
+ `Content list queries support at most ${MAX_INDEXED_FIELD_FILTERS} indexed field filters`,
2334
+ );
2335
+ }
2336
+ const rows = await this.db
2337
+ .selectFrom("_emdash_fields as field")
2338
+ .innerJoin("_emdash_collections as collection", "collection.id", "field.collection_id")
2339
+ .where("collection.slug", "=", type)
2340
+ .where("field.slug", "in", fields)
2341
+ .where("field.indexed", "=", 1)
2342
+ .select(["field.slug", "field.type"])
2343
+ .execute();
2344
+ const metadata = new Map(rows.map((row) => [row.slug, row.type as FieldType]));
2345
+
2346
+ if (metadata.size === 0 && !(await this.collectionExists(type))) return [];
2347
+
2348
+ for (const field of fields) {
2349
+ try {
2350
+ validateIdentifier(field, "content filter field");
2351
+ } catch {
2352
+ throw new EmDashValidationError(`Invalid content filter field: ${field}`);
2353
+ }
2354
+ }
2355
+
2356
+ const normalized = fields.map((field) => {
2357
+ const fieldType = metadata.get(field);
2358
+ if (!fieldType || !isIndexableFieldType(fieldType)) {
2359
+ throw new EmDashValidationError(
2360
+ `Cannot filter by field "${field}". Custom fields must be indexed before filtering.`,
2361
+ );
2362
+ }
2363
+ return this.normalizeFieldFilter(field, fieldType, resolvedFilters[field]);
2364
+ });
2365
+ const operandCount = normalized.reduce((total, filter) => {
2366
+ if (filter.kind === "null") return total;
2367
+ if (filter.kind === "exact") return total + 1;
2368
+ if (filter.kind === "in") return total + filter.values.length;
2369
+ return total + Object.keys(filter.bounds).length;
2370
+ }, 0);
2371
+ if (operandCount > SQL_BATCH_SIZE) {
2372
+ throw new EmDashValidationError(
2373
+ `Indexed field filters have a total operand budget of ${SQL_BATCH_SIZE}`,
2374
+ );
2375
+ }
2376
+ return normalized;
2377
+ }
2378
+
2379
+ private applyFieldFilters<QB extends { where: (cb: (eb: any) => unknown) => QB }>(
2380
+ query: QB,
2381
+ filters: ResolvedFieldFilter[],
2382
+ ): QB {
2383
+ let next = query;
2384
+ for (const filter of filters) {
2385
+ const column = sql.ref(filter.column);
2386
+ const isPresent = sql<boolean>`${column} IS NOT NULL`;
2387
+ if (filter.kind === "null") {
2388
+ next = next.where(() => sql<boolean>`(${isPresent}) = FALSE AND ${column} IS NULL`);
2389
+ continue;
2390
+ }
2391
+ if (filter.kind === "exact") {
2392
+ next = next.where(
2393
+ () => sql<boolean>`(${isPresent}) = TRUE AND ${column} = ${filter.value}`,
2394
+ );
2395
+ continue;
2396
+ }
2397
+ if (filter.kind === "in") {
2398
+ const values = sql.join(
2399
+ filter.values.map((value) => sql`${value}`),
2400
+ sql`, `,
2401
+ );
2402
+ next = next.where(() => sql<boolean>`(${isPresent}) = TRUE AND ${column} IN (${values})`);
2403
+ continue;
2404
+ }
2405
+
2406
+ next = next.where(() => sql<boolean>`(${isPresent}) = TRUE`);
2407
+ if (filter.bounds.gt !== undefined) {
2408
+ next = next.where(() => sql<boolean>`${column} > ${filter.bounds.gt}`);
2409
+ }
2410
+ if (filter.bounds.gte !== undefined) {
2411
+ next = next.where(() => sql<boolean>`${column} >= ${filter.bounds.gte}`);
2412
+ }
2413
+ if (filter.bounds.lt !== undefined) {
2414
+ next = next.where(() => sql<boolean>`${column} < ${filter.bounds.lt}`);
2415
+ }
2416
+ if (filter.bounds.lte !== undefined) {
2417
+ next = next.where(() => sql<boolean>`${column} <= ${filter.bounds.lte}`);
2418
+ }
2419
+ }
2420
+ return next;
2421
+ }
2422
+
1678
2423
  /**
1679
2424
  * Map order field names to database columns.
1680
2425
  * Only allows known fields to prevent column enumeration via crafted orderBy values.
1681
2426
  */
1682
- private mapOrderField(field: string): string {
1683
- const mapping: Record<string, string> = {
1684
- createdAt: "created_at",
1685
- updatedAt: "updated_at",
1686
- publishedAt: "published_at",
1687
- scheduledAt: "scheduled_at",
1688
- deletedAt: "deleted_at",
1689
- title: "title",
1690
- name: "name",
1691
- slug: "slug",
1692
- status: "status",
1693
- locale: "locale",
1694
- };
2427
+ private mapOrderField(field: string, sortableExtras: readonly string[] = []): string {
2428
+ const mapped = ORDER_FIELD_COLUMNS[field];
2429
+ if (mapped) return mapped;
2430
+
2431
+ // A collection's configured titleField/dateField are allowed as
2432
+ // sort columns. The caller passes the collection's *actual* values (resolved
2433
+ // server-side, never client-supplied), so this stays a closed set per
2434
+ // request and doesn't reopen the column-enumeration hole. The slug is a
2435
+ // validated identifier that maps directly to the column.
2436
+ if (sortableExtras.includes(field)) {
2437
+ validateIdentifier(field, "order field");
2438
+ return field;
2439
+ }
1695
2440
 
1696
- const mapped = mapping[field];
1697
- if (!mapped) {
1698
- throw new EmDashValidationError(`Invalid order field: ${field}`);
2441
+ throw new EmDashValidationError(`Invalid order field: ${field}`);
2442
+ }
2443
+
2444
+ private async resolveOrderField(
2445
+ type: string,
2446
+ field: string,
2447
+ sortableExtras: readonly string[] = [],
2448
+ ): Promise<ResolvedOrderField> {
2449
+ try {
2450
+ return { column: this.mapOrderField(field, sortableExtras), indexedCustomField: false };
2451
+ } catch (error) {
2452
+ if (!(error instanceof EmDashValidationError)) throw error;
2453
+ }
2454
+
2455
+ const customField = await this.db
2456
+ .selectFrom("_emdash_collections as collection")
2457
+ .leftJoin("_emdash_fields as field", (join) =>
2458
+ join
2459
+ .onRef("field.collection_id", "=", "collection.id")
2460
+ .on("field.slug", "=", field)
2461
+ .on("field.indexed", "=", 1),
2462
+ )
2463
+ .where("collection.slug", "=", type)
2464
+ .select(["collection.id as collectionId", "field.slug as fieldSlug"])
2465
+ .executeTakeFirst();
2466
+
2467
+ if (!customField) {
2468
+ throw new ContentCollectionNotFoundError(type);
1699
2469
  }
1700
- return mapped;
2470
+
2471
+ if (!customField.fieldSlug) {
2472
+ throw new EmDashValidationError(
2473
+ `Invalid order field: ${field}. Custom fields must be indexed before sorting.`,
2474
+ );
2475
+ }
2476
+
2477
+ validateIdentifier(customField.fieldSlug, "content order field");
2478
+ return { column: customField.fieldSlug, indexedCustomField: true };
1701
2479
  }
1702
2480
  }