emdash 0.9.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 (195) hide show
  1. package/dist/{adapters-DoNJiveC.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-DoNJiveC.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-BzltprvY.mjs → apply-UsrFuO7l.mjs} +156 -254
  4. package/dist/apply-UsrFuO7l.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +10 -2
  7. package/dist/astro/index.mjs.map +1 -1
  8. package/dist/astro/middleware/auth.d.mts +5 -5
  9. package/dist/astro/middleware/auth.mjs +5 -5
  10. package/dist/astro/middleware/redirect.mjs +5 -5
  11. package/dist/astro/middleware/request-context.mjs +4 -4
  12. package/dist/astro/middleware/setup.mjs +1 -1
  13. package/dist/astro/middleware.mjs +35 -34
  14. package/dist/astro/middleware.mjs.map +1 -1
  15. package/dist/astro/types.d.mts +8 -9
  16. package/dist/astro/types.d.mts.map +1 -1
  17. package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
  18. package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
  19. package/dist/{byline-BSaNL1w7.mjs → byline-C3vnhIpU.mjs} +4 -4
  20. package/dist/{byline-BSaNL1w7.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  21. package/dist/{bylines-CvJ3PYz2.mjs → bylines-esI7ioa9.mjs} +5 -5
  22. package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-esI7ioa9.mjs.map} +1 -1
  23. package/dist/{cache-C6N_hhN7.mjs → cache-fTzxgMFJ.mjs} +3 -3
  24. package/dist/{cache-C6N_hhN7.mjs.map → cache-fTzxgMFJ.mjs.map} +1 -1
  25. package/dist/{chunks-NBQVDOci.mjs → chunks-Da2-b-oA.mjs} +2 -2
  26. package/dist/{chunks-NBQVDOci.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  27. package/dist/cli/index.mjs +251 -79
  28. package/dist/cli/index.mjs.map +1 -1
  29. package/dist/client/cf-access.d.mts +1 -1
  30. package/dist/client/index.d.mts +1 -1
  31. package/dist/client/index.mjs +1 -1
  32. package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
  33. package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
  34. package/dist/{content-8lOYF0pr.mjs → content-C7G4QXkK.mjs} +14 -3
  35. package/dist/content-C7G4QXkK.mjs.map +1 -0
  36. package/dist/db/index.d.mts +3 -3
  37. package/dist/db/index.mjs +1 -1
  38. package/dist/db/libsql.d.mts +1 -1
  39. package/dist/db/postgres.d.mts +1 -1
  40. package/dist/db/sqlite.d.mts +1 -1
  41. package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  42. package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  43. package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
  44. package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  45. package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
  46. package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  47. package/dist/{index-BFRaVcD6.d.mts → index-DjPMOfO0.d.mts} +82 -67
  48. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  49. package/dist/index.d.mts +10 -10
  50. package/dist/index.mjs +28 -27
  51. package/dist/{load-DDqMMvZL.mjs → load-sXRuM7Us.mjs} +2 -2
  52. package/dist/{load-DDqMMvZL.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  53. package/dist/{loader-CKLbBnhK.mjs → loader-Bx2_9-5e.mjs} +31 -6
  54. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  55. package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
  56. package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
  57. package/dist/media/index.d.mts +1 -1
  58. package/dist/media/index.mjs +1 -1
  59. package/dist/media/local-runtime.d.mts +7 -7
  60. package/dist/media/local-runtime.mjs +3 -3
  61. package/dist/{media-BW32b4gi.mjs → media-D8FbNsl0.mjs} +2 -2
  62. package/dist/{media-BW32b4gi.mjs.map → media-D8FbNsl0.mjs.map} +1 -1
  63. package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
  64. package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  65. package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
  66. package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
  67. package/dist/page/index.d.mts +2 -2
  68. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  69. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  70. package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  71. package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  72. package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  73. package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  74. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  75. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  76. package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
  77. package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
  78. package/dist/{query-Cg9ZKRQ0.mjs → query-Bo-msrmu.mjs} +13 -13
  79. package/dist/{query-Cg9ZKRQ0.mjs.map → query-Bo-msrmu.mjs.map} +1 -1
  80. package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
  81. package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
  82. package/dist/{registry-Dw70ChxB.mjs → registry-Beb7wxFc.mjs} +5 -5
  83. package/dist/{registry-Dw70ChxB.mjs.map → registry-Beb7wxFc.mjs.map} +1 -1
  84. package/dist/{request-cache-B-bmkipQ.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  85. package/dist/{request-cache-B-bmkipQ.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  86. package/dist/{runner-Bnoj7vjK.d.mts → runner-Clwe4Mme.d.mts} +2 -2
  87. package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Clwe4Mme.d.mts.map} +1 -1
  88. package/dist/{runner-C7ADox5q.mjs → runner-DMnlIkh4.mjs} +433 -138
  89. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  90. package/dist/runtime.d.mts +6 -6
  91. package/dist/runtime.mjs +3 -3
  92. package/dist/{search-dOGEccMa.mjs → search-DkN-BqsS.mjs} +164 -92
  93. package/dist/search-DkN-BqsS.mjs.map +1 -0
  94. package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
  95. package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
  96. package/dist/seed/index.d.mts +2 -2
  97. package/dist/seed/index.mjs +15 -14
  98. package/dist/seo/index.d.mts +1 -1
  99. package/dist/storage/local.d.mts +1 -1
  100. package/dist/storage/local.mjs +1 -1
  101. package/dist/storage/s3.d.mts +1 -1
  102. package/dist/storage/s3.mjs +1 -1
  103. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  104. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  105. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  106. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  107. package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
  108. package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  109. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  110. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  111. package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  112. package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  113. package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
  114. package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  115. package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
  116. package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
  117. package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
  118. package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  119. package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
  120. package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  121. package/dist/{types-4fVtCIm0.mjs → types-CoO6mpV3.mjs} +1 -1
  122. package/dist/{types-4fVtCIm0.mjs.map → types-CoO6mpV3.mjs.map} +1 -1
  123. package/dist/{types-BuBIptGk.d.mts → types-D19uBYWn.d.mts} +149 -4
  124. package/dist/types-D19uBYWn.d.mts.map +1 -0
  125. package/dist/{types-BSyXeCFW.d.mts → types-Dl1fgFjn.d.mts} +1 -1
  126. package/dist/{types-BSyXeCFW.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  127. package/dist/{types-CrtWgIvl.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  128. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  129. package/dist/{types-CIOg5AR8.mjs → types-Eg829jj9.mjs} +1 -1
  130. package/dist/{types-CIOg5AR8.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  131. package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
  132. package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  133. package/dist/{validate-Baqf0slj.mjs → validate-CBIbxM3L.mjs} +14 -10
  134. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  135. package/dist/{validate-BfQh_C_y.d.mts → validate-DHGwADqO.d.mts} +18 -5
  136. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  137. package/dist/{validation-BfEI7tNe.mjs → validation-B1NYiEos.mjs} +5 -5
  138. package/dist/{validation-BfEI7tNe.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  139. package/dist/version-CMD42IRC.mjs +7 -0
  140. package/dist/{version-DoxrVdYf.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  141. package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-BNJDQBSZ.mjs} +8 -3
  142. package/dist/zod-generator-BNJDQBSZ.mjs.map +1 -0
  143. package/package.json +6 -6
  144. package/src/api/handlers/content.ts +11 -0
  145. package/src/api/handlers/dashboard.ts +29 -36
  146. package/src/api/handlers/menus.ts +256 -75
  147. package/src/api/handlers/taxonomies.ts +273 -97
  148. package/src/api/schemas/common.ts +7 -0
  149. package/src/api/schemas/menus.ts +23 -0
  150. package/src/api/schemas/taxonomies.ts +39 -0
  151. package/src/astro/integration/routes.ts +10 -0
  152. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  153. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  154. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  155. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  156. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  157. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  158. package/src/astro/routes/api/menus/[name].ts +19 -10
  159. package/src/astro/routes/api/menus/index.ts +9 -6
  160. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  161. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  162. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  163. package/src/astro/routes/api/taxonomies/index.ts +9 -6
  164. package/src/cli/commands/export-seed.ts +82 -21
  165. package/src/cli/commands/plugin-init.ts +216 -90
  166. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  167. package/src/database/migrations/runner.ts +2 -0
  168. package/src/database/repositories/content.ts +11 -0
  169. package/src/database/repositories/taxonomy.ts +193 -89
  170. package/src/database/types.ts +10 -2
  171. package/src/i18n/resolve.ts +37 -0
  172. package/src/loader.ts +49 -2
  173. package/src/mcp/server.ts +77 -18
  174. package/src/menus/index.ts +143 -124
  175. package/src/menus/types.ts +15 -1
  176. package/src/schema/zod-generator.ts +12 -2
  177. package/src/seed/apply.ts +140 -54
  178. package/src/seed/types.ts +14 -1
  179. package/src/seed/validate.ts +27 -13
  180. package/src/taxonomies/index.ts +230 -213
  181. package/src/taxonomies/types.ts +10 -0
  182. package/dist/apply-BzltprvY.mjs.map +0 -1
  183. package/dist/content-8lOYF0pr.mjs.map +0 -1
  184. package/dist/index-BFRaVcD6.d.mts.map +0 -1
  185. package/dist/loader-CKLbBnhK.mjs.map +0 -1
  186. package/dist/runner-C7ADox5q.mjs.map +0 -1
  187. package/dist/search-dOGEccMa.mjs.map +0 -1
  188. package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
  189. package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
  190. package/dist/types-BuBIptGk.d.mts.map +0 -1
  191. package/dist/types-CrtWgIvl.d.mts.map +0 -1
  192. package/dist/validate-Baqf0slj.mjs.map +0 -1
  193. package/dist/validate-BfQh_C_y.d.mts.map +0 -1
  194. package/dist/version-DoxrVdYf.mjs +0 -7
  195. package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
@@ -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
+ }
@@ -36,6 +36,7 @@ import * as m032 from "./032_rate_limits.js";
36
36
  import * as m033 from "./033_optimize_content_indexes.js";
37
37
  import * as m034 from "./034_published_at_index.js";
38
38
  import * as m035 from "./035_bounded_404_log.js";
39
+ import * as m036 from "./036_i18n_menus_and_taxonomies.js";
39
40
 
40
41
  const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
41
42
  "001_initial": m001,
@@ -72,6 +73,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
72
73
  "033_optimize_content_indexes": m033,
73
74
  "034_published_at_index": m034,
74
75
  "035_bounded_404_log": m035,
76
+ "036_i18n_menus_and_taxonomies": m036,
75
77
  });
76
78
 
77
79
  /** Total number of registered migrations. Exported for use in tests. */
@@ -637,12 +637,23 @@ export class ContentRepository {
637
637
  /**
638
638
  * Permanently delete content (cannot be undone)
639
639
  */
640
+ /**
641
+ * Permanently delete a soft-deleted content row.
642
+ *
643
+ * Returns `true` only when a soft-deleted (trashed) row was removed.
644
+ * Returns `false` when no row exists OR when the row exists but is live —
645
+ * the caller is responsible for distinguishing these cases (typically via
646
+ * a follow-up `findByIdOrSlugIncludingTrashed` to surface NOT_FOUND vs
647
+ * NOT_TRASHED). The `AND deleted_at IS NOT NULL` clause is the safety net
648
+ * that prevents permanent delete from bypassing the trash workflow.
649
+ */
640
650
  async permanentDelete(type: string, id: string): Promise<boolean> {
641
651
  const tableName = getTableName(type);
642
652
 
643
653
  const result = await sql`
644
654
  DELETE FROM ${sql.ref(tableName)}
645
655
  WHERE id = ${id}
656
+ AND deleted_at IS NOT NULL
646
657
  `.execute(this.db);
647
658
 
648
659
  return (result.numAffectedRows ?? 0n) > 0n;