emdash 0.34.0 → 0.35.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 (508) hide show
  1. package/dist/{adapters-34EN2eXO.d.mts → adapters-Bb1vG41D.d.mts} +8 -1
  2. package/dist/adapters-Bb1vG41D.d.mts.map +1 -0
  3. package/dist/api/route-utils.d.mts +3 -3
  4. package/dist/api/route-utils.mjs +9 -9
  5. package/dist/api/schemas/index.d.mts +1 -1
  6. package/dist/api/schemas/index.mjs +1 -1
  7. package/dist/{api-DdAsirBe.mjs → api-BeBglVxU.mjs} +10 -10
  8. package/dist/{api-DdAsirBe.mjs.map → api-BeBglVxU.mjs.map} +1 -1
  9. package/dist/{api-tokens-CFXu8F_2.mjs → api-tokens-KO24G1Fd.mjs} +2 -2
  10. package/dist/{api-tokens-CFXu8F_2.mjs.map → api-tokens-KO24G1Fd.mjs.map} +1 -1
  11. package/dist/{apply-D5zfeD09.mjs → apply-kUDt80-G.mjs} +16 -16
  12. package/dist/{apply-D5zfeD09.mjs.map → apply-kUDt80-G.mjs.map} +1 -1
  13. package/dist/astro/index.d.mts +12 -12
  14. package/dist/astro/index.d.mts.map +1 -1
  15. package/dist/astro/index.mjs +71 -15
  16. package/dist/astro/index.mjs.map +1 -1
  17. package/dist/astro/middleware/auth.d.mts +10 -10
  18. package/dist/astro/middleware/auth.mjs +4 -4
  19. package/dist/astro/middleware/redirect.mjs +2 -2
  20. package/dist/astro/middleware/request-context.mjs +1 -1
  21. package/dist/astro/middleware/setup.mjs +1 -1
  22. package/dist/astro/middleware.d.mts +10 -10
  23. package/dist/astro/middleware.d.mts.map +1 -1
  24. package/dist/astro/middleware.mjs +143 -74
  25. package/dist/astro/middleware.mjs.map +1 -1
  26. package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +1 -1
  27. package/dist/astro/routes/api/admin/allowed-domains/index.mjs +1 -1
  28. package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +2 -2
  29. package/dist/astro/routes/api/admin/api-tokens/index.mjs +2 -2
  30. package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +2 -2
  31. package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +3 -3
  32. package/dist/astro/routes/api/admin/byline-fields/index.mjs +3 -3
  33. package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +3 -3
  34. package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +7 -7
  35. package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +7 -7
  36. package/dist/astro/routes/api/admin/bylines/index.mjs +7 -7
  37. package/dist/astro/routes/api/admin/comments/_id_/status.mjs +5 -5
  38. package/dist/astro/routes/api/admin/comments/_id_.mjs +2 -2
  39. package/dist/astro/routes/api/admin/comments/bulk.mjs +3 -3
  40. package/dist/astro/routes/api/admin/comments/counts.mjs +2 -2
  41. package/dist/astro/routes/api/admin/comments/index.mjs +3 -3
  42. package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +1 -1
  43. package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +1 -1
  44. package/dist/astro/routes/api/admin/media-usage/collection-deletions/index.mjs +7 -7
  45. package/dist/astro/routes/api/admin/media-usage/collection-deletions/retry.mjs +7 -7
  46. package/dist/astro/routes/api/admin/media-usage/repair.mjs +4 -4
  47. package/dist/astro/routes/api/admin/media-usage/work/index.mjs +7 -7
  48. package/dist/astro/routes/api/admin/media-usage/work/retry.mjs +7 -7
  49. package/dist/astro/routes/api/admin/oauth-clients/_id_.mjs +1 -1
  50. package/dist/astro/routes/api/admin/oauth-clients/index.mjs +1 -1
  51. package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs +28 -28
  52. package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +28 -28
  53. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +28 -28
  54. package/dist/astro/routes/api/admin/plugins/_id_/mcp.mjs +1 -1
  55. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs +28 -28
  56. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +28 -28
  57. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +28 -28
  58. package/dist/astro/routes/api/admin/plugins/index.mjs +28 -28
  59. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +1 -1
  60. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +28 -28
  61. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +28 -28
  62. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +28 -28
  63. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +28 -28
  64. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +29 -29
  65. package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +28 -28
  66. package/dist/astro/routes/api/admin/plugins/registry/install.mjs +29 -29
  67. package/dist/astro/routes/api/admin/plugins/updates.mjs +28 -28
  68. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +28 -28
  69. package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +1 -1
  70. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +28 -28
  71. package/dist/astro/routes/api/admin/users/_id_/index.mjs +1 -1
  72. package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +1 -1
  73. package/dist/astro/routes/api/admin/users/index.mjs +1 -1
  74. package/dist/astro/routes/api/auth/dev-bypass.d.mts.map +1 -1
  75. package/dist/astro/routes/api/auth/dev-bypass.mjs +2 -6
  76. package/dist/astro/routes/api/auth/dev-bypass.mjs.map +1 -1
  77. package/dist/astro/routes/api/auth/invite/complete.mjs +3 -3
  78. package/dist/astro/routes/api/auth/invite/index.mjs +2 -2
  79. package/dist/astro/routes/api/auth/invite/register-options.mjs +3 -3
  80. package/dist/astro/routes/api/auth/logout.mjs +1 -1
  81. package/dist/astro/routes/api/auth/magic-link/send.mjs +3 -3
  82. package/dist/astro/routes/api/auth/magic-link/verify.mjs +1 -1
  83. package/dist/astro/routes/api/auth/me.mjs +2 -2
  84. package/dist/astro/routes/api/auth/mode.mjs +1 -1
  85. package/dist/astro/routes/api/auth/oauth/_provider_/callback.mjs +2 -2
  86. package/dist/astro/routes/api/auth/oauth/_provider_.mjs +1 -1
  87. package/dist/astro/routes/api/auth/passkey/_id_.mjs +1 -1
  88. package/dist/astro/routes/api/auth/passkey/options.mjs +4 -4
  89. package/dist/astro/routes/api/auth/passkey/register/options.mjs +3 -3
  90. package/dist/astro/routes/api/auth/passkey/register/verify.mjs +3 -3
  91. package/dist/astro/routes/api/auth/passkey/verify.mjs +3 -3
  92. package/dist/astro/routes/api/auth/signup/complete.mjs +3 -3
  93. package/dist/astro/routes/api/auth/signup/request.mjs +3 -3
  94. package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +5 -5
  95. package/dist/astro/routes/api/comments/_collection_/_contentId_/reactions.mjs +4 -4
  96. package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +1 -1
  97. package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +1 -1
  98. package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +1 -1
  99. package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +1 -1
  100. package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +3 -3
  101. package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +2 -2
  102. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/children.mjs +9 -9
  103. package/dist/astro/routes/api/content/_collection_/_id_/references/_relation_/parents.mjs +9 -9
  104. package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +1 -1
  105. package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +1 -1
  106. package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +2 -2
  107. package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +6 -6
  108. package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +1 -1
  109. package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +1 -1
  110. package/dist/astro/routes/api/content/_collection_/_id_.mjs +2 -2
  111. package/dist/astro/routes/api/content/_collection_/authors.mjs +1 -1
  112. package/dist/astro/routes/api/content/_collection_/index.mjs +2 -2
  113. package/dist/astro/routes/api/content/_collection_/trash.mjs +2 -2
  114. package/dist/astro/routes/api/dashboard.mjs +4 -4
  115. package/dist/astro/routes/api/import/probe.d.mts +3 -3
  116. package/dist/astro/routes/api/import/probe.mjs +5 -5
  117. package/dist/astro/routes/api/import/wordpress/analyze.mjs +1 -1
  118. package/dist/astro/routes/api/import/wordpress/execute.d.mts +11 -11
  119. package/dist/astro/routes/api/import/wordpress/execute.mjs +7 -7
  120. package/dist/astro/routes/api/import/wordpress/media.d.mts +10 -10
  121. package/dist/astro/routes/api/import/wordpress/media.mjs +2 -2
  122. package/dist/astro/routes/api/import/wordpress/prepare.mjs +4 -4
  123. package/dist/astro/routes/api/import/wordpress/rewrite-urls.d.mts +11 -11
  124. package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +4 -4
  125. package/dist/astro/routes/api/import/wordpress-plugin/analyze.d.mts +1 -1
  126. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +5 -5
  127. package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +11 -11
  128. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +11 -11
  129. package/dist/astro/routes/api/manifest.mjs +2 -2
  130. package/dist/astro/routes/api/mcp.mjs +38 -38
  131. package/dist/astro/routes/api/media/_id_/confirm.mjs +2 -2
  132. package/dist/astro/routes/api/media/_id_/upload.mjs +2 -2
  133. package/dist/astro/routes/api/media/_id_/usage.mjs +6 -6
  134. package/dist/astro/routes/api/media/_id_.mjs +5 -5
  135. package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +1 -1
  136. package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +1 -1
  137. package/dist/astro/routes/api/media/providers/index.mjs +1 -1
  138. package/dist/astro/routes/api/media/upload-url.mjs +3 -3
  139. package/dist/astro/routes/api/media.mjs +5 -5
  140. package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +3 -3
  141. package/dist/astro/routes/api/menus/_name_/items.mjs +3 -3
  142. package/dist/astro/routes/api/menus/_name_/reorder.mjs +3 -3
  143. package/dist/astro/routes/api/menus/_name_/translations.mjs +3 -3
  144. package/dist/astro/routes/api/menus/_name_.mjs +3 -3
  145. package/dist/astro/routes/api/menus/index.mjs +3 -3
  146. package/dist/astro/routes/api/oauth/authorize.mjs +5 -5
  147. package/dist/astro/routes/api/oauth/device/authorize.mjs +3 -3
  148. package/dist/astro/routes/api/oauth/device/code.mjs +4 -4
  149. package/dist/astro/routes/api/oauth/device/token.mjs +4 -4
  150. package/dist/astro/routes/api/oauth/register.mjs +1 -1
  151. package/dist/astro/routes/api/oauth/token/refresh.mjs +3 -3
  152. package/dist/astro/routes/api/oauth/token/revoke.mjs +3 -3
  153. package/dist/astro/routes/api/oauth/token.mjs +4 -4
  154. package/dist/astro/routes/api/openapi.json.mjs +1 -1
  155. package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +3 -3
  156. package/dist/astro/routes/api/redirects/404s/index.mjs +3 -3
  157. package/dist/astro/routes/api/redirects/404s/summary.mjs +3 -3
  158. package/dist/astro/routes/api/redirects/_id_.mjs +4 -4
  159. package/dist/astro/routes/api/redirects/index.mjs +4 -4
  160. package/dist/astro/routes/api/relations/_id_/index.mjs +9 -9
  161. package/dist/astro/routes/api/relations/_id_/translations.mjs +8 -8
  162. package/dist/astro/routes/api/relations/index.mjs +9 -9
  163. package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +1 -1
  164. package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +1 -1
  165. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +28 -28
  166. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +28 -28
  167. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +28 -28
  168. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +28 -28
  169. package/dist/astro/routes/api/schema/collections/index.mjs +28 -28
  170. package/dist/astro/routes/api/schema/collections/reorder.mjs +28 -28
  171. package/dist/astro/routes/api/schema/index.mjs +6 -6
  172. package/dist/astro/routes/api/schema/orphans/_slug_.mjs +28 -28
  173. package/dist/astro/routes/api/schema/orphans/index.mjs +28 -28
  174. package/dist/astro/routes/api/search/enable.mjs +5 -5
  175. package/dist/astro/routes/api/search/index.mjs +4 -4
  176. package/dist/astro/routes/api/search/rebuild.mjs +5 -5
  177. package/dist/astro/routes/api/search/stats.mjs +4 -4
  178. package/dist/astro/routes/api/search/suggest.mjs +4 -4
  179. package/dist/astro/routes/api/sections/_slug_.mjs +4 -4
  180. package/dist/astro/routes/api/sections/index.mjs +4 -4
  181. package/dist/astro/routes/api/settings/backups/archives/_name_.mjs +3 -3
  182. package/dist/astro/routes/api/settings/backups/archives/index.mjs +3 -3
  183. package/dist/astro/routes/api/settings/backups/export.mjs +3 -3
  184. package/dist/astro/routes/api/settings/backups/index.mjs +3 -3
  185. package/dist/astro/routes/api/settings/email.mjs +1 -1
  186. package/dist/astro/routes/api/settings.mjs +5 -5
  187. package/dist/astro/routes/api/setup/admin-verify.mjs +4 -4
  188. package/dist/astro/routes/api/setup/admin.mjs +4 -4
  189. package/dist/astro/routes/api/setup/dev-bypass.d.mts.map +1 -1
  190. package/dist/astro/routes/api/setup/dev-bypass.mjs +18 -21
  191. package/dist/astro/routes/api/setup/dev-bypass.mjs.map +1 -1
  192. package/dist/astro/routes/api/setup/index.d.mts.map +1 -1
  193. package/dist/astro/routes/api/setup/index.mjs +15 -21
  194. package/dist/astro/routes/api/setup/index.mjs.map +1 -1
  195. package/dist/astro/routes/api/setup/status.mjs +2 -2
  196. package/dist/astro/routes/api/snapshot.mjs +4 -4
  197. package/dist/astro/routes/api/taxonomies/_name_/reorder.mjs +6 -6
  198. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +6 -6
  199. package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +6 -6
  200. package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +6 -6
  201. package/dist/astro/routes/api/taxonomies/_name_/translations.mjs +6 -6
  202. package/dist/astro/routes/api/taxonomies/_name_.mjs +6 -6
  203. package/dist/astro/routes/api/taxonomies/index.mjs +6 -6
  204. package/dist/astro/routes/api/themes/preview.mjs +2 -2
  205. package/dist/astro/routes/api/typegen.mjs +3 -3
  206. package/dist/astro/routes/api/well-known/auth.mjs +1 -1
  207. package/dist/astro/routes/api/well-known/oauth-authorization-server.mjs +1 -1
  208. package/dist/astro/routes/api/well-known/oauth-protected-resource.mjs +1 -1
  209. package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +2 -2
  210. package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +4 -4
  211. package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +4 -4
  212. package/dist/astro/routes/api/widget-areas/_name_.mjs +3 -3
  213. package/dist/astro/routes/api/widget-areas/index.mjs +4 -4
  214. package/dist/astro/routes/robots.txt.mjs +2 -2
  215. package/dist/astro/routes/sitemap-_collection_.xml.mjs +2 -2
  216. package/dist/astro/routes/sitemap.xml.mjs +2 -2
  217. package/dist/astro/types.d.mts +14 -14
  218. package/dist/auth/providers/github.d.mts +1 -1
  219. package/dist/auth/providers/google.d.mts +1 -1
  220. package/dist/{authorize-1iFJzPaZ.mjs → authorize-DUElxgOQ.mjs} +1 -1
  221. package/dist/{authorize-1iFJzPaZ.mjs.map → authorize-DUElxgOQ.mjs.map} +1 -1
  222. package/dist/{backup-LHjIAVNP.mjs → backup-DWYpJlLj.mjs} +3 -3
  223. package/dist/{backup-LHjIAVNP.mjs.map → backup-DWYpJlLj.mjs.map} +1 -1
  224. package/dist/{bundle-CBEXrBnb.mjs → bundle-vn56sLos.mjs} +5 -5
  225. package/dist/{bundle-CBEXrBnb.mjs.map → bundle-vn56sLos.mjs.map} +1 -1
  226. package/dist/{byline-fields-C5S8wygO.mjs → byline-fields-txaLGoME.mjs} +1 -1
  227. package/dist/{byline-fields-C5S8wygO.mjs.map → byline-fields-txaLGoME.mjs.map} +1 -1
  228. package/dist/{byline-pEcoDps8.mjs → byline-yjvWq4ip.mjs} +4 -4
  229. package/dist/{byline-pEcoDps8.mjs.map → byline-yjvWq4ip.mjs.map} +1 -1
  230. package/dist/{bylines-DJfSfXCL.mjs → bylines-Bu7pchQ4.mjs} +3 -3
  231. package/dist/{bylines-DJfSfXCL.mjs.map → bylines-Bu7pchQ4.mjs.map} +1 -1
  232. package/dist/{bylines-BWemdX2k.mjs → bylines-DjXS5p0P.mjs} +5 -5
  233. package/dist/{bylines-BWemdX2k.mjs.map → bylines-DjXS5p0P.mjs.map} +1 -1
  234. package/dist/{cache-CBJaDy_E.mjs → cache-C46-3Irl.mjs} +2 -2
  235. package/dist/{cache-CBJaDy_E.mjs.map → cache-C46-3Irl.mjs.map} +1 -1
  236. package/dist/{challenge-store-CZyFVaad.mjs → challenge-store-SIL5DsF2.mjs} +1 -1
  237. package/dist/{challenge-store-CZyFVaad.mjs.map → challenge-store-SIL5DsF2.mjs.map} +1 -1
  238. package/dist/{chunks-DfPax1I0.mjs → chunks-fiZ8G9RJ.mjs} +2 -2
  239. package/dist/{chunks-DfPax1I0.mjs.map → chunks-fiZ8G9RJ.mjs.map} +1 -1
  240. package/dist/cli/index.mjs +551 -51
  241. package/dist/cli/index.mjs.map +1 -1
  242. package/dist/client/cf-access.d.mts +1 -1
  243. package/dist/client/index.d.mts +2 -2
  244. package/dist/client/index.mjs +2 -2
  245. package/dist/{collection-deletion-C8BugSGz.mjs → collection-deletion-DQPvMD8R.mjs} +2 -2
  246. package/dist/{collection-deletion-C8BugSGz.mjs.map → collection-deletion-DQPvMD8R.mjs.map} +1 -1
  247. package/dist/{comment-reaction-D6qfHKBc.mjs → comment-reaction-D-2J84UE.mjs} +2 -2
  248. package/dist/{comment-reaction-D6qfHKBc.mjs.map → comment-reaction-D-2J84UE.mjs.map} +1 -1
  249. package/dist/{comments-CTOuhTSX.mjs → comments-CTfZtLmM.mjs} +1 -1
  250. package/dist/{comments-CTOuhTSX.mjs.map → comments-CTfZtLmM.mjs.map} +1 -1
  251. package/dist/config-COQzTe5Q.d.mts +34 -0
  252. package/dist/config-COQzTe5Q.d.mts.map +1 -0
  253. package/dist/{content-BNfwh7xX.mjs → content-ZHCc1ngN.mjs} +3 -3
  254. package/dist/{content-BNfwh7xX.mjs.map → content-ZHCc1ngN.mjs.map} +1 -1
  255. package/dist/{content-list-query-DhQ84eVV.d.mts → content-list-query-wJ2WN8Vp.d.mts} +1 -1
  256. package/dist/{content-list-query-DhQ84eVV.d.mts.map → content-list-query-wJ2WN8Vp.d.mts.map} +1 -1
  257. package/dist/{content-refresh-DNERP4-f.mjs → content-refresh-B_8SJzuU.mjs} +2 -2
  258. package/dist/{content-refresh-DNERP4-f.mjs.map → content-refresh-B_8SJzuU.mjs.map} +1 -1
  259. package/dist/{context-DRgOvSnW.mjs → context-ZldbZcle.mjs} +7 -7
  260. package/dist/{context-DRgOvSnW.mjs.map → context-ZldbZcle.mjs.map} +1 -1
  261. package/dist/{dashboard-CB8J89rJ.mjs → dashboard-DvHbk_cv.mjs} +3 -3
  262. package/dist/{dashboard-CB8J89rJ.mjs.map → dashboard-DvHbk_cv.mjs.map} +1 -1
  263. package/dist/db/index.d.mts +4 -4
  264. package/dist/db/index.mjs +30 -7
  265. package/dist/db/index.mjs.map +1 -1
  266. package/dist/db/libsql-migrations.d.mts +11 -0
  267. package/dist/db/libsql-migrations.d.mts.map +1 -0
  268. package/dist/db/libsql-migrations.mjs +57 -0
  269. package/dist/db/libsql-migrations.mjs.map +1 -0
  270. package/dist/db/libsql.d.mts +1 -1
  271. package/dist/db/postgres-migrations.d.mts +10 -0
  272. package/dist/db/postgres-migrations.d.mts.map +1 -0
  273. package/dist/db/postgres-migrations.mjs +47 -0
  274. package/dist/db/postgres-migrations.mjs.map +1 -0
  275. package/dist/db/postgres.d.mts +1 -1
  276. package/dist/db/sqlite-migrations.d.mts +10 -0
  277. package/dist/db/sqlite-migrations.d.mts.map +1 -0
  278. package/dist/db/sqlite-migrations.mjs +28 -0
  279. package/dist/db/sqlite-migrations.mjs.map +1 -0
  280. package/dist/db/sqlite.d.mts +1 -1
  281. package/dist/{deferred-tasks-BMsa9F64.d.mts → deferred-tasks-SDWxrgzJ.d.mts} +1 -1
  282. package/dist/{deferred-tasks-BMsa9F64.d.mts.map → deferred-tasks-SDWxrgzJ.d.mts.map} +1 -1
  283. package/dist/{device-flow-CaioYGK3.mjs → device-flow-C0GdD_z8.mjs} +4 -4
  284. package/dist/{device-flow-CaioYGK3.mjs.map → device-flow-C0GdD_z8.mjs.map} +1 -1
  285. package/dist/direct-executor-CDXeJCSs.mjs +85 -0
  286. package/dist/direct-executor-CDXeJCSs.mjs.map +1 -0
  287. package/dist/{escape-C_aLKP3H.mjs → escape-DUY2r2Zs.mjs} +1 -1
  288. package/dist/{escape-C_aLKP3H.mjs.map → escape-DUY2r2Zs.mjs.map} +1 -1
  289. package/dist/{field-defs-cache-hg6QkjV9.mjs → field-defs-cache-BzwBlJrR.mjs} +2 -2
  290. package/dist/{field-defs-cache-hg6QkjV9.mjs.map → field-defs-cache-BzwBlJrR.mjs.map} +1 -1
  291. package/dist/{fts-manager-CZ-5dVwJ.mjs → fts-manager-DyZEohZC.mjs} +2 -2
  292. package/dist/{fts-manager-CZ-5dVwJ.mjs.map → fts-manager-DyZEohZC.mjs.map} +1 -1
  293. package/dist/{hreflang-DNswoYsi.mjs → hreflang-BBe83kKa.mjs} +5 -5
  294. package/dist/{hreflang-DNswoYsi.mjs.map → hreflang-BBe83kKa.mjs.map} +1 -1
  295. package/dist/{hreflang-D3Uehfv2.d.mts → hreflang-DwT2E0Sa.d.mts} +2 -2
  296. package/dist/{hreflang-D3Uehfv2.d.mts.map → hreflang-DwT2E0Sa.d.mts.map} +1 -1
  297. package/dist/identity-Ys6Dlc9C.mjs +35 -0
  298. package/dist/identity-Ys6Dlc9C.mjs.map +1 -0
  299. package/dist/{import-B41u8Rot.mjs → import-DPkLmeXO.mjs} +2 -2
  300. package/dist/{import-B41u8Rot.mjs.map → import-DPkLmeXO.mjs.map} +1 -1
  301. package/dist/{index-C3Y-rsyN.d.mts → index-BVBZj4-7.d.mts} +28 -49
  302. package/dist/index-BVBZj4-7.d.mts.map +1 -0
  303. package/dist/{index-Cof4-Fab.d.mts → index-C9KuQyO-.d.mts} +3 -3
  304. package/dist/{index-Cof4-Fab.d.mts.map → index-C9KuQyO-.d.mts.map} +1 -1
  305. package/dist/index.d.mts +21 -20
  306. package/dist/index.mjs +42 -42
  307. package/dist/{load-DmvJrtxS.mjs → load-YJ2xl3ra.mjs} +2 -2
  308. package/dist/{load-DmvJrtxS.mjs.map → load-YJ2xl3ra.mjs.map} +1 -1
  309. package/dist/{loader-DLBqRiGP.mjs → loader-CdG_Ci3H.mjs} +2 -2
  310. package/dist/{loader-DLBqRiGP.mjs.map → loader-CdG_Ci3H.mjs.map} +1 -1
  311. package/dist/manifest-builder-B252yQUf.mjs +189 -0
  312. package/dist/manifest-builder-B252yQUf.mjs.map +1 -0
  313. package/dist/media/index.d.mts +1 -1
  314. package/dist/media/local-runtime.d.mts +12 -12
  315. package/dist/media/local-runtime.mjs +2 -2
  316. package/dist/{media-allowlist-BD6HhgXA.mjs → media-allowlist-D6x-Us02.mjs} +1 -1
  317. package/dist/{media-allowlist-BD6HhgXA.mjs.map → media-allowlist-D6x-Us02.mjs.map} +1 -1
  318. package/dist/{media-upload-9yas9zGH.mjs → media-upload-CHBVn6m-.mjs} +2 -2
  319. package/dist/{media-upload-9yas9zGH.mjs.map → media-upload-CHBVn6m-.mjs.map} +1 -1
  320. package/dist/{media-usage-De3qvwjc.mjs → media-usage-BZCydfvu.mjs} +4 -4
  321. package/dist/{media-usage-De3qvwjc.mjs.map → media-usage-BZCydfvu.mjs.map} +1 -1
  322. package/dist/{media-usage-work-B9Bksh_b.mjs → media-usage-work-CLo_6tct.mjs} +2 -2
  323. package/dist/{media-usage-work-B9Bksh_b.mjs.map → media-usage-work-CLo_6tct.mjs.map} +1 -1
  324. package/dist/{menus-CC-yn1F9.mjs → menus-DSo5QeW0.mjs} +6 -6
  325. package/dist/{menus-CC-yn1F9.mjs.map → menus-DSo5QeW0.mjs.map} +1 -1
  326. package/dist/{menus-fxvEDYE6.mjs → menus-TCNSd2vp.mjs} +2 -2
  327. package/dist/{menus-fxvEDYE6.mjs.map → menus-TCNSd2vp.mjs.map} +1 -1
  328. package/dist/migrations/index.d.mts +22 -0
  329. package/dist/migrations/index.d.mts.map +1 -0
  330. package/dist/migrations/index.mjs +7 -0
  331. package/dist/{mode-Bpe-tNNy.mjs → mode-BDoH_WcF.mjs} +1 -1
  332. package/dist/{mode-Bpe-tNNy.mjs.map → mode-BDoH_WcF.mjs.map} +1 -1
  333. package/dist/{oauth-authorization-BXO1MKej.mjs → oauth-authorization-iOikmrJd.mjs} +4 -4
  334. package/dist/{oauth-authorization-BXO1MKej.mjs.map → oauth-authorization-iOikmrJd.mjs.map} +1 -1
  335. package/dist/{oauth-clients-C_DNqtRu.mjs → oauth-clients-CvI0nHMJ.mjs} +1 -1
  336. package/dist/{oauth-clients-C_DNqtRu.mjs.map → oauth-clients-CvI0nHMJ.mjs.map} +1 -1
  337. package/dist/{oauth-state-store-DwAxMtk4.mjs → oauth-state-store-Ba8KZlwW.mjs} +1 -1
  338. package/dist/{oauth-state-store-DwAxMtk4.mjs.map → oauth-state-store-Ba8KZlwW.mjs.map} +1 -1
  339. package/dist/{oauth-user-lookup-BxfZizdk.mjs → oauth-user-lookup-C5SQwGYC.mjs} +1 -1
  340. package/dist/{oauth-user-lookup-BxfZizdk.mjs.map → oauth-user-lookup-C5SQwGYC.mjs.map} +1 -1
  341. package/dist/object-cache/memory.d.mts +1 -1
  342. package/dist/{options-DRe1pIBL.d.mts → options--hhz-jZ1.d.mts} +3 -3
  343. package/dist/{options-DRe1pIBL.d.mts.map → options--hhz-jZ1.d.mts.map} +1 -1
  344. package/dist/page/index.d.mts +2 -2
  345. package/dist/{passkey-config-_2GOwwK8.mjs → passkey-config-DFAmUyuE.mjs} +1 -1
  346. package/dist/{passkey-config-_2GOwwK8.mjs.map → passkey-config-DFAmUyuE.mjs.map} +1 -1
  347. package/dist/{placeholder-CzW7Lshs.d.mts → placeholder-Dp0K3RkD.d.mts} +1 -1
  348. package/dist/{placeholder-CzW7Lshs.d.mts.map → placeholder-Dp0K3RkD.d.mts.map} +1 -1
  349. package/dist/plugin-types.d.mts +1 -1
  350. package/dist/plugin-utils.d.mts +10 -10
  351. package/dist/plugins/adapt-sandbox-entry.d.mts +10 -10
  352. package/dist/policy-TluUIxuu.mjs +45 -0
  353. package/dist/policy-TluUIxuu.mjs.map +1 -0
  354. package/dist/{portable-text-DhKbDAGr.mjs → portable-text-imBW5cHK.mjs} +1 -1
  355. package/dist/{portable-text-DhKbDAGr.mjs.map → portable-text-imBW5cHK.mjs.map} +1 -1
  356. package/dist/protocol-DjsJHTlK.d.mts +52 -0
  357. package/dist/protocol-DjsJHTlK.d.mts.map +1 -0
  358. package/dist/{query-vgZNwIhh.mjs → query-BWs7O5Il.mjs} +3 -3
  359. package/dist/{query-vgZNwIhh.mjs.map → query-BWs7O5Il.mjs.map} +1 -1
  360. package/dist/{query-BgjfiLUQ.mjs → query-zTVAR1Sl.mjs} +12 -12
  361. package/dist/{query-BgjfiLUQ.mjs.map → query-zTVAR1Sl.mjs.map} +1 -1
  362. package/dist/{rate-limit-L5anykgo.mjs → rate-limit-Bx4g9m3p.mjs} +1 -1
  363. package/dist/{rate-limit-L5anykgo.mjs.map → rate-limit-Bx4g9m3p.mjs.map} +1 -1
  364. package/dist/{redirect-KBNE3UEs.mjs → redirect-BgAOGHDq.mjs} +1 -1
  365. package/dist/{redirect-KBNE3UEs.mjs.map → redirect-BgAOGHDq.mjs.map} +1 -1
  366. package/dist/{redirects-cV9g7V-0.mjs → redirects-DTDHWH31.mjs} +2 -2
  367. package/dist/{redirects-cV9g7V-0.mjs.map → redirects-DTDHWH31.mjs.map} +1 -1
  368. package/dist/{redirects-CufRQt0Q.mjs → redirects-DyChwDkG.mjs} +1 -1
  369. package/dist/{redirects-CufRQt0Q.mjs.map → redirects-DyChwDkG.mjs.map} +1 -1
  370. package/dist/{registry-C581V4TP.mjs → registry-Cqa2I749.mjs} +6 -6
  371. package/dist/{registry-C581V4TP.mjs.map → registry-Cqa2I749.mjs.map} +1 -1
  372. package/dist/{relation-DtmAQH7s.mjs → relation-BnHB0T4J.mjs} +2 -2
  373. package/dist/{relation-DtmAQH7s.mjs.map → relation-BnHB0T4J.mjs.map} +1 -1
  374. package/dist/{relations-DItI3MN9.d.mts → relations-BHo5A8Zf.d.mts} +35 -35
  375. package/dist/{relations-DItI3MN9.d.mts.map → relations-BHo5A8Zf.d.mts.map} +1 -1
  376. package/dist/{relations-DICSyfsh.mjs → relations-D5sM5fW0.mjs} +4 -4
  377. package/dist/{relations-DICSyfsh.mjs.map → relations-D5sM5fW0.mjs.map} +1 -1
  378. package/dist/request-context.d.mts +1 -1
  379. package/dist/{runner-CgQbELYC.d.mts → runner-BYHZn3Jy.d.mts} +16 -3
  380. package/dist/runner-BYHZn3Jy.d.mts.map +1 -0
  381. package/dist/{runner-BiDvq5dv.mjs → runner-C1T11CfC.mjs} +34 -4
  382. package/dist/{runner-BiDvq5dv.mjs.map → runner-C1T11CfC.mjs.map} +1 -1
  383. package/dist/runtime.d.mts +11 -11
  384. package/dist/runtime.mjs +1 -1
  385. package/dist/{schema-CeQSNwB3.mjs → schema-2AOsSn2v.mjs} +4 -4
  386. package/dist/{schema-CeQSNwB3.mjs.map → schema-2AOsSn2v.mjs.map} +1 -1
  387. package/dist/{schema-BFyTg38T.mjs → schema-DZ5YVVZj.mjs} +3 -3
  388. package/dist/{schema-BFyTg38T.mjs.map → schema-DZ5YVVZj.mjs.map} +1 -1
  389. package/dist/{scopes-BVeuhGBE.mjs → scopes-8ClPF2bC.mjs} +2 -2
  390. package/dist/{scopes-BVeuhGBE.mjs.map → scopes-8ClPF2bC.mjs.map} +1 -1
  391. package/dist/{search-BpuPKZb0.mjs → search-u1QYdOCG.mjs} +4 -4
  392. package/dist/{search-BpuPKZb0.mjs.map → search-u1QYdOCG.mjs.map} +1 -1
  393. package/dist/{secrets-dtYs2hDo.mjs → secrets-BEgsAQEU.mjs} +1 -1
  394. package/dist/{secrets-dtYs2hDo.mjs.map → secrets-BEgsAQEU.mjs.map} +1 -1
  395. package/dist/{sections-COctk6hf.mjs → sections-DmHviGyM.mjs} +2 -2
  396. package/dist/{sections-COctk6hf.mjs.map → sections-DmHviGyM.mjs.map} +1 -1
  397. package/dist/seed/index.d.mts +2 -2
  398. package/dist/seed/index.mjs +13 -13
  399. package/dist/seo/index.d.mts +3 -3
  400. package/dist/seo/index.mjs +9 -9
  401. package/dist/{seo-CvDxfvFb.mjs → seo-Dq4G-RrO.mjs} +2 -2
  402. package/dist/{seo-CvDxfvFb.mjs.map → seo-Dq4G-RrO.mjs.map} +1 -1
  403. package/dist/{service-xMiMSTsR.mjs → service-BpArayPr.mjs} +2 -2
  404. package/dist/{service-xMiMSTsR.mjs.map → service-BpArayPr.mjs.map} +1 -1
  405. package/dist/{session-user-BzVemLkw.mjs → session-user-BEorKVT-.mjs} +1 -1
  406. package/dist/{session-user-BzVemLkw.mjs.map → session-user-BEorKVT-.mjs.map} +1 -1
  407. package/dist/{settings-BE9aqn_9.mjs → settings-DE9xtzqd.mjs} +3 -3
  408. package/dist/{settings-BE9aqn_9.mjs.map → settings-DE9xtzqd.mjs.map} +1 -1
  409. package/dist/{settings-B372MV0p.mjs → settings-xRdoUZr0.mjs} +2 -2
  410. package/dist/{settings-B372MV0p.mjs.map → settings-xRdoUZr0.mjs.map} +1 -1
  411. package/dist/{setup-complete-DF7TAfD5.mjs → setup-complete-BnT8pq_C.mjs} +1 -1
  412. package/dist/{setup-complete-DF7TAfD5.mjs.map → setup-complete-BnT8pq_C.mjs.map} +1 -1
  413. package/dist/{setup-nonce-Do-U_cg9.mjs → setup-nonce-CBitf_oQ.mjs} +1 -1
  414. package/dist/{setup-nonce-Do-U_cg9.mjs.map → setup-nonce-CBitf_oQ.mjs.map} +1 -1
  415. package/dist/{site-url-Bowsi9T-.mjs → site-url-DjpIXMXJ.mjs} +1 -1
  416. package/dist/{site-url-Bowsi9T-.mjs.map → site-url-DjpIXMXJ.mjs.map} +1 -1
  417. package/dist/{snapshot-B5h6aPMh.mjs → snapshot-BBlGkb2q.mjs} +1 -1
  418. package/dist/{snapshot-B5h6aPMh.mjs.map → snapshot-BBlGkb2q.mjs.map} +1 -1
  419. package/dist/storage/local.d.mts +1 -1
  420. package/dist/storage/local.mjs +2 -2
  421. package/dist/storage/s3.d.mts +1 -1
  422. package/dist/target-DixqqV5i.mjs +27 -0
  423. package/dist/target-DixqqV5i.mjs.map +1 -0
  424. package/dist/{taxonomies-B0o9ExNV.mjs → taxonomies-BlrncHi9.mjs} +5 -5
  425. package/dist/{taxonomies-B0o9ExNV.mjs.map → taxonomies-BlrncHi9.mjs.map} +1 -1
  426. package/dist/{taxonomies-Dk3MAklL.mjs → taxonomies-OHUIiIpI.mjs} +4 -4
  427. package/dist/{taxonomies-Dk3MAklL.mjs.map → taxonomies-OHUIiIpI.mjs.map} +1 -1
  428. package/dist/{taxonomy-CPeM8d2o.mjs → taxonomy-BqCZ1TdU.mjs} +3 -3
  429. package/dist/{taxonomy-CPeM8d2o.mjs.map → taxonomy-BqCZ1TdU.mjs.map} +1 -1
  430. package/dist/{transport--CfOflrp.mjs → transport-DBJwa-Si.mjs} +1 -1
  431. package/dist/{transport--CfOflrp.mjs.map → transport-DBJwa-Si.mjs.map} +1 -1
  432. package/dist/{transport-B_rfeU-a.d.mts → transport-RQE36APG.d.mts} +1 -1
  433. package/dist/{transport-B_rfeU-a.d.mts.map → transport-RQE36APG.d.mts.map} +1 -1
  434. package/dist/{types-CFUfyt1s.d.mts → types-BcqdcT-r.d.mts} +1 -1
  435. package/dist/{types-CFUfyt1s.d.mts.map → types-BcqdcT-r.d.mts.map} +1 -1
  436. package/dist/{types-CPx2lIGo.d.mts → types-BntycPTF.d.mts} +1 -1
  437. package/dist/{types-CPx2lIGo.d.mts.map → types-BntycPTF.d.mts.map} +1 -1
  438. package/dist/{types-C3_AbkDP.d.mts → types-C2pMvO62.d.mts} +1 -1
  439. package/dist/{types-C3_AbkDP.d.mts.map → types-C2pMvO62.d.mts.map} +1 -1
  440. package/dist/{types-DefV3OiO.d.mts → types-CXnOzDe1.d.mts} +1 -1
  441. package/dist/{types-DefV3OiO.d.mts.map → types-CXnOzDe1.d.mts.map} +1 -1
  442. package/dist/{types-D4_1Ga4L.d.mts → types-DN64_zzZ.d.mts} +3 -3
  443. package/dist/{types-D4_1Ga4L.d.mts.map → types-DN64_zzZ.d.mts.map} +1 -1
  444. package/dist/{types-CAGdePf3.d.mts → types-DQIDRKF2.d.mts} +1 -1
  445. package/dist/{types-CAGdePf3.d.mts.map → types-DQIDRKF2.d.mts.map} +1 -1
  446. package/dist/{types-BPuloofb.d.mts → types-DWRhk9xZ.d.mts} +1 -1
  447. package/dist/{types-BPuloofb.d.mts.map → types-DWRhk9xZ.d.mts.map} +1 -1
  448. package/dist/{types-X0MThr7x.d.mts → types-Dfk8KI5R.d.mts} +1 -1
  449. package/dist/{types-X0MThr7x.d.mts.map → types-Dfk8KI5R.d.mts.map} +1 -1
  450. package/dist/{types-Cs-TFLNf.d.mts → types-DrJrVCT1.d.mts} +3 -3
  451. package/dist/{types-Cs-TFLNf.d.mts.map → types-DrJrVCT1.d.mts.map} +1 -1
  452. package/dist/{upload-attempts-hlne3iA1.mjs → upload-attempts-D4FS8LT6.mjs} +1 -1
  453. package/dist/{upload-attempts-hlne3iA1.mjs.map → upload-attempts-D4FS8LT6.mjs.map} +1 -1
  454. package/dist/{user-sHgJ62SP.mjs → user-DJZfUJVE.mjs} +2 -2
  455. package/dist/{user-sHgJ62SP.mjs.map → user-DJZfUJVE.mjs.map} +1 -1
  456. package/dist/{validate-BkqIG_Uj.mjs → validate-BMLBvxOC.mjs} +2 -2
  457. package/dist/{validate-BkqIG_Uj.mjs.map → validate-BMLBvxOC.mjs.map} +1 -1
  458. package/dist/{validate-DeGtBN84.d.mts → validate-CTqt5nuE.d.mts} +5 -5
  459. package/dist/{validate-DeGtBN84.d.mts.map → validate-CTqt5nuE.d.mts.map} +1 -1
  460. package/dist/{validation-CiJb_X5o.mjs → validation-npvENUCB.mjs} +7 -7
  461. package/dist/{validation-CiJb_X5o.mjs.map → validation-npvENUCB.mjs.map} +1 -1
  462. package/dist/version-DB_fqCSI.mjs +7 -0
  463. package/dist/{version-x0NjTScG.mjs.map → version-DB_fqCSI.mjs.map} +1 -1
  464. package/dist/{widgets-Cwzvww0W.mjs → widgets-BD3ho7Rq.mjs} +2 -2
  465. package/dist/{widgets-Cwzvww0W.mjs.map → widgets-BD3ho7Rq.mjs.map} +1 -1
  466. package/dist/{wxr-taxonomies-QZjWxjyk.mjs → wxr-taxonomies-B1R3vBoD.mjs} +3 -3
  467. package/dist/{wxr-taxonomies-QZjWxjyk.mjs.map → wxr-taxonomies-B1R3vBoD.mjs.map} +1 -1
  468. package/dist/{wxr-taxonomies-CmC0ZsDr.d.mts → wxr-taxonomies-Ca68766D.d.mts} +1 -1
  469. package/dist/{wxr-taxonomies-CmC0ZsDr.d.mts.map → wxr-taxonomies-Ca68766D.d.mts.map} +1 -1
  470. package/dist/{zod-generator-D11MhxG4.mjs → zod-generator-LC-on_Vb.mjs} +2 -2
  471. package/dist/{zod-generator-D11MhxG4.mjs.map → zod-generator-LC-on_Vb.mjs.map} +1 -1
  472. package/package.json +21 -5
  473. package/src/astro/integration/index.ts +46 -14
  474. package/src/astro/integration/runtime.ts +3 -0
  475. package/src/astro/middleware/scoped-db.ts +21 -0
  476. package/src/astro/middleware.ts +67 -10
  477. package/src/astro/routes/api/auth/dev-bypass.ts +0 -4
  478. package/src/astro/routes/api/setup/dev-bypass.ts +2 -7
  479. package/src/astro/routes/api/setup/index.ts +5 -13
  480. package/src/cli/commands/migrate.ts +593 -0
  481. package/src/cli/index.ts +2 -23
  482. package/src/database/index.ts +8 -2
  483. package/src/database/migrations/policy.ts +70 -0
  484. package/src/database/migrations/runner.ts +49 -3
  485. package/src/db/adapters.ts +50 -2
  486. package/src/db/index.ts +3 -1
  487. package/src/db/libsql-migrations.ts +72 -0
  488. package/src/db/postgres-migrations.ts +59 -0
  489. package/src/db/sqlite-migrations.ts +37 -0
  490. package/src/emdash-runtime.ts +44 -15
  491. package/src/i18n/normalize.ts +31 -0
  492. package/src/index.ts +7 -1
  493. package/src/migrations/config-loader.ts +205 -0
  494. package/src/migrations/direct-executor.ts +141 -0
  495. package/src/migrations/identity.ts +46 -0
  496. package/src/migrations/index.ts +19 -0
  497. package/src/migrations/integration-metadata.ts +67 -0
  498. package/src/migrations/manifest-builder.ts +55 -0
  499. package/src/migrations/manifest-writer.ts +63 -0
  500. package/src/migrations/manifest.ts +197 -0
  501. package/src/migrations/protocol.ts +59 -0
  502. package/src/migrations/target.ts +35 -0
  503. package/src/virtual-modules.d.ts +8 -0
  504. package/dist/adapters-34EN2eXO.d.mts.map +0 -1
  505. package/dist/index-C3Y-rsyN.d.mts.map +0 -1
  506. package/dist/runner-CgQbELYC.d.mts.map +0 -1
  507. package/dist/version-x0NjTScG.mjs +0 -7
  508. /package/dist/{api-tokens-CyTsLszZ.mjs → api-tokens-rIXC6LdE.mjs} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"taxonomies-Dk3MAklL.mjs","names":[],"sources":["../src/api/handlers/taxonomies.ts"],"sourcesContent":["/**\n * Taxonomy and term CRUD handlers.\n *\n * i18n: terms and defs are per-locale. `(name, slug, locale)` is unique for\n * terms; `(name, locale)` for defs. Translations of the same term/def share a\n * `translation_group`. The content_taxonomies pivot stores translation_groups\n * so assignments span every locale of a post.\n */\n\nimport { sql, type Kysely, type Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { TaxonomyRepository, type SiblingPosition } from \"../../database/repositories/taxonomy.js\";\nimport { withTransaction } from \"../../database/transaction.js\";\nimport type { Database, TaxonomyDefTable } from \"../../database/types.js\";\nimport { getI18nConfig, resolveConfiguredLocale } from \"../../i18n/config.js\";\nimport { invalidateTaxonomyDefsCache, invalidateTermCache } from \"../../taxonomies/index.js\";\nimport { fetchVisibleTermCounts } from \"../../taxonomies/term-counts.js\";\nimport type { ApiResult } from \"../types.js\";\n\nconst NAME_PATTERN = /^[a-z][a-z0-9_]*$/;\nconst MAX_GENERATED_TERM_SLUG_ATTEMPTS = 16;\n\nfunction isTermSlugUniqueViolation(error: unknown): boolean {\n\tconst message = error instanceof Error ? error.message.toLowerCase() : \"\";\n\treturn (\n\t\t(message.includes(\"unique constraint failed\") || message.includes(\"duplicate key\")) &&\n\t\tmessage.includes(\"slug\")\n\t);\n}\n\n// ---------------------------------------------------------------------------\n// Response types\n// ---------------------------------------------------------------------------\n\nexport interface TaxonomyDef {\n\tid: string;\n\tname: string;\n\tlabel: string;\n\tlabelSingular?: string;\n\thierarchical: boolean;\n\tcollections: string[];\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TaxonomyListResponse {\n\ttaxonomies: TaxonomyDef[];\n}\n\nexport interface TaxonomyResponse {\n\ttaxonomy: TaxonomyDef;\n}\n\nexport interface TermData {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdescription?: string;\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TermWithCount extends TermData {\n\t/** Absent when the caller opted out of counts (`includeCounts: false`). */\n\tcount?: number;\n\tchildren: TermWithCount[];\n}\n\nexport interface TermListResponse {\n\tterms: TermWithCount[];\n}\n\nexport interface TermReorderResponse {\n\treordered: true;\n}\n\nexport interface TermResponse {\n\tterm: TermData;\n}\n\nexport interface TermGetResponse {\n\tterm: TermData & {\n\t\tcount: number;\n\t\tchildren: Array<{ id: string; slug: string; label: string }>;\n\t};\n}\n\nexport interface TermTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tslug: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\nexport interface TaxonomyDefTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tname: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build tree structure from flat terms\n */\nfunction buildTree(flatTerms: TermWithCount[], resolveByGroup = false): TermWithCount[] {\n\t// `parentId` holds the parent's translation_group, so resolve links by group.\n\t// Key by (locale, group): a child's parent lives in the same locale, and an\n\t// unfiltered list mixes locales whose translated siblings share a group —\n\t// keying by group alone would collide and misattach children across locales.\n\tconst byLocaleGroup = new Map<string, TermWithCount>();\n\tconst roots: TermWithCount[] = [];\n\tfor (const term of flatTerms) {\n\t\tconst key = resolveByGroup\n\t\t\t? (term.translationGroup ?? term.id)\n\t\t\t: `${term.locale}::${term.translationGroup ?? term.id}`;\n\t\tbyLocaleGroup.set(key, term);\n\t}\n\tfor (const term of flatTerms) {\n\t\tconst parent = term.parentId\n\t\t\t? byLocaleGroup.get(resolveByGroup ? term.parentId : `${term.locale}::${term.parentId}`)\n\t\t\t: undefined;\n\t\tif (parent) {\n\t\t\tparent.children.push(term);\n\t\t} else {\n\t\t\troots.push(term);\n\t\t}\n\t}\n\treturn roots;\n}\n\ntype TaxonomyDefLookup =\n\t| { success: true; def: Selectable<TaxonomyDefTable> }\n\t| { success: false; error: { code: string; message: string } };\n\nfunction taxonomyDefNotFound(name: string, locale?: string): TaxonomyDefLookup {\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"NOT_FOUND\",\n\t\t\tmessage: `Taxonomy '${name}' not found${locale !== undefined ? ` in locale '${locale}'` : \"\"}`,\n\t\t},\n\t};\n}\n\n/** Look up the exact definition addressed by a mutation. */\nasync function requireTaxonomyDef(\n\tdb: Kysely<Database>,\n\tname: string,\n\tlocale?: string,\n): Promise<TaxonomyDefLookup> {\n\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll().where(\"name\", \"=\", name);\n\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\tconst def = await query.orderBy(\"locale\", \"asc\").orderBy(\"id\", \"asc\").executeTakeFirst();\n\treturn def ? { success: true, def } : taxonomyDefNotFound(name, locale);\n}\n\n/**\n * Prefer the requested locale, then the configured default. Incomplete\n * translation groups fall back deterministically so their terms remain usable.\n */\nasync function requireTaxonomyDefWithFallback(\n\tdb: Kysely<Database>,\n\tname: string,\n\tlocale?: string,\n): Promise<TaxonomyDefLookup> {\n\tconst defs = await db\n\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t.selectAll()\n\t\t.where(\"name\", \"=\", name)\n\t\t.orderBy(\"locale\", \"asc\")\n\t\t.orderBy(\"id\", \"asc\")\n\t\t.execute();\n\tconst defaultLocale = getI18nConfig()?.defaultLocale;\n\tconst def =\n\t\t(locale ? defs.find((candidate) => candidate.locale === locale) : undefined) ??\n\t\t(defaultLocale ? defs.find((candidate) => candidate.locale === defaultLocale) : undefined) ??\n\t\tdefs[0];\n\treturn def ? { success: true, def } : taxonomyDefNotFound(name, locale);\n}\n\n/** The subset of `slugs` that still has a row in `_emdash_collections`. */\nasync function findExistingCollections(\n\tdb: Kysely<Database>,\n\tslugs: readonly string[],\n): Promise<Set<string>> {\n\tif (slugs.length === 0) return new Set();\n\tconst rows = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select(\"slug\")\n\t\t.where(\"slug\", \"in\", [...slugs])\n\t\t.execute();\n\treturn new Set(rows.map((r) => r.slug));\n}\n\n/**\n * Reject a `collections` list naming collections that don't exist. Shared by\n * create and update so both fail the same way instead of storing a reference\n * that reads back filtered out.\n */\nasync function validateCollections(\n\tdb: Kysely<Database>,\n\tcollections: readonly string[],\n): Promise<{ code: \"VALIDATION_ERROR\"; message: string } | null> {\n\tconst existing = await findExistingCollections(db, collections);\n\tconst invalid = collections.filter((slug) => !existing.has(slug));\n\tif (invalid.length === 0) return null;\n\treturn {\n\t\tcode: \"VALIDATION_ERROR\",\n\t\tmessage: `Unknown collection(s): ${invalid.join(\", \")}`,\n\t};\n}\n\n/**\n * Shape one definition row for a response, dropping collection references whose\n * collection is gone. Storage is untouched — re-creating the collection\n * re-links automatically, same as `handleTaxonomyList`.\n */\nasync function toTaxonomyResponse(\n\tdb: Kysely<Database>,\n\trow: Selectable<TaxonomyDefTable>,\n): Promise<TaxonomyResponse> {\n\tconst def = rowToDef(row);\n\tconst realCollections = await findExistingCollections(db, def.collections);\n\treturn {\n\t\ttaxonomy: {\n\t\t\t...def,\n\t\t\tcollections: def.collections.filter((slug) => realCollections.has(slug)),\n\t\t},\n\t};\n}\n\n/** Declared collections of a taxonomy def row (deduped, parsed from JSON). */\nfunction defCollections(def: Selectable<TaxonomyDefTable>): string[] {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- column stores a JSON string[] written by handleTaxonomyCreate\n\tconst parsed = def.collections ? (JSON.parse(def.collections) as string[]) : [];\n\treturn [...new Set(parsed)];\n}\n\nfunction rowToDef(row: Selectable<TaxonomyDefTable>): TaxonomyDef {\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tlabel: row.label,\n\t\tlabelSingular: row.label_singular ?? undefined,\n\t\thierarchical: row.hierarchical === 1,\n\t\tcollections: row.collections ? JSON.parse(row.collections) : [],\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Taxonomy definition handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all taxonomy definitions\n */\nexport async function handleTaxonomyList(\n\tdb: Kysely<Database>,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TaxonomyListResponse>> {\n\ttry {\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll();\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst [rows, collectionRows] = await Promise.all([\n\t\t\tquery.execute(),\n\t\t\tdb.selectFrom(\"_emdash_collections\").select(\"slug\").execute(),\n\t\t]);\n\n\t\t// Filter orphan collection references on read so the response stays\n\t\t// consistent with `schema_list_collections`. Storage is untouched —\n\t\t// re-creating the collection re-links automatically.\n\t\tconst realCollections = new Set(collectionRows.map((r) => r.slug));\n\n\t\tconst taxonomies: TaxonomyDef[] = rows.map((row) => {\n\t\t\tconst def = rowToDef(row);\n\t\t\treturn { ...def, collections: def.collections.filter((slug) => realCollections.has(slug)) };\n\t\t});\n\n\t\treturn { success: true, data: { taxonomies } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_LIST_ERROR\", message: \"Failed to list taxonomies\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a single taxonomy definition by name.\n *\n * Definitions are per-locale, so `locale` picks which one; without it the\n * lowest-locale match wins (same rule as `handleMenuGet`).\n */\nexport async function handleTaxonomyGet(\n\tdb: Kysely<Database>,\n\tname: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TaxonomyResponse>> {\n\ttry {\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst lookup = await requireTaxonomyDefWithFallback(db, name, locale);\n\t\tif (!lookup.success) return lookup;\n\n\t\treturn { success: true, data: await toTaxonomyResponse(db, lookup.def) };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_GET_ERROR\", message: \"Failed to get taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a new taxonomy definition\n */\nexport async function handleTaxonomyCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tname: string;\n\t\tlabel: string;\n\t\tlabelSingular?: string;\n\t\thierarchical?: boolean;\n\t\tcollections?: string[];\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<TaxonomyResponse>> {\n\ttry {\n\t\tconst locale = resolveConfiguredLocale(input.locale ?? getI18nConfig()?.defaultLocale ?? \"en\");\n\t\tif (!NAME_PATTERN.test(input.name)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Taxonomy name must start with a letter and contain only lowercase letters, numbers, and underscores\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst collections = [...new Set(input.collections ?? [])];\n\t\tconst collectionError = await validateCollections(db, collections);\n\t\tif (collectionError) {\n\t\t\treturn { success: false, error: collectionError };\n\t\t}\n\n\t\tlet translationGroup: string | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"id\", \"=\", input.translationOf)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Source taxonomy for translation not found\" },\n\t\t\t\t};\n\t\t\t}\n\t\t\ttranslationGroup = source.translation_group ?? source.id;\n\t\t}\n\n\t\t// Duplicate guard scoped to locale (so the same name can exist in ES\n\t\t// and EN).\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"name\", \"=\", input.name)\n\t\t\t.where(\"locale\", \"=\", locale)\n\t\t\t.executeTakeFirst();\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Taxonomy '${input.name}' already exists in locale '${locale}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst id = ulid();\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_taxonomy_defs\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tlabel: input.label,\n\t\t\t\tlabel_singular: input.labelSingular ?? null,\n\t\t\t\thierarchical: input.hierarchical ? 1 : 0,\n\t\t\t\tcollections: JSON.stringify(collections),\n\t\t\t\tlocale,\n\t\t\t\ttranslation_group: translationGroup ?? id,\n\t\t\t})\n\t\t\t.execute();\n\n\t\t// A new def changes which taxonomies exist — drop the isolate-wide\n\t\t// defs/names caches so this isolate reflects it immediately.\n\t\tinvalidateTaxonomyDefsCache();\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirstOrThrow();\n\t\treturn { success: true, data: { taxonomy: rowToDef(row) } };\n\t} catch (error) {\n\t\tif (error instanceof Error && error.message.includes(\"UNIQUE constraint failed\")) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"CONFLICT\", message: `Taxonomy '${input.name}' already exists` },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_CREATE_ERROR\", message: \"Failed to create taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a taxonomy definition.\n *\n * Writes the one row `(name, locale)` resolves to — every field here belongs to\n * a single locale's definition, so translating a taxonomy and then editing the\n * translation leaves the other locales alone. `name` and `locale` are immutable\n * (renaming would strand the terms, which are keyed on `name`).\n */\nexport async function handleTaxonomyUpdate(\n\tdb: Kysely<Database>,\n\tname: string,\n\tinput: {\n\t\tlabel?: string;\n\t\tlabelSingular?: string | null;\n\t\thierarchical?: boolean;\n\t\tcollections?: string[];\n\t\tlocale?: string;\n\t},\n): Promise<ApiResult<TaxonomyResponse>> {\n\ttry {\n\t\tconst locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;\n\t\tconst lookup = await requireTaxonomyDef(db, name, locale);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst collections = input.collections ? [...new Set(input.collections)] : undefined;\n\t\tif (collections !== undefined) {\n\t\t\tconst collectionError = await validateCollections(db, collections);\n\t\t\tif (collectionError) {\n\t\t\t\treturn { success: false, error: collectionError };\n\t\t\t}\n\t\t}\n\n\t\tconst updates: {\n\t\t\tlabel?: string;\n\t\t\tlabel_singular?: string | null;\n\t\t\thierarchical?: number;\n\t\t\tcollections?: string;\n\t\t} = {};\n\t\tif (input.label !== undefined) updates.label = input.label;\n\t\tif (input.labelSingular !== undefined) updates.label_singular = input.labelSingular;\n\t\tif (input.hierarchical !== undefined) updates.hierarchical = input.hierarchical ? 1 : 0;\n\t\tif (collections !== undefined) updates.collections = JSON.stringify(collections);\n\n\t\tif (Object.keys(updates).length > 0) {\n\t\t\tawait db\n\t\t\t\t.updateTable(\"_emdash_taxonomy_defs\")\n\t\t\t\t.set(updates)\n\t\t\t\t.where(\"id\", \"=\", lookup.def.id)\n\t\t\t\t.execute();\n\t\t\tinvalidateTaxonomyDefsCache();\n\t\t}\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", lookup.def.id)\n\t\t\t.executeTakeFirstOrThrow();\n\n\t\treturn { success: true, data: await toTaxonomyResponse(db, row) };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_UPDATE_ERROR\", message: \"Failed to update taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a taxonomy: every locale's definition, every term under that name in\n * every locale, and the term assignments those terms hold.\n *\n * There is no locale scope and no non-empty guard. Terms are keyed on `name`\n * rather than on a definition row, so leaving one locale's definition behind\n * would leave the taxonomy half-deleted — a definition with no terms, or terms\n * no definition describes.\n */\nexport async function handleTaxonomyDelete(\n\tdb: Kysely<Database>,\n\tname: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst lookup = await requireTaxonomyDef(db, name);\n\t\tif (!lookup.success) return lookup;\n\n\t\tawait withTransaction(db, async (trx) => {\n\t\t\t// `content_taxonomies.taxonomy_id` holds a term's translation_group, so\n\t\t\t// the assignments have to go before the terms they are matched against.\n\t\t\tawait trx\n\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t.where(\"taxonomy_id\", \"in\", (eb) =>\n\t\t\t\t\teb\n\t\t\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t\t\t.select(sql<string>`coalesce(translation_group, id)`.as(\"group\"))\n\t\t\t\t\t\t.where(\"name\", \"=\", name),\n\t\t\t\t)\n\t\t\t\t.execute();\n\t\t\tawait trx.deleteFrom(\"taxonomies\").where(\"name\", \"=\", name).execute();\n\t\t\tawait trx.deleteFrom(\"_emdash_taxonomy_defs\").where(\"name\", \"=\", name).execute();\n\t\t});\n\n\t\t// Covers the term caches too — see `invalidateTaxonomyDefsCache`.\n\t\tinvalidateTaxonomyDefsCache();\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch (error) {\n\t\tconsole.error(\"[taxonomies] delete failed:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_DELETE_ERROR\", message: \"Failed to delete taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * List every locale translation of a taxonomy def (by id or translation_group).\n */\nexport async function handleTaxonomyDefTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TaxonomyDefTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Taxonomy not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tname: r.name,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TAXONOMY_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list taxonomy translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Term handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all terms for a taxonomy (returns tree for hierarchical taxonomies)\n */\nexport async function handleTermList(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\toptions: { locale?: string; includeCounts?: boolean; resolveFallback?: boolean } = {},\n): Promise<ApiResult<TermListResponse>> {\n\ttry {\n\t\tif (options.resolveFallback && !options.locale) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"A locale is required when resolving taxonomy fallbacks\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\t// Definitions are per-locale but terms aren't bound to the def's locale —\n\t\t// use the active definition for its collection scope.\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst lookup = await requireTaxonomyDefWithFallback(db, taxonomyName, locale);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst terms =\n\t\t\toptions.resolveFallback && locale\n\t\t\t\t? await repo.findByNameResolved(\n\t\t\t\t\t\ttaxonomyName,\n\t\t\t\t\t\tlocale,\n\t\t\t\t\t\tgetI18nConfig()?.defaultLocale ?? \"en\",\n\t\t\t\t\t)\n\t\t\t\t: await repo.findByName(taxonomyName, { locale });\n\n\t\t// Counts match what visitors see on the public site: published (or\n\t\t// scheduled-and-due) entries that aren't soft-deleted, scoped to the\n\t\t// taxonomy's declared collections — one query for the whole list.\n\t\t// content_taxonomies.taxonomy_id stores the translation_group, so we\n\t\t// look up by group and map back to each term's id.\n\t\tconst includeCounts = options.includeCounts ?? true;\n\t\tconst countsByGroup = includeCounts\n\t\t\t? await fetchVisibleTermCounts(db, taxonomyName, defCollections(lookup.def), locale)\n\t\t\t: undefined;\n\n\t\tconst termData: TermWithCount[] = terms.map((term) => ({\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tparentId: term.parentId,\n\t\t\tdescription: typeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\tchildren: [],\n\t\t\t...(countsByGroup && { count: countsByGroup.get(term.translationGroup ?? term.id) ?? 0 }),\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translationGroup,\n\t\t}));\n\n\t\tconst isHierarchical = lookup.def.hierarchical === 1;\n\t\tconst result = isHierarchical\n\t\t\t? buildTree(termData, options.resolveFallback && !!locale)\n\t\t\t: termData;\n\t\treturn { success: true, data: { terms: result } };\n\t} catch (error) {\n\t\tconsole.error(\"[taxonomies] term list failed:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_LIST_ERROR\", message: \"Failed to list terms\" },\n\t\t};\n\t}\n}\n\n/**\n * Set the manual order of one sibling group.\n *\n * `ids` names the terms to move, in the desired order; each is a row id or a\n * translation_group and resolves to the latter, because a position belongs to a\n * term rather than to one of its locales. Reordering never reparents — moving a\n * term under a different parent is a term update.\n *\n * The group is `(taxonomy, parentId)` and has no locale: `parentId` is the\n * parent's translation_group (a row id is accepted and resolved), and `null`\n * selects the top level — which for a flat taxonomy is every term.\n *\n * `ids` may be a *subset* of the group, and the listed terms are permuted\n * within the positions they already occupy. A locale only renders the terms\n * translated into it, so a caller often cannot name every member; a member left\n * out keeps its place, which is also what makes a stale list harmless.\n */\nexport async function handleTermReorder(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\tinput: { parentId?: string | null; ids: string[] },\n): Promise<ApiResult<TermReorderResponse>> {\n\ttry {\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\n\t\tlet parentId = input.parentId ?? null;\n\t\tif (parentId !== null) {\n\t\t\t// Children store the parent's translation_group, so a caller passing a\n\t\t\t// row id still addresses the right group. A parent whose anchor row is\n\t\t\t// gone keeps the value as given (see TaxonomyRepository.resolveParentRef).\n\t\t\tconst parent = await repo.findById(parentId);\n\t\t\tif (parent) parentId = parent.translationGroup ?? parent.id;\n\t\t}\n\n\t\t// Every locale's rows: membership is locale-agnostic, and a term the\n\t\t// caller can't see still holds its slot.\n\t\t// The order carries into how ties are resolved, so it must be stable\n\t\t// across calls; with locales interleaved a tie falls to whichever\n\t\t// locale's label sorts first, not to the order the caller rendered.\n\t\tconst members = await repo.findByName(taxonomyName, { parentId });\n\t\tconst siblings: SiblingPosition[] = [];\n\t\tconst seen = new Set<string>();\n\t\tconst resolve = new Map<string, string>();\n\t\tfor (const term of members) {\n\t\t\tconst group = term.translationGroup ?? term.id;\n\t\t\tif (!seen.has(group)) {\n\t\t\t\tseen.add(group);\n\t\t\t\tsiblings.push({ group, position: term.sortOrder });\n\t\t\t}\n\t\t\t// Both spellings resolve to the group: a row id, and the group itself for\n\t\t\t// a caller that already holds one — including when its anchor row is gone\n\t\t\t// and no member's id matches it.\n\t\t\tresolve.set(group, group);\n\t\t\tresolve.set(term.id, group);\n\t\t}\n\n\t\tconst groups: string[] = [];\n\t\tfor (const id of input.ids) {\n\t\t\tconst group = resolve.get(id);\n\t\t\tif (!group) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"REORDER_MISMATCH\",\n\t\t\t\t\t\tmessage: `Term \"${id}\" is not in the sibling group being reordered`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (groups.includes(group)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"REORDER_MISMATCH\",\n\t\t\t\t\t\tmessage: `Term \"${id}\" is listed more than once`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tgroups.push(group);\n\t\t}\n\n\t\tawait repo.reorder(groups, siblings);\n\t\tinvalidateTermCache();\n\n\t\treturn { success: true, data: { reordered: true } };\n\t} catch (error) {\n\t\tconsole.error(\"[taxonomies] term reorder failed:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_REORDER_ERROR\", message: \"Failed to reorder terms\" },\n\t\t};\n\t}\n}\n\n/**\n * Validate a parent term reference for create/update.\n *\n * Returns `null` on success or a structured error message that callers\n * wrap in their own ApiResult.\n *\n * - `parentId === undefined` -> no-op (no parent change requested).\n * - `parentId === null` -> caller intends to detach; no-op here.\n * - parent must exist (FK exists -> term row not soft-deleted).\n * - parent must live in the same taxonomy.\n * - reject a parent in the term's own translation_group (self-parent),\n * including the create path: a translation inherits its source's group, so\n * `selfGroup` carries that prospective group when `termId` is absent.\n * - on update, walk up the parent chain to detect cycles.\n */\nasync function validateParentTerm(\n\trepo: TaxonomyRepository,\n\ttaxonomyName: string,\n\ttermId: string | undefined,\n\tparentId: string | null | undefined,\n\tselfGroup?: string | null,\n): Promise<{ code: \"VALIDATION_ERROR\"; message: string } | null> {\n\tif (parentId === undefined || parentId === null) return null;\n\n\tconst parent = await repo.findById(parentId);\n\tif (!parent) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' not found`,\n\t\t};\n\t}\n\tif (parent.name !== taxonomyName) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,\n\t\t};\n\t}\n\n\t// Parentage keys off translation_group (what `parent_id` persists), so the\n\t// self-parent and cycle checks compare groups, not row ids — picking a\n\t// sibling translation of the term as its parent is still self-parenting.\n\tconst parentGroup = parent.translationGroup ?? parent.id;\n\tlet termGroup: string | null = selfGroup ?? null;\n\tif (termId !== undefined) {\n\t\tconst term = await repo.findById(termId);\n\t\ttermGroup = term ? (term.translationGroup ?? term.id) : termId;\n\t}\n\tif (termGroup !== null && parentGroup === termGroup) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"A term cannot be its own parent\",\n\t\t};\n\t}\n\n\t// Walk up the parent chain. Two checks fold into one walk:\n\t// - Cycle detection (only on update — a non-existent term-being-\n\t// created can't be its own ancestor): if the walk revisits termId\n\t// the proposed parent makes the term a descendant of itself.\n\t// - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.\n\t// Runs on both create and update so a malicious or buggy caller\n\t// can't grow the tree without limit.\n\t//\n\t// The depth-exceeded error fires only when we hit the limit AND there\n\t// was still chain to walk — a legitimate chain of exactly MAX_DEPTH\n\t// ancestors exits with `cursor === null` and is accepted.\n\t// `parent_id` stores a translation_group, which always equals the anchor\n\t// row's id, so findById(cursor) still resolves each ancestor.\n\tconst MAX_DEPTH = 100;\n\tlet cursor: string | null = parent.parentId;\n\tlet steps = 0;\n\twhile (cursor !== null && steps < MAX_DEPTH) {\n\t\tif (termGroup !== null && cursor === termGroup) {\n\t\t\treturn {\n\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\tmessage: \"Cycle detected: cannot make a descendant the parent\",\n\t\t\t};\n\t\t}\n\t\tconst next = await repo.findById(cursor);\n\t\tif (!next) break;\n\t\tcursor = next.parentId;\n\t\tsteps++;\n\t}\n\tif (cursor !== null && steps >= MAX_DEPTH) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"Parent chain exceeds maximum depth\",\n\t\t};\n\t}\n\n\treturn null;\n}\n\n/**\n * Create a new term in a taxonomy\n */\nexport async function handleTermCreate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\tinput: {\n\t\tslug?: string;\n\t\tlabel: string;\n\t\tparentId?: string | null;\n\t\tdescription?: string;\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<TermResponse>> {\n\tlet attemptedSlug = input.slug;\n\ttry {\n\t\tconst locale = resolveConfiguredLocale(input.locale ?? getI18nConfig()?.defaultLocale ?? \"en\");\n\t\t// Taxonomy definitions are per-locale, but terms can exist in any locale\n\t\t// regardless of whether the def has been translated there. Look up the\n\t\t// def across all locales — we only care that it *exists*.\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\n\t\t// Coerce empty-string parentId to undefined (treat as \"no parent\").\n\t\tconst parentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Conflict check is scoped to locale (per-locale slugs are unique).\n\t\tconst existing =\n\t\t\tinput.slug === undefined ? null : await repo.findBySlug(taxonomyName, input.slug, locale);\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Term '${input.slug}' already exists in '${taxonomyName}' (${locale})`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// No locale re-pointing needed: `repo.create` persists the parent's\n\t\t// translation_group in `parent_id`, so a child stays nested under the\n\t\t// parent in every locale automatically — including parents translated\n\t\t// after the child was created.\n\n\t\t// A translation inherits its source's translation_group; pass that\n\t\t// prospective group so validateParentTerm can reject a parent in the same\n\t\t// group (cross-locale self-parent) even though the term doesn't exist yet.\n\t\tlet selfGroup: string | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tselfGroup = source ? (source.translationGroup ?? source.id) : null;\n\t\t}\n\n\t\t// Validate parentId: must exist AND belong to the same taxonomy.\n\t\t// (Cycle check is N/A on create — the term doesn't exist yet.)\n\t\tconst parentError = await validateParentTerm(\n\t\t\trepo,\n\t\t\ttaxonomyName,\n\t\t\tundefined,\n\t\t\tparentId,\n\t\t\tselfGroup,\n\t\t);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst create = (slug: string) =>\n\t\t\trepo.create({\n\t\t\t\tname: taxonomyName,\n\t\t\t\tslug,\n\t\t\t\tlabel: input.label,\n\t\t\t\tparentId: parentId ?? undefined,\n\t\t\t\tdata: input.description ? { description: input.description } : undefined,\n\t\t\t\tlocale,\n\t\t\t\ttranslationOf: input.translationOf,\n\t\t\t});\n\t\tlet term: Awaited<ReturnType<typeof create>> | undefined;\n\t\tlet lastSlugConflict: unknown;\n\t\tif (input.slug !== undefined) {\n\t\t\tterm = await create(input.slug);\n\t\t} else {\n\t\t\tfor (let attempt = 0; attempt < MAX_GENERATED_TERM_SLUG_ATTEMPTS; attempt++) {\n\t\t\t\tattemptedSlug = await repo.generateUniqueSlug(taxonomyName, input.label, locale);\n\t\t\t\ttry {\n\t\t\t\t\tterm = await create(attemptedSlug);\n\t\t\t\t\tbreak;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (!isTermSlugUniqueViolation(error)) throw error;\n\t\t\t\t\tlastSlugConflict = error;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!term) throw lastSlugConflict ?? new Error(\"Failed to create taxonomy term\");\n\n\t\tinvalidateTermCache();\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (isTermSlugUniqueViolation(error)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Term with slug '${attemptedSlug ?? \"(generated)\"}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_CREATE_ERROR\", message: \"Failed to create term\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a single term by slug\n */\nexport async function handleTermGet(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermGetResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Count matches public visibility (published or scheduled-and-due, not\n\t\t// soft-deleted) scoped to the def's declared collections. The def lookup\n\t\t// falls back for incomplete translations; a term with no def at all still\n\t\t// resolves with count 0.\n\t\tconst lookup = await requireTaxonomyDefWithFallback(db, taxonomyName, locale);\n\t\tconst counts = await fetchVisibleTermCounts(\n\t\t\tdb,\n\t\t\ttaxonomyName,\n\t\t\tlookup.success ? defCollections(lookup.def) : [],\n\t\t\tlocale ?? term.locale,\n\t\t);\n\t\tconst count = counts.get(term.translationGroup ?? term.id) ?? 0;\n\t\t// Children share this term's translation_group as their parent_id; scope\n\t\t// to the term's own locale so the response stays within one locale's tree.\n\t\tconst children = await repo.findChildren(term.id, term.locale);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tcount,\n\t\t\t\t\tchildren: children.map((c) => ({ id: c.id, slug: c.slug, label: c.label })),\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_GET_ERROR\", message: \"Failed to get term\" },\n\t\t};\n\t}\n}\n\n/** List every translation of a term (by id or translation_group). */\nexport async function handleTermTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TermTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Term not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tslug: r.slug,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_TRANSLATIONS_ERROR\", message: \"Failed to list term translations\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a term\n */\nexport async function handleTermUpdate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\tinput: { slug?: string; label?: string; parentId?: string | null; description?: string },\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Coerce empty-string slug/parentId to undefined (treat as \"no change\").\n\t\t// `null` parentId is a valid request meaning \"detach from parent\".\n\t\tconst newSlug = input.slug === \"\" || input.slug === undefined ? undefined : input.slug;\n\t\tconst newParentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Check if new slug conflicts (per-locale uniqueness).\n\t\tif (newSlug !== undefined && newSlug !== termSlug) {\n\t\t\tconst existing = await repo.findBySlug(taxonomyName, newSlug, locale);\n\t\t\tif (existing && existing.id !== term.id) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Validate parentId: existence, same-taxonomy, no self-parent, no cycle.\n\t\tconst parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst updated = await repo.update(term.id, {\n\t\t\tslug: newSlug,\n\t\t\tlabel: input.label,\n\t\t\tparentId: newParentId,\n\t\t\tdata: input.description !== undefined ? { description: input.description } : undefined,\n\t\t});\n\n\t\tinvalidateTermCache();\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: updated.id,\n\t\t\t\t\tname: updated.name,\n\t\t\t\t\tslug: updated.slug,\n\t\t\t\t\tlabel: updated.label,\n\t\t\t\t\tparentId: updated.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof updated.data?.description === \"string\" ? updated.data.description : undefined,\n\t\t\t\t\tlocale: updated.locale,\n\t\t\t\t\ttranslationGroup: updated.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a term\n */\nexport async function handleTermDelete(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Block deletion if the term's group still parents any child in any\n\t\t// locale — children store this group in parent_id, so removing the term\n\t\t// would orphan them (or null their parent_id via the self-FK).\n\t\tconst children = await repo.findChildren(term.id);\n\t\tif (children.length > 0) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"Cannot delete term with children. Delete children first.\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst deleted = await repo.delete(term.id);\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t\t};\n\t\t}\n\n\t\tinvalidateTermCache();\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"taxonomies-OHUIiIpI.mjs","names":[],"sources":["../src/api/handlers/taxonomies.ts"],"sourcesContent":["/**\n * Taxonomy and term CRUD handlers.\n *\n * i18n: terms and defs are per-locale. `(name, slug, locale)` is unique for\n * terms; `(name, locale)` for defs. Translations of the same term/def share a\n * `translation_group`. The content_taxonomies pivot stores translation_groups\n * so assignments span every locale of a post.\n */\n\nimport { sql, type Kysely, type Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { TaxonomyRepository, type SiblingPosition } from \"../../database/repositories/taxonomy.js\";\nimport { withTransaction } from \"../../database/transaction.js\";\nimport type { Database, TaxonomyDefTable } from \"../../database/types.js\";\nimport { getI18nConfig, resolveConfiguredLocale } from \"../../i18n/config.js\";\nimport { invalidateTaxonomyDefsCache, invalidateTermCache } from \"../../taxonomies/index.js\";\nimport { fetchVisibleTermCounts } from \"../../taxonomies/term-counts.js\";\nimport type { ApiResult } from \"../types.js\";\n\nconst NAME_PATTERN = /^[a-z][a-z0-9_]*$/;\nconst MAX_GENERATED_TERM_SLUG_ATTEMPTS = 16;\n\nfunction isTermSlugUniqueViolation(error: unknown): boolean {\n\tconst message = error instanceof Error ? error.message.toLowerCase() : \"\";\n\treturn (\n\t\t(message.includes(\"unique constraint failed\") || message.includes(\"duplicate key\")) &&\n\t\tmessage.includes(\"slug\")\n\t);\n}\n\n// ---------------------------------------------------------------------------\n// Response types\n// ---------------------------------------------------------------------------\n\nexport interface TaxonomyDef {\n\tid: string;\n\tname: string;\n\tlabel: string;\n\tlabelSingular?: string;\n\thierarchical: boolean;\n\tcollections: string[];\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TaxonomyListResponse {\n\ttaxonomies: TaxonomyDef[];\n}\n\nexport interface TaxonomyResponse {\n\ttaxonomy: TaxonomyDef;\n}\n\nexport interface TermData {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdescription?: string;\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TermWithCount extends TermData {\n\t/** Absent when the caller opted out of counts (`includeCounts: false`). */\n\tcount?: number;\n\tchildren: TermWithCount[];\n}\n\nexport interface TermListResponse {\n\tterms: TermWithCount[];\n}\n\nexport interface TermReorderResponse {\n\treordered: true;\n}\n\nexport interface TermResponse {\n\tterm: TermData;\n}\n\nexport interface TermGetResponse {\n\tterm: TermData & {\n\t\tcount: number;\n\t\tchildren: Array<{ id: string; slug: string; label: string }>;\n\t};\n}\n\nexport interface TermTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tslug: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\nexport interface TaxonomyDefTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tname: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build tree structure from flat terms\n */\nfunction buildTree(flatTerms: TermWithCount[], resolveByGroup = false): TermWithCount[] {\n\t// `parentId` holds the parent's translation_group, so resolve links by group.\n\t// Key by (locale, group): a child's parent lives in the same locale, and an\n\t// unfiltered list mixes locales whose translated siblings share a group —\n\t// keying by group alone would collide and misattach children across locales.\n\tconst byLocaleGroup = new Map<string, TermWithCount>();\n\tconst roots: TermWithCount[] = [];\n\tfor (const term of flatTerms) {\n\t\tconst key = resolveByGroup\n\t\t\t? (term.translationGroup ?? term.id)\n\t\t\t: `${term.locale}::${term.translationGroup ?? term.id}`;\n\t\tbyLocaleGroup.set(key, term);\n\t}\n\tfor (const term of flatTerms) {\n\t\tconst parent = term.parentId\n\t\t\t? byLocaleGroup.get(resolveByGroup ? term.parentId : `${term.locale}::${term.parentId}`)\n\t\t\t: undefined;\n\t\tif (parent) {\n\t\t\tparent.children.push(term);\n\t\t} else {\n\t\t\troots.push(term);\n\t\t}\n\t}\n\treturn roots;\n}\n\ntype TaxonomyDefLookup =\n\t| { success: true; def: Selectable<TaxonomyDefTable> }\n\t| { success: false; error: { code: string; message: string } };\n\nfunction taxonomyDefNotFound(name: string, locale?: string): TaxonomyDefLookup {\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"NOT_FOUND\",\n\t\t\tmessage: `Taxonomy '${name}' not found${locale !== undefined ? ` in locale '${locale}'` : \"\"}`,\n\t\t},\n\t};\n}\n\n/** Look up the exact definition addressed by a mutation. */\nasync function requireTaxonomyDef(\n\tdb: Kysely<Database>,\n\tname: string,\n\tlocale?: string,\n): Promise<TaxonomyDefLookup> {\n\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll().where(\"name\", \"=\", name);\n\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\tconst def = await query.orderBy(\"locale\", \"asc\").orderBy(\"id\", \"asc\").executeTakeFirst();\n\treturn def ? { success: true, def } : taxonomyDefNotFound(name, locale);\n}\n\n/**\n * Prefer the requested locale, then the configured default. Incomplete\n * translation groups fall back deterministically so their terms remain usable.\n */\nasync function requireTaxonomyDefWithFallback(\n\tdb: Kysely<Database>,\n\tname: string,\n\tlocale?: string,\n): Promise<TaxonomyDefLookup> {\n\tconst defs = await db\n\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t.selectAll()\n\t\t.where(\"name\", \"=\", name)\n\t\t.orderBy(\"locale\", \"asc\")\n\t\t.orderBy(\"id\", \"asc\")\n\t\t.execute();\n\tconst defaultLocale = getI18nConfig()?.defaultLocale;\n\tconst def =\n\t\t(locale ? defs.find((candidate) => candidate.locale === locale) : undefined) ??\n\t\t(defaultLocale ? defs.find((candidate) => candidate.locale === defaultLocale) : undefined) ??\n\t\tdefs[0];\n\treturn def ? { success: true, def } : taxonomyDefNotFound(name, locale);\n}\n\n/** The subset of `slugs` that still has a row in `_emdash_collections`. */\nasync function findExistingCollections(\n\tdb: Kysely<Database>,\n\tslugs: readonly string[],\n): Promise<Set<string>> {\n\tif (slugs.length === 0) return new Set();\n\tconst rows = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select(\"slug\")\n\t\t.where(\"slug\", \"in\", [...slugs])\n\t\t.execute();\n\treturn new Set(rows.map((r) => r.slug));\n}\n\n/**\n * Reject a `collections` list naming collections that don't exist. Shared by\n * create and update so both fail the same way instead of storing a reference\n * that reads back filtered out.\n */\nasync function validateCollections(\n\tdb: Kysely<Database>,\n\tcollections: readonly string[],\n): Promise<{ code: \"VALIDATION_ERROR\"; message: string } | null> {\n\tconst existing = await findExistingCollections(db, collections);\n\tconst invalid = collections.filter((slug) => !existing.has(slug));\n\tif (invalid.length === 0) return null;\n\treturn {\n\t\tcode: \"VALIDATION_ERROR\",\n\t\tmessage: `Unknown collection(s): ${invalid.join(\", \")}`,\n\t};\n}\n\n/**\n * Shape one definition row for a response, dropping collection references whose\n * collection is gone. Storage is untouched — re-creating the collection\n * re-links automatically, same as `handleTaxonomyList`.\n */\nasync function toTaxonomyResponse(\n\tdb: Kysely<Database>,\n\trow: Selectable<TaxonomyDefTable>,\n): Promise<TaxonomyResponse> {\n\tconst def = rowToDef(row);\n\tconst realCollections = await findExistingCollections(db, def.collections);\n\treturn {\n\t\ttaxonomy: {\n\t\t\t...def,\n\t\t\tcollections: def.collections.filter((slug) => realCollections.has(slug)),\n\t\t},\n\t};\n}\n\n/** Declared collections of a taxonomy def row (deduped, parsed from JSON). */\nfunction defCollections(def: Selectable<TaxonomyDefTable>): string[] {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- column stores a JSON string[] written by handleTaxonomyCreate\n\tconst parsed = def.collections ? (JSON.parse(def.collections) as string[]) : [];\n\treturn [...new Set(parsed)];\n}\n\nfunction rowToDef(row: Selectable<TaxonomyDefTable>): TaxonomyDef {\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tlabel: row.label,\n\t\tlabelSingular: row.label_singular ?? undefined,\n\t\thierarchical: row.hierarchical === 1,\n\t\tcollections: row.collections ? JSON.parse(row.collections) : [],\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Taxonomy definition handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all taxonomy definitions\n */\nexport async function handleTaxonomyList(\n\tdb: Kysely<Database>,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TaxonomyListResponse>> {\n\ttry {\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll();\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst [rows, collectionRows] = await Promise.all([\n\t\t\tquery.execute(),\n\t\t\tdb.selectFrom(\"_emdash_collections\").select(\"slug\").execute(),\n\t\t]);\n\n\t\t// Filter orphan collection references on read so the response stays\n\t\t// consistent with `schema_list_collections`. Storage is untouched —\n\t\t// re-creating the collection re-links automatically.\n\t\tconst realCollections = new Set(collectionRows.map((r) => r.slug));\n\n\t\tconst taxonomies: TaxonomyDef[] = rows.map((row) => {\n\t\t\tconst def = rowToDef(row);\n\t\t\treturn { ...def, collections: def.collections.filter((slug) => realCollections.has(slug)) };\n\t\t});\n\n\t\treturn { success: true, data: { taxonomies } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_LIST_ERROR\", message: \"Failed to list taxonomies\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a single taxonomy definition by name.\n *\n * Definitions are per-locale, so `locale` picks which one; without it the\n * lowest-locale match wins (same rule as `handleMenuGet`).\n */\nexport async function handleTaxonomyGet(\n\tdb: Kysely<Database>,\n\tname: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TaxonomyResponse>> {\n\ttry {\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst lookup = await requireTaxonomyDefWithFallback(db, name, locale);\n\t\tif (!lookup.success) return lookup;\n\n\t\treturn { success: true, data: await toTaxonomyResponse(db, lookup.def) };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_GET_ERROR\", message: \"Failed to get taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a new taxonomy definition\n */\nexport async function handleTaxonomyCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tname: string;\n\t\tlabel: string;\n\t\tlabelSingular?: string;\n\t\thierarchical?: boolean;\n\t\tcollections?: string[];\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<TaxonomyResponse>> {\n\ttry {\n\t\tconst locale = resolveConfiguredLocale(input.locale ?? getI18nConfig()?.defaultLocale ?? \"en\");\n\t\tif (!NAME_PATTERN.test(input.name)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Taxonomy name must start with a letter and contain only lowercase letters, numbers, and underscores\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst collections = [...new Set(input.collections ?? [])];\n\t\tconst collectionError = await validateCollections(db, collections);\n\t\tif (collectionError) {\n\t\t\treturn { success: false, error: collectionError };\n\t\t}\n\n\t\tlet translationGroup: string | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"id\", \"=\", input.translationOf)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Source taxonomy for translation not found\" },\n\t\t\t\t};\n\t\t\t}\n\t\t\ttranslationGroup = source.translation_group ?? source.id;\n\t\t}\n\n\t\t// Duplicate guard scoped to locale (so the same name can exist in ES\n\t\t// and EN).\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"name\", \"=\", input.name)\n\t\t\t.where(\"locale\", \"=\", locale)\n\t\t\t.executeTakeFirst();\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Taxonomy '${input.name}' already exists in locale '${locale}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst id = ulid();\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_taxonomy_defs\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tlabel: input.label,\n\t\t\t\tlabel_singular: input.labelSingular ?? null,\n\t\t\t\thierarchical: input.hierarchical ? 1 : 0,\n\t\t\t\tcollections: JSON.stringify(collections),\n\t\t\t\tlocale,\n\t\t\t\ttranslation_group: translationGroup ?? id,\n\t\t\t})\n\t\t\t.execute();\n\n\t\t// A new def changes which taxonomies exist — drop the isolate-wide\n\t\t// defs/names caches so this isolate reflects it immediately.\n\t\tinvalidateTaxonomyDefsCache();\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirstOrThrow();\n\t\treturn { success: true, data: { taxonomy: rowToDef(row) } };\n\t} catch (error) {\n\t\tif (error instanceof Error && error.message.includes(\"UNIQUE constraint failed\")) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"CONFLICT\", message: `Taxonomy '${input.name}' already exists` },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_CREATE_ERROR\", message: \"Failed to create taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a taxonomy definition.\n *\n * Writes the one row `(name, locale)` resolves to — every field here belongs to\n * a single locale's definition, so translating a taxonomy and then editing the\n * translation leaves the other locales alone. `name` and `locale` are immutable\n * (renaming would strand the terms, which are keyed on `name`).\n */\nexport async function handleTaxonomyUpdate(\n\tdb: Kysely<Database>,\n\tname: string,\n\tinput: {\n\t\tlabel?: string;\n\t\tlabelSingular?: string | null;\n\t\thierarchical?: boolean;\n\t\tcollections?: string[];\n\t\tlocale?: string;\n\t},\n): Promise<ApiResult<TaxonomyResponse>> {\n\ttry {\n\t\tconst locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;\n\t\tconst lookup = await requireTaxonomyDef(db, name, locale);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst collections = input.collections ? [...new Set(input.collections)] : undefined;\n\t\tif (collections !== undefined) {\n\t\t\tconst collectionError = await validateCollections(db, collections);\n\t\t\tif (collectionError) {\n\t\t\t\treturn { success: false, error: collectionError };\n\t\t\t}\n\t\t}\n\n\t\tconst updates: {\n\t\t\tlabel?: string;\n\t\t\tlabel_singular?: string | null;\n\t\t\thierarchical?: number;\n\t\t\tcollections?: string;\n\t\t} = {};\n\t\tif (input.label !== undefined) updates.label = input.label;\n\t\tif (input.labelSingular !== undefined) updates.label_singular = input.labelSingular;\n\t\tif (input.hierarchical !== undefined) updates.hierarchical = input.hierarchical ? 1 : 0;\n\t\tif (collections !== undefined) updates.collections = JSON.stringify(collections);\n\n\t\tif (Object.keys(updates).length > 0) {\n\t\t\tawait db\n\t\t\t\t.updateTable(\"_emdash_taxonomy_defs\")\n\t\t\t\t.set(updates)\n\t\t\t\t.where(\"id\", \"=\", lookup.def.id)\n\t\t\t\t.execute();\n\t\t\tinvalidateTaxonomyDefsCache();\n\t\t}\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", lookup.def.id)\n\t\t\t.executeTakeFirstOrThrow();\n\n\t\treturn { success: true, data: await toTaxonomyResponse(db, row) };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_UPDATE_ERROR\", message: \"Failed to update taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a taxonomy: every locale's definition, every term under that name in\n * every locale, and the term assignments those terms hold.\n *\n * There is no locale scope and no non-empty guard. Terms are keyed on `name`\n * rather than on a definition row, so leaving one locale's definition behind\n * would leave the taxonomy half-deleted — a definition with no terms, or terms\n * no definition describes.\n */\nexport async function handleTaxonomyDelete(\n\tdb: Kysely<Database>,\n\tname: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst lookup = await requireTaxonomyDef(db, name);\n\t\tif (!lookup.success) return lookup;\n\n\t\tawait withTransaction(db, async (trx) => {\n\t\t\t// `content_taxonomies.taxonomy_id` holds a term's translation_group, so\n\t\t\t// the assignments have to go before the terms they are matched against.\n\t\t\tawait trx\n\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t.where(\"taxonomy_id\", \"in\", (eb) =>\n\t\t\t\t\teb\n\t\t\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t\t\t.select(sql<string>`coalesce(translation_group, id)`.as(\"group\"))\n\t\t\t\t\t\t.where(\"name\", \"=\", name),\n\t\t\t\t)\n\t\t\t\t.execute();\n\t\t\tawait trx.deleteFrom(\"taxonomies\").where(\"name\", \"=\", name).execute();\n\t\t\tawait trx.deleteFrom(\"_emdash_taxonomy_defs\").where(\"name\", \"=\", name).execute();\n\t\t});\n\n\t\t// Covers the term caches too — see `invalidateTaxonomyDefsCache`.\n\t\tinvalidateTaxonomyDefsCache();\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch (error) {\n\t\tconsole.error(\"[taxonomies] delete failed:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_DELETE_ERROR\", message: \"Failed to delete taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * List every locale translation of a taxonomy def (by id or translation_group).\n */\nexport async function handleTaxonomyDefTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TaxonomyDefTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Taxonomy not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tname: r.name,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TAXONOMY_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list taxonomy translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Term handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all terms for a taxonomy (returns tree for hierarchical taxonomies)\n */\nexport async function handleTermList(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\toptions: { locale?: string; includeCounts?: boolean; resolveFallback?: boolean } = {},\n): Promise<ApiResult<TermListResponse>> {\n\ttry {\n\t\tif (options.resolveFallback && !options.locale) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"A locale is required when resolving taxonomy fallbacks\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\t// Definitions are per-locale but terms aren't bound to the def's locale —\n\t\t// use the active definition for its collection scope.\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst lookup = await requireTaxonomyDefWithFallback(db, taxonomyName, locale);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst terms =\n\t\t\toptions.resolveFallback && locale\n\t\t\t\t? await repo.findByNameResolved(\n\t\t\t\t\t\ttaxonomyName,\n\t\t\t\t\t\tlocale,\n\t\t\t\t\t\tgetI18nConfig()?.defaultLocale ?? \"en\",\n\t\t\t\t\t)\n\t\t\t\t: await repo.findByName(taxonomyName, { locale });\n\n\t\t// Counts match what visitors see on the public site: published (or\n\t\t// scheduled-and-due) entries that aren't soft-deleted, scoped to the\n\t\t// taxonomy's declared collections — one query for the whole list.\n\t\t// content_taxonomies.taxonomy_id stores the translation_group, so we\n\t\t// look up by group and map back to each term's id.\n\t\tconst includeCounts = options.includeCounts ?? true;\n\t\tconst countsByGroup = includeCounts\n\t\t\t? await fetchVisibleTermCounts(db, taxonomyName, defCollections(lookup.def), locale)\n\t\t\t: undefined;\n\n\t\tconst termData: TermWithCount[] = terms.map((term) => ({\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tparentId: term.parentId,\n\t\t\tdescription: typeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\tchildren: [],\n\t\t\t...(countsByGroup && { count: countsByGroup.get(term.translationGroup ?? term.id) ?? 0 }),\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translationGroup,\n\t\t}));\n\n\t\tconst isHierarchical = lookup.def.hierarchical === 1;\n\t\tconst result = isHierarchical\n\t\t\t? buildTree(termData, options.resolveFallback && !!locale)\n\t\t\t: termData;\n\t\treturn { success: true, data: { terms: result } };\n\t} catch (error) {\n\t\tconsole.error(\"[taxonomies] term list failed:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_LIST_ERROR\", message: \"Failed to list terms\" },\n\t\t};\n\t}\n}\n\n/**\n * Set the manual order of one sibling group.\n *\n * `ids` names the terms to move, in the desired order; each is a row id or a\n * translation_group and resolves to the latter, because a position belongs to a\n * term rather than to one of its locales. Reordering never reparents — moving a\n * term under a different parent is a term update.\n *\n * The group is `(taxonomy, parentId)` and has no locale: `parentId` is the\n * parent's translation_group (a row id is accepted and resolved), and `null`\n * selects the top level — which for a flat taxonomy is every term.\n *\n * `ids` may be a *subset* of the group, and the listed terms are permuted\n * within the positions they already occupy. A locale only renders the terms\n * translated into it, so a caller often cannot name every member; a member left\n * out keeps its place, which is also what makes a stale list harmless.\n */\nexport async function handleTermReorder(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\tinput: { parentId?: string | null; ids: string[] },\n): Promise<ApiResult<TermReorderResponse>> {\n\ttry {\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\n\t\tlet parentId = input.parentId ?? null;\n\t\tif (parentId !== null) {\n\t\t\t// Children store the parent's translation_group, so a caller passing a\n\t\t\t// row id still addresses the right group. A parent whose anchor row is\n\t\t\t// gone keeps the value as given (see TaxonomyRepository.resolveParentRef).\n\t\t\tconst parent = await repo.findById(parentId);\n\t\t\tif (parent) parentId = parent.translationGroup ?? parent.id;\n\t\t}\n\n\t\t// Every locale's rows: membership is locale-agnostic, and a term the\n\t\t// caller can't see still holds its slot.\n\t\t// The order carries into how ties are resolved, so it must be stable\n\t\t// across calls; with locales interleaved a tie falls to whichever\n\t\t// locale's label sorts first, not to the order the caller rendered.\n\t\tconst members = await repo.findByName(taxonomyName, { parentId });\n\t\tconst siblings: SiblingPosition[] = [];\n\t\tconst seen = new Set<string>();\n\t\tconst resolve = new Map<string, string>();\n\t\tfor (const term of members) {\n\t\t\tconst group = term.translationGroup ?? term.id;\n\t\t\tif (!seen.has(group)) {\n\t\t\t\tseen.add(group);\n\t\t\t\tsiblings.push({ group, position: term.sortOrder });\n\t\t\t}\n\t\t\t// Both spellings resolve to the group: a row id, and the group itself for\n\t\t\t// a caller that already holds one — including when its anchor row is gone\n\t\t\t// and no member's id matches it.\n\t\t\tresolve.set(group, group);\n\t\t\tresolve.set(term.id, group);\n\t\t}\n\n\t\tconst groups: string[] = [];\n\t\tfor (const id of input.ids) {\n\t\t\tconst group = resolve.get(id);\n\t\t\tif (!group) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"REORDER_MISMATCH\",\n\t\t\t\t\t\tmessage: `Term \"${id}\" is not in the sibling group being reordered`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (groups.includes(group)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"REORDER_MISMATCH\",\n\t\t\t\t\t\tmessage: `Term \"${id}\" is listed more than once`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tgroups.push(group);\n\t\t}\n\n\t\tawait repo.reorder(groups, siblings);\n\t\tinvalidateTermCache();\n\n\t\treturn { success: true, data: { reordered: true } };\n\t} catch (error) {\n\t\tconsole.error(\"[taxonomies] term reorder failed:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_REORDER_ERROR\", message: \"Failed to reorder terms\" },\n\t\t};\n\t}\n}\n\n/**\n * Validate a parent term reference for create/update.\n *\n * Returns `null` on success or a structured error message that callers\n * wrap in their own ApiResult.\n *\n * - `parentId === undefined` -> no-op (no parent change requested).\n * - `parentId === null` -> caller intends to detach; no-op here.\n * - parent must exist (FK exists -> term row not soft-deleted).\n * - parent must live in the same taxonomy.\n * - reject a parent in the term's own translation_group (self-parent),\n * including the create path: a translation inherits its source's group, so\n * `selfGroup` carries that prospective group when `termId` is absent.\n * - on update, walk up the parent chain to detect cycles.\n */\nasync function validateParentTerm(\n\trepo: TaxonomyRepository,\n\ttaxonomyName: string,\n\ttermId: string | undefined,\n\tparentId: string | null | undefined,\n\tselfGroup?: string | null,\n): Promise<{ code: \"VALIDATION_ERROR\"; message: string } | null> {\n\tif (parentId === undefined || parentId === null) return null;\n\n\tconst parent = await repo.findById(parentId);\n\tif (!parent) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' not found`,\n\t\t};\n\t}\n\tif (parent.name !== taxonomyName) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,\n\t\t};\n\t}\n\n\t// Parentage keys off translation_group (what `parent_id` persists), so the\n\t// self-parent and cycle checks compare groups, not row ids — picking a\n\t// sibling translation of the term as its parent is still self-parenting.\n\tconst parentGroup = parent.translationGroup ?? parent.id;\n\tlet termGroup: string | null = selfGroup ?? null;\n\tif (termId !== undefined) {\n\t\tconst term = await repo.findById(termId);\n\t\ttermGroup = term ? (term.translationGroup ?? term.id) : termId;\n\t}\n\tif (termGroup !== null && parentGroup === termGroup) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"A term cannot be its own parent\",\n\t\t};\n\t}\n\n\t// Walk up the parent chain. Two checks fold into one walk:\n\t// - Cycle detection (only on update — a non-existent term-being-\n\t// created can't be its own ancestor): if the walk revisits termId\n\t// the proposed parent makes the term a descendant of itself.\n\t// - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.\n\t// Runs on both create and update so a malicious or buggy caller\n\t// can't grow the tree without limit.\n\t//\n\t// The depth-exceeded error fires only when we hit the limit AND there\n\t// was still chain to walk — a legitimate chain of exactly MAX_DEPTH\n\t// ancestors exits with `cursor === null` and is accepted.\n\t// `parent_id` stores a translation_group, which always equals the anchor\n\t// row's id, so findById(cursor) still resolves each ancestor.\n\tconst MAX_DEPTH = 100;\n\tlet cursor: string | null = parent.parentId;\n\tlet steps = 0;\n\twhile (cursor !== null && steps < MAX_DEPTH) {\n\t\tif (termGroup !== null && cursor === termGroup) {\n\t\t\treturn {\n\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\tmessage: \"Cycle detected: cannot make a descendant the parent\",\n\t\t\t};\n\t\t}\n\t\tconst next = await repo.findById(cursor);\n\t\tif (!next) break;\n\t\tcursor = next.parentId;\n\t\tsteps++;\n\t}\n\tif (cursor !== null && steps >= MAX_DEPTH) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"Parent chain exceeds maximum depth\",\n\t\t};\n\t}\n\n\treturn null;\n}\n\n/**\n * Create a new term in a taxonomy\n */\nexport async function handleTermCreate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\tinput: {\n\t\tslug?: string;\n\t\tlabel: string;\n\t\tparentId?: string | null;\n\t\tdescription?: string;\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<TermResponse>> {\n\tlet attemptedSlug = input.slug;\n\ttry {\n\t\tconst locale = resolveConfiguredLocale(input.locale ?? getI18nConfig()?.defaultLocale ?? \"en\");\n\t\t// Taxonomy definitions are per-locale, but terms can exist in any locale\n\t\t// regardless of whether the def has been translated there. Look up the\n\t\t// def across all locales — we only care that it *exists*.\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\n\t\t// Coerce empty-string parentId to undefined (treat as \"no parent\").\n\t\tconst parentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Conflict check is scoped to locale (per-locale slugs are unique).\n\t\tconst existing =\n\t\t\tinput.slug === undefined ? null : await repo.findBySlug(taxonomyName, input.slug, locale);\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Term '${input.slug}' already exists in '${taxonomyName}' (${locale})`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// No locale re-pointing needed: `repo.create` persists the parent's\n\t\t// translation_group in `parent_id`, so a child stays nested under the\n\t\t// parent in every locale automatically — including parents translated\n\t\t// after the child was created.\n\n\t\t// A translation inherits its source's translation_group; pass that\n\t\t// prospective group so validateParentTerm can reject a parent in the same\n\t\t// group (cross-locale self-parent) even though the term doesn't exist yet.\n\t\tlet selfGroup: string | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tselfGroup = source ? (source.translationGroup ?? source.id) : null;\n\t\t}\n\n\t\t// Validate parentId: must exist AND belong to the same taxonomy.\n\t\t// (Cycle check is N/A on create — the term doesn't exist yet.)\n\t\tconst parentError = await validateParentTerm(\n\t\t\trepo,\n\t\t\ttaxonomyName,\n\t\t\tundefined,\n\t\t\tparentId,\n\t\t\tselfGroup,\n\t\t);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst create = (slug: string) =>\n\t\t\trepo.create({\n\t\t\t\tname: taxonomyName,\n\t\t\t\tslug,\n\t\t\t\tlabel: input.label,\n\t\t\t\tparentId: parentId ?? undefined,\n\t\t\t\tdata: input.description ? { description: input.description } : undefined,\n\t\t\t\tlocale,\n\t\t\t\ttranslationOf: input.translationOf,\n\t\t\t});\n\t\tlet term: Awaited<ReturnType<typeof create>> | undefined;\n\t\tlet lastSlugConflict: unknown;\n\t\tif (input.slug !== undefined) {\n\t\t\tterm = await create(input.slug);\n\t\t} else {\n\t\t\tfor (let attempt = 0; attempt < MAX_GENERATED_TERM_SLUG_ATTEMPTS; attempt++) {\n\t\t\t\tattemptedSlug = await repo.generateUniqueSlug(taxonomyName, input.label, locale);\n\t\t\t\ttry {\n\t\t\t\t\tterm = await create(attemptedSlug);\n\t\t\t\t\tbreak;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (!isTermSlugUniqueViolation(error)) throw error;\n\t\t\t\t\tlastSlugConflict = error;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!term) throw lastSlugConflict ?? new Error(\"Failed to create taxonomy term\");\n\n\t\tinvalidateTermCache();\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (isTermSlugUniqueViolation(error)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Term with slug '${attemptedSlug ?? \"(generated)\"}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_CREATE_ERROR\", message: \"Failed to create term\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a single term by slug\n */\nexport async function handleTermGet(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermGetResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Count matches public visibility (published or scheduled-and-due, not\n\t\t// soft-deleted) scoped to the def's declared collections. The def lookup\n\t\t// falls back for incomplete translations; a term with no def at all still\n\t\t// resolves with count 0.\n\t\tconst lookup = await requireTaxonomyDefWithFallback(db, taxonomyName, locale);\n\t\tconst counts = await fetchVisibleTermCounts(\n\t\t\tdb,\n\t\t\ttaxonomyName,\n\t\t\tlookup.success ? defCollections(lookup.def) : [],\n\t\t\tlocale ?? term.locale,\n\t\t);\n\t\tconst count = counts.get(term.translationGroup ?? term.id) ?? 0;\n\t\t// Children share this term's translation_group as their parent_id; scope\n\t\t// to the term's own locale so the response stays within one locale's tree.\n\t\tconst children = await repo.findChildren(term.id, term.locale);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tcount,\n\t\t\t\t\tchildren: children.map((c) => ({ id: c.id, slug: c.slug, label: c.label })),\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_GET_ERROR\", message: \"Failed to get term\" },\n\t\t};\n\t}\n}\n\n/** List every translation of a term (by id or translation_group). */\nexport async function handleTermTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TermTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Term not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tslug: r.slug,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_TRANSLATIONS_ERROR\", message: \"Failed to list term translations\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a term\n */\nexport async function handleTermUpdate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\tinput: { slug?: string; label?: string; parentId?: string | null; description?: string },\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Coerce empty-string slug/parentId to undefined (treat as \"no change\").\n\t\t// `null` parentId is a valid request meaning \"detach from parent\".\n\t\tconst newSlug = input.slug === \"\" || input.slug === undefined ? undefined : input.slug;\n\t\tconst newParentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Check if new slug conflicts (per-locale uniqueness).\n\t\tif (newSlug !== undefined && newSlug !== termSlug) {\n\t\t\tconst existing = await repo.findBySlug(taxonomyName, newSlug, locale);\n\t\t\tif (existing && existing.id !== term.id) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Validate parentId: existence, same-taxonomy, no self-parent, no cycle.\n\t\tconst parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst updated = await repo.update(term.id, {\n\t\t\tslug: newSlug,\n\t\t\tlabel: input.label,\n\t\t\tparentId: newParentId,\n\t\t\tdata: input.description !== undefined ? { description: input.description } : undefined,\n\t\t});\n\n\t\tinvalidateTermCache();\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: updated.id,\n\t\t\t\t\tname: updated.name,\n\t\t\t\t\tslug: updated.slug,\n\t\t\t\t\tlabel: updated.label,\n\t\t\t\t\tparentId: updated.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof updated.data?.description === \"string\" ? updated.data.description : undefined,\n\t\t\t\t\tlocale: updated.locale,\n\t\t\t\t\ttranslationGroup: updated.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a term\n */\nexport async function handleTermDelete(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined;\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Block deletion if the term's group still parents any child in any\n\t\t// locale — children store this group in parent_id, so removing the term\n\t\t// would orphan them (or null their parent_id via the self-FK).\n\t\tconst children = await repo.findChildren(term.id);\n\t\tif (children.length > 0) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"Cannot delete term with children. Delete children first.\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst deleted = await repo.delete(term.id);\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t\t};\n\t\t}\n\n\t\tinvalidateTermCache();\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t};\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoBA,MAAM,eAAe;AACrB,MAAM,mCAAmC;AAEzC,SAAS,0BAA0B,OAAyB;CAC3D,MAAM,UAAU,iBAAiB,QAAQ,MAAM,QAAQ,aAAa,GAAG;AACvE,SACE,QAAQ,SAAS,2BAA2B,IAAI,QAAQ,SAAS,gBAAgB,KAClF,QAAQ,SAAS,OAAO;;;;;AA0F1B,SAAS,UAAU,WAA4B,iBAAiB,OAAwB;CAKvF,MAAM,gCAAgB,IAAI,KAA4B;CACtD,MAAM,QAAyB,EAAE;AACjC,MAAK,MAAM,QAAQ,WAAW;EAC7B,MAAM,MAAM,iBACR,KAAK,oBAAoB,KAAK,KAC/B,GAAG,KAAK,OAAO,IAAI,KAAK,oBAAoB,KAAK;AACpD,gBAAc,IAAI,KAAK,KAAK;;AAE7B,MAAK,MAAM,QAAQ,WAAW;EAC7B,MAAM,SAAS,KAAK,WACjB,cAAc,IAAI,iBAAiB,KAAK,WAAW,GAAG,KAAK,OAAO,IAAI,KAAK,WAAW,GACtF;AACH,MAAI,OACH,QAAO,SAAS,KAAK,KAAK;MAE1B,OAAM,KAAK,KAAK;;AAGlB,QAAO;;AAOR,SAAS,oBAAoB,MAAc,QAAoC;AAC9E,QAAO;EACN,SAAS;EACT,OAAO;GACN,MAAM;GACN,SAAS,aAAa,KAAK,aAAa,WAAW,SAAY,eAAe,OAAO,KAAK;GAC1F;EACD;;;AAIF,eAAe,mBACd,IACA,MACA,QAC6B;CAC7B,IAAI,QAAQ,GAAG,WAAW,wBAAwB,CAAC,WAAW,CAAC,MAAM,QAAQ,KAAK,KAAK;AACvF,KAAI,WAAW,OAAW,SAAQ,MAAM,MAAM,UAAU,KAAK,OAAO;CACpE,MAAM,MAAM,MAAM,MAAM,QAAQ,UAAU,MAAM,CAAC,QAAQ,MAAM,MAAM,CAAC,kBAAkB;AACxF,QAAO,MAAM;EAAE,SAAS;EAAM;EAAK,GAAG,oBAAoB,MAAM,OAAO;;;;;;AAOxE,eAAe,+BACd,IACA,MACA,QAC6B;CAC7B,MAAM,OAAO,MAAM,GACjB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,QAAQ,KAAK,KAAK,CACxB,QAAQ,UAAU,MAAM,CACxB,QAAQ,MAAM,MAAM,CACpB,SAAS;CACX,MAAM,gBAAgB,eAAe,EAAE;CACvC,MAAM,OACJ,SAAS,KAAK,MAAM,cAAc,UAAU,WAAW,OAAO,GAAG,YACjE,gBAAgB,KAAK,MAAM,cAAc,UAAU,WAAW,cAAc,GAAG,WAChF,KAAK;AACN,QAAO,MAAM;EAAE,SAAS;EAAM;EAAK,GAAG,oBAAoB,MAAM,OAAO;;;AAIxE,eAAe,wBACd,IACA,OACuB;AACvB,KAAI,MAAM,WAAW,EAAG,wBAAO,IAAI,KAAK;CACxC,MAAM,OAAO,MAAM,GACjB,WAAW,sBAAsB,CACjC,OAAO,OAAO,CACd,MAAM,QAAQ,MAAM,CAAC,GAAG,MAAM,CAAC,CAC/B,SAAS;AACX,QAAO,IAAI,IAAI,KAAK,KAAK,MAAM,EAAE,KAAK,CAAC;;;;;;;AAQxC,eAAe,oBACd,IACA,aACgE;CAChE,MAAM,WAAW,MAAM,wBAAwB,IAAI,YAAY;CAC/D,MAAM,UAAU,YAAY,QAAQ,SAAS,CAAC,SAAS,IAAI,KAAK,CAAC;AACjE,KAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,QAAO;EACN,MAAM;EACN,SAAS,0BAA0B,QAAQ,KAAK,KAAK;EACrD;;;;;;;AAQF,eAAe,mBACd,IACA,KAC4B;CAC5B,MAAM,MAAM,SAAS,IAAI;CACzB,MAAM,kBAAkB,MAAM,wBAAwB,IAAI,IAAI,YAAY;AAC1E,QAAO,EACN,UAAU;EACT,GAAG;EACH,aAAa,IAAI,YAAY,QAAQ,SAAS,gBAAgB,IAAI,KAAK,CAAC;EACxE,EACD;;;AAIF,SAAS,eAAe,KAA6C;CAEpE,MAAM,SAAS,IAAI,cAAe,KAAK,MAAM,IAAI,YAAY,GAAgB,EAAE;AAC/E,QAAO,CAAC,GAAG,IAAI,IAAI,OAAO,CAAC;;AAG5B,SAAS,SAAS,KAAgD;AACjE,QAAO;EACN,IAAI,IAAI;EACR,MAAM,IAAI;EACV,OAAO,IAAI;EACX,eAAe,IAAI,kBAAkB;EACrC,cAAc,IAAI,iBAAiB;EACnC,aAAa,IAAI,cAAc,KAAK,MAAM,IAAI,YAAY,GAAG,EAAE;EAC/D,QAAQ,IAAI;EACZ,kBAAkB,IAAI;EACtB;;;;;AAUF,eAAsB,mBACrB,IACA,UAA+B,EAAE,EACU;AAC3C,KAAI;EACH,MAAM,SAAS,QAAQ,SAAS,wBAAwB,QAAQ,OAAO,GAAG;EAC1E,IAAI,QAAQ,GAAG,WAAW,wBAAwB,CAAC,WAAW;AAC9D,MAAI,WAAW,OAAW,SAAQ,MAAM,MAAM,UAAU,KAAK,OAAO;EACpE,MAAM,CAAC,MAAM,kBAAkB,MAAM,QAAQ,IAAI,CAChD,MAAM,SAAS,EACf,GAAG,WAAW,sBAAsB,CAAC,OAAO,OAAO,CAAC,SAAS,CAC7D,CAAC;EAKF,MAAM,kBAAkB,IAAI,IAAI,eAAe,KAAK,MAAM,EAAE,KAAK,CAAC;AAOlE,SAAO;GAAE,SAAS;GAAM,MAAM,EAAE,YALE,KAAK,KAAK,QAAQ;IACnD,MAAM,MAAM,SAAS,IAAI;AACzB,WAAO;KAAE,GAAG;KAAK,aAAa,IAAI,YAAY,QAAQ,SAAS,gBAAgB,IAAI,KAAK,CAAC;KAAE;KAC1F,EAE0C;GAAE;SACvC;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAuB,SAAS;IAA6B;GAC5E;;;;;;;;;AAUH,eAAsB,kBACrB,IACA,MACA,UAA+B,EAAE,EACM;AACvC,KAAI;EAEH,MAAM,SAAS,MAAM,+BAA+B,IAAI,MADzC,QAAQ,SAAS,wBAAwB,QAAQ,OAAO,GAAG,OACL;AACrE,MAAI,CAAC,OAAO,QAAS,QAAO;AAE5B,SAAO;GAAE,SAAS;GAAM,MAAM,MAAM,mBAAmB,IAAI,OAAO,IAAI;GAAE;SACjE;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAsB,SAAS;IAA0B;GACxE;;;;;;AAOH,eAAsB,qBACrB,IACA,OASuC;AACvC,KAAI;EACH,MAAM,SAAS,wBAAwB,MAAM,UAAU,eAAe,EAAE,iBAAiB,KAAK;AAC9F,MAAI,CAAC,aAAa,KAAK,MAAM,KAAK,CACjC,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SACC;IACD;GACD;EAGF,MAAM,cAAc,CAAC,GAAG,IAAI,IAAI,MAAM,eAAe,EAAE,CAAC,CAAC;EACzD,MAAM,kBAAkB,MAAM,oBAAoB,IAAI,YAAY;AAClE,MAAI,gBACH,QAAO;GAAE,SAAS;GAAO,OAAO;GAAiB;EAGlD,IAAI,mBAAkC;AACtC,MAAI,MAAM,eAAe;GACxB,MAAM,SAAS,MAAM,GACnB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,MAAM,KAAK,MAAM,cAAc,CACrC,kBAAkB;AACpB,OAAI,CAAC,OACJ,QAAO;IACN,SAAS;IACT,OAAO;KAAE,MAAM;KAAa,SAAS;KAA6C;IAClF;AAEF,sBAAmB,OAAO,qBAAqB,OAAO;;AAWvD,MANiB,MAAM,GACrB,WAAW,wBAAwB,CACnC,OAAO,KAAK,CACZ,MAAM,QAAQ,KAAK,MAAM,KAAK,CAC9B,MAAM,UAAU,KAAK,OAAO,CAC5B,kBAAkB,CAEnB,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS,aAAa,MAAM,KAAK,8BAA8B,OAAO;IACtE;GACD;EAGF,MAAM,KAAK,MAAM;AACjB,QAAM,GACJ,WAAW,wBAAwB,CACnC,OAAO;GACP;GACA,MAAM,MAAM;GACZ,OAAO,MAAM;GACb,gBAAgB,MAAM,iBAAiB;GACvC,cAAc,MAAM,eAAe,IAAI;GACvC,aAAa,KAAK,UAAU,YAAY;GACxC;GACA,mBAAmB,oBAAoB;GACvC,CAAC,CACD,SAAS;AAIX,+BAA6B;AAO7B,SAAO;GAAE,SAAS;GAAM,MAAM,EAAE,UAAU,SAL9B,MAAM,GAChB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,MAAM,KAAK,GAAG,CACpB,yBAAyB,CAC4B,EAAE;GAAE;UACnD,OAAO;AACf,MAAI,iBAAiB,SAAS,MAAM,QAAQ,SAAS,2BAA2B,CAC/E,QAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAY,SAAS,aAAa,MAAM,KAAK;IAAmB;GAC/E;AAEF,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAyB,SAAS;IAA6B;GAC9E;;;;;;;;;;;AAYH,eAAsB,qBACrB,IACA,MACA,OAOuC;AACvC,KAAI;EAEH,MAAM,SAAS,MAAM,mBAAmB,IAAI,MAD7B,MAAM,SAAS,wBAAwB,MAAM,OAAO,GAAG,OACb;AACzD,MAAI,CAAC,OAAO,QAAS,QAAO;EAE5B,MAAM,cAAc,MAAM,cAAc,CAAC,GAAG,IAAI,IAAI,MAAM,YAAY,CAAC,GAAG;AAC1E,MAAI,gBAAgB,QAAW;GAC9B,MAAM,kBAAkB,MAAM,oBAAoB,IAAI,YAAY;AAClE,OAAI,gBACH,QAAO;IAAE,SAAS;IAAO,OAAO;IAAiB;;EAInD,MAAM,UAKF,EAAE;AACN,MAAI,MAAM,UAAU,OAAW,SAAQ,QAAQ,MAAM;AACrD,MAAI,MAAM,kBAAkB,OAAW,SAAQ,iBAAiB,MAAM;AACtE,MAAI,MAAM,iBAAiB,OAAW,SAAQ,eAAe,MAAM,eAAe,IAAI;AACtF,MAAI,gBAAgB,OAAW,SAAQ,cAAc,KAAK,UAAU,YAAY;AAEhF,MAAI,OAAO,KAAK,QAAQ,CAAC,SAAS,GAAG;AACpC,SAAM,GACJ,YAAY,wBAAwB,CACpC,IAAI,QAAQ,CACZ,MAAM,MAAM,KAAK,OAAO,IAAI,GAAG,CAC/B,SAAS;AACX,gCAA6B;;AAS9B,SAAO;GAAE,SAAS;GAAM,MAAM,MAAM,mBAAmB,IAN3C,MAAM,GAChB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,MAAM,KAAK,OAAO,IAAI,GAAG,CAC/B,yBAAyB,CAEoC;GAAE;SAC1D;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAyB,SAAS;IAA6B;GAC9E;;;;;;;;;;;;AAaH,eAAsB,qBACrB,IACA,MACwC;AACxC,KAAI;EACH,MAAM,SAAS,MAAM,mBAAmB,IAAI,KAAK;AACjD,MAAI,CAAC,OAAO,QAAS,QAAO;AAE5B,QAAM,gBAAgB,IAAI,OAAO,QAAQ;AAGxC,SAAM,IACJ,WAAW,qBAAqB,CAChC,MAAM,eAAe,OAAO,OAC5B,GACE,WAAW,aAAa,CACxB,OAAO,GAAW,kCAAkC,GAAG,QAAQ,CAAC,CAChE,MAAM,QAAQ,KAAK,KAAK,CAC1B,CACA,SAAS;AACX,SAAM,IAAI,WAAW,aAAa,CAAC,MAAM,QAAQ,KAAK,KAAK,CAAC,SAAS;AACrE,SAAM,IAAI,WAAW,wBAAwB,CAAC,MAAM,QAAQ,KAAK,KAAK,CAAC,SAAS;IAC/E;AAGF,+BAA6B;AAE7B,SAAO;GAAE,SAAS;GAAM,MAAM,EAAE,SAAS,MAAM;GAAE;UACzC,OAAO;AACf,UAAQ,MAAM,+BAA+B,MAAM;AACnD,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAyB,SAAS;IAA6B;GAC9E;;;;;;AAOH,eAAsB,8BACrB,IACA,WACsD;AACtD,KAAI;EACH,MAAM,SAAS,MAAM,GACnB,WAAW,wBAAwB,CACnC,WAAW,CACX,OAAO,OAAO,GAAG,GAAG,CAAC,GAAG,MAAM,KAAK,UAAU,EAAE,GAAG,qBAAqB,KAAK,UAAU,CAAC,CAAC,CAAC,CACzF,kBAAkB;AACpB,MAAI,CAAC,OACJ,QAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAa,SAAS;IAAsB;GAC3D;EAEF,MAAM,QAAQ,OAAO,qBAAqB,OAAO;AAOjD,SAAO;GACN,SAAS;GACT,MAAM;IACL,kBAAkB;IAClB,eAVW,MAAM,GACjB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,qBAAqB,KAAK,MAAM,CACtC,QAAQ,UAAU,MAAM,CACxB,SAAS,EAKU,KAAK,OAAO;KAC9B,IAAI,EAAE;KACN,MAAM,EAAE;KACR,OAAO,EAAE;KACT,QAAQ,EAAE;KACV,EAAE;IACH;GACD;SACM;AACP,SAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS;IACT;GACD;;;;;;AAWH,eAAsB,eACrB,IACA,cACA,UAAmF,EAAE,EAC9C;AACvC,KAAI;AACH,MAAI,QAAQ,mBAAmB,CAAC,QAAQ,OACvC,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS;IACT;GACD;EAIF,MAAM,SAAS,QAAQ,SAAS,wBAAwB,QAAQ,OAAO,GAAG;EAC1E,MAAM,SAAS,MAAM,+BAA+B,IAAI,cAAc,OAAO;AAC7E,MAAI,CAAC,OAAO,QAAS,QAAO;EAE5B,MAAM,OAAO,IAAI,mBAAmB,GAAG;EACvC,MAAM,QACL,QAAQ,mBAAmB,SACxB,MAAM,KAAK,mBACX,cACA,QACA,eAAe,EAAE,iBAAiB,KAClC,GACA,MAAM,KAAK,WAAW,cAAc,EAAE,QAAQ,CAAC;EAQnD,MAAM,gBADgB,QAAQ,iBAAiB,OAE5C,MAAM,uBAAuB,IAAI,cAAc,eAAe,OAAO,IAAI,EAAE,OAAO,GAClF;EAEH,MAAM,WAA4B,MAAM,KAAK,UAAU;GACtD,IAAI,KAAK;GACT,MAAM,KAAK;GACX,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,UAAU,KAAK;GACf,aAAa,OAAO,KAAK,MAAM,gBAAgB,WAAW,KAAK,KAAK,cAAc;GAClF,UAAU,EAAE;GACZ,GAAI,iBAAiB,EAAE,OAAO,cAAc,IAAI,KAAK,oBAAoB,KAAK,GAAG,IAAI,GAAG;GACxF,QAAQ,KAAK;GACb,kBAAkB,KAAK;GACvB,EAAE;AAMH,SAAO;GAAE,SAAS;GAAM,MAAM,EAAE,OAJT,OAAO,IAAI,iBAAiB,IAEhD,UAAU,UAAU,QAAQ,mBAAmB,CAAC,CAAC,OAAO,GACxD,UAC4C;GAAE;UACzC,OAAO;AACf,UAAQ,MAAM,kCAAkC,MAAM;AACtD,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAmB,SAAS;IAAwB;GACnE;;;;;;;;;;;;;;;;;;;;AAqBH,eAAsB,kBACrB,IACA,cACA,OAC0C;AAC1C,KAAI;EACH,MAAM,SAAS,MAAM,mBAAmB,IAAI,aAAa;AACzD,MAAI,CAAC,OAAO,QAAS,QAAO;EAE5B,MAAM,OAAO,IAAI,mBAAmB,GAAG;EAEvC,IAAI,WAAW,MAAM,YAAY;AACjC,MAAI,aAAa,MAAM;GAItB,MAAM,SAAS,MAAM,KAAK,SAAS,SAAS;AAC5C,OAAI,OAAQ,YAAW,OAAO,oBAAoB,OAAO;;EAQ1D,MAAM,UAAU,MAAM,KAAK,WAAW,cAAc,EAAE,UAAU,CAAC;EACjE,MAAM,WAA8B,EAAE;EACtC,MAAM,uBAAO,IAAI,KAAa;EAC9B,MAAM,0BAAU,IAAI,KAAqB;AACzC,OAAK,MAAM,QAAQ,SAAS;GAC3B,MAAM,QAAQ,KAAK,oBAAoB,KAAK;AAC5C,OAAI,CAAC,KAAK,IAAI,MAAM,EAAE;AACrB,SAAK,IAAI,MAAM;AACf,aAAS,KAAK;KAAE;KAAO,UAAU,KAAK;KAAW,CAAC;;AAKnD,WAAQ,IAAI,OAAO,MAAM;AACzB,WAAQ,IAAI,KAAK,IAAI,MAAM;;EAG5B,MAAM,SAAmB,EAAE;AAC3B,OAAK,MAAM,MAAM,MAAM,KAAK;GAC3B,MAAM,QAAQ,QAAQ,IAAI,GAAG;AAC7B,OAAI,CAAC,MACJ,QAAO;IACN,SAAS;IACT,OAAO;KACN,MAAM;KACN,SAAS,SAAS,GAAG;KACrB;IACD;AAEF,OAAI,OAAO,SAAS,MAAM,CACzB,QAAO;IACN,SAAS;IACT,OAAO;KACN,MAAM;KACN,SAAS,SAAS,GAAG;KACrB;IACD;AAEF,UAAO,KAAK,MAAM;;AAGnB,QAAM,KAAK,QAAQ,QAAQ,SAAS;AACpC,uBAAqB;AAErB,SAAO;GAAE,SAAS;GAAM,MAAM,EAAE,WAAW,MAAM;GAAE;UAC3C,OAAO;AACf,UAAQ,MAAM,qCAAqC,MAAM;AACzD,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAsB,SAAS;IAA2B;GACzE;;;;;;;;;;;;;;;;;;AAmBH,eAAe,mBACd,MACA,cACA,QACA,UACA,WACgE;AAChE,KAAI,aAAa,UAAa,aAAa,KAAM,QAAO;CAExD,MAAM,SAAS,MAAM,KAAK,SAAS,SAAS;AAC5C,KAAI,CAAC,OACJ,QAAO;EACN,MAAM;EACN,SAAS,gBAAgB,SAAS;EAClC;AAEF,KAAI,OAAO,SAAS,aACnB,QAAO;EACN,MAAM;EACN,SAAS,gBAAgB,SAAS,yBAAyB,OAAO,KAAK,UAAU,aAAa;EAC9F;CAMF,MAAM,cAAc,OAAO,oBAAoB,OAAO;CACtD,IAAI,YAA2B,aAAa;AAC5C,KAAI,WAAW,QAAW;EACzB,MAAM,OAAO,MAAM,KAAK,SAAS,OAAO;AACxC,cAAY,OAAQ,KAAK,oBAAoB,KAAK,KAAM;;AAEzD,KAAI,cAAc,QAAQ,gBAAgB,UACzC,QAAO;EACN,MAAM;EACN,SAAS;EACT;CAgBF,MAAM,YAAY;CAClB,IAAI,SAAwB,OAAO;CACnC,IAAI,QAAQ;AACZ,QAAO,WAAW,QAAQ,QAAQ,WAAW;AAC5C,MAAI,cAAc,QAAQ,WAAW,UACpC,QAAO;GACN,MAAM;GACN,SAAS;GACT;EAEF,MAAM,OAAO,MAAM,KAAK,SAAS,OAAO;AACxC,MAAI,CAAC,KAAM;AACX,WAAS,KAAK;AACd;;AAED,KAAI,WAAW,QAAQ,SAAS,UAC/B,QAAO;EACN,MAAM;EACN,SAAS;EACT;AAGF,QAAO;;;;;AAMR,eAAsB,iBACrB,IACA,cACA,OAQmC;CACnC,IAAI,gBAAgB,MAAM;AAC1B,KAAI;EACH,MAAM,SAAS,wBAAwB,MAAM,UAAU,eAAe,EAAE,iBAAiB,KAAK;EAI9F,MAAM,SAAS,MAAM,mBAAmB,IAAI,aAAa;AACzD,MAAI,CAAC,OAAO,QAAS,QAAO;EAE5B,MAAM,OAAO,IAAI,mBAAmB,GAAG;EAGvC,MAAM,WACL,MAAM,aAAa,MAAM,MAAM,aAAa,SAAY,SAAY,MAAM;AAK3E,MADC,MAAM,SAAS,SAAY,OAAO,MAAM,KAAK,WAAW,cAAc,MAAM,MAAM,OAAO,CAEzF,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS,SAAS,MAAM,KAAK,uBAAuB,aAAa,KAAK,OAAO;IAC7E;GACD;EAWF,IAAI,YAA2B;AAC/B,MAAI,MAAM,eAAe;GACxB,MAAM,SAAS,MAAM,KAAK,SAAS,MAAM,cAAc;AACvD,eAAY,SAAU,OAAO,oBAAoB,OAAO,KAAM;;EAK/D,MAAM,cAAc,MAAM,mBACzB,MACA,cACA,QACA,UACA,UACA;AACD,MAAI,YACH,QAAO;GAAE,SAAS;GAAO,OAAO;GAAa;EAG9C,MAAM,UAAU,SACf,KAAK,OAAO;GACX,MAAM;GACN;GACA,OAAO,MAAM;GACb,UAAU,YAAY;GACtB,MAAM,MAAM,cAAc,EAAE,aAAa,MAAM,aAAa,GAAG;GAC/D;GACA,eAAe,MAAM;GACrB,CAAC;EACH,IAAI;EACJ,IAAI;AACJ,MAAI,MAAM,SAAS,OAClB,QAAO,MAAM,OAAO,MAAM,KAAK;MAE/B,MAAK,IAAI,UAAU,GAAG,UAAU,kCAAkC,WAAW;AAC5E,mBAAgB,MAAM,KAAK,mBAAmB,cAAc,MAAM,OAAO,OAAO;AAChF,OAAI;AACH,WAAO,MAAM,OAAO,cAAc;AAClC;YACQ,OAAO;AACf,QAAI,CAAC,0BAA0B,MAAM,CAAE,OAAM;AAC7C,uBAAmB;;;AAItB,MAAI,CAAC,KAAM,OAAM,oCAAoB,IAAI,MAAM,iCAAiC;AAEhF,uBAAqB;AAErB,SAAO;GACN,SAAS;GACT,MAAM,EACL,MAAM;IACL,IAAI,KAAK;IACT,MAAM,KAAK;IACX,MAAM,KAAK;IACX,OAAO,KAAK;IACZ,UAAU,KAAK;IACf,aACC,OAAO,KAAK,MAAM,gBAAgB,WAAW,KAAK,KAAK,cAAc;IACtE,QAAQ,KAAK;IACb,kBAAkB,KAAK;IACvB,EACD;GACD;UACO,OAAO;AACf,MAAI,0BAA0B,MAAM,CACnC,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS,mBAAmB,iBAAiB,cAAc,gCAAgC,aAAa;IACxG;GACD;AAEF,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAqB,SAAS;IAAyB;GACtE;;;;;;AAOH,eAAsB,cACrB,IACA,cACA,UACA,UAA+B,EAAE,EACK;AACtC,KAAI;EACH,MAAM,OAAO,IAAI,mBAAmB,GAAG;EACvC,MAAM,SAAS,QAAQ,SAAS,wBAAwB,QAAQ,OAAO,GAAG;EAC1E,MAAM,OAAO,MAAM,KAAK,WAAW,cAAc,UAAU,OAAO;AAElE,MAAI,CAAC,KACJ,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS,SAAS,SAAS,2BAA2B,aAAa;IACnE;GACD;EAOF,MAAM,SAAS,MAAM,+BAA+B,IAAI,cAAc,OAAO;EAO7E,MAAM,SANS,MAAM,uBACpB,IACA,cACA,OAAO,UAAU,eAAe,OAAO,IAAI,GAAG,EAAE,EAChD,UAAU,KAAK,OACf,EACoB,IAAI,KAAK,oBAAoB,KAAK,GAAG,IAAI;EAG9D,MAAM,WAAW,MAAM,KAAK,aAAa,KAAK,IAAI,KAAK,OAAO;AAE9D,SAAO;GACN,SAAS;GACT,MAAM,EACL,MAAM;IACL,IAAI,KAAK;IACT,MAAM,KAAK;IACX,MAAM,KAAK;IACX,OAAO,KAAK;IACZ,UAAU,KAAK;IACf,aACC,OAAO,KAAK,MAAM,gBAAgB,WAAW,KAAK,KAAK,cAAc;IACtE;IACA,UAAU,SAAS,KAAK,OAAO;KAAE,IAAI,EAAE;KAAI,MAAM,EAAE;KAAM,OAAO,EAAE;KAAO,EAAE;IAC3E,QAAQ,KAAK;IACb,kBAAkB,KAAK;IACvB,EACD;GACD;SACM;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAkB,SAAS;IAAsB;GAChE;;;;AAKH,eAAsB,uBACrB,IACA,WAC+C;AAC/C,KAAI;EACH,MAAM,SAAS,MAAM,GACnB,WAAW,aAAa,CACxB,WAAW,CACX,OAAO,OAAO,GAAG,GAAG,CAAC,GAAG,MAAM,KAAK,UAAU,EAAE,GAAG,qBAAqB,KAAK,UAAU,CAAC,CAAC,CAAC,CACzF,kBAAkB;AACpB,MAAI,CAAC,OACJ,QAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAa,SAAS;IAAkB;GACvD;EAEF,MAAM,QAAQ,OAAO,qBAAqB,OAAO;AAOjD,SAAO;GACN,SAAS;GACT,MAAM;IACL,kBAAkB;IAClB,eAVW,MAAM,GACjB,WAAW,aAAa,CACxB,WAAW,CACX,MAAM,qBAAqB,KAAK,MAAM,CACtC,QAAQ,UAAU,MAAM,CACxB,SAAS,EAKU,KAAK,OAAO;KAC9B,IAAI,EAAE;KACN,MAAM,EAAE;KACR,OAAO,EAAE;KACT,QAAQ,EAAE;KACV,EAAE;IACH;GACD;SACM;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAA2B,SAAS;IAAoC;GACvF;;;;;;AAOH,eAAsB,iBACrB,IACA,cACA,UACA,OACA,UAA+B,EAAE,EACE;AACnC,KAAI;EACH,MAAM,OAAO,IAAI,mBAAmB,GAAG;EACvC,MAAM,SAAS,QAAQ,SAAS,wBAAwB,QAAQ,OAAO,GAAG;EAC1E,MAAM,OAAO,MAAM,KAAK,WAAW,cAAc,UAAU,OAAO;AAElE,MAAI,CAAC,KACJ,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS,SAAS,SAAS,2BAA2B,aAAa;IACnE;GACD;EAKF,MAAM,UAAU,MAAM,SAAS,MAAM,MAAM,SAAS,SAAY,SAAY,MAAM;EAClF,MAAM,cACL,MAAM,aAAa,MAAM,MAAM,aAAa,SAAY,SAAY,MAAM;AAG3E,MAAI,YAAY,UAAa,YAAY,UAAU;GAClD,MAAM,WAAW,MAAM,KAAK,WAAW,cAAc,SAAS,OAAO;AACrE,OAAI,YAAY,SAAS,OAAO,KAAK,GACpC,QAAO;IACN,SAAS;IACT,OAAO;KACN,MAAM;KACN,SAAS,mBAAmB,QAAQ,gCAAgC,aAAa;KACjF;IACD;;EAKH,MAAM,cAAc,MAAM,mBAAmB,MAAM,cAAc,KAAK,IAAI,YAAY;AACtF,MAAI,YACH,QAAO;GAAE,SAAS;GAAO,OAAO;GAAa;EAG9C,MAAM,UAAU,MAAM,KAAK,OAAO,KAAK,IAAI;GAC1C,MAAM;GACN,OAAO,MAAM;GACb,UAAU;GACV,MAAM,MAAM,gBAAgB,SAAY,EAAE,aAAa,MAAM,aAAa,GAAG;GAC7E,CAAC;AAEF,uBAAqB;AAErB,MAAI,CAAC,QACJ,QAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAqB,SAAS;IAAyB;GACtE;AAGF,SAAO;GACN,SAAS;GACT,MAAM,EACL,MAAM;IACL,IAAI,QAAQ;IACZ,MAAM,QAAQ;IACd,MAAM,QAAQ;IACd,OAAO,QAAQ;IACf,UAAU,QAAQ;IAClB,aACC,OAAO,QAAQ,MAAM,gBAAgB,WAAW,QAAQ,KAAK,cAAc;IAC5E,QAAQ,QAAQ;IAChB,kBAAkB,QAAQ;IAC1B,EACD;GACD;SACM;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAqB,SAAS;IAAyB;GACtE;;;;;;AAOH,eAAsB,iBACrB,IACA,cACA,UACA,UAA+B,EAAE,EACO;AACxC,KAAI;EACH,MAAM,OAAO,IAAI,mBAAmB,GAAG;EACvC,MAAM,SAAS,QAAQ,SAAS,wBAAwB,QAAQ,OAAO,GAAG;EAC1E,MAAM,OAAO,MAAM,KAAK,WAAW,cAAc,UAAU,OAAO;AAElE,MAAI,CAAC,KACJ,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS,SAAS,SAAS,2BAA2B,aAAa;IACnE;GACD;AAOF,OADiB,MAAM,KAAK,aAAa,KAAK,GAAG,EACpC,SAAS,EACrB,QAAO;GACN,SAAS;GACT,OAAO;IACN,MAAM;IACN,SAAS;IACT;GACD;AAIF,MAAI,CADY,MAAM,KAAK,OAAO,KAAK,GAAG,CAEzC,QAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAqB,SAAS;IAAyB;GACtE;AAGF,uBAAqB;AACrB,SAAO;GAAE,SAAS;GAAM,MAAM,EAAE,SAAS,MAAM;GAAE;SAC1C;AACP,SAAO;GACN,SAAS;GACT,OAAO;IAAE,MAAM;IAAqB,SAAS;IAAyB;GACtE"}
