emdash 0.8.0 → 0.10.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 (317) hide show
  1. package/dist/{adapters-BKSf3T9R.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-x0eMK1lX.mjs → apply-UsrFuO7l.mjs} +207 -355
  4. package/dist/apply-UsrFuO7l.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +118 -4
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +14 -57
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +15 -10
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +8 -5
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +70 -121
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +25 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{byline-Chbr2GoP.mjs → byline-C3vnhIpU.mjs} +4 -4
  26. package/dist/{byline-Chbr2GoP.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  27. package/dist/bylines-esI7ioa9.mjs +113 -0
  28. package/dist/bylines-esI7ioa9.mjs.map +1 -0
  29. package/dist/cache-fTzxgMFJ.mjs +65 -0
  30. package/dist/cache-fTzxgMFJ.mjs.map +1 -0
  31. package/dist/{chunks-HGz06Soa.mjs → chunks-Da2-b-oA.mjs} +8 -2
  32. package/dist/{chunks-HGz06Soa.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  33. package/dist/cli/index.mjs +456 -90
  34. package/dist/cli/index.mjs.map +1 -1
  35. package/dist/client/cf-access.d.mts +1 -1
  36. package/dist/client/index.d.mts +1 -1
  37. package/dist/client/index.mjs +3 -3
  38. package/dist/client/index.mjs.map +1 -1
  39. package/dist/{config-BXwuX8Bx.mjs → config-CVssduLe.mjs} +1 -1
  40. package/dist/{config-BXwuX8Bx.mjs.map → config-CVssduLe.mjs.map} +1 -1
  41. package/dist/{content-BcQPYxdV.mjs → content-C7G4QXkK.mjs} +42 -14
  42. package/dist/content-C7G4QXkK.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs +2 -2
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/libsql.d.mts.map +1 -1
  47. package/dist/db/libsql.mjs +7 -2
  48. package/dist/db/libsql.mjs.map +1 -1
  49. package/dist/db/postgres.d.mts +1 -1
  50. package/dist/db/sqlite.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts.map +1 -1
  52. package/dist/db/sqlite.mjs +8 -3
  53. package/dist/db/sqlite.mjs.map +1 -1
  54. package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  55. package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  56. package/dist/{default-DCVqE5ib.mjs → default-pHuz9WF6.mjs} +1 -1
  57. package/dist/{default-DCVqE5ib.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  58. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  59. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  60. package/dist/{error-zG5T1UGA.mjs → error-DqnRMM5z.mjs} +1 -1
  61. package/dist/{error-zG5T1UGA.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  62. package/dist/{index-DIb-CzNx.d.mts → index-DjPMOfO0.d.mts} +162 -87
  63. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  64. package/dist/index.d.mts +11 -11
  65. package/dist/index.mjs +27 -24
  66. package/dist/{load-CyEoextb.mjs → load-sXRuM7Us.mjs} +2 -2
  67. package/dist/{load-CyEoextb.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  68. package/dist/{loader-CndGj8kM.mjs → loader-Bx2_9-5e.mjs} +53 -8
  69. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  70. package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-CXAbd1vH.mjs} +33 -3
  71. package/dist/manifest-schema-CXAbd1vH.mjs.map +1 -0
  72. package/dist/media/index.d.mts +1 -1
  73. package/dist/media/index.mjs +1 -1
  74. package/dist/media/local-runtime.d.mts +7 -7
  75. package/dist/{mode-BnAOqItE.mjs → mode-YhqNVef_.mjs} +1 -1
  76. package/dist/{mode-BnAOqItE.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  77. package/dist/options-nPxWnrya.mjs +117 -0
  78. package/dist/options-nPxWnrya.mjs.map +1 -0
  79. package/dist/page/index.d.mts +2 -2
  80. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  81. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  82. package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  83. package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  84. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  85. package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  86. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  87. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  88. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  89. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  90. package/dist/public-url-B1AxbbbQ.mjs +51 -0
  91. package/dist/public-url-B1AxbbbQ.mjs.map +1 -0
  92. package/dist/{query-fqEdLFms.mjs → query-Bo-msrmu.mjs} +114 -16
  93. package/dist/query-Bo-msrmu.mjs.map +1 -0
  94. package/dist/{redirect-D_pshWdf.mjs → redirect-C5H7VGIX.mjs} +11 -6
  95. package/dist/redirect-C5H7VGIX.mjs.map +1 -0
  96. package/dist/{registry-C3Mr0ODu.mjs → registry-Beb7wxFc.mjs} +39 -5
  97. package/dist/registry-Beb7wxFc.mjs.map +1 -0
  98. package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  99. package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  100. package/dist/runner-Clwe4Mme.d.mts +44 -0
  101. package/dist/runner-Clwe4Mme.d.mts.map +1 -0
  102. package/dist/{runner-tQ7BJ4T7.mjs → runner-DMnlIkh4.mjs} +616 -191
  103. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  104. package/dist/runtime.d.mts +6 -6
  105. package/dist/runtime.mjs +2 -2
  106. package/dist/{search-BoZYFuUk.mjs → search-DkN-BqsS.mjs} +270 -152
  107. package/dist/search-DkN-BqsS.mjs.map +1 -0
  108. package/dist/secrets-CZ8rxLX3.mjs +314 -0
  109. package/dist/secrets-CZ8rxLX3.mjs.map +1 -0
  110. package/dist/seed/index.d.mts +2 -2
  111. package/dist/seed/index.mjs +13 -11
  112. package/dist/seo/index.d.mts +1 -1
  113. package/dist/storage/local.d.mts +1 -1
  114. package/dist/storage/local.mjs +1 -1
  115. package/dist/storage/s3.d.mts +1 -1
  116. package/dist/storage/s3.mjs +1 -1
  117. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  118. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  119. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  120. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  121. package/dist/{tokens-D9vnZqYS.mjs → tokens-CyRDPVW2.mjs} +1 -1
  122. package/dist/{tokens-D9vnZqYS.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  123. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  124. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  125. package/dist/{transport-CUnEL3Vs.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  126. package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  127. package/dist/{transport-C9ugt2Nr.mjs → transport-xpzIjCIB.mjs} +6 -5
  128. package/dist/{transport-C9ugt2Nr.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  129. package/dist/{types-BrA0xf5I.d.mts → types-B_CXXnzh.d.mts} +1 -1
  130. package/dist/{types-BrA0xf5I.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  131. package/dist/{types-DIMwPFub.d.mts → types-C-aFbqmA.d.mts} +1 -1
  132. package/dist/{types-DIMwPFub.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  133. package/dist/types-CoO6mpV3.mjs +68 -0
  134. package/dist/types-CoO6mpV3.mjs.map +1 -0
  135. package/dist/{types-i36XcA_X.d.mts → types-D19uBYWn.d.mts} +83 -7
  136. package/dist/types-D19uBYWn.d.mts.map +1 -0
  137. package/dist/{types-BmPPSUEx.d.mts → types-Dl1fgFjn.d.mts} +24 -2
  138. package/dist/{types-BmPPSUEx.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  139. package/dist/{types-CS8FIX7L.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  140. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  141. package/dist/{types-Bm1dn-q3.mjs → types-Eg829jj9.mjs} +1 -1
  142. package/dist/{types-Bm1dn-q3.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  143. package/dist/{types-CgqmmMJB.mjs → types-K-EkEQCI.mjs} +1 -1
  144. package/dist/{types-CgqmmMJB.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  145. package/dist/{validate-CxVsLehf.mjs → validate-CBIbxM3L.mjs} +14 -10
  146. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  147. package/dist/{validate-DHxmpFJt.d.mts → validate-DHGwADqO.d.mts} +18 -5
  148. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  149. package/dist/{validation-C-ZpN2GI.mjs → validation-B1NYiEos.mjs} +6 -6
  150. package/dist/{validation-C-ZpN2GI.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  151. package/dist/version-CMD42IRC.mjs +7 -0
  152. package/dist/{version-Bbq8TCrz.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  153. package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-BNJDQBSZ.mjs} +11 -6
  154. package/dist/{zod-generator-CpwccCIv.mjs.map → zod-generator-BNJDQBSZ.mjs.map} +1 -1
  155. package/locals.d.ts +1 -6
  156. package/package.json +9 -8
  157. package/src/api/handlers/comments.ts +6 -4
  158. package/src/api/handlers/content.ts +40 -1
  159. package/src/api/handlers/dashboard.ts +29 -36
  160. package/src/api/handlers/device-flow.ts +5 -0
  161. package/src/api/handlers/marketplace.ts +11 -4
  162. package/src/api/handlers/menus.ts +256 -75
  163. package/src/api/handlers/oauth-authorization.ts +72 -33
  164. package/src/api/handlers/revision.ts +23 -14
  165. package/src/api/handlers/taxonomies.ts +273 -100
  166. package/src/api/public-url.ts +48 -2
  167. package/src/api/schemas/comments.ts +2 -2
  168. package/src/api/schemas/common.ts +7 -0
  169. package/src/api/schemas/content.ts +17 -0
  170. package/src/api/schemas/menus.ts +23 -0
  171. package/src/api/schemas/sections.ts +3 -3
  172. package/src/api/schemas/taxonomies.ts +39 -0
  173. package/src/api/schemas/users.ts +1 -1
  174. package/src/api/types.ts +5 -1
  175. package/src/astro/integration/index.ts +17 -0
  176. package/src/astro/integration/routes.ts +10 -0
  177. package/src/astro/integration/runtime.ts +30 -0
  178. package/src/astro/integration/virtual-modules.ts +32 -2
  179. package/src/astro/integration/vite-config.ts +6 -1
  180. package/src/astro/middleware/auth.ts +13 -6
  181. package/src/astro/middleware/redirect.ts +29 -16
  182. package/src/astro/middleware/request-context.ts +15 -5
  183. package/src/astro/middleware.ts +23 -9
  184. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  185. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  186. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  187. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  188. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  189. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  190. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  191. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  192. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  193. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  194. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  195. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  196. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  197. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  198. package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
  199. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  200. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  201. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  202. package/src/astro/routes/api/manifest.ts +62 -45
  203. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  204. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  205. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  206. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  207. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  208. package/src/astro/routes/api/menus/[name].ts +19 -10
  209. package/src/astro/routes/api/menus/index.ts +9 -6
  210. package/src/astro/routes/api/openapi.json.ts +27 -10
  211. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  212. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  213. package/src/astro/routes/api/redirects/[id].ts +10 -4
  214. package/src/astro/routes/api/redirects/index.ts +7 -3
  215. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  216. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  217. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  218. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  219. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  220. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  221. package/src/astro/routes/api/search/index.ts +10 -2
  222. package/src/astro/routes/api/sections/[slug].ts +10 -4
  223. package/src/astro/routes/api/sections/index.ts +7 -3
  224. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  225. package/src/astro/routes/api/snapshot.ts +44 -18
  226. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  227. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  228. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  229. package/src/astro/routes/api/taxonomies/index.ts +9 -7
  230. package/src/astro/routes/api/themes/preview.ts +11 -5
  231. package/src/astro/types.ts +23 -3
  232. package/src/auth/allowed-origins.ts +168 -0
  233. package/src/auth/passkey-config.ts +35 -13
  234. package/src/bylines/index.ts +37 -88
  235. package/src/cli/commands/auth.ts +28 -6
  236. package/src/cli/commands/bundle-utils.ts +11 -2
  237. package/src/cli/commands/bundle.ts +28 -8
  238. package/src/cli/commands/content.ts +13 -0
  239. package/src/cli/commands/export-seed.ts +82 -21
  240. package/src/cli/commands/login.ts +8 -1
  241. package/src/cli/commands/plugin-init.ts +216 -90
  242. package/src/cli/commands/publish.ts +24 -0
  243. package/src/cli/commands/secrets.ts +183 -0
  244. package/src/cli/credentials.ts +1 -1
  245. package/src/cli/index.ts +5 -1
  246. package/src/client/index.ts +4 -4
  247. package/src/client/transport.ts +17 -7
  248. package/src/components/Break.astro +2 -2
  249. package/src/components/EmDashHead.astro +18 -13
  250. package/src/components/Embed.astro +1 -1
  251. package/src/components/Gallery.astro +1 -1
  252. package/src/components/Image.astro +1 -1
  253. package/src/components/InlinePortableTextEditor.tsx +104 -18
  254. package/src/config/secrets.ts +528 -0
  255. package/src/database/dialect-helpers.ts +50 -0
  256. package/src/database/migrations/034_published_at_index.ts +1 -1
  257. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  258. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  259. package/src/database/migrations/runner.ts +158 -23
  260. package/src/database/repositories/content.ts +47 -12
  261. package/src/database/repositories/redirect.ts +14 -3
  262. package/src/database/repositories/taxonomy.ts +212 -82
  263. package/src/database/types.ts +10 -2
  264. package/src/db/libsql.ts +1 -3
  265. package/src/db/sqlite.ts +2 -5
  266. package/src/emdash-runtime.ts +84 -159
  267. package/src/i18n/resolve.ts +37 -0
  268. package/src/index.ts +9 -0
  269. package/src/loader.ts +73 -3
  270. package/src/mcp/server.ts +180 -54
  271. package/src/menus/index.ts +143 -124
  272. package/src/menus/types.ts +15 -1
  273. package/src/page/site-identity.ts +58 -0
  274. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  275. package/src/plugins/context.ts +13 -10
  276. package/src/plugins/define-plugin.ts +40 -12
  277. package/src/plugins/hooks.ts +23 -19
  278. package/src/plugins/index.ts +9 -0
  279. package/src/plugins/manifest-schema.ts +37 -2
  280. package/src/plugins/types.ts +151 -11
  281. package/src/preview/urls.ts +23 -3
  282. package/src/query.ts +148 -5
  283. package/src/redirects/cache.ts +38 -18
  284. package/src/schema/registry.ts +56 -0
  285. package/src/schema/zod-generator.ts +39 -7
  286. package/src/seed/apply.ts +142 -54
  287. package/src/seed/types.ts +14 -1
  288. package/src/seed/validate.ts +27 -13
  289. package/src/settings/index.ts +80 -6
  290. package/src/settings/types.ts +23 -1
  291. package/src/taxonomies/index.ts +237 -210
  292. package/src/taxonomies/types.ts +10 -0
  293. package/dist/apply-x0eMK1lX.mjs.map +0 -1
  294. package/dist/bylines-CRNsVG88.mjs +0 -157
  295. package/dist/bylines-CRNsVG88.mjs.map +0 -1
  296. package/dist/cache-BkKBuIvS.mjs +0 -56
  297. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  298. package/dist/chunk-ClPoSABd.mjs +0 -21
  299. package/dist/content-BcQPYxdV.mjs.map +0 -1
  300. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  301. package/dist/index-DIb-CzNx.d.mts.map +0 -1
  302. package/dist/loader-CndGj8kM.mjs.map +0 -1
  303. package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
  304. package/dist/query-fqEdLFms.mjs.map +0 -1
  305. package/dist/redirect-D_pshWdf.mjs.map +0 -1
  306. package/dist/registry-C3Mr0ODu.mjs.map +0 -1
  307. package/dist/runner-OURCaApa.d.mts +0 -34
  308. package/dist/runner-OURCaApa.d.mts.map +0 -1
  309. package/dist/runner-tQ7BJ4T7.mjs.map +0 -1
  310. package/dist/search-BoZYFuUk.mjs.map +0 -1
  311. package/dist/taxonomies-B4IAshV8.mjs +0 -308
  312. package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
  313. package/dist/types-CS8FIX7L.d.mts.map +0 -1
  314. package/dist/types-i36XcA_X.d.mts.map +0 -1
  315. package/dist/validate-CxVsLehf.mjs.map +0 -1
  316. package/dist/validate-DHxmpFJt.d.mts.map +0 -1
  317. package/dist/version-Bbq8TCrz.mjs +0 -7
@@ -1,6 +1,8 @@
1
1
  import type { Kysely } from "kysely";
2
2
  import { sql } from "kysely";
3
3
 
4
+ import { columnExists } from "../dialect-helpers.js";
5
+
4
6
  /**
5
7
  * Migration: Bounded 404 logging
6
8
  *
@@ -19,16 +21,22 @@ import { sql } from "kysely";
19
21
  */
20
22
 
21
23
  export async function up(db: Kysely<unknown>): Promise<void> {
24
+ const hitsExists = await columnExists(db, "_emdash_404_log", "hits");
25
+
22
26
  // 1. Add columns.
23
- await db.schema
24
- .alterTable("_emdash_404_log")
25
- .addColumn("hits", "integer", (col) => col.notNull().defaultTo(1))
26
- .execute();
27
+ if (!hitsExists) {
28
+ await db.schema
29
+ .alterTable("_emdash_404_log")
30
+ .addColumn("hits", "integer", (col) => col.notNull().defaultTo(1))
31
+ .execute();
32
+ }
27
33
 
28
34
  // SQLite won't accept a non-constant default when adding a NOT NULL column
29
35
  // to a table with existing rows, so backfill in two steps: add nullable,
30
36
  // populate, then rely on the application layer / future inserts to set it.
31
- await db.schema.alterTable("_emdash_404_log").addColumn("last_seen_at", "text").execute();
37
+ if (!(await columnExists(db, "_emdash_404_log", "last_seen_at"))) {
38
+ await db.schema.alterTable("_emdash_404_log").addColumn("last_seen_at", "text").execute();
39
+ }
32
40
 
33
41
  // Backfill last_seen_at from created_at for existing rows.
34
42
  await sql`
@@ -44,68 +52,77 @@ export async function up(db: Kysely<unknown>): Promise<void> {
44
52
  // (3.25+, 2018) and Postgres. The previous GROUP BY approach was
45
53
  // accepted by SQLite but invalid on Postgres because `id` wasn't in
46
54
  // the GROUP BY or wrapped in an aggregate.
47
- await sql`
48
- WITH ranked AS (
49
- SELECT
50
- id,
51
- path,
52
- ROW_NUMBER() OVER (
53
- PARTITION BY path
54
- ORDER BY created_at DESC, id DESC
55
- ) AS rn,
56
- COUNT(*) OVER (PARTITION BY path) AS path_count,
57
- MAX(created_at) OVER (PARTITION BY path) AS latest_created_at
58
- FROM _emdash_404_log
59
- )
60
- UPDATE _emdash_404_log
61
- SET
62
- hits = (SELECT path_count FROM ranked WHERE ranked.id = _emdash_404_log.id),
63
- last_seen_at = (SELECT latest_created_at FROM ranked WHERE ranked.id = _emdash_404_log.id)
64
- WHERE id IN (SELECT id FROM ranked WHERE rn = 1)
65
- `.execute(db);
66
-
67
- // Delete the non-keepers (every row except the freshest per path).
68
- await sql`
69
- DELETE FROM _emdash_404_log
70
- WHERE id IN (
71
- SELECT id FROM (
55
+ if (!hitsExists) {
56
+ await sql`
57
+ WITH ranked AS (
72
58
  SELECT
73
59
  id,
60
+ path,
74
61
  ROW_NUMBER() OVER (
75
62
  PARTITION BY path
76
63
  ORDER BY created_at DESC, id DESC
77
- ) AS rn
64
+ ) AS rn,
65
+ COUNT(*) OVER (PARTITION BY path) AS path_count,
66
+ MAX(created_at) OVER (PARTITION BY path) AS latest_created_at
78
67
  FROM _emdash_404_log
79
- ) AS ranked
80
- WHERE rn > 1
81
- )
82
- `.execute(db);
68
+ )
69
+ UPDATE _emdash_404_log
70
+ SET
71
+ hits = (SELECT path_count FROM ranked WHERE ranked.id = _emdash_404_log.id),
72
+ last_seen_at = (SELECT latest_created_at FROM ranked WHERE ranked.id = _emdash_404_log.id)
73
+ WHERE id IN (SELECT id FROM ranked WHERE rn = 1)
74
+ `.execute(db);
75
+
76
+ // Delete the non-keepers (every row except the freshest per path).
77
+ await sql`
78
+ DELETE FROM _emdash_404_log
79
+ WHERE id IN (
80
+ SELECT id FROM (
81
+ SELECT
82
+ id,
83
+ ROW_NUMBER() OVER (
84
+ PARTITION BY path
85
+ ORDER BY created_at DESC, id DESC
86
+ ) AS rn
87
+ FROM _emdash_404_log
88
+ ) AS ranked
89
+ WHERE rn > 1
90
+ )
91
+ `.execute(db);
92
+ }
83
93
 
84
94
  // 3. Add unique index on path for upsert semantics.
85
95
  await db.schema
86
96
  .createIndex("idx_404_log_path_unique")
97
+ .ifNotExists()
87
98
  .on("_emdash_404_log")
88
99
  .column("path")
89
100
  .unique()
90
101
  .execute();
91
102
 
92
103
  // Drop the old non-unique index; the unique one covers the same lookups.
93
- await db.schema.dropIndex("idx_404_log_path").execute();
104
+ await db.schema.dropIndex("idx_404_log_path").ifExists().execute();
94
105
 
95
106
  // 4. Index on last_seen_at for eviction ordering.
96
107
  await db.schema
97
108
  .createIndex("idx_404_log_last_seen")
109
+ .ifNotExists()
98
110
  .on("_emdash_404_log")
99
111
  .column("last_seen_at")
100
112
  .execute();
101
113
  }
102
114
 
103
115
  export async function down(db: Kysely<unknown>): Promise<void> {
104
- await db.schema.dropIndex("idx_404_log_last_seen").execute();
105
- await db.schema.dropIndex("idx_404_log_path_unique").execute();
116
+ await db.schema.dropIndex("idx_404_log_last_seen").ifExists().execute();
117
+ await db.schema.dropIndex("idx_404_log_path_unique").ifExists().execute();
106
118
 
107
119
  // Restore the original non-unique path index.
108
- await db.schema.createIndex("idx_404_log_path").on("_emdash_404_log").column("path").execute();
120
+ await db.schema
121
+ .createIndex("idx_404_log_path")
122
+ .ifNotExists()
123
+ .on("_emdash_404_log")
124
+ .column("path")
125
+ .execute();
109
126
 
110
127
  await db.schema.alterTable("_emdash_404_log").dropColumn("last_seen_at").execute();
111
128
  await db.schema.alterTable("_emdash_404_log").dropColumn("hits").execute();
@@ -0,0 +1,477 @@
1
+ import type { Kysely } from "kysely";
2
+ import { sql } from "kysely";
3
+
4
+ import { getI18nConfig } from "../../i18n/config.js";
5
+ import { currentTimestamp, isSqlite } from "../dialect-helpers.js";
6
+ import { validateIdentifier } from "../validate.js";
7
+
8
+ /**
9
+ * i18n for menus + taxonomies. Adds `locale` + `translation_group` to system
10
+ * tables and stores translation_groups (not row ids) in
11
+ * `_emdash_menu_items.reference_id` and `content_taxonomies.taxonomy_id`.
12
+ * Backfill locale and column DEFAULTs use the site's configured defaultLocale.
13
+ */
14
+
15
+ function getDefaultLocale(): string {
16
+ return getI18nConfig()?.defaultLocale ?? "en";
17
+ }
18
+
19
+ export async function up(db: Kysely<unknown>): Promise<void> {
20
+ const defaultLocale = getDefaultLocale();
21
+
22
+ if (isSqlite(db)) {
23
+ // FKs off: rebuilding `taxonomies` would CASCADE-wipe `content_taxonomies`.
24
+ await sql.raw(`PRAGMA foreign_keys = OFF`).execute(db);
25
+ try {
26
+ await rebuildMenus(db, defaultLocale);
27
+ await addItemColumns(db, defaultLocale);
28
+ await rebuildTaxonomies(db, defaultLocale);
29
+ await rebuildTaxonomyDefs(db, defaultLocale);
30
+ await rebuildContentTaxonomies(db);
31
+ await remapMenuItemRefs(db);
32
+ } finally {
33
+ await sql.raw(`PRAGMA foreign_keys = ON`).execute(db);
34
+ }
35
+ return;
36
+ }
37
+
38
+ await pgWiden(db, "_emdash_menus", ["name"], ["name", "locale"], defaultLocale);
39
+ await pgWiden(db, "_emdash_menu_items", null, null, defaultLocale);
40
+ await pgWiden(db, "taxonomies", ["name", "slug"], ["name", "slug", "locale"], defaultLocale);
41
+ await pgWiden(db, "_emdash_taxonomy_defs", ["name"], ["name", "locale"], defaultLocale);
42
+ await pgRemapContentTaxonomies(db);
43
+ await remapMenuItemRefs(db);
44
+ }
45
+
46
+ async function rebuildMenus(db: Kysely<unknown>, defaultLocale: string): Promise<void> {
47
+ if (await hasColumn(db, "_emdash_menus", "locale")) return;
48
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_menus_new"`).execute(db);
49
+
50
+ await db.schema
51
+ .createTable("_emdash_menus_new")
52
+ .addColumn("id", "text", (c) => c.primaryKey())
53
+ .addColumn("name", "text", (c) => c.notNull())
54
+ .addColumn("label", "text", (c) => c.notNull())
55
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
56
+ .addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
57
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
58
+ .addColumn("translation_group", "text")
59
+ .addUniqueConstraint("_emdash_menus_name_locale_unique", ["name", "locale"])
60
+ .execute();
61
+
62
+ await sql`
63
+ INSERT INTO _emdash_menus_new (id, name, label, created_at, updated_at, locale, translation_group)
64
+ SELECT id, name, label, created_at, updated_at, ${defaultLocale}, id FROM _emdash_menus
65
+ `.execute(db);
66
+
67
+ await db.schema.dropTable("_emdash_menus").execute();
68
+ await sql`ALTER TABLE _emdash_menus_new RENAME TO _emdash_menus`.execute(db);
69
+
70
+ await db.schema
71
+ .createIndex("idx__emdash_menus_locale")
72
+ .on("_emdash_menus")
73
+ .column("locale")
74
+ .execute();
75
+ await db.schema
76
+ .createIndex("idx__emdash_menus_translation_group")
77
+ .on("_emdash_menus")
78
+ .column("translation_group")
79
+ .execute();
80
+ }
81
+
82
+ async function addItemColumns(db: Kysely<unknown>, defaultLocale: string): Promise<void> {
83
+ if (await hasColumn(db, "_emdash_menu_items", "locale")) return;
84
+
85
+ await db.schema
86
+ .alterTable("_emdash_menu_items")
87
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
88
+ .execute();
89
+ await db.schema.alterTable("_emdash_menu_items").addColumn("translation_group", "text").execute();
90
+
91
+ await sql`UPDATE _emdash_menu_items SET translation_group = id`.execute(db);
92
+
93
+ await db.schema
94
+ .createIndex("idx__emdash_menu_items_locale")
95
+ .on("_emdash_menu_items")
96
+ .column("locale")
97
+ .execute();
98
+ await db.schema
99
+ .createIndex("idx__emdash_menu_items_translation_group")
100
+ .on("_emdash_menu_items")
101
+ .column("translation_group")
102
+ .execute();
103
+ }
104
+
105
+ async function rebuildTaxonomies(db: Kysely<unknown>, defaultLocale: string): Promise<void> {
106
+ if (await hasColumn(db, "taxonomies", "locale")) return;
107
+ await sql.raw(`DROP TABLE IF EXISTS "taxonomies_new"`).execute(db);
108
+ await sql`DROP INDEX IF EXISTS idx_taxonomies_name`.execute(db);
109
+
110
+ await db.schema
111
+ .createTable("taxonomies_new")
112
+ .addColumn("id", "text", (c) => c.primaryKey())
113
+ .addColumn("name", "text", (c) => c.notNull())
114
+ .addColumn("slug", "text", (c) => c.notNull())
115
+ .addColumn("label", "text", (c) => c.notNull())
116
+ .addColumn("parent_id", "text")
117
+ .addColumn("data", "text")
118
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
119
+ .addColumn("translation_group", "text")
120
+ .addUniqueConstraint("taxonomies_name_slug_locale_unique", ["name", "slug", "locale"])
121
+ .addForeignKeyConstraint("taxonomies_parent_fk", ["parent_id"], "taxonomies", ["id"], (cb) =>
122
+ cb.onDelete("set null"),
123
+ )
124
+ .execute();
125
+
126
+ await sql`
127
+ INSERT INTO taxonomies_new (id, name, slug, label, parent_id, data, locale, translation_group)
128
+ SELECT id, name, slug, label, parent_id, data, ${defaultLocale}, id FROM taxonomies
129
+ `.execute(db);
130
+
131
+ await db.schema.dropTable("taxonomies").execute();
132
+ await sql`ALTER TABLE taxonomies_new RENAME TO taxonomies`.execute(db);
133
+
134
+ await db.schema.createIndex("idx_taxonomies_name").on("taxonomies").column("name").execute();
135
+ await db.schema.createIndex("idx_taxonomies_locale").on("taxonomies").column("locale").execute();
136
+ await db.schema
137
+ .createIndex("idx_taxonomies_translation_group")
138
+ .on("taxonomies")
139
+ .column("translation_group")
140
+ .execute();
141
+ }
142
+
143
+ async function rebuildTaxonomyDefs(db: Kysely<unknown>, defaultLocale: string): Promise<void> {
144
+ if (await hasColumn(db, "_emdash_taxonomy_defs", "locale")) return;
145
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_taxonomy_defs_new"`).execute(db);
146
+
147
+ await db.schema
148
+ .createTable("_emdash_taxonomy_defs_new")
149
+ .addColumn("id", "text", (c) => c.primaryKey())
150
+ .addColumn("name", "text", (c) => c.notNull())
151
+ .addColumn("label", "text", (c) => c.notNull())
152
+ .addColumn("label_singular", "text")
153
+ .addColumn("hierarchical", "integer", (c) => c.defaultTo(0))
154
+ .addColumn("collections", "text")
155
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
156
+ .addColumn("locale", "text", (c) => c.notNull().defaultTo(defaultLocale))
157
+ .addColumn("translation_group", "text")
158
+ .addUniqueConstraint("_emdash_taxonomy_defs_name_locale_unique", ["name", "locale"])
159
+ .execute();
160
+
161
+ await sql`
162
+ INSERT INTO _emdash_taxonomy_defs_new
163
+ (id, name, label, label_singular, hierarchical, collections, created_at, locale, translation_group)
164
+ SELECT id, name, label, label_singular, hierarchical, collections, created_at, ${defaultLocale}, id
165
+ FROM _emdash_taxonomy_defs
166
+ `.execute(db);
167
+
168
+ await db.schema.dropTable("_emdash_taxonomy_defs").execute();
169
+ await sql`ALTER TABLE _emdash_taxonomy_defs_new RENAME TO _emdash_taxonomy_defs`.execute(db);
170
+
171
+ await db.schema
172
+ .createIndex("idx__emdash_taxonomy_defs_locale")
173
+ .on("_emdash_taxonomy_defs")
174
+ .column("locale")
175
+ .execute();
176
+ await db.schema
177
+ .createIndex("idx__emdash_taxonomy_defs_translation_group")
178
+ .on("_emdash_taxonomy_defs")
179
+ .column("translation_group")
180
+ .execute();
181
+ }
182
+
183
+ async function rebuildContentTaxonomies(db: Kysely<unknown>): Promise<void> {
184
+ // Drop the FK (taxonomy_id now points at translation_group, not a row id)
185
+ // and remap the values.
186
+ const fks = await sql<{ id: number }>`PRAGMA foreign_key_list(content_taxonomies)`.execute(db);
187
+ if (fks.rows.length === 0) return;
188
+
189
+ await sql.raw(`DROP TABLE IF EXISTS "content_taxonomies_new"`).execute(db);
190
+ await db.schema
191
+ .createTable("content_taxonomies_new")
192
+ .addColumn("collection", "text", (c) => c.notNull())
193
+ .addColumn("entry_id", "text", (c) => c.notNull())
194
+ .addColumn("taxonomy_id", "text", (c) => c.notNull())
195
+ .addPrimaryKeyConstraint("content_taxonomies_pk", ["collection", "entry_id", "taxonomy_id"])
196
+ .execute();
197
+
198
+ await sql`
199
+ INSERT OR IGNORE INTO content_taxonomies_new (collection, entry_id, taxonomy_id)
200
+ SELECT ct.collection, ct.entry_id, COALESCE(
201
+ (SELECT t.translation_group FROM taxonomies t WHERE t.id = ct.taxonomy_id),
202
+ ct.taxonomy_id
203
+ )
204
+ FROM content_taxonomies ct
205
+ `.execute(db);
206
+
207
+ await db.schema.dropTable("content_taxonomies").execute();
208
+ await sql`ALTER TABLE content_taxonomies_new RENAME TO content_taxonomies`.execute(db);
209
+ }
210
+
211
+ async function remapMenuItemRefs(db: Kysely<unknown>): Promise<void> {
212
+ // Items with `reference_collection IS NULL` are left untouched — the
213
+ // runtime fallback in `menus/index.ts` resolves them by id.
214
+ const collections = await sql<{ slug: string }>`SELECT slug FROM _emdash_collections`.execute(db);
215
+ for (const { slug } of collections.rows) {
216
+ validateIdentifier(slug, "collection slug");
217
+ const ec = sql.ref(`ec_${slug}`);
218
+ await sql`
219
+ UPDATE _emdash_menu_items SET reference_id = (
220
+ SELECT translation_group FROM ${ec} WHERE ${ec}.id = _emdash_menu_items.reference_id
221
+ )
222
+ WHERE reference_collection = ${slug} AND reference_id IS NOT NULL
223
+ AND EXISTS (SELECT 1 FROM ${ec} WHERE ${ec}.id = _emdash_menu_items.reference_id)
224
+ `.execute(db);
225
+ }
226
+ await sql`
227
+ UPDATE _emdash_menu_items SET reference_id = (
228
+ SELECT translation_group FROM taxonomies WHERE taxonomies.id = _emdash_menu_items.reference_id
229
+ )
230
+ WHERE type = 'taxonomy' AND reference_id IS NOT NULL
231
+ AND EXISTS (SELECT 1 FROM taxonomies WHERE taxonomies.id = _emdash_menu_items.reference_id)
232
+ `.execute(db);
233
+ }
234
+
235
+ async function pgWiden(
236
+ db: Kysely<unknown>,
237
+ table: string,
238
+ oldCols: string[] | null,
239
+ newCols: string[] | null,
240
+ defaultLocale: string,
241
+ ): Promise<void> {
242
+ validateSystemIdent(table);
243
+ const ref = sql.ref(table);
244
+ await sql`ALTER TABLE ${ref} ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT ${sql.lit(defaultLocale)}`.execute(
245
+ db,
246
+ );
247
+ await sql`ALTER TABLE ${ref} ADD COLUMN IF NOT EXISTS translation_group TEXT`.execute(db);
248
+ await sql`UPDATE ${ref} SET translation_group = id WHERE translation_group IS NULL`.execute(db);
249
+ await sql`CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table}_locale`)} ON ${ref} (locale)`.execute(
250
+ db,
251
+ );
252
+ await sql`
253
+ CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table}_translation_group`)} ON ${ref} (translation_group)
254
+ `.execute(db);
255
+
256
+ if (!oldCols || !newCols) return;
257
+ for (const c of [...oldCols, ...newCols]) validateSystemIdent(c);
258
+ const cons = await sql<{ conname: string }>`
259
+ SELECT conname FROM pg_constraint c
260
+ WHERE c.conrelid = ${table}::regclass AND c.contype = 'u'
261
+ AND array_length(c.conkey, 1) = ${oldCols.length}
262
+ AND (
263
+ SELECT array_agg(a.attname ORDER BY pos.ord)
264
+ FROM unnest(c.conkey) WITH ORDINALITY AS pos(attnum, ord)
265
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = pos.attnum
266
+ )::text[] = ${oldCols}::text[]
267
+ `.execute(db);
268
+ for (const c of cons.rows) {
269
+ await sql`ALTER TABLE ${ref} DROP CONSTRAINT ${sql.ref(c.conname)}`.execute(db);
270
+ }
271
+ const cols = sql.join(
272
+ newCols.map((c) => sql.ref(c)),
273
+ sql`, `,
274
+ );
275
+ await sql`
276
+ ALTER TABLE ${ref}
277
+ ADD CONSTRAINT ${sql.ref(`${table}_${newCols.join("_")}_unique`)} UNIQUE (${cols})
278
+ `.execute(db);
279
+ }
280
+
281
+ async function pgRemapContentTaxonomies(db: Kysely<unknown>): Promise<void> {
282
+ const fks = await sql<{ conname: string }>`
283
+ SELECT conname FROM pg_constraint
284
+ WHERE conrelid = 'content_taxonomies'::regclass AND contype = 'f'
285
+ `.execute(db);
286
+ for (const c of fks.rows) {
287
+ await sql`ALTER TABLE content_taxonomies DROP CONSTRAINT ${sql.ref(c.conname)}`.execute(db);
288
+ }
289
+ await sql`
290
+ UPDATE content_taxonomies SET taxonomy_id = t.translation_group
291
+ FROM taxonomies t WHERE t.id = content_taxonomies.taxonomy_id
292
+ `.execute(db);
293
+ }
294
+
295
+ async function hasColumn(db: Kysely<unknown>, table: string, column: string): Promise<boolean> {
296
+ const rows = await sql<{ name: string }>`PRAGMA table_info(${sql.ref(table)})`.execute(db);
297
+ return rows.rows.some((r) => r.name === column);
298
+ }
299
+
300
+ const SYSTEM_IDENT = /^[_a-z][a-z0-9_]*$/;
301
+ function validateSystemIdent(name: string): void {
302
+ if (!SYSTEM_IDENT.test(name)) throw new Error(`Invalid identifier: "${name}"`);
303
+ }
304
+
305
+ /**
306
+ * down() is destructive on multi-locale installs (dropping `locale` collapses
307
+ * translated rows onto an ambiguous unique key). Refuse to run when any row
308
+ * sits at a locale other than the configured defaultLocale.
309
+ */
310
+ async function assertSingleLocale(db: Kysely<unknown>, defaultLocale: string): Promise<void> {
311
+ const tables = ["_emdash_menus", "_emdash_menu_items", "taxonomies", "_emdash_taxonomy_defs"];
312
+ for (const table of tables) {
313
+ validateSystemIdent(table);
314
+ const result = await sql<{ count: number | string }>`
315
+ SELECT COUNT(*) AS count FROM ${sql.ref(table)} WHERE locale != ${defaultLocale}
316
+ `.execute(db);
317
+ const count = Number(result.rows[0]?.count ?? 0);
318
+ if (count > 0) {
319
+ throw new Error(
320
+ `Cannot revert migration 036_i18n_menus_and_taxonomies: ` +
321
+ `${count} row(s) in "${table}" use a non-default locale ` +
322
+ `(defaultLocale="${defaultLocale}"). ` +
323
+ `Reverting would drop them silently. Export translations first ` +
324
+ `(or delete them) and re-run the rollback. ` +
325
+ `See packages/core/src/database/migrations/036_i18n_menus_and_taxonomies.ts.`,
326
+ );
327
+ }
328
+ }
329
+ }
330
+
331
+ export async function down(db: Kysely<unknown>): Promise<void> {
332
+ const defaultLocale = getDefaultLocale();
333
+ await assertSingleLocale(db, defaultLocale);
334
+
335
+ const widenedTables = [
336
+ "_emdash_menus",
337
+ "_emdash_menu_items",
338
+ "taxonomies",
339
+ "_emdash_taxonomy_defs",
340
+ ];
341
+
342
+ if (isSqlite(db)) {
343
+ // FKs off — same reason as up().
344
+ await sql.raw(`PRAGMA foreign_keys = OFF`).execute(db);
345
+ try {
346
+ // Indexes first: a locale index blocks DROP COLUMN on _emdash_menu_items.
347
+ for (const t of widenedTables) {
348
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_locale`).execute(db);
349
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_translation_group`).execute(db);
350
+ }
351
+
352
+ await rebuildContentTaxonomiesDown(db, defaultLocale);
353
+ await rebuildMenusDown(db);
354
+ await rebuildMenuItemsDown(db);
355
+ await rebuildTaxonomiesDown(db);
356
+ await rebuildTaxonomyDefsDown(db);
357
+ } finally {
358
+ await sql.raw(`PRAGMA foreign_keys = ON`).execute(db);
359
+ }
360
+ return;
361
+ }
362
+
363
+ for (const t of widenedTables) {
364
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_locale`).execute(db);
365
+ await sql.raw(`DROP INDEX IF EXISTS idx_${t}_translation_group`).execute(db);
366
+ await sql.raw(`ALTER TABLE "${t}" DROP COLUMN IF EXISTS locale`).execute(db);
367
+ await sql.raw(`ALTER TABLE "${t}" DROP COLUMN IF EXISTS translation_group`).execute(db);
368
+ }
369
+ }
370
+
371
+ async function rebuildContentTaxonomiesDown(
372
+ db: Kysely<unknown>,
373
+ defaultLocale: string,
374
+ ): Promise<void> {
375
+ await sql.raw(`DROP TABLE IF EXISTS "content_taxonomies_new"`).execute(db);
376
+ await db.schema
377
+ .createTable("content_taxonomies_new")
378
+ .addColumn("collection", "text", (c) => c.notNull())
379
+ .addColumn("entry_id", "text", (c) => c.notNull())
380
+ .addColumn("taxonomy_id", "text", (c) => c.notNull())
381
+ .addPrimaryKeyConstraint("content_taxonomies_pk", ["collection", "entry_id", "taxonomy_id"])
382
+ .addForeignKeyConstraint(
383
+ "content_taxonomies_taxonomy_fk",
384
+ ["taxonomy_id"],
385
+ "taxonomies",
386
+ ["id"],
387
+ (cb) => cb.onDelete("cascade"),
388
+ )
389
+ .execute();
390
+
391
+ // Map translation_group back to a row id (assertSingleLocale guarantees a 1:1 match).
392
+ await sql`
393
+ INSERT OR IGNORE INTO content_taxonomies_new (collection, entry_id, taxonomy_id)
394
+ SELECT ct.collection, ct.entry_id, COALESCE(
395
+ (SELECT t.id FROM taxonomies t WHERE t.translation_group = ct.taxonomy_id AND t.locale = ${defaultLocale}),
396
+ ct.taxonomy_id
397
+ )
398
+ FROM content_taxonomies ct
399
+ `.execute(db);
400
+
401
+ await db.schema.dropTable("content_taxonomies").execute();
402
+ await sql`ALTER TABLE content_taxonomies_new RENAME TO content_taxonomies`.execute(db);
403
+ }
404
+
405
+ async function rebuildMenusDown(db: Kysely<unknown>): Promise<void> {
406
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_menus_old"`).execute(db);
407
+ await db.schema
408
+ .createTable("_emdash_menus_old")
409
+ .addColumn("id", "text", (c) => c.primaryKey())
410
+ .addColumn("name", "text", (c) => c.notNull().unique())
411
+ .addColumn("label", "text", (c) => c.notNull())
412
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
413
+ .addColumn("updated_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
414
+ .execute();
415
+ await sql`
416
+ INSERT INTO _emdash_menus_old (id, name, label, created_at, updated_at)
417
+ SELECT id, name, label, created_at, updated_at FROM _emdash_menus
418
+ `.execute(db);
419
+ await db.schema.dropTable("_emdash_menus").execute();
420
+ await sql`ALTER TABLE _emdash_menus_old RENAME TO _emdash_menus`.execute(db);
421
+ }
422
+
423
+ async function rebuildMenuItemsDown(db: Kysely<unknown>): Promise<void> {
424
+ // No UNIQUE on (locale,…) here, so DROP COLUMN is enough.
425
+ await sql.raw(`ALTER TABLE _emdash_menu_items DROP COLUMN locale`).execute(db);
426
+ await sql.raw(`ALTER TABLE _emdash_menu_items DROP COLUMN translation_group`).execute(db);
427
+ }
428
+
429
+ async function rebuildTaxonomiesDown(db: Kysely<unknown>): Promise<void> {
430
+ await sql.raw(`DROP TABLE IF EXISTS "taxonomies_old"`).execute(db);
431
+ await db.schema
432
+ .createTable("taxonomies_old")
433
+ .addColumn("id", "text", (c) => c.primaryKey())
434
+ .addColumn("name", "text", (c) => c.notNull())
435
+ .addColumn("slug", "text", (c) => c.notNull())
436
+ .addColumn("label", "text", (c) => c.notNull())
437
+ .addColumn("parent_id", "text")
438
+ .addColumn("data", "text")
439
+ .addUniqueConstraint("taxonomies_name_slug_unique", ["name", "slug"])
440
+ .addForeignKeyConstraint(
441
+ "taxonomies_parent_fk",
442
+ ["parent_id"],
443
+ "taxonomies_old",
444
+ ["id"],
445
+ (cb) => cb.onDelete("set null"),
446
+ )
447
+ .execute();
448
+ await sql`
449
+ INSERT INTO taxonomies_old (id, name, slug, label, parent_id, data)
450
+ SELECT id, name, slug, label, parent_id, data FROM taxonomies
451
+ `.execute(db);
452
+ await db.schema.dropTable("taxonomies").execute();
453
+ await sql`ALTER TABLE taxonomies_old RENAME TO taxonomies`.execute(db);
454
+ await db.schema.createIndex("idx_taxonomies_name").on("taxonomies").column("name").execute();
455
+ }
456
+
457
+ async function rebuildTaxonomyDefsDown(db: Kysely<unknown>): Promise<void> {
458
+ await sql.raw(`DROP TABLE IF EXISTS "_emdash_taxonomy_defs_old"`).execute(db);
459
+ await db.schema
460
+ .createTable("_emdash_taxonomy_defs_old")
461
+ .addColumn("id", "text", (c) => c.primaryKey())
462
+ .addColumn("name", "text", (c) => c.notNull().unique())
463
+ .addColumn("label", "text", (c) => c.notNull())
464
+ .addColumn("label_singular", "text")
465
+ .addColumn("hierarchical", "integer", (c) => c.defaultTo(0))
466
+ .addColumn("collections", "text")
467
+ .addColumn("created_at", "text", (c) => c.defaultTo(currentTimestamp(db)))
468
+ .execute();
469
+ await sql`
470
+ INSERT INTO _emdash_taxonomy_defs_old
471
+ (id, name, label, label_singular, hierarchical, collections, created_at)
472
+ SELECT id, name, label, label_singular, hierarchical, collections, created_at
473
+ FROM _emdash_taxonomy_defs
474
+ `.execute(db);
475
+ await db.schema.dropTable("_emdash_taxonomy_defs").execute();
476
+ await sql`ALTER TABLE _emdash_taxonomy_defs_old RENAME TO _emdash_taxonomy_defs`.execute(db);
477
+ }