emdash 0.9.0 → 0.11.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 (239) 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-Ded_1vng.mjs} +167 -254
  4. package/dist/apply-Ded_1vng.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.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +94 -43
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/types.d.mts +12 -11
  17. package/dist/astro/types.d.mts.map +1 -1
  18. package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
  19. package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
  20. package/dist/{byline-BSaNL1w7.mjs → byline-gFn1r0vA.mjs} +4 -4
  21. package/dist/{byline-BSaNL1w7.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
  22. package/dist/{bylines-CvJ3PYz2.mjs → bylines-DTFI8nDM.mjs} +5 -5
  23. package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
  24. package/dist/{cache-C6N_hhN7.mjs → cache-BAJbeoZ8.mjs} +3 -3
  25. package/dist/{cache-C6N_hhN7.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
  26. package/dist/{chunks-NBQVDOci.mjs → chunks-BK1oZS-l.mjs} +2 -2
  27. package/dist/{chunks-NBQVDOci.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
  28. package/dist/cli/index.mjs +342 -95
  29. package/dist/cli/index.mjs.map +1 -1
  30. package/dist/client/cf-access.d.mts +1 -1
  31. package/dist/client/index.d.mts +1 -1
  32. package/dist/client/index.mjs +1 -1
  33. package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
  34. package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
  35. package/dist/{content-8lOYF0pr.mjs → content-CERxPUN0.mjs} +14 -3
  36. package/dist/content-CERxPUN0.mjs.map +1 -0
  37. package/dist/database/instrumentation.d.mts +6 -4
  38. package/dist/database/instrumentation.d.mts.map +1 -1
  39. package/dist/database/instrumentation.mjs +19 -7
  40. package/dist/database/instrumentation.mjs.map +1 -1
  41. package/dist/db/index.d.mts +3 -3
  42. package/dist/db/index.mjs +1 -1
  43. package/dist/db/libsql.d.mts +1 -1
  44. package/dist/db/postgres.d.mts +1 -1
  45. package/dist/db/sqlite.d.mts +1 -1
  46. package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  47. package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  48. package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
  49. package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  50. package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
  51. package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  52. package/dist/{index-BFRaVcD6.d.mts → index-Cg-rC4Gj.d.mts} +110 -87
  53. package/dist/index-Cg-rC4Gj.d.mts.map +1 -0
  54. package/dist/index.d.mts +11 -11
  55. package/dist/index.mjs +29 -28
  56. package/dist/{load-DDqMMvZL.mjs → load-DR1VwFXR.mjs} +2 -2
  57. package/dist/{load-DDqMMvZL.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
  58. package/dist/{loader-CKLbBnhK.mjs → loader-ou_PXAjg.mjs} +31 -6
  59. package/dist/loader-ou_PXAjg.mjs.map +1 -0
  60. package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
  61. package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
  62. package/dist/media/index.d.mts +1 -1
  63. package/dist/media/index.mjs +1 -1
  64. package/dist/media/local-runtime.d.mts +7 -7
  65. package/dist/media/local-runtime.mjs +3 -3
  66. package/dist/{media-BW32b4gi.mjs → media-1fFhub9c.mjs} +22 -10
  67. package/dist/media-1fFhub9c.mjs.map +1 -0
  68. package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
  69. package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  70. package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
  71. package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
  72. package/dist/page/index.d.mts +2 -2
  73. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  74. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  75. package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  76. package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  77. package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  78. package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  79. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  80. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  81. package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
  82. package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
  83. package/dist/{query-Cg9ZKRQ0.mjs → query-8c_meo_K.mjs} +13 -13
  84. package/dist/{query-Cg9ZKRQ0.mjs.map → query-8c_meo_K.mjs.map} +1 -1
  85. package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
  86. package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
  87. package/dist/{registry-Dw70ChxB.mjs → registry-Do34mz_P.mjs} +7 -6
  88. package/dist/registry-Do34mz_P.mjs.map +1 -0
  89. package/dist/{request-cache-B-bmkipQ.mjs → request-cache-D4I69LeL.mjs} +6 -2
  90. package/dist/request-cache-D4I69LeL.mjs.map +1 -0
  91. package/dist/request-context.d.mts +27 -1
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs +16 -3
  94. package/dist/request-context.mjs.map +1 -1
  95. package/dist/{runner-C7ADox5q.mjs → runner-DIcU2UCC.mjs} +465 -148
  96. package/dist/runner-DIcU2UCC.mjs.map +1 -0
  97. package/dist/{runner-Bnoj7vjK.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
  98. package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
  99. package/dist/runtime.d.mts +6 -6
  100. package/dist/runtime.mjs +3 -3
  101. package/dist/{search-dOGEccMa.mjs → search-DuWhx4NG.mjs} +322 -108
  102. package/dist/search-DuWhx4NG.mjs.map +1 -0
  103. package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
  104. package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
  105. package/dist/seed/index.d.mts +2 -2
  106. package/dist/seed/index.mjs +15 -14
  107. package/dist/seo/index.d.mts +1 -1
  108. package/dist/storage/local.d.mts +1 -1
  109. package/dist/storage/local.mjs +1 -1
  110. package/dist/storage/s3.d.mts +1 -1
  111. package/dist/storage/s3.mjs +1 -1
  112. package/dist/taxonomies-Bw76xAxo.mjs +407 -0
  113. package/dist/taxonomies-Bw76xAxo.mjs.map +1 -0
  114. package/dist/taxonomy-D6NvlKo8.mjs +218 -0
  115. package/dist/taxonomy-D6NvlKo8.mjs.map +1 -0
  116. package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
  117. package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  118. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  119. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  120. package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  121. package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  122. package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
  123. package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  124. package/dist/{types-CIOg5AR8.mjs → types-56BKbld_.mjs} +1 -1
  125. package/dist/types-56BKbld_.mjs.map +1 -0
  126. package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
  127. package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
  128. package/dist/{types-CrtWgIvl.d.mts → types-BQx6ZXpR.d.mts} +10 -1
  129. package/dist/types-BQx6ZXpR.d.mts.map +1 -0
  130. package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
  131. package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  132. package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
  133. package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  134. package/dist/types-DiI8NOG_.mjs +16 -0
  135. package/dist/types-DiI8NOG_.mjs.map +1 -0
  136. package/dist/{types-BuBIptGk.d.mts → types-IN5z_S3P.d.mts} +158 -92
  137. package/dist/types-IN5z_S3P.d.mts.map +1 -0
  138. package/dist/{types-BSyXeCFW.d.mts → types-IZSZfEwv.d.mts} +4 -3
  139. package/dist/types-IZSZfEwv.d.mts.map +1 -0
  140. package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
  141. package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  142. package/dist/{validate-BfQh_C_y.d.mts → validate-CO3JjFV5.d.mts} +22 -5
  143. package/dist/validate-CO3JjFV5.d.mts.map +1 -0
  144. package/dist/{validate-Baqf0slj.mjs → validate-UK4Ja1uo.mjs} +14 -10
  145. package/dist/validate-UK4Ja1uo.mjs.map +1 -0
  146. package/dist/{validation-BfEI7tNe.mjs → validation-Vc5DQkJa.mjs} +5 -5
  147. package/dist/{validation-BfEI7tNe.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
  148. package/dist/version-Bg31I_Ff.mjs +7 -0
  149. package/dist/{version-DoxrVdYf.mjs.map → version-Bg31I_Ff.mjs.map} +1 -1
  150. package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-CHnJUP2l.mjs} +8 -3
  151. package/dist/zod-generator-CHnJUP2l.mjs.map +1 -0
  152. package/package.json +9 -8
  153. package/src/api/errors.ts +5 -0
  154. package/src/api/handlers/content.ts +20 -0
  155. package/src/api/handlers/dashboard.ts +29 -36
  156. package/src/api/handlers/media-allowlist.ts +40 -0
  157. package/src/api/handlers/media.ts +1 -1
  158. package/src/api/handlers/menus.ts +400 -89
  159. package/src/api/handlers/taxonomies.ts +273 -97
  160. package/src/api/handlers/validate-media-fields.ts +125 -0
  161. package/src/api/schemas/common.ts +7 -0
  162. package/src/api/schemas/media.ts +23 -3
  163. package/src/api/schemas/menus.ts +23 -0
  164. package/src/api/schemas/schema.ts +11 -2
  165. package/src/api/schemas/taxonomies.ts +39 -0
  166. package/src/astro/integration/routes.ts +10 -0
  167. package/src/astro/middleware.ts +46 -11
  168. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  169. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  170. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  171. package/src/astro/routes/api/media/upload-url.ts +10 -4
  172. package/src/astro/routes/api/media.ts +12 -4
  173. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  174. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  175. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  176. package/src/astro/routes/api/menus/[name].ts +19 -10
  177. package/src/astro/routes/api/menus/index.ts +9 -6
  178. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  179. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  180. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  181. package/src/astro/routes/api/taxonomies/index.ts +9 -6
  182. package/src/astro/types.ts +5 -1
  183. package/src/auth/rate-limit.ts +3 -3
  184. package/src/cli/commands/bundle-utils.ts +81 -6
  185. package/src/cli/commands/bundle.ts +18 -15
  186. package/src/cli/commands/export-seed.ts +139 -24
  187. package/src/cli/commands/plugin-init.ts +216 -90
  188. package/src/database/instrumentation.ts +22 -8
  189. package/src/database/migrations/016_api_tokens.ts +18 -3
  190. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  191. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  192. package/src/database/migrations/runner.ts +4 -0
  193. package/src/database/repositories/content.ts +11 -0
  194. package/src/database/repositories/media.ts +40 -10
  195. package/src/database/repositories/taxonomy.ts +193 -89
  196. package/src/database/types.ts +12 -3
  197. package/src/emdash-runtime.ts +16 -3
  198. package/src/fields/file.ts +7 -6
  199. package/src/fields/image.ts +12 -11
  200. package/src/fields/types.ts +3 -0
  201. package/src/i18n/resolve.ts +37 -0
  202. package/src/index.ts +1 -1
  203. package/src/loader.ts +49 -2
  204. package/src/mcp/server.ts +114 -26
  205. package/src/media/mime.ts +75 -0
  206. package/src/menus/index.ts +143 -124
  207. package/src/menus/types.ts +15 -1
  208. package/src/plugins/types.ts +81 -191
  209. package/src/request-cache.ts +6 -2
  210. package/src/request-context.ts +42 -2
  211. package/src/schema/registry.ts +5 -5
  212. package/src/schema/types.ts +3 -2
  213. package/src/schema/zod-generator.ts +12 -2
  214. package/src/seed/apply.ts +157 -54
  215. package/src/seed/types.ts +18 -1
  216. package/src/seed/validate.ts +27 -13
  217. package/src/taxonomies/index.ts +230 -213
  218. package/src/taxonomies/types.ts +10 -0
  219. package/dist/apply-BzltprvY.mjs.map +0 -1
  220. package/dist/content-8lOYF0pr.mjs.map +0 -1
  221. package/dist/index-BFRaVcD6.d.mts.map +0 -1
  222. package/dist/loader-CKLbBnhK.mjs.map +0 -1
  223. package/dist/media-BW32b4gi.mjs.map +0 -1
  224. package/dist/registry-Dw70ChxB.mjs.map +0 -1
  225. package/dist/request-cache-B-bmkipQ.mjs.map +0 -1
  226. package/dist/runner-C7ADox5q.mjs.map +0 -1
  227. package/dist/search-dOGEccMa.mjs.map +0 -1
  228. package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
  229. package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
  230. package/dist/types-4fVtCIm0.mjs +0 -68
  231. package/dist/types-4fVtCIm0.mjs.map +0 -1
  232. package/dist/types-BSyXeCFW.d.mts.map +0 -1
  233. package/dist/types-BuBIptGk.d.mts.map +0 -1
  234. package/dist/types-CIOg5AR8.mjs.map +0 -1
  235. package/dist/types-CrtWgIvl.d.mts.map +0 -1
  236. package/dist/validate-Baqf0slj.mjs.map +0 -1
  237. package/dist/validate-BfQh_C_y.d.mts.map +0 -1
  238. package/dist/version-DoxrVdYf.mjs +0 -7
  239. package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
@@ -1,29 +1,30 @@
1
1
  /**
2
- * Menu CRUD handlers
2
+ * Menu CRUD handlers.
3
3
  *
4
- * Business logic for menu and menu-item endpoints.
5
- * Routes are thin wrappers that parse input, check auth, and call these.
4
+ * Business logic for menu and menu-item endpoints. Routes are thin wrappers
5
+ * that parse input, check auth, and call these.
6
+ *
7
+ * i18n: Menus are per-locale. `(name, locale)` is unique, so the same `name`
8
+ * (e.g. "primary") can exist in several locales within one translation_group.
9
+ * Menu items carry a `locale` + `translation_group` as well, and their
10
+ * `reference_id` points at the referenced content's translation_group (not a
11
+ * specific row id), so a single menu item target survives content translations.
6
12
  */
7
13
 
8
- import type { Kysely } from "kysely";
14
+ import type { Kysely, Selectable } from "kysely";
9
15
  import { ulid } from "ulidx";
10
16
 
11
17
  import { withTransaction } from "../../database/transaction.js";
12
18
  import type { Database, MenuItemTable, MenuTable } from "../../database/types.js";
19
+ import { getI18nConfig } from "../../i18n/config.js";
13
20
  import type { ApiResult } from "../types.js";
14
21
 
15
22
  // ---------------------------------------------------------------------------
16
23
  // Response types
17
24
  // ---------------------------------------------------------------------------
18
25
 
19
- type MenuRow = Omit<MenuTable, "created_at" | "updated_at"> & {
20
- created_at: string;
21
- updated_at: string;
22
- };
23
-
24
- type MenuItemRow = Omit<MenuItemTable, "created_at"> & {
25
- created_at: string;
26
- };
26
+ export type MenuRow = Selectable<MenuTable>;
27
+ export type MenuItemRow = Selectable<MenuItemTable>;
27
28
 
28
29
  export interface MenuListItem extends MenuRow {
29
30
  itemCount: number;
@@ -33,18 +34,56 @@ export interface MenuWithItems extends MenuRow {
33
34
  items: MenuItemRow[];
34
35
  }
35
36
 
37
+ export interface MenuTranslationsResponse {
38
+ translationGroup: string | null;
39
+ translations: Array<{
40
+ id: string;
41
+ name: string;
42
+ locale: string;
43
+ label: string;
44
+ updatedAt: string;
45
+ }>;
46
+ }
47
+
48
+ /**
49
+ * Error returned when a menu lookup by `name` matches multiple locale
50
+ * variants and the caller did not pass `locale` to disambiguate. Maps to
51
+ * HTTP 400 via `mapErrorStatus`. The available locales are surfaced in the
52
+ * message so MCP/REST callers can recover by re-issuing with `locale`.
53
+ */
54
+ function ambiguousMenuLocaleError(
55
+ name: string,
56
+ locales: readonly string[],
57
+ ): { success: false; error: { code: "AMBIGUOUS_LOCALE"; message: string } } {
58
+ const sortedLocales = locales.toSorted();
59
+ return {
60
+ success: false,
61
+ error: {
62
+ code: "AMBIGUOUS_LOCALE",
63
+ message: `Menu '${name}' exists in multiple locales (${sortedLocales.join(
64
+ ", ",
65
+ )}); pass 'locale' to disambiguate.`,
66
+ },
67
+ };
68
+ }
69
+
36
70
  // ---------------------------------------------------------------------------
37
71
  // Menu handlers
38
72
  // ---------------------------------------------------------------------------
39
73
 
40
74
  /**
41
- * List all menus with item counts.
75
+ * List menus with item counts. Filter by `locale` when provided; otherwise
76
+ * return every menu row (each locale counts as its own menu for admin listing
77
+ * purposes).
42
78
  */
43
- export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<MenuListItem[]>> {
79
+ export async function handleMenuList(
80
+ db: Kysely<Database>,
81
+ options: { locale?: string } = {},
82
+ ): Promise<ApiResult<MenuListItem[]>> {
44
83
  try {
45
84
  // Single query: LEFT JOIN + GROUP BY for the per-menu item count.
46
85
  // Avoids the N+1 of one count query per menu.
47
- const rows = await db
86
+ let query = db
48
87
  .selectFrom("_emdash_menus as m")
49
88
  .leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
50
89
  .select(({ fn }) => [
@@ -53,11 +92,22 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
53
92
  "m.label",
54
93
  "m.created_at",
55
94
  "m.updated_at",
95
+ "m.locale",
96
+ "m.translation_group",
56
97
  fn.count<number>("i.id").as("itemCount"),
57
98
  ])
58
- .groupBy(["m.id", "m.name", "m.label", "m.created_at", "m.updated_at"])
59
- .orderBy("m.name", "asc")
60
- .execute();
99
+ .groupBy([
100
+ "m.id",
101
+ "m.name",
102
+ "m.label",
103
+ "m.created_at",
104
+ "m.updated_at",
105
+ "m.locale",
106
+ "m.translation_group",
107
+ ])
108
+ .orderBy("m.name", "asc");
109
+ if (options.locale !== undefined) query = query.where("m.locale", "=", options.locale);
110
+ const rows = await query.execute();
61
111
 
62
112
  // SQLite returns count as `number`, but some dialects (Postgres)
63
113
  // return `string` from a count() aggregate. Normalize to number.
@@ -67,6 +117,8 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
67
117
  label: row.label,
68
118
  created_at: row.created_at,
69
119
  updated_at: row.updated_at,
120
+ locale: row.locale,
121
+ translation_group: row.translation_group,
70
122
  itemCount: typeof row.itemCount === "string" ? Number(row.itemCount) : row.itemCount,
71
123
  }));
72
124
 
@@ -80,42 +132,132 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
80
132
  }
81
133
 
82
134
  /**
83
- * Create a new menu.
135
+ * Create a new menu. When `translationOf` is supplied the new menu joins the
136
+ * source menu's translation_group (and gets the source's items cloned).
84
137
  */
85
138
  export async function handleMenuCreate(
86
139
  db: Kysely<Database>,
87
- input: { name: string; label: string },
140
+ input: { name: string; label: string; locale?: string; translationOf?: string },
88
141
  ): Promise<ApiResult<MenuRow>> {
89
142
  try {
143
+ // Translating from a source menu only makes sense when the caller
144
+ // names the target locale: otherwise we'd silently clone into the
145
+ // configured default, which is almost never what's intended (and
146
+ // will collide if the source is already the default-locale menu).
147
+ // Enforced here so REST/SDK callers get the same guard as MCP.
148
+ if (input.translationOf && !input.locale) {
149
+ return {
150
+ success: false,
151
+ error: {
152
+ code: "VALIDATION_ERROR",
153
+ message: "`locale` is required when `translationOf` is provided",
154
+ },
155
+ };
156
+ }
157
+
158
+ // Resolve translation group + source (if we're creating a translation).
159
+ let translationGroup: string | null = null;
160
+ let sourceMenu: MenuRow | null = null;
161
+ if (input.translationOf) {
162
+ const src = await db
163
+ .selectFrom("_emdash_menus")
164
+ .selectAll()
165
+ .where("id", "=", input.translationOf)
166
+ .executeTakeFirst();
167
+ if (!src) {
168
+ return {
169
+ success: false,
170
+ error: { code: "NOT_FOUND", message: "Source menu for translation not found" },
171
+ };
172
+ }
173
+ sourceMenu = src;
174
+ translationGroup = src.translation_group ?? src.id;
175
+ }
176
+
177
+ // Duplicate guard: same (name, locale). Falls back to the configured
178
+ // defaultLocale to match the column DEFAULT set by migration 036.
179
+ const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
90
180
  const existing = await db
91
181
  .selectFrom("_emdash_menus")
92
182
  .select("id")
93
183
  .where("name", "=", input.name)
184
+ .where("locale", "=", effectiveLocale)
94
185
  .executeTakeFirst();
95
-
96
186
  if (existing) {
97
187
  return {
98
188
  success: false,
99
- error: { code: "CONFLICT", message: `Menu with name "${input.name}" already exists` },
189
+ error: {
190
+ code: "CONFLICT",
191
+ message: `Menu "${input.name}" already exists${
192
+ input.locale ? ` in locale "${input.locale}"` : ""
193
+ }`,
194
+ },
100
195
  };
101
196
  }
102
197
 
103
198
  const id = ulid();
104
- await db
105
- .insertInto("_emdash_menus")
106
- .values({
107
- id,
108
- name: input.name,
109
- label: input.label,
110
- })
111
- .execute();
199
+
200
+ await withTransaction(db, async (trx) => {
201
+ await trx
202
+ .insertInto("_emdash_menus")
203
+ .values({
204
+ id,
205
+ name: input.name,
206
+ label: input.label,
207
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
208
+ translation_group: translationGroup ?? id,
209
+ })
210
+ .execute();
211
+
212
+ // Clone items from the source menu (same reference_ids — they are
213
+ // translation_groups, which are locale-agnostic). Each clone
214
+ // inherits the source item's translation_group so a nav entry
215
+ // identifies as the same logical item across menu translations.
216
+ if (sourceMenu) {
217
+ const sourceItems = await trx
218
+ .selectFrom("_emdash_menu_items")
219
+ .selectAll()
220
+ .where("menu_id", "=", sourceMenu.id)
221
+ .orderBy("sort_order", "asc")
222
+ .execute();
223
+ if (sourceItems.length > 0) {
224
+ // Build old-id → new-id map so parent pointers land on the clones.
225
+ const idMap = new Map<string, string>();
226
+ for (const item of sourceItems) idMap.set(item.id, ulid());
227
+
228
+ await trx
229
+ .insertInto("_emdash_menu_items")
230
+ .values(
231
+ sourceItems.map((item) => {
232
+ const newId = idMap.get(item.id)!;
233
+ return {
234
+ id: newId,
235
+ menu_id: id,
236
+ parent_id: item.parent_id ? (idMap.get(item.parent_id) ?? null) : null,
237
+ sort_order: item.sort_order,
238
+ type: item.type,
239
+ reference_collection: item.reference_collection,
240
+ reference_id: item.reference_id,
241
+ custom_url: item.custom_url,
242
+ label: item.label,
243
+ title_attr: item.title_attr,
244
+ target: item.target,
245
+ css_classes: item.css_classes,
246
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
247
+ translation_group: item.translation_group ?? item.id,
248
+ };
249
+ }),
250
+ )
251
+ .execute();
252
+ }
253
+ }
254
+ });
112
255
 
113
256
  const menu = await db
114
257
  .selectFrom("_emdash_menus")
115
258
  .selectAll()
116
259
  .where("id", "=", id)
117
260
  .executeTakeFirstOrThrow();
118
-
119
261
  return { success: true, data: menu };
120
262
  } catch {
121
263
  return {
@@ -126,18 +268,18 @@ export async function handleMenuCreate(
126
268
  }
127
269
 
128
270
  /**
129
- * Get a single menu with all its items.
271
+ * Get a single menu by name. Honours an optional `locale` filter; when two
272
+ * menus share a name across locales, the locale distinguishes them.
130
273
  */
131
274
  export async function handleMenuGet(
132
275
  db: Kysely<Database>,
133
276
  name: string,
277
+ options: { locale?: string } = {},
134
278
  ): Promise<ApiResult<MenuWithItems>> {
135
279
  try {
136
- const menu = await db
137
- .selectFrom("_emdash_menus")
138
- .selectAll()
139
- .where("name", "=", name)
140
- .executeTakeFirst();
280
+ let query = db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
281
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
282
+ const menu = await query.orderBy("locale", "asc").executeTakeFirst();
141
283
 
142
284
  if (!menu) {
143
285
  return {
@@ -163,26 +305,73 @@ export async function handleMenuGet(
163
305
  }
164
306
 
165
307
  /**
166
- * Update a menu's metadata.
308
+ * Get a menu by id. Useful when the caller already has the id (e.g. after
309
+ * creating a translation and navigating to it).
167
310
  */
168
- export async function handleMenuUpdate(
311
+ export async function handleMenuGetById(
169
312
  db: Kysely<Database>,
170
- name: string,
171
- input: { label?: string },
172
- ): Promise<ApiResult<MenuRow>> {
313
+ id: string,
314
+ ): Promise<ApiResult<MenuWithItems>> {
173
315
  try {
174
316
  const menu = await db
175
317
  .selectFrom("_emdash_menus")
176
- .select("id")
177
- .where("name", "=", name)
318
+ .selectAll()
319
+ .where("id", "=", id)
178
320
  .executeTakeFirst();
179
-
180
321
  if (!menu) {
181
322
  return {
182
323
  success: false,
183
- error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
324
+ error: { code: "NOT_FOUND", message: `Menu '${id}' not found` },
325
+ };
326
+ }
327
+ const items = await db
328
+ .selectFrom("_emdash_menu_items")
329
+ .selectAll()
330
+ .where("menu_id", "=", menu.id)
331
+ .orderBy("sort_order", "asc")
332
+ .execute();
333
+ return { success: true, data: { ...menu, items } };
334
+ } catch {
335
+ return {
336
+ success: false,
337
+ error: { code: "MENU_GET_ERROR", message: "Failed to fetch menu" },
338
+ };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Update a menu's label. The name + locale are immutable.
344
+ */
345
+ export async function handleMenuUpdate(
346
+ db: Kysely<Database>,
347
+ name: string,
348
+ input: { label?: string; locale?: string },
349
+ ): Promise<ApiResult<MenuRow>> {
350
+ try {
351
+ // Fetch every row matching the name (filtered by locale if supplied)
352
+ // so we can fail loud when an omitted-locale lookup is ambiguous.
353
+ // (name, locale) is unique, so length > 1 only happens when the
354
+ // caller didn't pass `locale` and the menu exists in >1 translation.
355
+ let query = db.selectFrom("_emdash_menus").select(["id", "locale"]).where("name", "=", name);
356
+ if (input.locale !== undefined) query = query.where("locale", "=", input.locale);
357
+ const matches = await query.execute();
358
+
359
+ if (matches.length === 0) {
360
+ return {
361
+ success: false,
362
+ error: {
363
+ code: "NOT_FOUND",
364
+ message: `Menu '${name}' not found${input.locale ? ` in locale '${input.locale}'` : ""}`,
365
+ },
184
366
  };
185
367
  }
368
+ if (matches.length > 1) {
369
+ return ambiguousMenuLocaleError(
370
+ name,
371
+ matches.map((m) => m.locale),
372
+ );
373
+ }
374
+ const menu = matches[0]!;
186
375
 
187
376
  if (input.label) {
188
377
  await db
@@ -197,7 +386,6 @@ export async function handleMenuUpdate(
197
386
  .selectAll()
198
387
  .where("id", "=", menu.id)
199
388
  .executeTakeFirstOrThrow();
200
-
201
389
  return { success: true, data: updated };
202
390
  } catch {
203
391
  return {
@@ -208,32 +396,43 @@ export async function handleMenuUpdate(
208
396
  }
209
397
 
210
398
  /**
211
- * Delete a menu and its items (cascade).
399
+ * Delete a menu (and items, via cascade).
212
400
  */
213
401
  export async function handleMenuDelete(
214
402
  db: Kysely<Database>,
215
403
  name: string,
404
+ options: { locale?: string } = {},
216
405
  ): Promise<ApiResult<{ deleted: true }>> {
217
406
  try {
218
- const menu = await db
219
- .selectFrom("_emdash_menus")
220
- .select("id")
221
- .where("name", "=", name)
222
- .executeTakeFirst();
407
+ // See ambiguousMenuLocaleError for why we fetch all matches.
408
+ let query = db.selectFrom("_emdash_menus").select(["id", "locale"]).where("name", "=", name);
409
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
410
+ const matches = await query.execute();
223
411
 
224
- if (!menu) {
412
+ if (matches.length === 0) {
225
413
  return {
226
414
  success: false,
227
- error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
415
+ error: {
416
+ code: "NOT_FOUND",
417
+ message: `Menu '${name}' not found${
418
+ options.locale ? ` in locale '${options.locale}'` : ""
419
+ }`,
420
+ },
228
421
  };
229
422
  }
423
+ if (matches.length > 1) {
424
+ return ambiguousMenuLocaleError(
425
+ name,
426
+ matches.map((m) => m.locale),
427
+ );
428
+ }
429
+ const menu = matches[0]!;
230
430
 
231
431
  // D1 has FOREIGN KEYS off by default, so the migration's `ON DELETE
232
432
  // CASCADE` won't fire there. Delete items explicitly first — this is
233
433
  // idempotent on SQLite/Postgres where the cascade also fires.
234
434
  await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
235
435
  await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
236
-
237
436
  return { success: true, data: { deleted: true } };
238
437
  } catch {
239
438
  return {
@@ -243,6 +442,53 @@ export async function handleMenuDelete(
243
442
  }
244
443
  }
245
444
 
445
+ /**
446
+ * List every translation of a menu (by id or translation_group).
447
+ */
448
+ export async function handleMenuTranslations(
449
+ db: Kysely<Database>,
450
+ idOrGroup: string,
451
+ ): Promise<ApiResult<MenuTranslationsResponse>> {
452
+ try {
453
+ const anchor = await db
454
+ .selectFrom("_emdash_menus")
455
+ .selectAll()
456
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
457
+ .executeTakeFirst();
458
+ if (!anchor) {
459
+ return {
460
+ success: false,
461
+ error: { code: "NOT_FOUND", message: "Menu not found" },
462
+ };
463
+ }
464
+ const group = anchor.translation_group ?? anchor.id;
465
+ const rows = await db
466
+ .selectFrom("_emdash_menus")
467
+ .selectAll()
468
+ .where("translation_group", "=", group)
469
+ .orderBy("locale", "asc")
470
+ .execute();
471
+ return {
472
+ success: true,
473
+ data: {
474
+ translationGroup: group,
475
+ translations: rows.map((row) => ({
476
+ id: row.id,
477
+ name: row.name,
478
+ locale: row.locale,
479
+ label: row.label,
480
+ updatedAt: row.updated_at,
481
+ })),
482
+ },
483
+ };
484
+ } catch {
485
+ return {
486
+ success: false,
487
+ error: { code: "MENU_TRANSLATIONS_ERROR", message: "Failed to list menu translations" },
488
+ };
489
+ }
490
+ }
491
+
246
492
  // ---------------------------------------------------------------------------
247
493
  // Menu item handlers
248
494
  // ---------------------------------------------------------------------------
@@ -261,26 +507,38 @@ export interface CreateMenuItemInput {
261
507
  }
262
508
 
263
509
  /**
264
- * Add an item to a menu.
510
+ * Add an item to a menu. The item inherits the menu's locale (so listing
511
+ * items by locale stays trivial).
265
512
  */
266
513
  export async function handleMenuItemCreate(
267
514
  db: Kysely<Database>,
268
515
  menuName: string,
269
516
  input: CreateMenuItemInput,
517
+ options: { locale?: string } = {},
270
518
  ): Promise<ApiResult<MenuItemRow>> {
271
519
  try {
272
- const menu = await db
520
+ // Same fail-loud rule as handleMenuUpdate / Delete / SetItems —
521
+ // see ambiguousMenuLocaleError for the rationale.
522
+ let menuQuery = db
273
523
  .selectFrom("_emdash_menus")
274
- .select("id")
275
- .where("name", "=", menuName)
276
- .executeTakeFirst();
524
+ .select(["id", "locale"])
525
+ .where("name", "=", menuName);
526
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
527
+ const matches = await menuQuery.execute();
277
528
 
278
- if (!menu) {
529
+ if (matches.length === 0) {
279
530
  return {
280
531
  success: false,
281
532
  error: { code: "NOT_FOUND", message: "Menu not found" },
282
533
  };
283
534
  }
535
+ if (matches.length > 1) {
536
+ return ambiguousMenuLocaleError(
537
+ menuName,
538
+ matches.map((m) => m.locale),
539
+ );
540
+ }
541
+ const menu = matches[0]!;
284
542
 
285
543
  let sortOrder = input.sortOrder ?? 0;
286
544
  if (input.sortOrder === undefined) {
@@ -290,7 +548,6 @@ export async function handleMenuItemCreate(
290
548
  .where("menu_id", "=", menu.id)
291
549
  .where("parent_id", "is", input.parentId ?? null)
292
550
  .executeTakeFirst();
293
-
294
551
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely fn.max returns unknown; always a number for sort_order column
295
552
  sortOrder = ((maxOrder?.max as number) ?? -1) + 1;
296
553
  }
@@ -311,6 +568,8 @@ export async function handleMenuItemCreate(
311
568
  title_attr: input.titleAttr ?? null,
312
569
  target: input.target ?? null,
313
570
  css_classes: input.cssClasses ?? null,
571
+ locale: menu.locale,
572
+ translation_group: id,
314
573
  })
315
574
  .execute();
316
575
 
@@ -319,7 +578,6 @@ export async function handleMenuItemCreate(
319
578
  .selectAll()
320
579
  .where("id", "=", id)
321
580
  .executeTakeFirstOrThrow();
322
-
323
581
  return { success: true, data: item };
324
582
  } catch {
325
583
  return {
@@ -347,20 +605,30 @@ export async function handleMenuItemUpdate(
347
605
  menuName: string,
348
606
  itemId: string,
349
607
  input: UpdateMenuItemInput,
608
+ options: { locale?: string } = {},
350
609
  ): Promise<ApiResult<MenuItemRow>> {
351
610
  try {
352
- const menu = await db
611
+ // See ambiguousMenuLocaleError for the rationale.
612
+ let menuQuery = db
353
613
  .selectFrom("_emdash_menus")
354
- .select("id")
355
- .where("name", "=", menuName)
356
- .executeTakeFirst();
614
+ .select(["id", "locale"])
615
+ .where("name", "=", menuName);
616
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
617
+ const matches = await menuQuery.execute();
357
618
 
358
- if (!menu) {
619
+ if (matches.length === 0) {
359
620
  return {
360
621
  success: false,
361
622
  error: { code: "NOT_FOUND", message: "Menu not found" },
362
623
  };
363
624
  }
625
+ if (matches.length > 1) {
626
+ return ambiguousMenuLocaleError(
627
+ menuName,
628
+ matches.map((m) => m.locale),
629
+ );
630
+ }
631
+ const menu = matches[0]!;
364
632
 
365
633
  const item = await db
366
634
  .selectFrom("_emdash_menu_items")
@@ -394,7 +662,6 @@ export async function handleMenuItemUpdate(
394
662
  .selectAll()
395
663
  .where("id", "=", itemId)
396
664
  .executeTakeFirstOrThrow();
397
-
398
665
  return { success: true, data: updated };
399
666
  } catch {
400
667
  return {
@@ -411,20 +678,30 @@ export async function handleMenuItemDelete(
411
678
  db: Kysely<Database>,
412
679
  menuName: string,
413
680
  itemId: string,
681
+ options: { locale?: string } = {},
414
682
  ): Promise<ApiResult<{ deleted: true }>> {
415
683
  try {
416
- const menu = await db
684
+ // See ambiguousMenuLocaleError for the rationale.
685
+ let menuQuery = db
417
686
  .selectFrom("_emdash_menus")
418
- .select("id")
419
- .where("name", "=", menuName)
420
- .executeTakeFirst();
687
+ .select(["id", "locale"])
688
+ .where("name", "=", menuName);
689
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
690
+ const matches = await menuQuery.execute();
421
691
 
422
- if (!menu) {
692
+ if (matches.length === 0) {
423
693
  return {
424
694
  success: false,
425
695
  error: { code: "NOT_FOUND", message: "Menu not found" },
426
696
  };
427
697
  }
698
+ if (matches.length > 1) {
699
+ return ambiguousMenuLocaleError(
700
+ menuName,
701
+ matches.map((m) => m.locale),
702
+ );
703
+ }
704
+ const menu = matches[0]!;
428
705
 
429
706
  const result = await db
430
707
  .deleteFrom("_emdash_menu_items")
@@ -486,6 +763,7 @@ export async function handleMenuSetItems(
486
763
  db: Kysely<Database>,
487
764
  menuName: string,
488
765
  items: MenuSetItemsInput[],
766
+ options: { locale?: string } = {},
489
767
  ): Promise<ApiResult<{ name: string; itemCount: number }>> {
490
768
  // Validate parentIndex references — must be strictly earlier so
491
769
  // the array can be inserted in order with parents resolved first.
@@ -508,24 +786,38 @@ export async function handleMenuSetItems(
508
786
  }
509
787
 
510
788
  try {
511
- // Sentinel for "menu not found" thrown from inside the transaction
512
- // so the rollback fires before we return the structured error.
789
+ // Sentinels thrown from inside the transaction so the rollback
790
+ // fires before we return the structured error.
513
791
  const notFoundSentinel = Symbol("menu-not-found");
792
+ // We capture the locale list rather than constructing the error
793
+ // inside the transaction, so the helper stays the single source
794
+ // of truth for AMBIGUOUS_LOCALE message shape.
795
+ let ambiguousLocales: string[] | null = null;
796
+ const ambiguousSentinel = Symbol("menu-ambiguous-locale");
514
797
 
515
798
  try {
516
799
  await withTransaction(db, async (trx) => {
517
800
  // Existence check INSIDE the transaction so a concurrent
518
801
  // menu_delete between lookup and write can't leave orphan
519
- // items on D1 (FKs disabled by default).
520
- const menu = await trx
802
+ // items on D1 (FKs disabled by default). Same fail-loud
803
+ // rule as handleMenuUpdate / handleMenuDelete.
804
+ let menuQuery = trx
521
805
  .selectFrom("_emdash_menus")
522
- .select("id")
523
- .where("name", "=", menuName)
524
- .executeTakeFirst();
806
+ .select(["id", "locale"])
807
+ .where("name", "=", menuName);
808
+ if (options.locale !== undefined) {
809
+ menuQuery = menuQuery.where("locale", "=", options.locale);
810
+ }
811
+ const matches = await menuQuery.execute();
525
812
 
526
- if (!menu) {
813
+ if (matches.length === 0) {
527
814
  throw notFoundSentinel;
528
815
  }
816
+ if (matches.length > 1) {
817
+ ambiguousLocales = matches.map((m) => m.locale);
818
+ throw ambiguousSentinel;
819
+ }
820
+ const menu = matches[0]!;
529
821
 
530
822
  await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
531
823
 
@@ -551,6 +843,7 @@ export async function handleMenuSetItems(
551
843
  title_attr: item.titleAttr ?? null,
552
844
  target: item.target ?? null,
553
845
  css_classes: item.cssClasses ?? null,
846
+ locale: menu.locale,
554
847
  })
555
848
  .execute();
556
849
  insertedIds.push(id);
@@ -566,9 +859,17 @@ export async function handleMenuSetItems(
566
859
  if (error === notFoundSentinel) {
567
860
  return {
568
861
  success: false,
569
- error: { code: "NOT_FOUND", message: `Menu '${menuName}' not found` },
862
+ error: {
863
+ code: "NOT_FOUND",
864
+ message: `Menu '${menuName}' not found${
865
+ options.locale ? ` in locale '${options.locale}'` : ""
866
+ }`,
867
+ },
570
868
  };
571
869
  }
870
+ if (error === ambiguousSentinel && ambiguousLocales) {
871
+ return ambiguousMenuLocaleError(menuName, ambiguousLocales);
872
+ }
572
873
  throw error;
573
874
  }
574
875
 
@@ -589,20 +890,30 @@ export async function handleMenuItemReorder(
589
890
  db: Kysely<Database>,
590
891
  menuName: string,
591
892
  items: ReorderItem[],
893
+ options: { locale?: string } = {},
592
894
  ): Promise<ApiResult<MenuItemRow[]>> {
593
895
  try {
594
- const menu = await db
896
+ // See ambiguousMenuLocaleError for the rationale.
897
+ let menuQuery = db
595
898
  .selectFrom("_emdash_menus")
596
- .select("id")
597
- .where("name", "=", menuName)
598
- .executeTakeFirst();
899
+ .select(["id", "locale"])
900
+ .where("name", "=", menuName);
901
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
902
+ const matches = await menuQuery.execute();
599
903
 
600
- if (!menu) {
904
+ if (matches.length === 0) {
601
905
  return {
602
906
  success: false,
603
907
  error: { code: "NOT_FOUND", message: "Menu not found" },
604
908
  };
605
909
  }
910
+ if (matches.length > 1) {
911
+ return ambiguousMenuLocaleError(
912
+ menuName,
913
+ matches.map((m) => m.locale),
914
+ );
915
+ }
916
+ const menu = matches[0]!;
606
917
 
607
918
  const updatedItems = await withTransaction(db, async (trx) => {
608
919
  for (const item of items) {