@@ -1,4 +1,4 @@
1
- import { o as __exportAll } from "./runner-BiDvq5dv.mjs";
1
+ import { c as __exportAll } from "./runner-C1T11CfC.mjs";
2
2
  import { t as validateIdentifier } from "./validate-BpwoDOgt.mjs";
3
3
  import { p as invalidateTaxonomyObjectCache } from "./object-cache-71tPfV4A.mjs";
4
4
  import { n as slugify } from "./slugify-B994zkXC.mjs";
@@ -456,7 +456,7 @@ var TaxonomyRepository = class {
456
456
  */
457
457
  async countEntriesForTerms(translationGroups) {
458
458
  if (translationGroups.length === 0) return /* @__PURE__ */ new Map();
459
- const { chunks, SQL_BATCH_SIZE } = await import("./chunks-DfPax1I0.mjs").then((n) => n.r);
459
+ const { chunks, SQL_BATCH_SIZE } = await import("./chunks-fiZ8G9RJ.mjs").then((n) => n.r);
460
460
  const counts = /* @__PURE__ */ new Map();
461
461
  for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {
462
462
  const rows = await this.db.selectFrom("content_taxonomies").select(["taxonomy_id", (eb) => eb.fn.count("entry_id").as("count")]).where("taxonomy_id", "in", chunk).groupBy("taxonomy_id").execute();
@@ -481,4 +481,4 @@ var TaxonomyRepository = class {
481
481
 
482
482
  //#endregion
483
483
  export { taxonomy_exports as n, TaxonomyRepository as t };
484
- //# sourceMappingURL=taxonomy-CPeM8d2o.mjs.map
484
+ //# sourceMappingURL=taxonomy-BqCZ1TdU.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"taxonomy-CPeM8d2o.mjs","names":[],"sources":["../src/database/repositories/taxonomy.ts"],"sourcesContent":["import { sql, type Kysely, type Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { invalidateTaxonomyObjectCache } from \"../../object-cache/index.js\";\nimport { slugify } from \"../../utils/slugify.js\";\nimport { withTransaction } from \"../transaction.js\";\nimport type { Database, TaxonomyTable } from \"../types.js\";\nimport { validateIdentifier } from \"../validate.js\";\n\n/** A member of one sibling group and the position it currently holds. */\nexport interface SiblingPosition {\n\tgroup: string;\n\tposition: number;\n}\n\n/**\n * Translation groups per reorder `UPDATE`. Each costs three bound parameters —\n * a CASE `WHEN`/`THEN` pair plus one slot in the `IN` list — which keeps a\n * statement inside D1's 100-parameter ceiling.\n */\nconst GROUPS_PER_UPDATE = 32;\nconst NUMERIC_SUFFIX_PATTERN = /^\\d+$/;\n\n/** Deal the listed groups back out over the slots they hold, in the order given. */\nfunction permuteWithinSlots(\n\tlisted: readonly string[],\n\toccupied: readonly number[],\n): Map<string, number> {\n\tconst slots = occupied.toSorted((a, b) => a - b);\n\tconst target = new Map<string, number>();\n\tlisted.forEach((group, index) => {\n\t\tconst position = slots[index];\n\t\tif (position !== undefined) target.set(group, position);\n\t});\n\treturn target;\n}\n\n/**\n * Renumber a whole sibling group 0..n-1, with the listed groups in the order\n * given and every other member left in the place it already held.\n *\n * Works off each member's index in the sequence rather than its stored value,\n * which is what lets it resolve positions that tie. The sort is stable, so\n * tied members keep the order `siblings` arrives in — deterministic, but a\n * listing that mixes locales breaks a tie on whichever locale's label sorts\n * first, not on the order any one caller rendered.\n */\nfunction renumberSiblings(\n\tlisted: readonly string[],\n\tsiblings: readonly SiblingPosition[],\n): Map<string, number> {\n\tconst sequence = siblings.toSorted((a, b) => a.position - b.position);\n\tconst wanted = new Set(listed);\n\tconst target = new Map<string, number>();\n\tlet next = 0;\n\tsequence.forEach(({ group }, index) => {\n\t\tconst replacement = wanted.has(group) ? listed[next++] : undefined;\n\t\ttarget.set(replacement ?? group, index);\n\t});\n\treturn target;\n}\n\nexport interface Taxonomy {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdata: Record<string, unknown> | null;\n\tlocale: string;\n\ttranslationGroup: string | null;\n\t/**\n\t * Position among siblings. Shared by every row of a `translation_group` —\n\t * a term sits in the same place in every locale it is translated into.\n\t */\n\tsortOrder: number;\n}\n\nexport interface CreateTaxonomyInput {\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId?: string;\n\tdata?: Record<string, unknown>;\n\t/** Omit to let the DB default (current value: 'en') apply. Higher layers\n\t * resolve the locale from the request context / i18n config. */\n\tlocale?: string;\n\t/** When set, links the new term into the source term's translation_group. */\n\ttranslationOf?: string;\n}\n\nexport interface UpdateTaxonomyInput {\n\tslug?: string;\n\tlabel?: string;\n\tparentId?: string | null;\n\tdata?: Record<string, unknown>;\n}\n\nexport interface FindOptions {\n\tparentId?: string | null;\n\tlocale?: string;\n}\n\nexport interface TaxonomyManualPageCursor {\n\tsortOrder: number;\n\tlabel: string;\n\tid: string;\n}\n\nexport interface TaxonomyPageOptions extends FindOptions {\n\tcursor?: TaxonomyManualPageCursor;\n\tlimit?: number;\n}\n\nexport interface TaxonomyPage {\n\titems: Taxonomy[];\n\thasMore: boolean;\n}\n\nexport interface TaxonomyAssignmentTranslation {\n\tid: string;\n\tslug: string;\n\tlocale: string;\n}\n\nexport interface TaxonomyAssignmentResolution {\n\ttranslationGroup: string;\n\tterm: Taxonomy | null;\n\tavailableLocales: string[];\n\ttranslations: TaxonomyAssignmentTranslation[];\n}\n\n/**\n * Taxonomy repository for categories, tags, and other classification.\n *\n * Terms are per-locale. Translations of the same term share a `translation_group`\n * ULID. `content_taxonomies` stores translation_groups on both sides so a single\n * association spans every locale of a post and term.\n *\n * Strict lookup methods use only the locale callers supply. The explicitly\n * resolved methods accept both the preferred and default locales so their\n * fallback policy stays visible at the call site.\n *\n * `sort_order` is per translation_group, not per row: every row sharing a\n * translation_group carries the same value, so a term holds one position across\n * all its locales. Sibling groups are keyed on the raw `parent_id` column, which\n * is locale-agnostic for the same reason (it stores the parent's\n * translation_group). Writes must preserve both invariants.\n */\nexport class TaxonomyRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Create a new taxonomy term. When `translationOf` is set the new row joins\n\t * the source term's translation_group; otherwise a fresh group is minted\n\t * (matching the migration backfill pattern `translation_group = id`).\n\t */\n\tasync create(input: CreateTaxonomyInput): Promise<Taxonomy> {\n\t\tconst id = ulid();\n\n\t\t// Empty-string parentId is coerced to null defensively. Higher layers\n\t\t// also normalize this — see handleTermCreate / handleTermUpdate.\n\t\t// `parent_id` stores the parent's locale-agnostic translation_group (not a\n\t\t// row id), mirroring content_taxonomies.taxonomy_id, so a child stays\n\t\t// nested in every locale's tree. resolveTranslationGroup accepts either a\n\t\t// row id or an already-resolved group, so this is idempotent.\n\t\tconst parentInput =\n\t\t\tinput.parentId === undefined || input.parentId === \"\" ? null : input.parentId;\n\t\tconst parentId = parentInput ? await this.resolveParentRef(parentInput) : null;\n\n\t\tlet translationGroup = id;\n\t\tlet sortOrder: number | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await this.findById(input.translationOf);\n\t\t\tif (source?.translationGroup) translationGroup = source.translationGroup;\n\t\t\t// A translation is the same term in another locale, so it takes the\n\t\t\t// group's position — but only while it stays in the group that position\n\t\t\t// belongs to. Landing under a different parent makes it a new member of\n\t\t\t// that sibling group, and the source's position means nothing there.\n\t\t\tif (source && source.parentId === parentId) sortOrder = source.sortOrder;\n\t\t}\n\t\tsortOrder ??= await this.nextSortOrder(input.name, parentId);\n\n\t\tawait this.db\n\t\t\t.insertInto(\"taxonomies\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tslug: input.slug,\n\t\t\t\tlabel: input.label,\n\t\t\t\tparent_id: parentId,\n\t\t\t\tdata: input.data ? JSON.stringify(input.data) : null,\n\t\t\t\tsort_order: sortOrder,\n\t\t\t\t// When omitted, the DB DEFAULT 'en' is used — keeps behaviour\n\t\t\t\t// consistent with ContentRepository and lets higher layers\n\t\t\t\t// supply an explicit locale from request context.\n\t\t\t\t...(input.locale !== undefined ? { locale: input.locale } : {}),\n\t\t\t\ttranslation_group: translationGroup,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tinvalidateTaxonomyObjectCache();\n\n\t\tconst taxonomy = await this.findById(id);\n\t\tif (!taxonomy) throw new Error(\"Failed to create taxonomy\");\n\t\treturn taxonomy;\n\t}\n\n\tasync findById(id: string): Promise<Taxonomy | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\t\treturn row ? this.rowToTaxonomy(row) : null;\n\t}\n\n\t/**\n\t * Find a term by (name, slug). When `locale` is provided, filter by it.\n\t * When omitted, returns the lowest-locale-code match (deterministic across\n\t * calls). Mirrors `ContentRepository.findBySlug`.\n\t */\n\tasync findBySlug(name: string, slug: string, locale?: string): Promise<Taxonomy | null> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where(\"slug\", \"=\", slug);\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst row = await query.orderBy(\"locale\", \"asc\").executeTakeFirst();\n\t\treturn row ? this.rowToTaxonomy(row) : null;\n\t}\n\n\t/** Generate a locale-scoped term slug, adding a numeric suffix when needed. */\n\tasync generateUniqueSlug(name: string, text: string, locale?: string): Promise<string> {\n\t\tconst baseSlug = slugify(text);\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select(\"slug\")\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where((eb) => eb.or([eb(\"slug\", \"=\", baseSlug), eb(\"slug\", \"like\", `${baseSlug}-%`)]));\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst candidates = await query.execute();\n\t\tif (!candidates.some((candidate) => candidate.slug === baseSlug)) return baseSlug;\n\n\t\tlet maxSuffix = 0;\n\t\tconst prefix = `${baseSlug}-`;\n\t\tfor (const candidate of candidates) {\n\t\t\tif (!candidate.slug.startsWith(prefix)) continue;\n\t\t\tconst suffix = candidate.slug.slice(prefix.length);\n\t\t\tif (!NUMERIC_SUFFIX_PATTERN.test(suffix)) continue;\n\t\t\tmaxSuffix = Math.max(maxSuffix, Number.parseInt(suffix, 10));\n\t\t}\n\t\treturn `${baseSlug}-${maxSuffix + 1}`;\n\t}\n\n\t/**\n\t * Get all terms for a taxonomy (e.g., all categories).\n\t *\n\t * `sort_order` carries the manual order set from the admin; it is 0 for\n\t * terms nobody has reordered, so an untouched taxonomy still comes back\n\t * alphabetically. `id asc` is a stable tiebreaker for terms that share both\n\t * values. Without it the SQL ordering is implementation-defined when they match.\n\t */\n\tasync findByName(name: string, options: FindOptions = {}): Promise<Taxonomy[]> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\");\n\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\n\t\tif (options.parentId !== undefined) {\n\t\t\tif (options.parentId === null) {\n\t\t\t\tquery = query.where(\"parent_id\", \"is\", null);\n\t\t\t} else {\n\t\t\t\tquery = query.where(\"parent_id\", \"=\", options.parentId);\n\t\t\t}\n\t\t}\n\n\t\tconst rows = await query.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync findByNameResolved(\n\t\tname: string,\n\t\tlocale: string,\n\t\tdefaultLocale: string,\n\t): Promise<Taxonomy[]> {\n\t\tconst locales = [...new Set([locale, defaultLocale])];\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where(\"locale\", \"in\", locales)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\")\n\t\t\t.execute();\n\n\t\tconst selected = new Map<string, Taxonomy>();\n\t\tfor (const row of rows) {\n\t\t\tconst term = this.rowToTaxonomy(row);\n\t\t\tconst group = term.translationGroup ?? term.id;\n\t\t\tconst current = selected.get(group);\n\t\t\tif (!current || term.locale === locale) selected.set(group, term);\n\t\t}\n\t\treturn [...selected.values()].toSorted(\n\t\t\t(a, b) =>\n\t\t\t\ta.sortOrder - b.sortOrder || a.label.localeCompare(b.label) || a.id.localeCompare(b.id),\n\t\t);\n\t}\n\n\tasync findPageByName(name: string, options: TaxonomyPageOptions = {}): Promise<TaxonomyPage> {\n\t\tconst limit = Math.max(1, Math.min(options.limit ?? 50, 100));\n\t\tlet query = this.db.selectFrom(\"taxonomies\").selectAll().where(\"name\", \"=\", name);\n\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\n\t\tif (options.parentId !== undefined) {\n\t\t\tquery =\n\t\t\t\toptions.parentId === null\n\t\t\t\t\t? query.where(\"parent_id\", \"is\", null)\n\t\t\t\t\t: query.where(\"parent_id\", \"=\", options.parentId);\n\t\t}\n\n\t\tif (options.cursor) {\n\t\t\tconst cursor = options.cursor;\n\t\t\tquery = query.where((eb) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"sort_order\", \">\", cursor.sortOrder),\n\t\t\t\t\teb.and([eb(\"sort_order\", \"=\", cursor.sortOrder), eb(\"label\", \">\", cursor.label)]),\n\t\t\t\t\teb.and([\n\t\t\t\t\t\teb(\"sort_order\", \"=\", cursor.sortOrder),\n\t\t\t\t\t\teb(\"label\", \"=\", cursor.label),\n\t\t\t\t\t\teb(\"id\", \">\", cursor.id),\n\t\t\t\t\t]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\t\tquery = query.orderBy(\"sort_order\", \"asc\").orderBy(\"label\", \"asc\").orderBy(\"id\", \"asc\");\n\n\t\tconst rows = await query.limit(limit + 1).execute();\n\t\treturn {\n\t\t\titems: rows.slice(0, limit).map((row) => this.rowToTaxonomy(row)),\n\t\t\thasMore: rows.length > limit,\n\t\t};\n\t}\n\n\t/**\n\t * Children of a term. Accepts a term id OR a translation_group and resolves\n\t * to the group, since `parent_id` stores the parent's translation_group.\n\t * Pass `locale` to scope to one locale's tree (children share the parent's\n\t * group across locales); omit it to find children in every locale (used to\n\t * block deletes that would orphan a sibling translation's subtree).\n\t */\n\tasync findChildren(parentIdOrGroup: string, locale?: string): Promise<Taxonomy[]> {\n\t\tconst group = await this.resolveTranslationGroup(parentIdOrGroup);\n\t\tif (!group) return [];\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"parent_id\", \"=\", group)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\");\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\n\t\tconst rows = await query.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\t/**\n\t * Every translation sibling of a term (including itself), identified by\n\t * their shared `translation_group`.\n\t */\n\tasync findTranslations(translationGroup: string): Promise<Taxonomy[]> {\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", translationGroup)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync update(id: string, input: UpdateTaxonomyInput): Promise<Taxonomy | null> {\n\t\tconst existing = await this.findById(id);\n\t\tif (!existing) return null;\n\n\t\t// Per-row display fields. `parent_id` and `sort_order` are not here: both\n\t\t// belong to the translation_group and are written across it below.\n\t\tconst updates: Record<string, unknown> = {};\n\t\tif (input.slug !== undefined) updates.slug = input.slug;\n\t\tif (input.label !== undefined) updates.label = input.label;\n\t\tif (input.data !== undefined) updates.data = JSON.stringify(input.data);\n\n\t\tconst group: { parent_id?: string | null; sort_order?: number } = {};\n\t\tif (input.parentId !== undefined) {\n\t\t\t// Defense in depth: empty-string parentId means null (no parent).\n\t\t\t// Otherwise persist the parent's translation_group (locale-agnostic),\n\t\t\t// matching create() — see the note there.\n\t\t\tconst parentId =\n\t\t\t\tinput.parentId === \"\" || input.parentId === null\n\t\t\t\t\t? null\n\t\t\t\t\t: await this.resolveParentRef(input.parentId);\n\n\t\t\tif (parentId !== existing.parentId) {\n\t\t\t\tgroup.parent_id = parentId;\n\t\t\t\t// A position only means anything within one sibling group, so a term\n\t\t\t\t// that changes parent is appended to the group it lands in.\n\t\t\t\tgroup.sort_order = await this.nextSortOrder(existing.name, parentId);\n\t\t\t}\n\t\t}\n\n\t\tconst hasRowUpdates = Object.keys(updates).length > 0;\n\t\tconst hasGroupUpdates = Object.keys(group).length > 0;\n\t\tif (hasRowUpdates || hasGroupUpdates) {\n\t\t\tawait withTransaction(this.db, async (trx) => {\n\t\t\t\tif (hasRowUpdates) {\n\t\t\t\t\tawait trx.updateTable(\"taxonomies\").set(updates).where(\"id\", \"=\", id).execute();\n\t\t\t\t}\n\t\t\t\tif (hasGroupUpdates) {\n\t\t\t\t\tawait trx\n\t\t\t\t\t\t.updateTable(\"taxonomies\")\n\t\t\t\t\t\t.set(group)\n\t\t\t\t\t\t.where(\"translation_group\", \"=\", existing.translationGroup ?? existing.id)\n\t\t\t\t\t\t.execute();\n\t\t\t\t}\n\t\t\t});\n\t\t\tinvalidateTaxonomyObjectCache();\n\t\t}\n\n\t\treturn this.findById(id);\n\t}\n\n\t/**\n\t * Move `groups` (translation_groups, in the desired order) into the positions\n\t * those same groups already occupy, leaving every other member of the sibling\n\t * group where it is.\n\t *\n\t * `groups` may be a subset: a locale renders only the terms translated into\n\t * it, so an admin in `fr` often cannot name every member. A group left out\n\t * keeps its place, which is also what makes a stale list harmless.\n\t *\n\t * `siblings` is every member of the group with the position it holds, in a\n\t * listing's order — tied positions are resolved by that order, so pass it as\n\t * read. Groups already at their target are skipped, so one swap rewrites two\n\t * groups rather than the whole list.\n\t */\n\tasync reorder(groups: string[], siblings: readonly SiblingPosition[]): Promise<void> {\n\t\tconst current = new Map(siblings.map(({ group, position }) => [group, position]));\n\n\t\tconst listed: string[] = [];\n\t\tconst occupied: number[] = [];\n\t\tfor (const group of groups) {\n\t\t\tconst position = current.get(group);\n\t\t\tif (position === undefined) continue;\n\t\t\tlisted.push(group);\n\t\t\toccupied.push(position);\n\t\t}\n\t\tif (listed.length === 0) return;\n\n\t\t// Tied positions have no distinct order to permute into, so the requested\n\t\t// one would be dropped without a word. Renumbering is the only way to\n\t\t// honour it, and it repairs the tie on the way through.\n\t\tconst target =\n\t\t\tnew Set(occupied).size === occupied.length\n\t\t\t\t? permuteWithinSlots(listed, occupied)\n\t\t\t\t: renumberSiblings(listed, siblings);\n\n\t\tconst changed = [...target].filter(([group, position]) => current.get(group) !== position);\n\t\tif (changed.length === 0) return;\n\n\t\tawait this.applyPositions(changed);\n\n\t\tinvalidateTaxonomyObjectCache();\n\t}\n\n\t/**\n\t * Write one position per translation_group, GROUPS_PER_UPDATE at a time so\n\t * each statement stays inside D1's parameter ceiling.\n\t *\n\t * D1 has no transactions — `withTransaction` runs its callback bare there —\n\t * so a chunk is the unit that can't tear. A reorder spanning several chunks\n\t * can, and leaves ties, which the next reorder renumbers away.\n\t */\n\tprivate async applyPositions(positions: readonly (readonly [string, number])[]): Promise<void> {\n\t\tfor (let index = 0; index < positions.length; index += GROUPS_PER_UPDATE) {\n\t\t\tconst chunk = positions.slice(index, index + GROUPS_PER_UPDATE);\n\t\t\t// The CAST types the bound position. Postgres resolves a CASE whose THEN\n\t\t\t// arms are all untyped parameters to text, then refuses to assign text to\n\t\t\t// an integer column.\n\t\t\tconst arms = sql.join(\n\t\t\t\tchunk.map(([group, position]) => sql`WHEN ${group} THEN CAST(${position} AS INTEGER)`),\n\t\t\t\tsql` `,\n\t\t\t);\n\t\t\tconst keys = sql.join(chunk.map(([group]) => sql`${group}`));\n\t\t\tawait sql`\n\t\t\t\tUPDATE taxonomies\n\t\t\t\tSET sort_order = CASE translation_group ${arms} END\n\t\t\t\tWHERE translation_group IN (${keys})\n\t\t\t`.execute(this.db);\n\t\t}\n\t}\n\n\t/**\n\t * Position for a term joining a sibling group: one past the last member, or\n\t * 0 when the group is empty.\n\t *\n\t * Bounds are taken across every locale because a position belongs to the\n\t * translation_group, not to a row — a term translated into only one locale\n\t * still occupies its slot for all of them.\n\t */\n\tprivate async nextSortOrder(name: string, parentId: string | null): Promise<number> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select((eb) => eb.fn.max(\"sort_order\").as(\"max\"))\n\t\t\t.where(\"name\", \"=\", name);\n\t\tquery =\n\t\t\tparentId === null\n\t\t\t\t? query.where(\"parent_id\", \"is\", null)\n\t\t\t\t: query.where(\"parent_id\", \"=\", parentId);\n\n\t\tconst bounds = await query.executeTakeFirst();\n\t\t// Null only when the group is empty.\n\t\tif (!bounds || bounds.max === null) return 0;\n\t\treturn bounds.max + 1;\n\t}\n\n\tasync delete(id: string): Promise<boolean> {\n\t\tconst term = await this.findById(id);\n\t\tif (!term) return false;\n\n\t\t// When deleting the last translation of a group the pivot rows that\n\t\t// reference that translation_group become orphaned — purge them.\n\t\tif (term.translationGroup) {\n\t\t\tconst siblings = await this.db\n\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"translation_group\", \"=\", term.translationGroup)\n\t\t\t\t.where(\"id\", \"!=\", id)\n\t\t\t\t.execute();\n\t\t\tif (siblings.length === 0) {\n\t\t\t\tawait this.db\n\t\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t\t.where(\"taxonomy_id\", \"=\", term.translationGroup)\n\t\t\t\t\t.execute();\n\t\t\t}\n\t\t}\n\n\t\tconst result = await this.db.deleteFrom(\"taxonomies\").where(\"id\", \"=\", id).executeTakeFirst();\n\t\tinvalidateTaxonomyObjectCache();\n\t\treturn (result.numDeletedRows ?? 0n) > 0n;\n\t}\n\n\t// --- Content-Taxonomy Junction (both ids store translation_groups) ---\n\n\tasync attachToEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {\n\t\tconst taxonomyGroup = await this.resolveTranslationGroup(taxonomyId);\n\t\tif (!taxonomyGroup) return;\n\t\tawait this.attachGroupsToEntry(collection, entryId, [taxonomyGroup]);\n\t}\n\n\t/**\n\t * Attach already-resolved term translation groups in one insert and return\n\t * the number of assignments that did not already exist.\n\t */\n\tasync attachGroupsToEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyGroups: string[],\n\t): Promise<number> {\n\t\tconst uniqueGroups = [...new Set(taxonomyGroups)];\n\t\tif (uniqueGroups.length === 0) return 0;\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.insertInto(\"content_taxonomies\")\n\t\t\t.values(\n\t\t\t\tuniqueGroups.map((taxonomy_id) => ({\n\t\t\t\t\tcollection,\n\t\t\t\t\tentry_id: entryGroup,\n\t\t\t\t\ttaxonomy_id,\n\t\t\t\t})),\n\t\t\t)\n\t\t\t.onConflict((oc) => oc.doNothing())\n\t\t\t.executeTakeFirst();\n\t\tconst inserted = Number(result.numInsertedOrUpdatedRows ?? 0n);\n\t\tif (inserted > 0) invalidateTaxonomyObjectCache();\n\t\treturn inserted;\n\t}\n\n\tasync detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {\n\t\tconst [entryGroup, taxonomyGroup] = await Promise.all([\n\t\t\tthis.resolveEntryTranslationGroup(collection, entryId),\n\t\t\tthis.resolveTranslationGroup(taxonomyId),\n\t\t]);\n\t\tif (!entryGroup || !taxonomyGroup) return;\n\n\t\tawait this.db\n\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomy_id\", \"=\", taxonomyGroup)\n\t\t\t.execute();\n\t\tinvalidateTaxonomyObjectCache();\n\t}\n\n\t/**\n\t * Taxonomy terms assigned to a content entry, resolved into a specific locale.\n\t * Terms whose translation_group lacks a row in the requested locale are\n\t * omitted — callers wanting fallback behaviour apply it themselves.\n\t */\n\tasync getTermsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName?: string,\n\t\tlocale?: string,\n\t): Promise<Taxonomy[]> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return [];\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.selectAll(\"taxonomies\")\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup);\n\n\t\tif (taxonomyName) query = query.where(\"taxonomies.name\", \"=\", taxonomyName);\n\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\n\t\tconst rows = await query.orderBy(\"taxonomies.locale\", \"asc\").execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync getTermAssignmentsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName: string,\n\t\tlocale: string,\n\t\tdefaultLocale: string,\n\t): Promise<TaxonomyAssignmentResolution[]> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return [];\n\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.selectAll(\"taxonomies\")\n\t\t\t.select(\"content_taxonomies.taxonomy_id as assignment_group\")\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t.orderBy(\"content_taxonomies.taxonomy_id\", \"asc\")\n\t\t\t.orderBy(\"taxonomies.locale\", \"asc\")\n\t\t\t.execute();\n\n\t\tconst byGroup = new Map<string, Taxonomy[]>();\n\t\tfor (const row of rows) {\n\t\t\tconst variants = byGroup.get(row.assignment_group) ?? [];\n\t\t\tvariants.push(this.rowToTaxonomy(row));\n\t\t\tbyGroup.set(row.assignment_group, variants);\n\t\t}\n\n\t\treturn Array.from(byGroup, ([translationGroup, variants]) => {\n\t\t\tconst term =\n\t\t\t\tvariants.find((variant) => variant.locale === locale) ??\n\t\t\t\tvariants.find((variant) => variant.locale === defaultLocale) ??\n\t\t\t\tnull;\n\t\t\treturn {\n\t\t\t\ttranslationGroup,\n\t\t\t\tterm,\n\t\t\t\tavailableLocales: variants.map((variant) => variant.locale),\n\t\t\t\ttranslations: variants.map((variant) => ({\n\t\t\t\t\tid: variant.id,\n\t\t\t\t\tslug: variant.slug,\n\t\t\t\t\tlocale: variant.locale,\n\t\t\t\t})),\n\t\t\t};\n\t\t});\n\t}\n\n\t/**\n\t * Replace all assignments of a given taxonomy for one content entry.\n\t * Term ids OR translation_groups are accepted and normalised to groups.\n\t */\n\tasync setTermsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName: string,\n\t\ttermIds: string[],\n\t): Promise<void> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return;\n\n\t\tconst groups: string[] = [];\n\t\tfor (const id of termIds) {\n\t\t\tconst group = await this.resolveTranslationGroup(id);\n\t\t\tif (group) groups.push(group);\n\t\t}\n\t\tconst newGroups = new Set(groups);\n\n\t\tconst current = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.select([\"content_taxonomies.taxonomy_id as group\"])\n\t\t\t.distinct()\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t.execute();\n\t\tconst currentGroups = new Set(current.map((r) => r.group));\n\n\t\tconst toRemove = [...currentGroups].filter((g) => !newGroups.has(g));\n\t\tif (toRemove.length > 0) {\n\t\t\tawait this.db\n\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t\t.where(\"taxonomy_id\", \"in\", toRemove)\n\t\t\t\t.execute();\n\t\t}\n\n\t\tconst toAdd = [...newGroups].filter((g) => !currentGroups.has(g));\n\t\tif (toAdd.length > 0) {\n\t\t\tawait this.db\n\t\t\t\t.insertInto(\"content_taxonomies\")\n\t\t\t\t.values(\n\t\t\t\t\ttoAdd.map((taxonomy_id) => ({\n\t\t\t\t\t\tcollection,\n\t\t\t\t\t\tentry_id: entryGroup,\n\t\t\t\t\t\ttaxonomy_id,\n\t\t\t\t\t})),\n\t\t\t\t)\n\t\t\t\t.onConflict((oc) => oc.doNothing())\n\t\t\t\t.execute();\n\t\t}\n\n\t\tif (toRemove.length > 0 || toAdd.length > 0) invalidateTaxonomyObjectCache();\n\t}\n\n\tasync clearEntryTerms(collection: string, entryId: string): Promise<number> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t.executeTakeFirst();\n\t\tconst removed = Number(result.numDeletedRows ?? 0);\n\t\tif (removed > 0) invalidateTaxonomyObjectCache();\n\t\treturn removed;\n\t}\n\n\tprivate async resolveEntryTranslationGroup(\n\t\tcollection: string,\n\t\tentryIdOrGroup: string,\n\t): Promise<string | null> {\n\t\tvalidateIdentifier(collection, \"collection type\");\n\t\tconst tableName = `ec_${collection}`;\n\t\tconst result = await sql<{ translation_group: string }>`\n\t\t\tSELECT translation_group\n\t\t\tFROM ${sql.ref(tableName)}\n\t\t\tWHERE id = ${entryIdOrGroup} OR translation_group = ${entryIdOrGroup}\n\t\t\tLIMIT 1\n\t\t`.execute(this.db);\n\t\treturn result.rows[0]?.translation_group ?? null;\n\t}\n\n\t/**\n\t * Count content entries that use any translation of this term. Accepts\n\t * either a term id or a translation_group — we normalise to the group.\n\t *\n\t * Counts raw pivot rows regardless of the entry's status or deletion —\n\t * drafts and trashed entries are included. User-facing counts (admin term\n\t * list/get, public widget and term pages) use `fetchVisibleTermCounts`\n\t * from `taxonomies/term-counts.ts` instead, which counts only publicly\n\t * visible entries.\n\t */\n\tasync countEntriesWithTerm(termIdOrGroup: string): Promise<number> {\n\t\tconst group = await this.resolveTranslationGroup(termIdOrGroup);\n\t\tif (!group) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select((eb) => eb.fn.count(\"entry_id\").as(\"count\"))\n\t\t\t.where(\"taxonomy_id\", \"=\", group)\n\t\t\t.executeTakeFirst();\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Resolve a parent reference (a row id or a translation_group) to the value\n\t * persisted in `parent_id`: the parent's translation_group, which is\n\t * locale-agnostic so the child stays nested in every locale. A\n\t * translation_group normally equals its anchor row's id, which satisfies the\n\t * self-FK on `parent_id`. If that anchor row is missing (a translation whose\n\t * anchor was deleted), fall back to the id we were given so we never write a\n\t * dangling FK value.\n\t */\n\tprivate async resolveParentRef(idOrGroup: string): Promise<string> {\n\t\tconst group = await this.resolveTranslationGroup(idOrGroup);\n\t\tif (!group) return idOrGroup;\n\t\tconst anchor = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"id\", \"=\", group)\n\t\t\t.executeTakeFirst();\n\t\treturn anchor ? group : idOrGroup;\n\t}\n\n\tprivate async resolveTranslationGroup(idOrGroup: string): Promise<string | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select([\"translation_group\"])\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\treturn row?.translation_group ?? null;\n\t}\n\n\t/**\n\t * Batch count entries for multiple taxonomy translation_groups.\n\t * Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.\n\t * Returns a Map from translation_group to count.\n\t *\n\t * Pass translation_groups (not term ids) — `content_taxonomies.taxonomy_id`\n\t * stores the translation_group so a single assignment spans every locale.\n\t *\n\t * Like `countEntriesWithTerm`, this counts raw pivot rows regardless of\n\t * status/deletion; user-facing counts go through `fetchVisibleTermCounts`.\n\t */\n\tasync countEntriesForTerms(translationGroups: string[]): Promise<Map<string, number>> {\n\t\tif (translationGroups.length === 0) return new Map();\n\n\t\tconst { chunks, SQL_BATCH_SIZE } = await import(\"../../utils/chunks.js\");\n\n\t\tconst counts = new Map<string, number>();\n\t\tfor (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {\n\t\t\tconst rows = await this.db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.select([\"taxonomy_id\", (eb) => eb.fn.count(\"entry_id\").as(\"count\")])\n\t\t\t\t.where(\"taxonomy_id\", \"in\", chunk)\n\t\t\t\t.groupBy(\"taxonomy_id\")\n\t\t\t\t.execute();\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tcounts.set(row.taxonomy_id, Number(row.count || 0));\n\t\t\t}\n\t\t}\n\t\treturn counts;\n\t}\n\n\tprivate rowToTaxonomy(row: Selectable<TaxonomyTable>): Taxonomy {\n\t\treturn {\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tslug: row.slug,\n\t\t\tlabel: row.label,\n\t\t\tparentId: row.parent_id,\n\t\t\tdata: row.data ? JSON.parse(row.data) : null,\n\t\t\tlocale: row.locale,\n\t\t\ttranslationGroup: row.translation_group,\n\t\t\tsortOrder: row.sort_order,\n\t\t};\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"taxonomy-BqCZ1TdU.mjs","names":[],"sources":["../src/database/repositories/taxonomy.ts"],"sourcesContent":["import { sql, type Kysely, type Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { invalidateTaxonomyObjectCache } from \"../../object-cache/index.js\";\nimport { slugify } from \"../../utils/slugify.js\";\nimport { withTransaction } from \"../transaction.js\";\nimport type { Database, TaxonomyTable } from \"../types.js\";\nimport { validateIdentifier } from \"../validate.js\";\n\n/** A member of one sibling group and the position it currently holds. */\nexport interface SiblingPosition {\n\tgroup: string;\n\tposition: number;\n}\n\n/**\n * Translation groups per reorder `UPDATE`. Each costs three bound parameters —\n * a CASE `WHEN`/`THEN` pair plus one slot in the `IN` list — which keeps a\n * statement inside D1's 100-parameter ceiling.\n */\nconst GROUPS_PER_UPDATE = 32;\nconst NUMERIC_SUFFIX_PATTERN = /^\\d+$/;\n\n/** Deal the listed groups back out over the slots they hold, in the order given. */\nfunction permuteWithinSlots(\n\tlisted: readonly string[],\n\toccupied: readonly number[],\n): Map<string, number> {\n\tconst slots = occupied.toSorted((a, b) => a - b);\n\tconst target = new Map<string, number>();\n\tlisted.forEach((group, index) => {\n\t\tconst position = slots[index];\n\t\tif (position !== undefined) target.set(group, position);\n\t});\n\treturn target;\n}\n\n/**\n * Renumber a whole sibling group 0..n-1, with the listed groups in the order\n * given and every other member left in the place it already held.\n *\n * Works off each member's index in the sequence rather than its stored value,\n * which is what lets it resolve positions that tie. The sort is stable, so\n * tied members keep the order `siblings` arrives in — deterministic, but a\n * listing that mixes locales breaks a tie on whichever locale's label sorts\n * first, not on the order any one caller rendered.\n */\nfunction renumberSiblings(\n\tlisted: readonly string[],\n\tsiblings: readonly SiblingPosition[],\n): Map<string, number> {\n\tconst sequence = siblings.toSorted((a, b) => a.position - b.position);\n\tconst wanted = new Set(listed);\n\tconst target = new Map<string, number>();\n\tlet next = 0;\n\tsequence.forEach(({ group }, index) => {\n\t\tconst replacement = wanted.has(group) ? listed[next++] : undefined;\n\t\ttarget.set(replacement ?? group, index);\n\t});\n\treturn target;\n}\n\nexport interface Taxonomy {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdata: Record<string, unknown> | null;\n\tlocale: string;\n\ttranslationGroup: string | null;\n\t/**\n\t * Position among siblings. Shared by every row of a `translation_group` —\n\t * a term sits in the same place in every locale it is translated into.\n\t */\n\tsortOrder: number;\n}\n\nexport interface CreateTaxonomyInput {\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId?: string;\n\tdata?: Record<string, unknown>;\n\t/** Omit to let the DB default (current value: 'en') apply. Higher layers\n\t * resolve the locale from the request context / i18n config. */\n\tlocale?: string;\n\t/** When set, links the new term into the source term's translation_group. */\n\ttranslationOf?: string;\n}\n\nexport interface UpdateTaxonomyInput {\n\tslug?: string;\n\tlabel?: string;\n\tparentId?: string | null;\n\tdata?: Record<string, unknown>;\n}\n\nexport interface FindOptions {\n\tparentId?: string | null;\n\tlocale?: string;\n}\n\nexport interface TaxonomyManualPageCursor {\n\tsortOrder: number;\n\tlabel: string;\n\tid: string;\n}\n\nexport interface TaxonomyPageOptions extends FindOptions {\n\tcursor?: TaxonomyManualPageCursor;\n\tlimit?: number;\n}\n\nexport interface TaxonomyPage {\n\titems: Taxonomy[];\n\thasMore: boolean;\n}\n\nexport interface TaxonomyAssignmentTranslation {\n\tid: string;\n\tslug: string;\n\tlocale: string;\n}\n\nexport interface TaxonomyAssignmentResolution {\n\ttranslationGroup: string;\n\tterm: Taxonomy | null;\n\tavailableLocales: string[];\n\ttranslations: TaxonomyAssignmentTranslation[];\n}\n\n/**\n * Taxonomy repository for categories, tags, and other classification.\n *\n * Terms are per-locale. Translations of the same term share a `translation_group`\n * ULID. `content_taxonomies` stores translation_groups on both sides so a single\n * association spans every locale of a post and term.\n *\n * Strict lookup methods use only the locale callers supply. The explicitly\n * resolved methods accept both the preferred and default locales so their\n * fallback policy stays visible at the call site.\n *\n * `sort_order` is per translation_group, not per row: every row sharing a\n * translation_group carries the same value, so a term holds one position across\n * all its locales. Sibling groups are keyed on the raw `parent_id` column, which\n * is locale-agnostic for the same reason (it stores the parent's\n * translation_group). Writes must preserve both invariants.\n */\nexport class TaxonomyRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Create a new taxonomy term. When `translationOf` is set the new row joins\n\t * the source term's translation_group; otherwise a fresh group is minted\n\t * (matching the migration backfill pattern `translation_group = id`).\n\t */\n\tasync create(input: CreateTaxonomyInput): Promise<Taxonomy> {\n\t\tconst id = ulid();\n\n\t\t// Empty-string parentId is coerced to null defensively. Higher layers\n\t\t// also normalize this — see handleTermCreate / handleTermUpdate.\n\t\t// `parent_id` stores the parent's locale-agnostic translation_group (not a\n\t\t// row id), mirroring content_taxonomies.taxonomy_id, so a child stays\n\t\t// nested in every locale's tree. resolveTranslationGroup accepts either a\n\t\t// row id or an already-resolved group, so this is idempotent.\n\t\tconst parentInput =\n\t\t\tinput.parentId === undefined || input.parentId === \"\" ? null : input.parentId;\n\t\tconst parentId = parentInput ? await this.resolveParentRef(parentInput) : null;\n\n\t\tlet translationGroup = id;\n\t\tlet sortOrder: number | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await this.findById(input.translationOf);\n\t\t\tif (source?.translationGroup) translationGroup = source.translationGroup;\n\t\t\t// A translation is the same term in another locale, so it takes the\n\t\t\t// group's position — but only while it stays in the group that position\n\t\t\t// belongs to. Landing under a different parent makes it a new member of\n\t\t\t// that sibling group, and the source's position means nothing there.\n\t\t\tif (source && source.parentId === parentId) sortOrder = source.sortOrder;\n\t\t}\n\t\tsortOrder ??= await this.nextSortOrder(input.name, parentId);\n\n\t\tawait this.db\n\t\t\t.insertInto(\"taxonomies\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tslug: input.slug,\n\t\t\t\tlabel: input.label,\n\t\t\t\tparent_id: parentId,\n\t\t\t\tdata: input.data ? JSON.stringify(input.data) : null,\n\t\t\t\tsort_order: sortOrder,\n\t\t\t\t// When omitted, the DB DEFAULT 'en' is used — keeps behaviour\n\t\t\t\t// consistent with ContentRepository and lets higher layers\n\t\t\t\t// supply an explicit locale from request context.\n\t\t\t\t...(input.locale !== undefined ? { locale: input.locale } : {}),\n\t\t\t\ttranslation_group: translationGroup,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tinvalidateTaxonomyObjectCache();\n\n\t\tconst taxonomy = await this.findById(id);\n\t\tif (!taxonomy) throw new Error(\"Failed to create taxonomy\");\n\t\treturn taxonomy;\n\t}\n\n\tasync findById(id: string): Promise<Taxonomy | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirst();\n\t\treturn row ? this.rowToTaxonomy(row) : null;\n\t}\n\n\t/**\n\t * Find a term by (name, slug). When `locale` is provided, filter by it.\n\t * When omitted, returns the lowest-locale-code match (deterministic across\n\t * calls). Mirrors `ContentRepository.findBySlug`.\n\t */\n\tasync findBySlug(name: string, slug: string, locale?: string): Promise<Taxonomy | null> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where(\"slug\", \"=\", slug);\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst row = await query.orderBy(\"locale\", \"asc\").executeTakeFirst();\n\t\treturn row ? this.rowToTaxonomy(row) : null;\n\t}\n\n\t/** Generate a locale-scoped term slug, adding a numeric suffix when needed. */\n\tasync generateUniqueSlug(name: string, text: string, locale?: string): Promise<string> {\n\t\tconst baseSlug = slugify(text);\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select(\"slug\")\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where((eb) => eb.or([eb(\"slug\", \"=\", baseSlug), eb(\"slug\", \"like\", `${baseSlug}-%`)]));\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst candidates = await query.execute();\n\t\tif (!candidates.some((candidate) => candidate.slug === baseSlug)) return baseSlug;\n\n\t\tlet maxSuffix = 0;\n\t\tconst prefix = `${baseSlug}-`;\n\t\tfor (const candidate of candidates) {\n\t\t\tif (!candidate.slug.startsWith(prefix)) continue;\n\t\t\tconst suffix = candidate.slug.slice(prefix.length);\n\t\t\tif (!NUMERIC_SUFFIX_PATTERN.test(suffix)) continue;\n\t\t\tmaxSuffix = Math.max(maxSuffix, Number.parseInt(suffix, 10));\n\t\t}\n\t\treturn `${baseSlug}-${maxSuffix + 1}`;\n\t}\n\n\t/**\n\t * Get all terms for a taxonomy (e.g., all categories).\n\t *\n\t * `sort_order` carries the manual order set from the admin; it is 0 for\n\t * terms nobody has reordered, so an untouched taxonomy still comes back\n\t * alphabetically. `id asc` is a stable tiebreaker for terms that share both\n\t * values. Without it the SQL ordering is implementation-defined when they match.\n\t */\n\tasync findByName(name: string, options: FindOptions = {}): Promise<Taxonomy[]> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\");\n\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\n\t\tif (options.parentId !== undefined) {\n\t\t\tif (options.parentId === null) {\n\t\t\t\tquery = query.where(\"parent_id\", \"is\", null);\n\t\t\t} else {\n\t\t\t\tquery = query.where(\"parent_id\", \"=\", options.parentId);\n\t\t\t}\n\t\t}\n\n\t\tconst rows = await query.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync findByNameResolved(\n\t\tname: string,\n\t\tlocale: string,\n\t\tdefaultLocale: string,\n\t): Promise<Taxonomy[]> {\n\t\tconst locales = [...new Set([locale, defaultLocale])];\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", name)\n\t\t\t.where(\"locale\", \"in\", locales)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\")\n\t\t\t.execute();\n\n\t\tconst selected = new Map<string, Taxonomy>();\n\t\tfor (const row of rows) {\n\t\t\tconst term = this.rowToTaxonomy(row);\n\t\t\tconst group = term.translationGroup ?? term.id;\n\t\t\tconst current = selected.get(group);\n\t\t\tif (!current || term.locale === locale) selected.set(group, term);\n\t\t}\n\t\treturn [...selected.values()].toSorted(\n\t\t\t(a, b) =>\n\t\t\t\ta.sortOrder - b.sortOrder || a.label.localeCompare(b.label) || a.id.localeCompare(b.id),\n\t\t);\n\t}\n\n\tasync findPageByName(name: string, options: TaxonomyPageOptions = {}): Promise<TaxonomyPage> {\n\t\tconst limit = Math.max(1, Math.min(options.limit ?? 50, 100));\n\t\tlet query = this.db.selectFrom(\"taxonomies\").selectAll().where(\"name\", \"=\", name);\n\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\n\t\tif (options.parentId !== undefined) {\n\t\t\tquery =\n\t\t\t\toptions.parentId === null\n\t\t\t\t\t? query.where(\"parent_id\", \"is\", null)\n\t\t\t\t\t: query.where(\"parent_id\", \"=\", options.parentId);\n\t\t}\n\n\t\tif (options.cursor) {\n\t\t\tconst cursor = options.cursor;\n\t\t\tquery = query.where((eb) =>\n\t\t\t\teb.or([\n\t\t\t\t\teb(\"sort_order\", \">\", cursor.sortOrder),\n\t\t\t\t\teb.and([eb(\"sort_order\", \"=\", cursor.sortOrder), eb(\"label\", \">\", cursor.label)]),\n\t\t\t\t\teb.and([\n\t\t\t\t\t\teb(\"sort_order\", \"=\", cursor.sortOrder),\n\t\t\t\t\t\teb(\"label\", \"=\", cursor.label),\n\t\t\t\t\t\teb(\"id\", \">\", cursor.id),\n\t\t\t\t\t]),\n\t\t\t\t]),\n\t\t\t);\n\t\t}\n\t\tquery = query.orderBy(\"sort_order\", \"asc\").orderBy(\"label\", \"asc\").orderBy(\"id\", \"asc\");\n\n\t\tconst rows = await query.limit(limit + 1).execute();\n\t\treturn {\n\t\t\titems: rows.slice(0, limit).map((row) => this.rowToTaxonomy(row)),\n\t\t\thasMore: rows.length > limit,\n\t\t};\n\t}\n\n\t/**\n\t * Children of a term. Accepts a term id OR a translation_group and resolves\n\t * to the group, since `parent_id` stores the parent's translation_group.\n\t * Pass `locale` to scope to one locale's tree (children share the parent's\n\t * group across locales); omit it to find children in every locale (used to\n\t * block deletes that would orphan a sibling translation's subtree).\n\t */\n\tasync findChildren(parentIdOrGroup: string, locale?: string): Promise<Taxonomy[]> {\n\t\tconst group = await this.resolveTranslationGroup(parentIdOrGroup);\n\t\tif (!group) return [];\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"parent_id\", \"=\", group)\n\t\t\t.orderBy(\"sort_order\", \"asc\")\n\t\t\t.orderBy(\"label\", \"asc\")\n\t\t\t.orderBy(\"id\", \"asc\");\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\n\t\tconst rows = await query.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\t/**\n\t * Every translation sibling of a term (including itself), identified by\n\t * their shared `translation_group`.\n\t */\n\tasync findTranslations(translationGroup: string): Promise<Taxonomy[]> {\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", translationGroup)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync update(id: string, input: UpdateTaxonomyInput): Promise<Taxonomy | null> {\n\t\tconst existing = await this.findById(id);\n\t\tif (!existing) return null;\n\n\t\t// Per-row display fields. `parent_id` and `sort_order` are not here: both\n\t\t// belong to the translation_group and are written across it below.\n\t\tconst updates: Record<string, unknown> = {};\n\t\tif (input.slug !== undefined) updates.slug = input.slug;\n\t\tif (input.label !== undefined) updates.label = input.label;\n\t\tif (input.data !== undefined) updates.data = JSON.stringify(input.data);\n\n\t\tconst group: { parent_id?: string | null; sort_order?: number } = {};\n\t\tif (input.parentId !== undefined) {\n\t\t\t// Defense in depth: empty-string parentId means null (no parent).\n\t\t\t// Otherwise persist the parent's translation_group (locale-agnostic),\n\t\t\t// matching create() — see the note there.\n\t\t\tconst parentId =\n\t\t\t\tinput.parentId === \"\" || input.parentId === null\n\t\t\t\t\t? null\n\t\t\t\t\t: await this.resolveParentRef(input.parentId);\n\n\t\t\tif (parentId !== existing.parentId) {\n\t\t\t\tgroup.parent_id = parentId;\n\t\t\t\t// A position only means anything within one sibling group, so a term\n\t\t\t\t// that changes parent is appended to the group it lands in.\n\t\t\t\tgroup.sort_order = await this.nextSortOrder(existing.name, parentId);\n\t\t\t}\n\t\t}\n\n\t\tconst hasRowUpdates = Object.keys(updates).length > 0;\n\t\tconst hasGroupUpdates = Object.keys(group).length > 0;\n\t\tif (hasRowUpdates || hasGroupUpdates) {\n\t\t\tawait withTransaction(this.db, async (trx) => {\n\t\t\t\tif (hasRowUpdates) {\n\t\t\t\t\tawait trx.updateTable(\"taxonomies\").set(updates).where(\"id\", \"=\", id).execute();\n\t\t\t\t}\n\t\t\t\tif (hasGroupUpdates) {\n\t\t\t\t\tawait trx\n\t\t\t\t\t\t.updateTable(\"taxonomies\")\n\t\t\t\t\t\t.set(group)\n\t\t\t\t\t\t.where(\"translation_group\", \"=\", existing.translationGroup ?? existing.id)\n\t\t\t\t\t\t.execute();\n\t\t\t\t}\n\t\t\t});\n\t\t\tinvalidateTaxonomyObjectCache();\n\t\t}\n\n\t\treturn this.findById(id);\n\t}\n\n\t/**\n\t * Move `groups` (translation_groups, in the desired order) into the positions\n\t * those same groups already occupy, leaving every other member of the sibling\n\t * group where it is.\n\t *\n\t * `groups` may be a subset: a locale renders only the terms translated into\n\t * it, so an admin in `fr` often cannot name every member. A group left out\n\t * keeps its place, which is also what makes a stale list harmless.\n\t *\n\t * `siblings` is every member of the group with the position it holds, in a\n\t * listing's order — tied positions are resolved by that order, so pass it as\n\t * read. Groups already at their target are skipped, so one swap rewrites two\n\t * groups rather than the whole list.\n\t */\n\tasync reorder(groups: string[], siblings: readonly SiblingPosition[]): Promise<void> {\n\t\tconst current = new Map(siblings.map(({ group, position }) => [group, position]));\n\n\t\tconst listed: string[] = [];\n\t\tconst occupied: number[] = [];\n\t\tfor (const group of groups) {\n\t\t\tconst position = current.get(group);\n\t\t\tif (position === undefined) continue;\n\t\t\tlisted.push(group);\n\t\t\toccupied.push(position);\n\t\t}\n\t\tif (listed.length === 0) return;\n\n\t\t// Tied positions have no distinct order to permute into, so the requested\n\t\t// one would be dropped without a word. Renumbering is the only way to\n\t\t// honour it, and it repairs the tie on the way through.\n\t\tconst target =\n\t\t\tnew Set(occupied).size === occupied.length\n\t\t\t\t? permuteWithinSlots(listed, occupied)\n\t\t\t\t: renumberSiblings(listed, siblings);\n\n\t\tconst changed = [...target].filter(([group, position]) => current.get(group) !== position);\n\t\tif (changed.length === 0) return;\n\n\t\tawait this.applyPositions(changed);\n\n\t\tinvalidateTaxonomyObjectCache();\n\t}\n\n\t/**\n\t * Write one position per translation_group, GROUPS_PER_UPDATE at a time so\n\t * each statement stays inside D1's parameter ceiling.\n\t *\n\t * D1 has no transactions — `withTransaction` runs its callback bare there —\n\t * so a chunk is the unit that can't tear. A reorder spanning several chunks\n\t * can, and leaves ties, which the next reorder renumbers away.\n\t */\n\tprivate async applyPositions(positions: readonly (readonly [string, number])[]): Promise<void> {\n\t\tfor (let index = 0; index < positions.length; index += GROUPS_PER_UPDATE) {\n\t\t\tconst chunk = positions.slice(index, index + GROUPS_PER_UPDATE);\n\t\t\t// The CAST types the bound position. Postgres resolves a CASE whose THEN\n\t\t\t// arms are all untyped parameters to text, then refuses to assign text to\n\t\t\t// an integer column.\n\t\t\tconst arms = sql.join(\n\t\t\t\tchunk.map(([group, position]) => sql`WHEN ${group} THEN CAST(${position} AS INTEGER)`),\n\t\t\t\tsql` `,\n\t\t\t);\n\t\t\tconst keys = sql.join(chunk.map(([group]) => sql`${group}`));\n\t\t\tawait sql`\n\t\t\t\tUPDATE taxonomies\n\t\t\t\tSET sort_order = CASE translation_group ${arms} END\n\t\t\t\tWHERE translation_group IN (${keys})\n\t\t\t`.execute(this.db);\n\t\t}\n\t}\n\n\t/**\n\t * Position for a term joining a sibling group: one past the last member, or\n\t * 0 when the group is empty.\n\t *\n\t * Bounds are taken across every locale because a position belongs to the\n\t * translation_group, not to a row — a term translated into only one locale\n\t * still occupies its slot for all of them.\n\t */\n\tprivate async nextSortOrder(name: string, parentId: string | null): Promise<number> {\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select((eb) => eb.fn.max(\"sort_order\").as(\"max\"))\n\t\t\t.where(\"name\", \"=\", name);\n\t\tquery =\n\t\t\tparentId === null\n\t\t\t\t? query.where(\"parent_id\", \"is\", null)\n\t\t\t\t: query.where(\"parent_id\", \"=\", parentId);\n\n\t\tconst bounds = await query.executeTakeFirst();\n\t\t// Null only when the group is empty.\n\t\tif (!bounds || bounds.max === null) return 0;\n\t\treturn bounds.max + 1;\n\t}\n\n\tasync delete(id: string): Promise<boolean> {\n\t\tconst term = await this.findById(id);\n\t\tif (!term) return false;\n\n\t\t// When deleting the last translation of a group the pivot rows that\n\t\t// reference that translation_group become orphaned — purge them.\n\t\tif (term.translationGroup) {\n\t\t\tconst siblings = await this.db\n\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"translation_group\", \"=\", term.translationGroup)\n\t\t\t\t.where(\"id\", \"!=\", id)\n\t\t\t\t.execute();\n\t\t\tif (siblings.length === 0) {\n\t\t\t\tawait this.db\n\t\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t\t.where(\"taxonomy_id\", \"=\", term.translationGroup)\n\t\t\t\t\t.execute();\n\t\t\t}\n\t\t}\n\n\t\tconst result = await this.db.deleteFrom(\"taxonomies\").where(\"id\", \"=\", id).executeTakeFirst();\n\t\tinvalidateTaxonomyObjectCache();\n\t\treturn (result.numDeletedRows ?? 0n) > 0n;\n\t}\n\n\t// --- Content-Taxonomy Junction (both ids store translation_groups) ---\n\n\tasync attachToEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {\n\t\tconst taxonomyGroup = await this.resolveTranslationGroup(taxonomyId);\n\t\tif (!taxonomyGroup) return;\n\t\tawait this.attachGroupsToEntry(collection, entryId, [taxonomyGroup]);\n\t}\n\n\t/**\n\t * Attach already-resolved term translation groups in one insert and return\n\t * the number of assignments that did not already exist.\n\t */\n\tasync attachGroupsToEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyGroups: string[],\n\t): Promise<number> {\n\t\tconst uniqueGroups = [...new Set(taxonomyGroups)];\n\t\tif (uniqueGroups.length === 0) return 0;\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.insertInto(\"content_taxonomies\")\n\t\t\t.values(\n\t\t\t\tuniqueGroups.map((taxonomy_id) => ({\n\t\t\t\t\tcollection,\n\t\t\t\t\tentry_id: entryGroup,\n\t\t\t\t\ttaxonomy_id,\n\t\t\t\t})),\n\t\t\t)\n\t\t\t.onConflict((oc) => oc.doNothing())\n\t\t\t.executeTakeFirst();\n\t\tconst inserted = Number(result.numInsertedOrUpdatedRows ?? 0n);\n\t\tif (inserted > 0) invalidateTaxonomyObjectCache();\n\t\treturn inserted;\n\t}\n\n\tasync detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {\n\t\tconst [entryGroup, taxonomyGroup] = await Promise.all([\n\t\t\tthis.resolveEntryTranslationGroup(collection, entryId),\n\t\t\tthis.resolveTranslationGroup(taxonomyId),\n\t\t]);\n\t\tif (!entryGroup || !taxonomyGroup) return;\n\n\t\tawait this.db\n\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomy_id\", \"=\", taxonomyGroup)\n\t\t\t.execute();\n\t\tinvalidateTaxonomyObjectCache();\n\t}\n\n\t/**\n\t * Taxonomy terms assigned to a content entry, resolved into a specific locale.\n\t * Terms whose translation_group lacks a row in the requested locale are\n\t * omitted — callers wanting fallback behaviour apply it themselves.\n\t */\n\tasync getTermsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName?: string,\n\t\tlocale?: string,\n\t): Promise<Taxonomy[]> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return [];\n\n\t\tlet query = this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.selectAll(\"taxonomies\")\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup);\n\n\t\tif (taxonomyName) query = query.where(\"taxonomies.name\", \"=\", taxonomyName);\n\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\n\t\tconst rows = await query.orderBy(\"taxonomies.locale\", \"asc\").execute();\n\t\treturn rows.map((row) => this.rowToTaxonomy(row));\n\t}\n\n\tasync getTermAssignmentsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName: string,\n\t\tlocale: string,\n\t\tdefaultLocale: string,\n\t): Promise<TaxonomyAssignmentResolution[]> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return [];\n\n\t\tconst rows = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.selectAll(\"taxonomies\")\n\t\t\t.select(\"content_taxonomies.taxonomy_id as assignment_group\")\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t.orderBy(\"content_taxonomies.taxonomy_id\", \"asc\")\n\t\t\t.orderBy(\"taxonomies.locale\", \"asc\")\n\t\t\t.execute();\n\n\t\tconst byGroup = new Map<string, Taxonomy[]>();\n\t\tfor (const row of rows) {\n\t\t\tconst variants = byGroup.get(row.assignment_group) ?? [];\n\t\t\tvariants.push(this.rowToTaxonomy(row));\n\t\t\tbyGroup.set(row.assignment_group, variants);\n\t\t}\n\n\t\treturn Array.from(byGroup, ([translationGroup, variants]) => {\n\t\t\tconst term =\n\t\t\t\tvariants.find((variant) => variant.locale === locale) ??\n\t\t\t\tvariants.find((variant) => variant.locale === defaultLocale) ??\n\t\t\t\tnull;\n\t\t\treturn {\n\t\t\t\ttranslationGroup,\n\t\t\t\tterm,\n\t\t\t\tavailableLocales: variants.map((variant) => variant.locale),\n\t\t\t\ttranslations: variants.map((variant) => ({\n\t\t\t\t\tid: variant.id,\n\t\t\t\t\tslug: variant.slug,\n\t\t\t\t\tlocale: variant.locale,\n\t\t\t\t})),\n\t\t\t};\n\t\t});\n\t}\n\n\t/**\n\t * Replace all assignments of a given taxonomy for one content entry.\n\t * Term ids OR translation_groups are accepted and normalised to groups.\n\t */\n\tasync setTermsForEntry(\n\t\tcollection: string,\n\t\tentryId: string,\n\t\ttaxonomyName: string,\n\t\ttermIds: string[],\n\t): Promise<void> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return;\n\n\t\tconst groups: string[] = [];\n\t\tfor (const id of termIds) {\n\t\t\tconst group = await this.resolveTranslationGroup(id);\n\t\t\tif (group) groups.push(group);\n\t\t}\n\t\tconst newGroups = new Set(groups);\n\n\t\tconst current = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t.select([\"content_taxonomies.taxonomy_id as group\"])\n\t\t\t.distinct()\n\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryGroup)\n\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t.execute();\n\t\tconst currentGroups = new Set(current.map((r) => r.group));\n\n\t\tconst toRemove = [...currentGroups].filter((g) => !newGroups.has(g));\n\t\tif (toRemove.length > 0) {\n\t\t\tawait this.db\n\t\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t\t.where(\"taxonomy_id\", \"in\", toRemove)\n\t\t\t\t.execute();\n\t\t}\n\n\t\tconst toAdd = [...newGroups].filter((g) => !currentGroups.has(g));\n\t\tif (toAdd.length > 0) {\n\t\t\tawait this.db\n\t\t\t\t.insertInto(\"content_taxonomies\")\n\t\t\t\t.values(\n\t\t\t\t\ttoAdd.map((taxonomy_id) => ({\n\t\t\t\t\t\tcollection,\n\t\t\t\t\t\tentry_id: entryGroup,\n\t\t\t\t\t\ttaxonomy_id,\n\t\t\t\t\t})),\n\t\t\t\t)\n\t\t\t\t.onConflict((oc) => oc.doNothing())\n\t\t\t\t.execute();\n\t\t}\n\n\t\tif (toRemove.length > 0 || toAdd.length > 0) invalidateTaxonomyObjectCache();\n\t}\n\n\tasync clearEntryTerms(collection: string, entryId: string): Promise<number> {\n\t\tconst entryGroup = await this.resolveEntryTranslationGroup(collection, entryId);\n\t\tif (!entryGroup) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.deleteFrom(\"content_taxonomies\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"entry_id\", \"=\", entryGroup)\n\t\t\t.executeTakeFirst();\n\t\tconst removed = Number(result.numDeletedRows ?? 0);\n\t\tif (removed > 0) invalidateTaxonomyObjectCache();\n\t\treturn removed;\n\t}\n\n\tprivate async resolveEntryTranslationGroup(\n\t\tcollection: string,\n\t\tentryIdOrGroup: string,\n\t): Promise<string | null> {\n\t\tvalidateIdentifier(collection, \"collection type\");\n\t\tconst tableName = `ec_${collection}`;\n\t\tconst result = await sql<{ translation_group: string }>`\n\t\t\tSELECT translation_group\n\t\t\tFROM ${sql.ref(tableName)}\n\t\t\tWHERE id = ${entryIdOrGroup} OR translation_group = ${entryIdOrGroup}\n\t\t\tLIMIT 1\n\t\t`.execute(this.db);\n\t\treturn result.rows[0]?.translation_group ?? null;\n\t}\n\n\t/**\n\t * Count content entries that use any translation of this term. Accepts\n\t * either a term id or a translation_group — we normalise to the group.\n\t *\n\t * Counts raw pivot rows regardless of the entry's status or deletion —\n\t * drafts and trashed entries are included. User-facing counts (admin term\n\t * list/get, public widget and term pages) use `fetchVisibleTermCounts`\n\t * from `taxonomies/term-counts.ts` instead, which counts only publicly\n\t * visible entries.\n\t */\n\tasync countEntriesWithTerm(termIdOrGroup: string): Promise<number> {\n\t\tconst group = await this.resolveTranslationGroup(termIdOrGroup);\n\t\tif (!group) return 0;\n\n\t\tconst result = await this.db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select((eb) => eb.fn.count(\"entry_id\").as(\"count\"))\n\t\t\t.where(\"taxonomy_id\", \"=\", group)\n\t\t\t.executeTakeFirst();\n\t\treturn Number(result?.count ?? 0);\n\t}\n\n\t/**\n\t * Resolve a parent reference (a row id or a translation_group) to the value\n\t * persisted in `parent_id`: the parent's translation_group, which is\n\t * locale-agnostic so the child stays nested in every locale. A\n\t * translation_group normally equals its anchor row's id, which satisfies the\n\t * self-FK on `parent_id`. If that anchor row is missing (a translation whose\n\t * anchor was deleted), fall back to the id we were given so we never write a\n\t * dangling FK value.\n\t */\n\tprivate async resolveParentRef(idOrGroup: string): Promise<string> {\n\t\tconst group = await this.resolveTranslationGroup(idOrGroup);\n\t\tif (!group) return idOrGroup;\n\t\tconst anchor = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"id\", \"=\", group)\n\t\t\t.executeTakeFirst();\n\t\treturn anchor ? group : idOrGroup;\n\t}\n\n\tprivate async resolveTranslationGroup(idOrGroup: string): Promise<string | null> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.select([\"translation_group\"])\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\treturn row?.translation_group ?? null;\n\t}\n\n\t/**\n\t * Batch count entries for multiple taxonomy translation_groups.\n\t * Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.\n\t * Returns a Map from translation_group to count.\n\t *\n\t * Pass translation_groups (not term ids) — `content_taxonomies.taxonomy_id`\n\t * stores the translation_group so a single assignment spans every locale.\n\t *\n\t * Like `countEntriesWithTerm`, this counts raw pivot rows regardless of\n\t * status/deletion; user-facing counts go through `fetchVisibleTermCounts`.\n\t */\n\tasync countEntriesForTerms(translationGroups: string[]): Promise<Map<string, number>> {\n\t\tif (translationGroups.length === 0) return new Map();\n\n\t\tconst { chunks, SQL_BATCH_SIZE } = await import(\"../../utils/chunks.js\");\n\n\t\tconst counts = new Map<string, number>();\n\t\tfor (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {\n\t\t\tconst rows = await this.db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.select([\"taxonomy_id\", (eb) => eb.fn.count(\"entry_id\").as(\"count\")])\n\t\t\t\t.where(\"taxonomy_id\", \"in\", chunk)\n\t\t\t\t.groupBy(\"taxonomy_id\")\n\t\t\t\t.execute();\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tcounts.set(row.taxonomy_id, Number(row.count || 0));\n\t\t\t}\n\t\t}\n\t\treturn counts;\n\t}\n\n\tprivate rowToTaxonomy(row: Selectable<TaxonomyTable>): Taxonomy {\n\t\treturn {\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tslug: row.slug,\n\t\t\tlabel: row.label,\n\t\t\tparentId: row.parent_id,\n\t\t\tdata: row.data ? JSON.parse(row.data) : null,\n\t\t\tlocale: row.locale,\n\t\t\ttranslationGroup: row.translation_group,\n\t\t\tsortOrder: row.sort_order,\n\t\t};\n\t}\n}\n"],"mappings":"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"}
@@ -132,4 +132,4 @@ function refreshInterceptor(options) {
132
132
 
133
133
  //#endregion
134
134
  export { tokenInterceptor as a, refreshInterceptor as i, csrfInterceptor as n, devBypassInterceptor as r, createTransport as t };
135
- //# sourceMappingURL=transport--CfOflrp.mjs.map
135
+ //# sourceMappingURL=transport-DBJwa-Si.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"transport--CfOflrp.mjs","names":[],"sources":["../src/client/transport.ts"],"sourcesContent":["/**\n * Transport layer for the EmDash client.\n *\n * Implements a composable interceptor pipeline that modifies requests\n * and responses. The client calls `transport.fetch(request)` — everything\n * else (auth, CSRF, retry) is handled by interceptors.\n */\n\n// Regex patterns for transport utilities\nconst COOKIE_NAME_VALUE_PATTERN = /^([^;]+)/;\n\n/**\n * An interceptor can modify the request, call next(), inspect\n * the response, and optionally retry.\n */\nexport type Interceptor = (\n\trequest: Request,\n\tnext: (request: Request) => Promise<Response>,\n) => Promise<Response>;\n\nexport interface TransportOptions {\n\tinterceptors?: Interceptor[];\n}\n\nfunction baseFetch(request: Request): Promise<Response> {\n\treturn globalThis.fetch(request);\n}\n\n/**\n * Creates a fetch function that runs requests through an interceptor pipeline.\n */\nexport function createTransport(options: TransportOptions = {}): {\n\tfetch: (request: Request) => Promise<Response>;\n} {\n\tconst interceptors = options.interceptors ?? [];\n\n\t// Build the chain once — interceptors don't change after construction\n\tlet chain: (request: Request) => Promise<Response> = baseFetch;\n\tfor (let i = interceptors.length - 1; i >= 0; i--) {\n\t\tconst interceptor = interceptors[i];\n\t\tconst next = chain;\n\t\tchain = (req) => interceptor(req, next);\n\t}\n\n\treturn { fetch: chain };\n}\n\n// ---------------------------------------------------------------------------\n// Built-in interceptors\n// ---------------------------------------------------------------------------\n\n/**\n * Adds X-EmDash-Request: 1 and Origin headers to mutation requests\n * (POST, PUT, DELETE). The custom header satisfies EmDash's CSRF check;\n * the Origin header satisfies Astro's built-in origin verification which\n * rejects server-side POST requests that lack a matching Origin.\n */\nexport function csrfInterceptor(): Interceptor {\n\tconst MUTATION_METHODS = new Set([\"POST\", \"PUT\", \"DELETE\", \"PATCH\"]);\n\n\treturn (request, next) => {\n\t\tif (MUTATION_METHODS.has(request.method)) {\n\t\t\tconst headers = new Headers(request.headers);\n\t\t\theaders.set(\"X-EmDash-Request\", \"1\");\n\t\t\tif (!headers.has(\"Origin\")) {\n\t\t\t\tconst url = new URL(request.url);\n\t\t\t\theaders.set(\"Origin\", url.origin);\n\t\t\t}\n\t\t\treturn next(new Request(request, { headers }));\n\t\t}\n\t\treturn next(request);\n\t};\n}\n\n/**\n * Adds Authorization: Bearer header from a static token.\n */\nexport function tokenInterceptor(token: string): Interceptor {\n\treturn (request, next) => {\n\t\tconst headers = new Headers(request.headers);\n\t\theaders.set(\"Authorization\", `Bearer ${token}`);\n\t\treturn next(new Request(request, { headers }));\n\t};\n}\n\n/**\n * Dev bypass interceptor. Calls the dev-bypass endpoint on first request\n * to establish a session, then forwards the session cookie on subsequent\n * requests.\n */\nexport function devBypassInterceptor(baseUrl: string): Interceptor {\n\tlet sessionCookie: string | null = null;\n\tlet initializing: Promise<void> | null = null;\n\n\tasync function init(): Promise<void> {\n\t\tconst bypassUrl = new URL(\"/_emdash/api/auth/dev-bypass\", baseUrl);\n\t\tconst res = await globalThis.fetch(bypassUrl, { redirect: \"manual\" });\n\n\t\t// Extract session cookie from Set-Cookie header\n\t\tconst setCookie = res.headers.get(\"set-cookie\");\n\t\tif (setCookie) {\n\t\t\t// Extract just the cookie name=value part\n\t\t\tconst match = setCookie.match(COOKIE_NAME_VALUE_PATTERN);\n\t\t\tif (match) {\n\t\t\t\tsessionCookie = match[1]!;\n\t\t\t}\n\t\t}\n\n\t\t// Consume the response body\n\t\tif (res.body) {\n\t\t\tawait res.text().catch(() => {});\n\t\t}\n\t}\n\n\treturn async (request, next) => {\n\t\t// Ensure we've initialized (only once, even with concurrent requests)\n\t\tif (!sessionCookie) {\n\t\t\tif (!initializing) {\n\t\t\t\tinitializing = init();\n\t\t\t}\n\t\t\tawait initializing;\n\t\t}\n\n\t\tif (sessionCookie) {\n\t\t\tconst headers = new Headers(request.headers);\n\t\t\tconst existing = headers.get(\"cookie\");\n\t\t\theaders.set(\"cookie\", existing ? `${existing}; ${sessionCookie}` : sessionCookie);\n\t\t\treturn next(new Request(request, { headers }));\n\t\t}\n\n\t\treturn next(request);\n\t};\n}\n\n/**\n * Auto-refreshes expired OAuth tokens on 401 responses.\n * Requires a refresh token and the token endpoint URL.\n */\nexport function refreshInterceptor(options: {\n\trefreshToken: string;\n\ttokenEndpoint: string;\n\tonTokenRefreshed?: (accessToken: string, refreshToken: string, expiresAt: string) => void;\n}): Interceptor {\n\tlet refreshing: Promise<string | null> | null = null;\n\n\tasync function refresh(): Promise<string | null> {\n\t\tconst res = await globalThis.fetch(options.tokenEndpoint, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tgrant_type: \"refresh_token\",\n\t\t\t\trefresh_token: options.refreshToken,\n\t\t\t}),\n\t\t});\n\n\t\tif (!res.ok) return null;\n\n\t\tinterface TokenFields {\n\t\t\taccess_token: string;\n\t\t\trefresh_token?: string;\n\t\t\texpires_in?: number;\n\t\t}\n\n\t\tconst json = (await res.json()) as Record<string, unknown>;\n\n\t\t// The token endpoint wraps the response in { success, data: ... } via apiSuccess.\n\t\t// Handle both wrapped and bare shapes for robustness.\n\t\tconst tokenData: TokenFields =\n\t\t\tjson.data && typeof json.data === \"object\" && \"access_token\" in json.data\n\t\t\t\t? (json.data as TokenFields)\n\t\t\t\t: (json as unknown as TokenFields);\n\n\t\tconst expiresAt = tokenData.expires_in\n\t\t\t? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()\n\t\t\t: new Date(Date.now() + 3600_000).toISOString();\n\n\t\tif (options.onTokenRefreshed) {\n\t\t\toptions.onTokenRefreshed(\n\t\t\t\ttokenData.access_token,\n\t\t\t\ttokenData.refresh_token ?? options.refreshToken,\n\t\t\t\texpiresAt,\n\t\t\t);\n\t\t}\n\n\t\treturn tokenData.access_token;\n\t}\n\n\treturn async (request, next) => {\n\t\tconst response = await next(request);\n\n\t\tif (response.status === 401) {\n\t\t\t// Try to refresh\n\t\t\tif (!refreshing) {\n\t\t\t\trefreshing = refresh().finally(() => {\n\t\t\t\t\trefreshing = null;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst newToken = await refreshing;\n\t\t\tif (newToken) {\n\t\t\t\t// Retry with new token\n\t\t\t\tconst headers = new Headers(request.headers);\n\t\t\t\theaders.set(\"Authorization\", `Bearer ${newToken}`);\n\t\t\t\treturn next(new Request(request, { headers }));\n\t\t\t}\n\t\t}\n\n\t\treturn response;\n\t};\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"transport-DBJwa-Si.mjs","names":[],"sources":["../src/client/transport.ts"],"sourcesContent":["/**\n * Transport layer for the EmDash client.\n *\n * Implements a composable interceptor pipeline that modifies requests\n * and responses. The client calls `transport.fetch(request)` — everything\n * else (auth, CSRF, retry) is handled by interceptors.\n */\n\n// Regex patterns for transport utilities\nconst COOKIE_NAME_VALUE_PATTERN = /^([^;]+)/;\n\n/**\n * An interceptor can modify the request, call next(), inspect\n * the response, and optionally retry.\n */\nexport type Interceptor = (\n\trequest: Request,\n\tnext: (request: Request) => Promise<Response>,\n) => Promise<Response>;\n\nexport interface TransportOptions {\n\tinterceptors?: Interceptor[];\n}\n\nfunction baseFetch(request: Request): Promise<Response> {\n\treturn globalThis.fetch(request);\n}\n\n/**\n * Creates a fetch function that runs requests through an interceptor pipeline.\n */\nexport function createTransport(options: TransportOptions = {}): {\n\tfetch: (request: Request) => Promise<Response>;\n} {\n\tconst interceptors = options.interceptors ?? [];\n\n\t// Build the chain once — interceptors don't change after construction\n\tlet chain: (request: Request) => Promise<Response> = baseFetch;\n\tfor (let i = interceptors.length - 1; i >= 0; i--) {\n\t\tconst interceptor = interceptors[i];\n\t\tconst next = chain;\n\t\tchain = (req) => interceptor(req, next);\n\t}\n\n\treturn { fetch: chain };\n}\n\n// ---------------------------------------------------------------------------\n// Built-in interceptors\n// ---------------------------------------------------------------------------\n\n/**\n * Adds X-EmDash-Request: 1 and Origin headers to mutation requests\n * (POST, PUT, DELETE). The custom header satisfies EmDash's CSRF check;\n * the Origin header satisfies Astro's built-in origin verification which\n * rejects server-side POST requests that lack a matching Origin.\n */\nexport function csrfInterceptor(): Interceptor {\n\tconst MUTATION_METHODS = new Set([\"POST\", \"PUT\", \"DELETE\", \"PATCH\"]);\n\n\treturn (request, next) => {\n\t\tif (MUTATION_METHODS.has(request.method)) {\n\t\t\tconst headers = new Headers(request.headers);\n\t\t\theaders.set(\"X-EmDash-Request\", \"1\");\n\t\t\tif (!headers.has(\"Origin\")) {\n\t\t\t\tconst url = new URL(request.url);\n\t\t\t\theaders.set(\"Origin\", url.origin);\n\t\t\t}\n\t\t\treturn next(new Request(request, { headers }));\n\t\t}\n\t\treturn next(request);\n\t};\n}\n\n/**\n * Adds Authorization: Bearer header from a static token.\n */\nexport function tokenInterceptor(token: string): Interceptor {\n\treturn (request, next) => {\n\t\tconst headers = new Headers(request.headers);\n\t\theaders.set(\"Authorization\", `Bearer ${token}`);\n\t\treturn next(new Request(request, { headers }));\n\t};\n}\n\n/**\n * Dev bypass interceptor. Calls the dev-bypass endpoint on first request\n * to establish a session, then forwards the session cookie on subsequent\n * requests.\n */\nexport function devBypassInterceptor(baseUrl: string): Interceptor {\n\tlet sessionCookie: string | null = null;\n\tlet initializing: Promise<void> | null = null;\n\n\tasync function init(): Promise<void> {\n\t\tconst bypassUrl = new URL(\"/_emdash/api/auth/dev-bypass\", baseUrl);\n\t\tconst res = await globalThis.fetch(bypassUrl, { redirect: \"manual\" });\n\n\t\t// Extract session cookie from Set-Cookie header\n\t\tconst setCookie = res.headers.get(\"set-cookie\");\n\t\tif (setCookie) {\n\t\t\t// Extract just the cookie name=value part\n\t\t\tconst match = setCookie.match(COOKIE_NAME_VALUE_PATTERN);\n\t\t\tif (match) {\n\t\t\t\tsessionCookie = match[1]!;\n\t\t\t}\n\t\t}\n\n\t\t// Consume the response body\n\t\tif (res.body) {\n\t\t\tawait res.text().catch(() => {});\n\t\t}\n\t}\n\n\treturn async (request, next) => {\n\t\t// Ensure we've initialized (only once, even with concurrent requests)\n\t\tif (!sessionCookie) {\n\t\t\tif (!initializing) {\n\t\t\t\tinitializing = init();\n\t\t\t}\n\t\t\tawait initializing;\n\t\t}\n\n\t\tif (sessionCookie) {\n\t\t\tconst headers = new Headers(request.headers);\n\t\t\tconst existing = headers.get(\"cookie\");\n\t\t\theaders.set(\"cookie\", existing ? `${existing}; ${sessionCookie}` : sessionCookie);\n\t\t\treturn next(new Request(request, { headers }));\n\t\t}\n\n\t\treturn next(request);\n\t};\n}\n\n/**\n * Auto-refreshes expired OAuth tokens on 401 responses.\n * Requires a refresh token and the token endpoint URL.\n */\nexport function refreshInterceptor(options: {\n\trefreshToken: string;\n\ttokenEndpoint: string;\n\tonTokenRefreshed?: (accessToken: string, refreshToken: string, expiresAt: string) => void;\n}): Interceptor {\n\tlet refreshing: Promise<string | null> | null = null;\n\n\tasync function refresh(): Promise<string | null> {\n\t\tconst res = await globalThis.fetch(options.tokenEndpoint, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: JSON.stringify({\n\t\t\t\tgrant_type: \"refresh_token\",\n\t\t\t\trefresh_token: options.refreshToken,\n\t\t\t}),\n\t\t});\n\n\t\tif (!res.ok) return null;\n\n\t\tinterface TokenFields {\n\t\t\taccess_token: string;\n\t\t\trefresh_token?: string;\n\t\t\texpires_in?: number;\n\t\t}\n\n\t\tconst json = (await res.json()) as Record<string, unknown>;\n\n\t\t// The token endpoint wraps the response in { success, data: ... } via apiSuccess.\n\t\t// Handle both wrapped and bare shapes for robustness.\n\t\tconst tokenData: TokenFields =\n\t\t\tjson.data && typeof json.data === \"object\" && \"access_token\" in json.data\n\t\t\t\t? (json.data as TokenFields)\n\t\t\t\t: (json as unknown as TokenFields);\n\n\t\tconst expiresAt = tokenData.expires_in\n\t\t\t? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()\n\t\t\t: new Date(Date.now() + 3600_000).toISOString();\n\n\t\tif (options.onTokenRefreshed) {\n\t\t\toptions.onTokenRefreshed(\n\t\t\t\ttokenData.access_token,\n\t\t\t\ttokenData.refresh_token ?? options.refreshToken,\n\t\t\t\texpiresAt,\n\t\t\t);\n\t\t}\n\n\t\treturn tokenData.access_token;\n\t}\n\n\treturn async (request, next) => {\n\t\tconst response = await next(request);\n\n\t\tif (response.status === 401) {\n\t\t\t// Try to refresh\n\t\t\tif (!refreshing) {\n\t\t\t\trefreshing = refresh().finally(() => {\n\t\t\t\t\trefreshing = null;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst newToken = await refreshing;\n\t\t\tif (newToken) {\n\t\t\t\t// Retry with new token\n\t\t\t\tconst headers = new Headers(request.headers);\n\t\t\t\theaders.set(\"Authorization\", `Bearer ${newToken}`);\n\t\t\t\treturn next(new Request(request, { headers }));\n\t\t\t}\n\t\t}\n\n\t\treturn response;\n\t};\n}\n"],"mappings":"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"}