emdash 0.7.0 → 1.0.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 (225) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
  3. package/dist/{apply-5uslYdUu.mjs → apply-x0eMK1lX.mjs} +18 -17
  4. package/dist/apply-x0eMK1lX.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 +86 -15
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +22 -2
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.mjs +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +259 -71
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +16 -8
  20. package/dist/astro/types.d.mts.map +1 -1
  21. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  22. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  23. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  24. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  25. package/dist/cli/index.mjs +16 -12
  26. package/dist/cli/index.mjs.map +1 -1
  27. package/dist/client/cf-access.d.mts +1 -1
  28. package/dist/client/index.d.mts +1 -1
  29. package/dist/client/index.mjs +1 -1
  30. package/dist/{content-D7J5y73J.mjs → content-BcQPYxdV.mjs} +13 -15
  31. package/dist/content-BcQPYxdV.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/libsql.d.mts +1 -1
  34. package/dist/db/postgres.d.mts +1 -1
  35. package/dist/db/sqlite.d.mts +1 -1
  36. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  37. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  38. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  39. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  40. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  41. package/dist/error-zG5T1UGA.mjs.map +1 -0
  42. package/dist/{index-De6_Xv3v.d.mts → index-DIb-CzNx.d.mts} +157 -14
  43. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +22 -20
  46. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  47. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  48. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  49. package/dist/loader-CndGj8kM.mjs.map +1 -0
  50. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  51. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/local-runtime.d.mts +7 -7
  54. package/dist/media/local-runtime.mjs +2 -2
  55. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  56. package/dist/media-D8FbNsl0.mjs.map +1 -0
  57. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  58. package/dist/mode-BnAOqItE.mjs.map +1 -0
  59. package/dist/page/index.d.mts +2 -2
  60. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  61. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  62. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  63. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  64. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  65. package/dist/{query-g4Ug-9j9.mjs → query-fqEdLFms.mjs} +9 -9
  66. package/dist/{query-g4Ug-9j9.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  67. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-D_pshWdf.mjs} +4 -4
  68. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  69. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  70. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  71. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  72. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  73. package/dist/{runner-BR2xKwhn.d.mts → runner-OURCaApa.d.mts} +2 -2
  74. package/dist/{runner-BR2xKwhn.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  75. package/dist/runtime.d.mts +6 -6
  76. package/dist/runtime.mjs +2 -2
  77. package/dist/{search-B0effn3j.mjs → search-BoZYFuUk.mjs} +227 -84
  78. package/dist/search-BoZYFuUk.mjs.map +1 -0
  79. package/dist/seed/index.d.mts +2 -2
  80. package/dist/seed/index.mjs +12 -12
  81. package/dist/seo/index.d.mts +1 -1
  82. package/dist/storage/local.d.mts +1 -1
  83. package/dist/storage/local.mjs +1 -1
  84. package/dist/storage/s3.d.mts +1 -1
  85. package/dist/storage/s3.d.mts.map +1 -1
  86. package/dist/storage/s3.mjs +4 -4
  87. package/dist/storage/s3.mjs.map +1 -1
  88. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  89. package/dist/{taxonomies-K2z0Uhnj.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  90. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  91. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  92. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  93. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  94. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  95. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  96. package/dist/types-BIgulNsW.mjs +68 -0
  97. package/dist/types-BIgulNsW.mjs.map +1 -0
  98. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  99. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  100. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  101. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  102. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  103. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  104. package/dist/{types-C2v0c34j.d.mts → types-CS8FIX7L.d.mts} +1 -1
  105. package/dist/{types-C2v0c34j.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  106. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  107. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  108. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  109. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  110. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  111. package/dist/types-i36XcA_X.d.mts.map +1 -0
  112. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  113. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  114. package/dist/{validate-kM8Pjuf7.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  115. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  116. package/dist/validation-C-ZpN2GI.mjs +144 -0
  117. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  118. package/dist/version-DJrV1K0M.mjs +7 -0
  119. package/dist/{version-BnTKdfam.mjs.map → version-DJrV1K0M.mjs.map} +1 -1
  120. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  121. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  122. package/package.json +19 -6
  123. package/src/api/auth-storage.ts +37 -0
  124. package/src/api/error.ts +6 -0
  125. package/src/api/errors.ts +8 -0
  126. package/src/api/handlers/comments.ts +13 -0
  127. package/src/api/handlers/content.ts +122 -3
  128. package/src/api/handlers/index.ts +2 -0
  129. package/src/api/handlers/media.ts +8 -1
  130. package/src/api/handlers/menus.ts +160 -21
  131. package/src/api/handlers/redirects.ts +16 -3
  132. package/src/api/handlers/sections.ts +8 -1
  133. package/src/api/handlers/taxonomies.ts +128 -16
  134. package/src/api/handlers/validation.ts +212 -0
  135. package/src/api/openapi/document.ts +4 -1
  136. package/src/api/public-url.ts +6 -3
  137. package/src/api/route-utils.ts +14 -0
  138. package/src/api/schemas/common.ts +1 -1
  139. package/src/api/schemas/setup.ts +8 -0
  140. package/src/api/schemas/widgets.ts +12 -10
  141. package/src/api/setup-complete.ts +40 -0
  142. package/src/astro/integration/index.ts +13 -2
  143. package/src/astro/integration/routes.ts +28 -0
  144. package/src/astro/integration/runtime.ts +19 -1
  145. package/src/astro/integration/virtual-modules.ts +41 -0
  146. package/src/astro/integration/vite-config.ts +43 -12
  147. package/src/astro/middleware/auth.ts +21 -0
  148. package/src/astro/middleware.ts +18 -1
  149. package/src/astro/routes/PluginRegistry.tsx +10 -1
  150. package/src/astro/routes/api/auth/mode.ts +57 -0
  151. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  152. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  153. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  154. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  155. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  156. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  157. package/src/astro/routes/api/settings/email.ts +4 -9
  158. package/src/astro/routes/api/setup/admin.ts +8 -2
  159. package/src/astro/routes/api/setup/index.ts +2 -2
  160. package/src/astro/routes/api/setup/status.ts +3 -1
  161. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  162. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  163. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  164. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  165. package/src/astro/types.ts +9 -0
  166. package/src/auth/mode.ts +15 -3
  167. package/src/auth/providers/github-admin.tsx +29 -0
  168. package/src/auth/providers/github.ts +31 -0
  169. package/src/auth/providers/google-admin.tsx +44 -0
  170. package/src/auth/providers/google.ts +31 -0
  171. package/src/auth/types.ts +114 -4
  172. package/src/cli/commands/bundle.ts +3 -1
  173. package/src/components/EmDashImage.astro +7 -6
  174. package/src/components/Gallery.astro +5 -3
  175. package/src/components/Image.astro +8 -3
  176. package/src/components/InlinePortableTextEditor.tsx +2 -1
  177. package/src/components/LiveSearch.astro +5 -14
  178. package/src/database/repositories/audit.ts +6 -8
  179. package/src/database/repositories/byline.ts +6 -8
  180. package/src/database/repositories/comment.ts +12 -16
  181. package/src/database/repositories/content.ts +40 -40
  182. package/src/database/repositories/index.ts +1 -1
  183. package/src/database/repositories/media.ts +10 -13
  184. package/src/database/repositories/plugin-storage.ts +4 -6
  185. package/src/database/repositories/redirect.ts +12 -16
  186. package/src/database/repositories/taxonomy.ts +14 -3
  187. package/src/database/repositories/types.ts +57 -8
  188. package/src/database/repositories/user.ts +6 -8
  189. package/src/emdash-runtime.ts +306 -90
  190. package/src/index.ts +5 -1
  191. package/src/loader.ts +6 -5
  192. package/src/mcp/server.ts +678 -105
  193. package/src/media/normalize.ts +1 -1
  194. package/src/media/url.ts +78 -0
  195. package/src/plugins/email-console.ts +10 -3
  196. package/src/plugins/hooks.ts +11 -0
  197. package/src/plugins/manifest-schema.ts +12 -0
  198. package/src/plugins/types.ts +23 -2
  199. package/src/query.ts +1 -1
  200. package/src/request-cache.ts +3 -0
  201. package/src/schema/registry.ts +41 -5
  202. package/src/search/fts-manager.ts +0 -2
  203. package/src/search/query.ts +111 -26
  204. package/src/search/types.ts +8 -1
  205. package/src/sections/index.ts +7 -9
  206. package/src/storage/s3.ts +12 -6
  207. package/src/virtual-modules.d.ts +21 -1
  208. package/src/widgets/index.ts +1 -1
  209. package/dist/apply-5uslYdUu.mjs.map +0 -1
  210. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  211. package/dist/content-D7J5y73J.mjs.map +0 -1
  212. package/dist/error-CiYn9yDu.mjs.map +0 -1
  213. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  214. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  215. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  216. package/dist/media-DqHVh136.mjs.map +0 -1
  217. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  218. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  219. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  220. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  221. package/dist/search-B0effn3j.mjs.map +0 -1
  222. package/dist/types-CMMN0pNg.mjs +0 -31
  223. package/dist/types-CMMN0pNg.mjs.map +0 -1
  224. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  225. package/dist/version-BnTKdfam.mjs +0 -7
@@ -14,6 +14,7 @@ import { RevisionRepository } from "../../database/repositories/revision.js";
14
14
  import { SeoRepository } from "../../database/repositories/seo.js";
15
15
  import {
16
16
  EmDashValidationError,
17
+ InvalidCursorError,
17
18
  type ContentItem,
18
19
  type ContentSeo,
19
20
  type ContentSeoInput,
@@ -23,9 +24,26 @@ import type { Database } from "../../database/types.js";
23
24
  import { validateIdentifier } from "../../database/validate.js";
24
25
  import { isI18nEnabled } from "../../i18n/config.js";
25
26
  import { invalidateRedirectCache } from "../../redirects/cache.js";
27
+ import { isMissingTableError } from "../../utils/db-errors.js";
26
28
  import { encodeRev, validateRev } from "../rev.js";
27
29
  import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
28
30
 
31
+ /**
32
+ * Narrow a caught error to one carrying a structured `apiError` discriminant.
33
+ * Used by transaction callbacks that want to surface a specific error code
34
+ * through the standard Error throwing path.
35
+ */
36
+ function hasApiError(error: unknown): error is Error & { apiError: { code: string } } {
37
+ if (!(error instanceof Error) || !("apiError" in error)) return false;
38
+ const { apiError } = error;
39
+ return (
40
+ typeof apiError === "object" &&
41
+ apiError !== null &&
42
+ "code" in apiError &&
43
+ typeof apiError.code === "string"
44
+ );
45
+ }
46
+
29
47
  /**
30
48
  * Extract a slug source (title or name) from content data.
31
49
  * Returns null if no suitable string field is found.
@@ -267,6 +285,28 @@ export async function handleContentList(
267
285
  },
268
286
  };
269
287
  } catch (error) {
288
+ if (error instanceof InvalidCursorError) {
289
+ return {
290
+ success: false,
291
+ error: { code: "INVALID_CURSOR", message: error.message },
292
+ };
293
+ }
294
+ if (isMissingTableError(error)) {
295
+ return {
296
+ success: false,
297
+ error: {
298
+ code: "COLLECTION_NOT_FOUND",
299
+ message: `Collection '${collection}' not found`,
300
+ },
301
+ };
302
+ }
303
+ if (error instanceof EmDashValidationError) {
304
+ // e.g. invalid orderBy field
305
+ return {
306
+ success: false,
307
+ error: { code: "VALIDATION_ERROR", message: error.message },
308
+ };
309
+ }
270
310
  console.error("Content list error:", error);
271
311
  return {
272
312
  success: false,
@@ -453,6 +493,46 @@ export async function handleContentCreate(
453
493
  data: { item, _rev: encodeRev(item) },
454
494
  };
455
495
  } catch (error) {
496
+ if (isMissingTableError(error)) {
497
+ return {
498
+ success: false,
499
+ error: {
500
+ code: "COLLECTION_NOT_FOUND",
501
+ message: `Collection '${collection}' not found`,
502
+ },
503
+ };
504
+ }
505
+ if (error instanceof EmDashValidationError) {
506
+ return {
507
+ success: false,
508
+ error: { code: "VALIDATION_ERROR", message: error.message },
509
+ };
510
+ }
511
+ // SQLite UNIQUE constraint OR Postgres unique_violation — slug
512
+ // collisions and any other unique violations land here. Match
513
+ // specifically on "unique constraint failed" / "duplicate key" so we
514
+ // don't false-positive on NOT NULL or CHECK violations whose
515
+ // messages also contain "constraint failed".
516
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
517
+ if (message.includes("unique constraint failed") || message.includes("duplicate key")) {
518
+ // Detect slug-specific collisions by message fingerprint
519
+ if (message.includes("slug")) {
520
+ return {
521
+ success: false,
522
+ error: {
523
+ code: "SLUG_CONFLICT",
524
+ message: `Slug '${body.slug ?? "(auto-generated)"}' already exists in collection '${collection}'`,
525
+ },
526
+ };
527
+ }
528
+ return {
529
+ success: false,
530
+ error: {
531
+ code: "CONFLICT",
532
+ message: "Unique constraint violation",
533
+ },
534
+ };
535
+ }
456
536
  console.error("Content create error:", error);
457
537
  return {
458
538
  success: false,
@@ -604,11 +684,44 @@ export async function handleContentUpdate(
604
684
  } catch (error) {
605
685
  // Handle structured errors thrown from inside the transaction
606
686
  // (rev check failures, not-found)
607
- if (error instanceof Error && "apiError" in error) {
608
- const { code } = (error as Error & { apiError: { code: string } }).apiError;
687
+ if (hasApiError(error)) {
688
+ return {
689
+ success: false,
690
+ error: { code: error.apiError.code, message: error.message },
691
+ };
692
+ }
693
+ if (isMissingTableError(error)) {
694
+ return {
695
+ success: false,
696
+ error: {
697
+ code: "COLLECTION_NOT_FOUND",
698
+ message: `Collection '${collection}' not found`,
699
+ },
700
+ };
701
+ }
702
+ if (error instanceof EmDashValidationError) {
609
703
  return {
610
704
  success: false,
611
- error: { code, message: error.message },
705
+ error: { code: "VALIDATION_ERROR", message: error.message },
706
+ };
707
+ }
708
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
709
+ if (message.includes("unique constraint failed") || message.includes("duplicate key")) {
710
+ if (message.includes("slug")) {
711
+ return {
712
+ success: false,
713
+ error: {
714
+ code: "SLUG_CONFLICT",
715
+ message: `Slug '${body.slug ?? id}' already exists in collection '${collection}'`,
716
+ },
717
+ };
718
+ }
719
+ return {
720
+ success: false,
721
+ error: {
722
+ code: "CONFLICT",
723
+ message: "Unique constraint violation",
724
+ },
612
725
  };
613
726
  }
614
727
  console.error("Content update error:", error);
@@ -869,6 +982,12 @@ export async function handleContentListTrashed(
869
982
  },
870
983
  };
871
984
  } catch (error) {
985
+ if (error instanceof InvalidCursorError) {
986
+ return {
987
+ success: false,
988
+ error: { code: "INVALID_CURSOR", message: error.message },
989
+ };
990
+ }
872
991
  console.error("Content list trashed error:", error);
873
992
  return {
874
993
  success: false,
@@ -113,11 +113,13 @@ export {
113
113
  handleMenuItemUpdate,
114
114
  handleMenuItemDelete,
115
115
  handleMenuItemReorder,
116
+ handleMenuSetItems,
116
117
  type MenuListItem,
117
118
  type MenuWithItems,
118
119
  type CreateMenuItemInput,
119
120
  type UpdateMenuItemInput,
120
121
  type ReorderItem,
122
+ type MenuSetItemsInput,
121
123
  } from "./menus.js";
122
124
 
123
125
  // Section handlers
@@ -5,6 +5,7 @@
5
5
  import type { Kysely } from "kysely";
6
6
 
7
7
  import { MediaRepository, type MediaItem } from "../../database/repositories/media.js";
8
+ import { InvalidCursorError } from "../../database/repositories/types.js";
8
9
  import type { Database } from "../../database/types.js";
9
10
  import type { ApiResult } from "../types.js";
10
11
 
@@ -43,7 +44,13 @@ export async function handleMediaList(
43
44
  nextCursor: result.nextCursor,
44
45
  },
45
46
  };
46
- } catch {
47
+ } catch (error) {
48
+ if (error instanceof InvalidCursorError) {
49
+ return {
50
+ success: false,
51
+ error: { code: "INVALID_CURSOR", message: error.message },
52
+ };
53
+ }
47
54
  return {
48
55
  success: false,
49
56
  error: {
@@ -42,26 +42,33 @@ export interface MenuWithItems extends MenuRow {
42
42
  */
43
43
  export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<MenuListItem[]>> {
44
44
  try {
45
- const menus = await db
46
- .selectFrom("_emdash_menus")
47
- .select(["id", "name", "label", "created_at", "updated_at"])
48
- .orderBy("name", "asc")
45
+ // Single query: LEFT JOIN + GROUP BY for the per-menu item count.
46
+ // Avoids the N+1 of one count query per menu.
47
+ const rows = await db
48
+ .selectFrom("_emdash_menus as m")
49
+ .leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
50
+ .select(({ fn }) => [
51
+ "m.id",
52
+ "m.name",
53
+ "m.label",
54
+ "m.created_at",
55
+ "m.updated_at",
56
+ fn.count<number>("i.id").as("itemCount"),
57
+ ])
58
+ .groupBy(["m.id", "m.name", "m.label", "m.created_at", "m.updated_at"])
59
+ .orderBy("m.name", "asc")
49
60
  .execute();
50
61
 
51
- const menusWithCounts = await Promise.all(
52
- menus.map(async (menu) => {
53
- const { count } = await db
54
- .selectFrom("_emdash_menu_items")
55
- .select(({ fn }) => fn.countAll<number>().as("count"))
56
- .where("menu_id", "=", menu.id)
57
- .executeTakeFirstOrThrow();
58
-
59
- return {
60
- ...menu,
61
- itemCount: count,
62
- };
63
- }),
64
- );
62
+ // SQLite returns count as `number`, but some dialects (Postgres)
63
+ // return `string` from a count() aggregate. Normalize to number.
64
+ const menusWithCounts: MenuListItem[] = rows.map((row) => ({
65
+ id: row.id,
66
+ name: row.name,
67
+ label: row.label,
68
+ created_at: row.created_at,
69
+ updated_at: row.updated_at,
70
+ itemCount: typeof row.itemCount === "string" ? Number(row.itemCount) : row.itemCount,
71
+ }));
65
72
 
66
73
  return { success: true, data: menusWithCounts };
67
74
  } catch {
@@ -135,7 +142,7 @@ export async function handleMenuGet(
135
142
  if (!menu) {
136
143
  return {
137
144
  success: false,
138
- error: { code: "NOT_FOUND", message: "Menu not found" },
145
+ error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
139
146
  };
140
147
  }
141
148
 
@@ -173,7 +180,7 @@ export async function handleMenuUpdate(
173
180
  if (!menu) {
174
181
  return {
175
182
  success: false,
176
- error: { code: "NOT_FOUND", message: "Menu not found" },
183
+ error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
177
184
  };
178
185
  }
179
186
 
@@ -217,10 +224,14 @@ export async function handleMenuDelete(
217
224
  if (!menu) {
218
225
  return {
219
226
  success: false,
220
- error: { code: "NOT_FOUND", message: "Menu not found" },
227
+ error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
221
228
  };
222
229
  }
223
230
 
231
+ // D1 has FOREIGN KEYS off by default, so the migration's `ON DELETE
232
+ // CASCADE` won't fire there. Delete items explicitly first — this is
233
+ // idempotent on SQLite/Postgres where the cascade also fires.
234
+ await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
224
235
  await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
225
236
 
226
237
  return { success: true, data: { deleted: true } };
@@ -443,6 +454,134 @@ export interface ReorderItem {
443
454
  sortOrder: number;
444
455
  }
445
456
 
457
+ // ---------------------------------------------------------------------------
458
+ // Atomic-replace menu items (used by the MCP `menu_set_items` tool)
459
+ // ---------------------------------------------------------------------------
460
+
461
+ export interface MenuSetItemsInput {
462
+ label: string;
463
+ type: "custom" | "page" | "post" | "taxonomy" | "collection";
464
+ customUrl?: string;
465
+ referenceCollection?: string;
466
+ referenceId?: string;
467
+ titleAttr?: string;
468
+ target?: string;
469
+ cssClasses?: string;
470
+ /**
471
+ * Index of the parent item in this same array. Must be strictly less
472
+ * than the current item's index so the insert order resolves parents
473
+ * before children. `undefined` makes the item top-level.
474
+ */
475
+ parentIndex?: number;
476
+ }
477
+
478
+ /**
479
+ * Replace the entire set of items for a menu in one atomic transaction.
480
+ *
481
+ * Existing items are deleted and the new list is inserted in the order
482
+ * provided. `parentIndex` references resolve to actual parent IDs as the
483
+ * insert proceeds.
484
+ */
485
+ export async function handleMenuSetItems(
486
+ db: Kysely<Database>,
487
+ menuName: string,
488
+ items: MenuSetItemsInput[],
489
+ ): Promise<ApiResult<{ name: string; itemCount: number }>> {
490
+ // Validate parentIndex references — must be strictly earlier so
491
+ // the array can be inserted in order with parents resolved first.
492
+ // Negative indices are out of range; only Zod's `.nonnegative()` at
493
+ // the MCP boundary catches them today, so guard explicitly here for
494
+ // any caller that bypasses Zod (REST routes, direct handler use).
495
+ for (let i = 0; i < items.length; i++) {
496
+ const item = items[i];
497
+ if (item?.parentIndex !== undefined) {
498
+ if (item.parentIndex < 0 || item.parentIndex >= i) {
499
+ return {
500
+ success: false,
501
+ error: {
502
+ code: "VALIDATION_ERROR",
503
+ message: `item[${i}].parentIndex (${item.parentIndex}) must reference an earlier item`,
504
+ },
505
+ };
506
+ }
507
+ }
508
+ }
509
+
510
+ try {
511
+ // Sentinel for "menu not found" thrown from inside the transaction
512
+ // so the rollback fires before we return the structured error.
513
+ const notFoundSentinel = Symbol("menu-not-found");
514
+
515
+ try {
516
+ await withTransaction(db, async (trx) => {
517
+ // Existence check INSIDE the transaction so a concurrent
518
+ // menu_delete between lookup and write can't leave orphan
519
+ // items on D1 (FKs disabled by default).
520
+ const menu = await trx
521
+ .selectFrom("_emdash_menus")
522
+ .select("id")
523
+ .where("name", "=", menuName)
524
+ .executeTakeFirst();
525
+
526
+ if (!menu) {
527
+ throw notFoundSentinel;
528
+ }
529
+
530
+ await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
531
+
532
+ const insertedIds: string[] = [];
533
+ for (let i = 0; i < items.length; i++) {
534
+ const item = items[i];
535
+ if (!item) continue;
536
+ const id = ulid();
537
+ const parentId =
538
+ item.parentIndex !== undefined ? (insertedIds[item.parentIndex] ?? null) : null;
539
+ await trx
540
+ .insertInto("_emdash_menu_items")
541
+ .values({
542
+ id,
543
+ menu_id: menu.id,
544
+ parent_id: parentId,
545
+ sort_order: i,
546
+ type: item.type,
547
+ reference_collection: item.referenceCollection ?? null,
548
+ reference_id: item.referenceId ?? null,
549
+ custom_url: item.customUrl ?? null,
550
+ label: item.label,
551
+ title_attr: item.titleAttr ?? null,
552
+ target: item.target ?? null,
553
+ css_classes: item.cssClasses ?? null,
554
+ })
555
+ .execute();
556
+ insertedIds.push(id);
557
+ }
558
+
559
+ await trx
560
+ .updateTable("_emdash_menus")
561
+ .set({ updated_at: new Date().toISOString() })
562
+ .where("id", "=", menu.id)
563
+ .execute();
564
+ });
565
+ } catch (error) {
566
+ if (error === notFoundSentinel) {
567
+ return {
568
+ success: false,
569
+ error: { code: "NOT_FOUND", message: `Menu '${menuName}' not found` },
570
+ };
571
+ }
572
+ throw error;
573
+ }
574
+
575
+ return { success: true, data: { name: menuName, itemCount: items.length } };
576
+ } catch (error) {
577
+ console.error("[emdash] handleMenuSetItems failed:", error);
578
+ return {
579
+ success: false,
580
+ error: { code: "MENU_SET_ITEMS_ERROR", message: "Failed to set menu items" },
581
+ };
582
+ }
583
+ }
584
+
446
585
  /**
447
586
  * Batch reorder menu items.
448
587
  */
@@ -11,6 +11,7 @@ import {
11
11
  type NotFoundEntry,
12
12
  type NotFoundSummary,
13
13
  } from "../../database/repositories/redirect.js";
14
+ import { InvalidCursorError } from "../../database/repositories/types.js";
14
15
  import type { FindManyResult } from "../../database/repositories/types.js";
15
16
  import type { Database } from "../../database/types.js";
16
17
  import { wouldCreateLoop, detectLoops, type RedirectEdge } from "../../redirects/loops.js";
@@ -48,7 +49,13 @@ export async function handleRedirectList(
48
49
  ...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}),
49
50
  },
50
51
  };
51
- } catch {
52
+ } catch (error) {
53
+ if (error instanceof InvalidCursorError) {
54
+ return {
55
+ success: false,
56
+ error: { code: "INVALID_CURSOR", message: error.message },
57
+ };
58
+ }
52
59
  return {
53
60
  success: false,
54
61
  error: { code: "REDIRECT_LIST_ERROR", message: "Failed to fetch redirects" },
@@ -318,7 +325,7 @@ export async function handleRedirectDelete(
318
325
  function loopError(loopPath: string[]): ApiResult<never> {
319
326
  const hops = loopPath
320
327
  .slice(0, -1)
321
- .map((p, i) => `${p} \u2192 ${loopPath[i + 1]!}`)
328
+ .map((p, i) => `${p} \u2192 ${loopPath[i + 1]}`)
322
329
  .join("\n");
323
330
  return {
324
331
  success: false,
@@ -387,7 +394,13 @@ export async function handleNotFoundList(
387
394
  const repo = new RedirectRepository(db);
388
395
  const result = await repo.find404s(params);
389
396
  return { success: true, data: result };
390
- } catch {
397
+ } catch (error) {
398
+ if (error instanceof InvalidCursorError) {
399
+ return {
400
+ success: false,
401
+ error: { code: "INVALID_CURSOR", message: error.message },
402
+ };
403
+ }
391
404
  return {
392
405
  success: false,
393
406
  error: { code: "NOT_FOUND_LIST_ERROR", message: "Failed to fetch 404 log" },
@@ -5,6 +5,7 @@
5
5
  import type { Kysely } from "kysely";
6
6
  import { ulid } from "ulidx";
7
7
 
8
+ import { InvalidCursorError } from "../../database/repositories/types.js";
8
9
  import type { FindManyResult } from "../../database/repositories/types.js";
9
10
  import type { Database } from "../../database/types.js";
10
11
  import {
@@ -36,7 +37,13 @@ export async function handleSectionList(
36
37
  });
37
38
 
38
39
  return { success: true, data: result };
39
- } catch {
40
+ } catch (error) {
41
+ if (error instanceof InvalidCursorError) {
42
+ return {
43
+ success: false,
44
+ error: { code: "INVALID_CURSOR", message: error.message },
45
+ };
46
+ }
40
47
  return {
41
48
  success: false,
42
49
  error: { code: "SECTION_LIST_ERROR", message: "Failed to fetch sections" },
@@ -122,16 +122,27 @@ export async function handleTaxonomyList(
122
122
  db: Kysely<Database>,
123
123
  ): Promise<ApiResult<TaxonomyListResponse>> {
124
124
  try {
125
- const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
126
-
127
- const taxonomies: TaxonomyDef[] = rows.map((row) => ({
128
- id: row.id,
129
- name: row.name,
130
- label: row.label,
131
- labelSingular: row.label_singular ?? undefined,
132
- hierarchical: row.hierarchical === 1,
133
- collections: row.collections ? JSON.parse(row.collections) : [],
134
- }));
125
+ const [rows, collectionRows] = await Promise.all([
126
+ db.selectFrom("_emdash_taxonomy_defs").selectAll().execute(),
127
+ db.selectFrom("_emdash_collections").select("slug").execute(),
128
+ ]);
129
+
130
+ // Filter orphan collection references on read so the response stays
131
+ // consistent with `schema_list_collections`. Storage is untouched —
132
+ // re-creating the collection re-links automatically.
133
+ const realCollections = new Set(collectionRows.map((r) => r.slug));
134
+
135
+ const taxonomies: TaxonomyDef[] = rows.map((row) => {
136
+ const stored: string[] = row.collections ? JSON.parse(row.collections) : [];
137
+ return {
138
+ id: row.id,
139
+ name: row.name,
140
+ label: row.label,
141
+ labelSingular: row.label_singular ?? undefined,
142
+ hierarchical: row.hierarchical === 1,
143
+ collections: stored.filter((slug) => realCollections.has(slug)),
144
+ };
145
+ });
135
146
 
136
147
  return { success: true, data: { taxonomies } };
137
148
  } catch {
@@ -290,6 +301,84 @@ export async function handleTermList(
290
301
  }
291
302
  }
292
303
 
304
+ /**
305
+ * Validate a parent term reference for create/update.
306
+ *
307
+ * Returns `null` on success or a structured error message that callers
308
+ * wrap in their own ApiResult.
309
+ *
310
+ * - `parentId === undefined` -> no-op (no parent change requested).
311
+ * - `parentId === null` -> caller intends to detach; no-op here.
312
+ * - parent must exist (FK exists -> term row not soft-deleted).
313
+ * - parent must live in the same taxonomy.
314
+ * - if `termId` is provided (update path), reject `parentId === termId`
315
+ * (self-parent) and walk up the parent chain to detect cycles.
316
+ */
317
+ async function validateParentTerm(
318
+ repo: TaxonomyRepository,
319
+ taxonomyName: string,
320
+ termId: string | undefined,
321
+ parentId: string | null | undefined,
322
+ ): Promise<{ code: "VALIDATION_ERROR"; message: string } | null> {
323
+ if (parentId === undefined || parentId === null) return null;
324
+
325
+ if (termId !== undefined && parentId === termId) {
326
+ return {
327
+ code: "VALIDATION_ERROR",
328
+ message: "A term cannot be its own parent",
329
+ };
330
+ }
331
+
332
+ const parent = await repo.findById(parentId);
333
+ if (!parent) {
334
+ return {
335
+ code: "VALIDATION_ERROR",
336
+ message: `Parent term '${parentId}' not found`,
337
+ };
338
+ }
339
+ if (parent.name !== taxonomyName) {
340
+ return {
341
+ code: "VALIDATION_ERROR",
342
+ message: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,
343
+ };
344
+ }
345
+
346
+ // Walk up the parent chain. Two checks fold into one walk:
347
+ // - Cycle detection (only on update — a non-existent term-being-
348
+ // created can't be its own ancestor): if the walk revisits termId
349
+ // the proposed parent makes the term a descendant of itself.
350
+ // - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.
351
+ // Runs on both create and update so a malicious or buggy caller
352
+ // can't grow the tree without limit.
353
+ //
354
+ // The depth-exceeded error fires only when we hit the limit AND there
355
+ // was still chain to walk — a legitimate chain of exactly MAX_DEPTH
356
+ // ancestors exits with `cursor === null` and is accepted.
357
+ const MAX_DEPTH = 100;
358
+ let cursor: string | null = parent.parentId;
359
+ let steps = 0;
360
+ while (cursor !== null && steps < MAX_DEPTH) {
361
+ if (termId !== undefined && cursor === termId) {
362
+ return {
363
+ code: "VALIDATION_ERROR",
364
+ message: "Cycle detected: cannot make a descendant the parent",
365
+ };
366
+ }
367
+ const next = await repo.findById(cursor);
368
+ if (!next) break;
369
+ cursor = next.parentId;
370
+ steps++;
371
+ }
372
+ if (cursor !== null && steps >= MAX_DEPTH) {
373
+ return {
374
+ code: "VALIDATION_ERROR",
375
+ message: "Parent chain exceeds maximum depth",
376
+ };
377
+ }
378
+
379
+ return null;
380
+ }
381
+
293
382
  /**
294
383
  * Create a new term in a taxonomy
295
384
  */
@@ -304,6 +393,10 @@ export async function handleTermCreate(
304
393
 
305
394
  const repo = new TaxonomyRepository(db);
306
395
 
396
+ // Coerce empty-string parentId to undefined (treat as "no parent").
397
+ const parentId =
398
+ input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
399
+
307
400
  // Check for slug conflict
308
401
  const existing = await repo.findBySlug(taxonomyName, input.slug);
309
402
  if (existing) {
@@ -316,11 +409,18 @@ export async function handleTermCreate(
316
409
  };
317
410
  }
318
411
 
412
+ // Validate parentId: must exist AND belong to the same taxonomy.
413
+ // (Cycle check is N/A on create — the term doesn't exist yet.)
414
+ const parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);
415
+ if (parentError) {
416
+ return { success: false, error: parentError };
417
+ }
418
+
319
419
  const term = await repo.create({
320
420
  name: taxonomyName,
321
421
  slug: input.slug,
322
422
  label: input.label,
323
- parentId: input.parentId ?? undefined,
423
+ parentId: parentId ?? undefined,
324
424
  data: input.description ? { description: input.description } : undefined,
325
425
  });
326
426
 
@@ -426,24 +526,36 @@ export async function handleTermUpdate(
426
526
  };
427
527
  }
428
528
 
529
+ // Coerce empty-string slug/parentId to undefined (treat as "no change").
530
+ // `null` parentId is a valid request meaning "detach from parent".
531
+ const newSlug = input.slug === "" || input.slug === undefined ? undefined : input.slug;
532
+ const newParentId =
533
+ input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
534
+
429
535
  // Check if new slug conflicts
430
- if (input.slug && input.slug !== termSlug) {
431
- const existing = await repo.findBySlug(taxonomyName, input.slug);
536
+ if (newSlug !== undefined && newSlug !== termSlug) {
537
+ const existing = await repo.findBySlug(taxonomyName, newSlug);
432
538
  if (existing && existing.id !== term.id) {
433
539
  return {
434
540
  success: false,
435
541
  error: {
436
542
  code: "CONFLICT",
437
- message: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
543
+ message: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,
438
544
  },
439
545
  };
440
546
  }
441
547
  }
442
548
 
549
+ // Validate parentId: existence, same-taxonomy, no self-parent, no cycle.
550
+ const parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);
551
+ if (parentError) {
552
+ return { success: false, error: parentError };
553
+ }
554
+
443
555
  const updated = await repo.update(term.id, {
444
- slug: input.slug,
556
+ slug: newSlug,
445
557
  label: input.label,
446
- parentId: input.parentId,
558
+ parentId: newParentId,
447
559
  data: input.description !== undefined ? { description: input.description } : undefined,
448
560
  });
449
561