emdash 0.6.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 (263) 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-B4MsLM-w.mjs → apply-x0eMK1lX.mjs} +186 -28
  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 +92 -17
  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 +7 -2
  15. package/dist/astro/middleware/request-context.mjs.map +1 -1
  16. package/dist/astro/middleware/setup.mjs +1 -1
  17. package/dist/astro/middleware.d.mts.map +1 -1
  18. package/dist/astro/middleware.mjs +263 -74
  19. package/dist/astro/middleware.mjs.map +1 -1
  20. package/dist/astro/types.d.mts +25 -8
  21. package/dist/astro/types.d.mts.map +1 -1
  22. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  23. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  24. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  25. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  26. package/dist/cli/index.mjs +17 -13
  27. package/dist/cli/index.mjs.map +1 -1
  28. package/dist/client/cf-access.d.mts +1 -1
  29. package/dist/client/index.d.mts +1 -1
  30. package/dist/client/index.mjs +1 -1
  31. package/dist/{content-BsBoyj8G.mjs → content-BcQPYxdV.mjs} +39 -15
  32. package/dist/content-BcQPYxdV.mjs.map +1 -0
  33. package/dist/db/index.d.mts +3 -3
  34. package/dist/db/index.mjs +1 -1
  35. package/dist/db/libsql.d.mts +1 -1
  36. package/dist/db/postgres.d.mts +1 -1
  37. package/dist/db/sqlite.d.mts +1 -1
  38. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  39. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  40. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  41. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  42. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  43. package/dist/error-zG5T1UGA.mjs.map +1 -0
  44. package/dist/{index-BYv0mB9g.d.mts → index-DIb-CzNx.d.mts} +232 -15
  45. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  46. package/dist/index.d.mts +11 -11
  47. package/dist/index.mjs +23 -21
  48. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  49. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  50. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  51. package/dist/loader-CndGj8kM.mjs.map +1 -0
  52. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  53. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  54. package/dist/media/index.d.mts +1 -1
  55. package/dist/media/local-runtime.d.mts +7 -7
  56. package/dist/media/local-runtime.mjs +2 -2
  57. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  58. package/dist/media-D8FbNsl0.mjs.map +1 -0
  59. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  60. package/dist/mode-BnAOqItE.mjs.map +1 -0
  61. package/dist/page/index.d.mts +2 -2
  62. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  63. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  64. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  65. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  66. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  67. package/dist/{query-Bk_3vKvU.mjs → query-fqEdLFms.mjs} +9 -9
  68. package/dist/{query-Bk_3vKvU.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  69. package/dist/{redirect-7lGhLBNZ.mjs → redirect-D_pshWdf.mjs} +69 -13
  70. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  71. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  72. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  73. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  74. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  75. package/dist/{runner-Fl2NcUUz.d.mts → runner-OURCaApa.d.mts} +2 -2
  76. package/dist/{runner-Fl2NcUUz.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  77. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  78. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  79. package/dist/runtime.d.mts +6 -6
  80. package/dist/runtime.mjs +2 -2
  81. package/dist/{search-DI4bM2w9.mjs → search-BoZYFuUk.mjs} +339 -102
  82. package/dist/search-BoZYFuUk.mjs.map +1 -0
  83. package/dist/seed/index.d.mts +2 -2
  84. package/dist/seed/index.mjs +12 -12
  85. package/dist/seo/index.d.mts +1 -1
  86. package/dist/storage/local.d.mts +1 -1
  87. package/dist/storage/local.mjs +1 -1
  88. package/dist/storage/s3.d.mts +1 -1
  89. package/dist/storage/s3.d.mts.map +1 -1
  90. package/dist/storage/s3.mjs +4 -4
  91. package/dist/storage/s3.mjs.map +1 -1
  92. package/dist/{taxonomies-DbrKzDju.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  93. package/dist/{taxonomies-DbrKzDju.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  94. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  95. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  96. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  97. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  98. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  99. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  100. package/dist/types-BIgulNsW.mjs +68 -0
  101. package/dist/types-BIgulNsW.mjs.map +1 -0
  102. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  103. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  104. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  105. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  106. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  107. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  108. package/dist/{types-8xrvl_68.d.mts → types-CS8FIX7L.d.mts} +10 -1
  109. package/dist/{types-8xrvl_68.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  110. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  111. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  112. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  113. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  114. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  115. package/dist/types-i36XcA_X.d.mts.map +1 -0
  116. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  117. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  118. package/dist/{validate-CaLH1Ia2.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  119. package/dist/{validate-CaLH1Ia2.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  120. package/dist/validation-C-ZpN2GI.mjs +144 -0
  121. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  122. package/dist/version-DJrV1K0M.mjs +7 -0
  123. package/dist/{version-Uaf2ynPX.mjs.map → version-DJrV1K0M.mjs.map} +1 -1
  124. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  125. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  126. package/package.json +19 -6
  127. package/src/api/auth-storage.ts +37 -0
  128. package/src/api/error.ts +6 -0
  129. package/src/api/errors.ts +8 -0
  130. package/src/api/handlers/comments.ts +13 -0
  131. package/src/api/handlers/content.ts +124 -3
  132. package/src/api/handlers/index.ts +2 -0
  133. package/src/api/handlers/media.ts +8 -1
  134. package/src/api/handlers/menus.ts +160 -21
  135. package/src/api/handlers/redirects.ts +16 -3
  136. package/src/api/handlers/sections.ts +8 -1
  137. package/src/api/handlers/taxonomies.ts +128 -16
  138. package/src/api/handlers/validation.ts +212 -0
  139. package/src/api/openapi/document.ts +4 -1
  140. package/src/api/public-url.ts +6 -3
  141. package/src/api/route-utils.ts +14 -0
  142. package/src/api/schemas/common.ts +1 -1
  143. package/src/api/schemas/content.ts +8 -0
  144. package/src/api/schemas/setup.ts +8 -0
  145. package/src/api/schemas/widgets.ts +12 -10
  146. package/src/api/setup-complete.ts +40 -0
  147. package/src/astro/integration/font-provider.ts +3 -1
  148. package/src/astro/integration/index.ts +15 -2
  149. package/src/astro/integration/routes.ts +28 -0
  150. package/src/astro/integration/runtime.ts +74 -2
  151. package/src/astro/integration/virtual-modules.ts +41 -0
  152. package/src/astro/integration/vite-config.ts +43 -12
  153. package/src/astro/middleware/auth.ts +21 -0
  154. package/src/astro/middleware.ts +18 -1
  155. package/src/astro/routes/PluginRegistry.tsx +10 -1
  156. package/src/astro/routes/admin.astro +14 -7
  157. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  158. package/src/astro/routes/api/auth/mode.ts +57 -0
  159. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  160. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  161. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  162. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  163. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  164. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  167. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
  168. package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
  169. package/src/astro/routes/api/content/[collection]/index.ts +20 -10
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  172. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  173. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  174. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +4 -3
  175. package/src/astro/routes/api/manifest.ts +7 -0
  176. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  177. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  178. package/src/astro/routes/api/settings/email.ts +4 -9
  179. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  180. package/src/astro/routes/api/setup/admin.ts +38 -8
  181. package/src/astro/routes/api/setup/index.ts +7 -4
  182. package/src/astro/routes/api/setup/status.ts +3 -1
  183. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  184. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  185. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  186. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  187. package/src/astro/types.ts +18 -0
  188. package/src/auth/mode.ts +15 -3
  189. package/src/auth/providers/github-admin.tsx +29 -0
  190. package/src/auth/providers/github.ts +31 -0
  191. package/src/auth/providers/google-admin.tsx +44 -0
  192. package/src/auth/providers/google.ts +31 -0
  193. package/src/auth/rate-limit.ts +50 -22
  194. package/src/auth/setup-nonce.ts +22 -0
  195. package/src/auth/trusted-proxy.ts +92 -0
  196. package/src/auth/types.ts +114 -4
  197. package/src/cli/commands/bundle.ts +3 -1
  198. package/src/components/EmDashImage.astro +7 -6
  199. package/src/components/Gallery.astro +5 -3
  200. package/src/components/Image.astro +8 -3
  201. package/src/components/InlinePortableTextEditor.tsx +2 -1
  202. package/src/components/LiveSearch.astro +5 -14
  203. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  204. package/src/database/migrations/runner.ts +2 -0
  205. package/src/database/repositories/audit.ts +6 -8
  206. package/src/database/repositories/byline.ts +6 -8
  207. package/src/database/repositories/comment.ts +12 -16
  208. package/src/database/repositories/content.ts +79 -40
  209. package/src/database/repositories/index.ts +1 -1
  210. package/src/database/repositories/media.ts +10 -13
  211. package/src/database/repositories/options.ts +25 -0
  212. package/src/database/repositories/plugin-storage.ts +4 -6
  213. package/src/database/repositories/redirect.ts +123 -24
  214. package/src/database/repositories/taxonomy.ts +14 -3
  215. package/src/database/repositories/types.ts +57 -8
  216. package/src/database/repositories/user.ts +6 -8
  217. package/src/database/types.ts +9 -0
  218. package/src/emdash-runtime.ts +309 -91
  219. package/src/import/registry.ts +4 -3
  220. package/src/import/ssrf.ts +253 -12
  221. package/src/index.ts +5 -1
  222. package/src/loader.ts +6 -5
  223. package/src/mcp/server.ts +753 -107
  224. package/src/media/normalize.ts +1 -1
  225. package/src/media/url.ts +78 -0
  226. package/src/plugins/context.ts +15 -3
  227. package/src/plugins/email-console.ts +10 -3
  228. package/src/plugins/hooks.ts +11 -0
  229. package/src/plugins/manager.ts +6 -0
  230. package/src/plugins/manifest-schema.ts +12 -0
  231. package/src/plugins/request-meta.ts +66 -15
  232. package/src/plugins/routes.ts +3 -1
  233. package/src/plugins/types.ts +23 -2
  234. package/src/query.ts +1 -1
  235. package/src/request-cache.ts +3 -0
  236. package/src/schema/registry.ts +41 -5
  237. package/src/search/fts-manager.ts +0 -2
  238. package/src/search/query.ts +111 -26
  239. package/src/search/types.ts +8 -1
  240. package/src/sections/index.ts +7 -9
  241. package/src/seed/apply.ts +26 -0
  242. package/src/storage/s3.ts +12 -6
  243. package/src/virtual-modules.d.ts +21 -1
  244. package/src/visual-editing/toolbar.ts +6 -1
  245. package/src/widgets/index.ts +1 -1
  246. package/dist/apply-B4MsLM-w.mjs.map +0 -1
  247. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  248. package/dist/content-BsBoyj8G.mjs.map +0 -1
  249. package/dist/error-CiYn9yDu.mjs.map +0 -1
  250. package/dist/index-BYv0mB9g.d.mts.map +0 -1
  251. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  252. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  253. package/dist/media-DqHVh136.mjs.map +0 -1
  254. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  255. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  256. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  257. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  258. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  259. package/dist/search-DI4bM2w9.mjs.map +0 -1
  260. package/dist/types-CMMN0pNg.mjs +0 -31
  261. package/dist/types-CMMN0pNg.mjs.map +0 -1
  262. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  263. package/dist/version-Uaf2ynPX.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,
@@ -483,6 +563,7 @@ export async function handleContentUpdate(
483
563
  bylines?: ContentBylineInput[];
484
564
  _rev?: string;
485
565
  seo?: ContentSeoInput;
566
+ publishedAt?: string | null;
486
567
  },
487
568
  ): Promise<ApiResult<ContentResponse>> {
488
569
  try {
@@ -542,6 +623,7 @@ export async function handleContentUpdate(
542
623
  slug: body.slug,
543
624
  status: body.status,
544
625
  authorId: body.authorId,
626
+ publishedAt: body.publishedAt,
545
627
  });
546
628
 
547
629
  if (body.bylines !== undefined) {
@@ -602,11 +684,44 @@ export async function handleContentUpdate(
602
684
  } catch (error) {
603
685
  // Handle structured errors thrown from inside the transaction
604
686
  // (rev check failures, not-found)
605
- if (error instanceof Error && "apiError" in error) {
606
- 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)) {
607
694
  return {
608
695
  success: false,
609
- error: { code, message: error.message },
696
+ error: {
697
+ code: "COLLECTION_NOT_FOUND",
698
+ message: `Collection '${collection}' not found`,
699
+ },
700
+ };
701
+ }
702
+ if (error instanceof EmDashValidationError) {
703
+ return {
704
+ success: false,
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
+ },
610
725
  };
611
726
  }
612
727
  console.error("Content update error:", error);
@@ -867,6 +982,12 @@ export async function handleContentListTrashed(
867
982
  },
868
983
  };
869
984
  } catch (error) {
985
+ if (error instanceof InvalidCursorError) {
986
+ return {
987
+ success: false,
988
+ error: { code: "INVALID_CURSOR", message: error.message },
989
+ };
990
+ }
870
991
  console.error("Content list trashed error:", error);
871
992
  return {
872
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" },