emdash 0.8.0 → 0.9.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 (286) hide show
  1. package/dist/{adapters-BKSf3T9R.d.mts → adapters-DoNJiveC.d.mts} +1 -1
  2. package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-DoNJiveC.d.mts.map} +1 -1
  3. package/dist/{apply-x0eMK1lX.mjs → apply-BzltprvY.mjs} +85 -135
  4. package/dist/apply-BzltprvY.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 +110 -4
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +16 -59
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +17 -12
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +9 -6
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +72 -124
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +26 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{base64-MBPo9ozB.mjs → base64-BRICGH2l.mjs} +1 -1
  26. package/dist/{base64-MBPo9ozB.mjs.map → base64-BRICGH2l.mjs.map} +1 -1
  27. package/dist/{byline-Chbr2GoP.mjs → byline-BSaNL1w7.mjs} +4 -4
  28. package/dist/{byline-Chbr2GoP.mjs.map → byline-BSaNL1w7.mjs.map} +1 -1
  29. package/dist/bylines-CvJ3PYz2.mjs +113 -0
  30. package/dist/bylines-CvJ3PYz2.mjs.map +1 -0
  31. package/dist/cache-C6N_hhN7.mjs +65 -0
  32. package/dist/cache-C6N_hhN7.mjs.map +1 -0
  33. package/dist/{chunks-HGz06Soa.mjs → chunks-NBQVDOci.mjs} +8 -2
  34. package/dist/{chunks-HGz06Soa.mjs.map → chunks-NBQVDOci.mjs.map} +1 -1
  35. package/dist/cli/index.mjs +224 -30
  36. package/dist/cli/index.mjs.map +1 -1
  37. package/dist/client/cf-access.d.mts +1 -1
  38. package/dist/client/index.d.mts +1 -1
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/client/index.mjs.map +1 -1
  41. package/dist/{config-BXwuX8Bx.mjs → config-BI0V3ICQ.mjs} +1 -1
  42. package/dist/{config-BXwuX8Bx.mjs.map → config-BI0V3ICQ.mjs.map} +1 -1
  43. package/dist/{content-BcQPYxdV.mjs → content-8lOYF0pr.mjs} +32 -15
  44. package/dist/{content-BcQPYxdV.mjs.map → content-8lOYF0pr.mjs.map} +1 -1
  45. package/dist/db/index.d.mts +3 -3
  46. package/dist/db/index.mjs +2 -2
  47. package/dist/db/libsql.d.mts +1 -1
  48. package/dist/db/libsql.d.mts.map +1 -1
  49. package/dist/db/libsql.mjs +7 -2
  50. package/dist/db/libsql.mjs.map +1 -1
  51. package/dist/db/postgres.d.mts +1 -1
  52. package/dist/db/sqlite.d.mts +1 -1
  53. package/dist/db/sqlite.d.mts.map +1 -1
  54. package/dist/db/sqlite.mjs +8 -3
  55. package/dist/db/sqlite.mjs.map +1 -1
  56. package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-WRezodiz.mjs} +1 -1
  57. package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-WRezodiz.mjs.map} +1 -1
  58. package/dist/{default-DCVqE5ib.mjs → default-D8ksjWhO.mjs} +1 -1
  59. package/dist/{default-DCVqE5ib.mjs.map → default-D8ksjWhO.mjs.map} +1 -1
  60. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  61. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  62. package/dist/{error-zG5T1UGA.mjs → error-D_-tqP-I.mjs} +1 -1
  63. package/dist/{error-zG5T1UGA.mjs.map → error-D_-tqP-I.mjs.map} +1 -1
  64. package/dist/{index-DIb-CzNx.d.mts → index-BFRaVcD6.d.mts} +94 -34
  65. package/dist/index-BFRaVcD6.d.mts.map +1 -0
  66. package/dist/index.d.mts +11 -11
  67. package/dist/index.mjs +29 -27
  68. package/dist/{load-CyEoextb.mjs → load-DDqMMvZL.mjs} +2 -2
  69. package/dist/{load-CyEoextb.mjs.map → load-DDqMMvZL.mjs.map} +1 -1
  70. package/dist/{loader-CndGj8kM.mjs → loader-CKLbBnhK.mjs} +27 -7
  71. package/dist/loader-CKLbBnhK.mjs.map +1 -0
  72. package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-DqWNC3lM.mjs} +33 -3
  73. package/dist/manifest-schema-DqWNC3lM.mjs.map +1 -0
  74. package/dist/media/index.d.mts +1 -1
  75. package/dist/media/index.mjs +1 -1
  76. package/dist/media/local-runtime.d.mts +7 -7
  77. package/dist/media/local-runtime.mjs +3 -3
  78. package/dist/{media-D8FbNsl0.mjs → media-BW32b4gi.mjs} +2 -2
  79. package/dist/{media-D8FbNsl0.mjs.map → media-BW32b4gi.mjs.map} +1 -1
  80. package/dist/{mode-BnAOqItE.mjs → mode-ier8jbBk.mjs} +1 -1
  81. package/dist/{mode-BnAOqItE.mjs.map → mode-ier8jbBk.mjs.map} +1 -1
  82. package/dist/options-BVp3UsTS.mjs +117 -0
  83. package/dist/options-BVp3UsTS.mjs.map +1 -0
  84. package/dist/page/index.d.mts +2 -2
  85. package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-BE4o_2dc.d.mts} +1 -1
  86. package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-BE4o_2dc.d.mts.map} +1 -1
  87. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-CIJejMlK.mjs} +1 -1
  88. package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-CIJejMlK.mjs.map} +1 -1
  89. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  90. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  91. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  92. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  93. package/dist/public-url-DByxYjUw.mjs +51 -0
  94. package/dist/public-url-DByxYjUw.mjs.map +1 -0
  95. package/dist/{query-fqEdLFms.mjs → query-Cg9ZKRQ0.mjs} +114 -16
  96. package/dist/query-Cg9ZKRQ0.mjs.map +1 -0
  97. package/dist/{redirect-D_pshWdf.mjs → redirect-BhUBKRc1.mjs} +11 -6
  98. package/dist/redirect-BhUBKRc1.mjs.map +1 -0
  99. package/dist/{registry-C3Mr0ODu.mjs → registry-Dw70ChxB.mjs} +38 -4
  100. package/dist/registry-Dw70ChxB.mjs.map +1 -0
  101. package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-B-bmkipQ.mjs} +1 -1
  102. package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-B-bmkipQ.mjs.map} +1 -1
  103. package/dist/runner-Bnoj7vjK.d.mts +44 -0
  104. package/dist/runner-Bnoj7vjK.d.mts.map +1 -0
  105. package/dist/{runner-tQ7BJ4T7.mjs → runner-C7ADox5q.mjs} +185 -55
  106. package/dist/{runner-tQ7BJ4T7.mjs.map → runner-C7ADox5q.mjs.map} +1 -1
  107. package/dist/runtime.d.mts +6 -6
  108. package/dist/runtime.mjs +4 -4
  109. package/dist/{search-BoZYFuUk.mjs → search-dOGEccMa.mjs} +129 -83
  110. package/dist/search-dOGEccMa.mjs.map +1 -0
  111. package/dist/secrets-CW3reAnU.mjs +314 -0
  112. package/dist/secrets-CW3reAnU.mjs.map +1 -0
  113. package/dist/seed/index.d.mts +2 -2
  114. package/dist/seed/index.mjs +15 -14
  115. package/dist/seo/index.d.mts +1 -1
  116. package/dist/storage/local.d.mts +1 -1
  117. package/dist/storage/local.mjs +1 -1
  118. package/dist/storage/s3.d.mts +1 -1
  119. package/dist/storage/s3.mjs +1 -1
  120. package/dist/{taxonomies-B4IAshV8.mjs → taxonomies-ZlRtD6AG.mjs} +14 -7
  121. package/dist/taxonomies-ZlRtD6AG.mjs.map +1 -0
  122. package/dist/{tokens-D9vnZqYS.mjs → tokens-D7zMmWi2.mjs} +2 -2
  123. package/dist/{tokens-D9vnZqYS.mjs.map → tokens-D7zMmWi2.mjs.map} +1 -1
  124. package/dist/{transport-C9ugt2Nr.mjs → transport-BeMCmin1.mjs} +6 -5
  125. package/dist/{transport-C9ugt2Nr.mjs.map → transport-BeMCmin1.mjs.map} +1 -1
  126. package/dist/{transport-CUnEL3Vs.d.mts → transport-DNEfeMaU.d.mts} +1 -1
  127. package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DNEfeMaU.d.mts.map} +1 -1
  128. package/dist/types-4fVtCIm0.mjs +68 -0
  129. package/dist/types-4fVtCIm0.mjs.map +1 -0
  130. package/dist/{types-BmPPSUEx.d.mts → types-BSyXeCFW.d.mts} +24 -2
  131. package/dist/{types-BmPPSUEx.d.mts.map → types-BSyXeCFW.d.mts.map} +1 -1
  132. package/dist/{types-i36XcA_X.d.mts → types-BuBIptGk.d.mts} +65 -134
  133. package/dist/types-BuBIptGk.d.mts.map +1 -0
  134. package/dist/{types-CgqmmMJB.mjs → types-CDbKp7ND.mjs} +1 -1
  135. package/dist/{types-CgqmmMJB.mjs.map → types-CDbKp7ND.mjs.map} +1 -1
  136. package/dist/{types-Bm1dn-q3.mjs → types-CIOg5AR8.mjs} +1 -1
  137. package/dist/{types-Bm1dn-q3.mjs.map → types-CIOg5AR8.mjs.map} +1 -1
  138. package/dist/{types-BrA0xf5I.d.mts → types-CJsYGpco.d.mts} +1 -1
  139. package/dist/{types-BrA0xf5I.d.mts.map → types-CJsYGpco.d.mts.map} +1 -1
  140. package/dist/{types-BIgulNsW.mjs → types-CRxNbK-Z.mjs} +2 -2
  141. package/dist/{types-BIgulNsW.mjs.map → types-CRxNbK-Z.mjs.map} +1 -1
  142. package/dist/{types-CS8FIX7L.d.mts → types-CrtWgIvl.d.mts} +1 -1
  143. package/dist/{types-CS8FIX7L.d.mts.map → types-CrtWgIvl.d.mts.map} +1 -1
  144. package/dist/{types-DIMwPFub.d.mts → types-M78DQ1lx.d.mts} +1 -1
  145. package/dist/{types-DIMwPFub.d.mts.map → types-M78DQ1lx.d.mts.map} +1 -1
  146. package/dist/{validate-CxVsLehf.mjs → validate-Baqf0slj.mjs} +3 -3
  147. package/dist/{validate-CxVsLehf.mjs.map → validate-Baqf0slj.mjs.map} +1 -1
  148. package/dist/{validate-DHxmpFJt.d.mts → validate-BfQh_C_y.d.mts} +4 -4
  149. package/dist/{validate-DHxmpFJt.d.mts.map → validate-BfQh_C_y.d.mts.map} +1 -1
  150. package/dist/{validation-C-ZpN2GI.mjs → validation-BfEI7tNe.mjs} +6 -6
  151. package/dist/{validation-C-ZpN2GI.mjs.map → validation-BfEI7tNe.mjs.map} +1 -1
  152. package/dist/version-DoxrVdYf.mjs +7 -0
  153. package/dist/{version-Bbq8TCrz.mjs.map → version-DoxrVdYf.mjs.map} +1 -1
  154. package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-CC0xNe_K.mjs} +4 -4
  155. package/dist/zod-generator-CC0xNe_K.mjs.map +1 -0
  156. package/locals.d.ts +1 -6
  157. package/package.json +9 -8
  158. package/src/api/handlers/comments.ts +6 -4
  159. package/src/api/handlers/content.ts +29 -1
  160. package/src/api/handlers/device-flow.ts +5 -0
  161. package/src/api/handlers/marketplace.ts +11 -4
  162. package/src/api/handlers/oauth-authorization.ts +72 -33
  163. package/src/api/handlers/revision.ts +23 -14
  164. package/src/api/handlers/taxonomies.ts +3 -6
  165. package/src/api/public-url.ts +48 -2
  166. package/src/api/schemas/comments.ts +2 -2
  167. package/src/api/schemas/content.ts +17 -0
  168. package/src/api/schemas/sections.ts +3 -3
  169. package/src/api/schemas/users.ts +1 -1
  170. package/src/api/types.ts +5 -1
  171. package/src/astro/integration/index.ts +17 -0
  172. package/src/astro/integration/runtime.ts +30 -0
  173. package/src/astro/integration/virtual-modules.ts +32 -2
  174. package/src/astro/integration/vite-config.ts +6 -1
  175. package/src/astro/middleware/auth.ts +13 -6
  176. package/src/astro/middleware/redirect.ts +29 -16
  177. package/src/astro/middleware/request-context.ts +15 -5
  178. package/src/astro/middleware.ts +23 -9
  179. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  180. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  181. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  182. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  183. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  184. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  185. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  186. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  187. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  188. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  189. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  190. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  191. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  192. package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
  193. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  194. package/src/astro/routes/api/manifest.ts +62 -45
  195. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  196. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  197. package/src/astro/routes/api/openapi.json.ts +27 -10
  198. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  199. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  200. package/src/astro/routes/api/redirects/[id].ts +10 -4
  201. package/src/astro/routes/api/redirects/index.ts +7 -3
  202. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  203. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  204. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  205. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  206. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  207. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  208. package/src/astro/routes/api/search/index.ts +10 -2
  209. package/src/astro/routes/api/sections/[slug].ts +10 -4
  210. package/src/astro/routes/api/sections/index.ts +7 -3
  211. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  212. package/src/astro/routes/api/snapshot.ts +44 -18
  213. package/src/astro/routes/api/taxonomies/index.ts +0 -1
  214. package/src/astro/routes/api/themes/preview.ts +11 -5
  215. package/src/astro/types.ts +23 -3
  216. package/src/auth/allowed-origins.ts +168 -0
  217. package/src/auth/passkey-config.ts +35 -13
  218. package/src/bylines/index.ts +37 -88
  219. package/src/cli/commands/auth.ts +28 -6
  220. package/src/cli/commands/bundle-utils.ts +11 -2
  221. package/src/cli/commands/bundle.ts +28 -8
  222. package/src/cli/commands/content.ts +13 -0
  223. package/src/cli/commands/login.ts +8 -1
  224. package/src/cli/commands/publish.ts +24 -0
  225. package/src/cli/commands/secrets.ts +183 -0
  226. package/src/cli/credentials.ts +1 -1
  227. package/src/cli/index.ts +5 -1
  228. package/src/client/index.ts +4 -4
  229. package/src/client/transport.ts +17 -7
  230. package/src/components/Break.astro +2 -2
  231. package/src/components/EmDashHead.astro +18 -13
  232. package/src/components/Embed.astro +1 -1
  233. package/src/components/Gallery.astro +1 -1
  234. package/src/components/Image.astro +1 -1
  235. package/src/components/InlinePortableTextEditor.tsx +104 -18
  236. package/src/config/secrets.ts +528 -0
  237. package/src/database/dialect-helpers.ts +50 -0
  238. package/src/database/migrations/034_published_at_index.ts +1 -1
  239. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  240. package/src/database/migrations/runner.ts +156 -23
  241. package/src/database/repositories/content.ts +36 -12
  242. package/src/database/repositories/redirect.ts +14 -3
  243. package/src/database/repositories/taxonomy.ts +26 -0
  244. package/src/db/libsql.ts +1 -3
  245. package/src/db/sqlite.ts +2 -5
  246. package/src/emdash-runtime.ts +84 -159
  247. package/src/index.ts +9 -0
  248. package/src/loader.ts +24 -1
  249. package/src/mcp/server.ts +103 -36
  250. package/src/page/site-identity.ts +58 -0
  251. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  252. package/src/plugins/context.ts +13 -10
  253. package/src/plugins/define-plugin.ts +40 -12
  254. package/src/plugins/hooks.ts +23 -19
  255. package/src/plugins/index.ts +9 -0
  256. package/src/plugins/manifest-schema.ts +37 -2
  257. package/src/plugins/types.ts +151 -11
  258. package/src/preview/urls.ts +23 -3
  259. package/src/query.ts +148 -5
  260. package/src/redirects/cache.ts +38 -18
  261. package/src/schema/registry.ts +56 -0
  262. package/src/schema/zod-generator.ts +27 -5
  263. package/src/seed/apply.ts +2 -0
  264. package/src/settings/index.ts +80 -6
  265. package/src/settings/types.ts +23 -1
  266. package/src/taxonomies/index.ts +11 -1
  267. package/dist/apply-x0eMK1lX.mjs.map +0 -1
  268. package/dist/bylines-CRNsVG88.mjs +0 -157
  269. package/dist/bylines-CRNsVG88.mjs.map +0 -1
  270. package/dist/cache-BkKBuIvS.mjs +0 -56
  271. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  272. package/dist/chunk-ClPoSABd.mjs +0 -21
  273. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  274. package/dist/index-DIb-CzNx.d.mts.map +0 -1
  275. package/dist/loader-CndGj8kM.mjs.map +0 -1
  276. package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
  277. package/dist/query-fqEdLFms.mjs.map +0 -1
  278. package/dist/redirect-D_pshWdf.mjs.map +0 -1
  279. package/dist/registry-C3Mr0ODu.mjs.map +0 -1
  280. package/dist/runner-OURCaApa.d.mts +0 -34
  281. package/dist/runner-OURCaApa.d.mts.map +0 -1
  282. package/dist/search-BoZYFuUk.mjs.map +0 -1
  283. package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
  284. package/dist/types-i36XcA_X.d.mts.map +0 -1
  285. package/dist/version-Bbq8TCrz.mjs +0 -7
  286. package/dist/zod-generator-CpwccCIv.mjs.map +0 -1
@@ -1,6 +1,8 @@
1
1
  import type { Kysely } from "kysely";
2
2
  import { sql } from "kysely";
3
3
 
4
+ import { columnExists } from "../dialect-helpers.js";
5
+
4
6
  /**
5
7
  * Migration: Bounded 404 logging
6
8
  *
@@ -19,16 +21,22 @@ import { sql } from "kysely";
19
21
  */
20
22
 
21
23
  export async function up(db: Kysely<unknown>): Promise<void> {
24
+ const hitsExists = await columnExists(db, "_emdash_404_log", "hits");
25
+
22
26
  // 1. Add columns.
23
- await db.schema
24
- .alterTable("_emdash_404_log")
25
- .addColumn("hits", "integer", (col) => col.notNull().defaultTo(1))
26
- .execute();
27
+ if (!hitsExists) {
28
+ await db.schema
29
+ .alterTable("_emdash_404_log")
30
+ .addColumn("hits", "integer", (col) => col.notNull().defaultTo(1))
31
+ .execute();
32
+ }
27
33
 
28
34
  // SQLite won't accept a non-constant default when adding a NOT NULL column
29
35
  // to a table with existing rows, so backfill in two steps: add nullable,
30
36
  // populate, then rely on the application layer / future inserts to set it.
31
- await db.schema.alterTable("_emdash_404_log").addColumn("last_seen_at", "text").execute();
37
+ if (!(await columnExists(db, "_emdash_404_log", "last_seen_at"))) {
38
+ await db.schema.alterTable("_emdash_404_log").addColumn("last_seen_at", "text").execute();
39
+ }
32
40
 
33
41
  // Backfill last_seen_at from created_at for existing rows.
34
42
  await sql`
@@ -44,68 +52,77 @@ export async function up(db: Kysely<unknown>): Promise<void> {
44
52
  // (3.25+, 2018) and Postgres. The previous GROUP BY approach was
45
53
  // accepted by SQLite but invalid on Postgres because `id` wasn't in
46
54
  // the GROUP BY or wrapped in an aggregate.
47
- await sql`
48
- WITH ranked AS (
49
- SELECT
50
- id,
51
- path,
52
- ROW_NUMBER() OVER (
53
- PARTITION BY path
54
- ORDER BY created_at DESC, id DESC
55
- ) AS rn,
56
- COUNT(*) OVER (PARTITION BY path) AS path_count,
57
- MAX(created_at) OVER (PARTITION BY path) AS latest_created_at
58
- FROM _emdash_404_log
59
- )
60
- UPDATE _emdash_404_log
61
- SET
62
- hits = (SELECT path_count FROM ranked WHERE ranked.id = _emdash_404_log.id),
63
- last_seen_at = (SELECT latest_created_at FROM ranked WHERE ranked.id = _emdash_404_log.id)
64
- WHERE id IN (SELECT id FROM ranked WHERE rn = 1)
65
- `.execute(db);
66
-
67
- // Delete the non-keepers (every row except the freshest per path).
68
- await sql`
69
- DELETE FROM _emdash_404_log
70
- WHERE id IN (
71
- SELECT id FROM (
55
+ if (!hitsExists) {
56
+ await sql`
57
+ WITH ranked AS (
72
58
  SELECT
73
59
  id,
60
+ path,
74
61
  ROW_NUMBER() OVER (
75
62
  PARTITION BY path
76
63
  ORDER BY created_at DESC, id DESC
77
- ) AS rn
64
+ ) AS rn,
65
+ COUNT(*) OVER (PARTITION BY path) AS path_count,
66
+ MAX(created_at) OVER (PARTITION BY path) AS latest_created_at
78
67
  FROM _emdash_404_log
79
- ) AS ranked
80
- WHERE rn > 1
81
- )
82
- `.execute(db);
68
+ )
69
+ UPDATE _emdash_404_log
70
+ SET
71
+ hits = (SELECT path_count FROM ranked WHERE ranked.id = _emdash_404_log.id),
72
+ last_seen_at = (SELECT latest_created_at FROM ranked WHERE ranked.id = _emdash_404_log.id)
73
+ WHERE id IN (SELECT id FROM ranked WHERE rn = 1)
74
+ `.execute(db);
75
+
76
+ // Delete the non-keepers (every row except the freshest per path).
77
+ await sql`
78
+ DELETE FROM _emdash_404_log
79
+ WHERE id IN (
80
+ SELECT id FROM (
81
+ SELECT
82
+ id,
83
+ ROW_NUMBER() OVER (
84
+ PARTITION BY path
85
+ ORDER BY created_at DESC, id DESC
86
+ ) AS rn
87
+ FROM _emdash_404_log
88
+ ) AS ranked
89
+ WHERE rn > 1
90
+ )
91
+ `.execute(db);
92
+ }
83
93
 
84
94
  // 3. Add unique index on path for upsert semantics.
85
95
  await db.schema
86
96
  .createIndex("idx_404_log_path_unique")
97
+ .ifNotExists()
87
98
  .on("_emdash_404_log")
88
99
  .column("path")
89
100
  .unique()
90
101
  .execute();
91
102
 
92
103
  // Drop the old non-unique index; the unique one covers the same lookups.
93
- await db.schema.dropIndex("idx_404_log_path").execute();
104
+ await db.schema.dropIndex("idx_404_log_path").ifExists().execute();
94
105
 
95
106
  // 4. Index on last_seen_at for eviction ordering.
96
107
  await db.schema
97
108
  .createIndex("idx_404_log_last_seen")
109
+ .ifNotExists()
98
110
  .on("_emdash_404_log")
99
111
  .column("last_seen_at")
100
112
  .execute();
101
113
  }
102
114
 
103
115
  export async function down(db: Kysely<unknown>): Promise<void> {
104
- await db.schema.dropIndex("idx_404_log_last_seen").execute();
105
- await db.schema.dropIndex("idx_404_log_path_unique").execute();
116
+ await db.schema.dropIndex("idx_404_log_last_seen").ifExists().execute();
117
+ await db.schema.dropIndex("idx_404_log_path_unique").ifExists().execute();
106
118
 
107
119
  // Restore the original non-unique path index.
108
- await db.schema.createIndex("idx_404_log_path").on("_emdash_404_log").column("path").execute();
120
+ await db.schema
121
+ .createIndex("idx_404_log_path")
122
+ .ifNotExists()
123
+ .on("_emdash_404_log")
124
+ .column("path")
125
+ .execute();
109
126
 
110
127
  await db.schema.alterTable("_emdash_404_log").dropColumn("last_seen_at").execute();
111
128
  await db.schema.alterTable("_emdash_404_log").dropColumn("hits").execute();
@@ -123,29 +123,156 @@ export async function getMigrationStatus(db: Kysely<Database>): Promise<Migratio
123
123
  return { applied, pending };
124
124
  }
125
125
 
126
+ /** Pattern for escaping special regex characters. Matches the shared helper in `database/repositories/content.ts`. */
127
+ const REGEX_ESCAPE_PATTERN = /[.*+?^${}()|[\]\\]/g;
128
+
129
+ /** Escape special regex characters so a string can be embedded literally in `new RegExp()`. */
130
+ function escapeRegExp(value: string): string {
131
+ return value.replace(REGEX_ESCAPE_PATTERN, "\\$&");
132
+ }
133
+
126
134
  /**
127
- * Run all pending migrations.
135
+ * Pattern used to detect the concurrent-migration race. The Kysely
136
+ * `SqliteAdapter.acquireMigrationLock` is a no-op (inherited by `kysely-d1`
137
+ * and our `EmDashD1Dialect`), so two isolates running migrations against the
138
+ * same database can both attempt `INSERT INTO _emdash_migrations` for the
139
+ * same migration name. The losing insert fails with a UNIQUE constraint
140
+ * error, which is benign: the other isolate is applying the same schema.
128
141
  *
129
- * Includes a fast-path: if the migration table already exists and contains
130
- * exactly MIGRATION_COUNT rows, all migrations have been applied and we can
131
- * skip the Kysely Migrator entirely. This avoids the expensive
132
- * `pragma_table_info` introspection that Kysely runs for every table in the
133
- * database (twice!) just to check if the migration tables exist.
134
- * On D1 with ~57 tables, that's ~116 queries saved per init.
142
+ * We match on the table name (not the full error text) because different
143
+ * SQLite drivers phrase the message differently
144
+ * (`UNIQUE constraint failed: _emdash_migrations.name` for better-sqlite3,
145
+ * `D1_ERROR: UNIQUE constraint failed: _emdash_migrations.name: SQLITE_CONSTRAINT`
146
+ * for D1, etc.). The pattern is built from `MIGRATION_TABLE` so a rename
147
+ * cannot silently disable race detection.
135
148
  */
136
- export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
137
- // Fast path: check if all migrations are already applied.
138
- // A single cheap query vs the Migrator's full schema introspection.
149
+ const MIGRATION_RACE_PATTERN = new RegExp(
150
+ `UNIQUE constraint failed: ${escapeRegExp(MIGRATION_TABLE)}\\.name`,
151
+ "i",
152
+ );
153
+
154
+ /** How long to wait for a concurrent migrator to finish before giving up. */
155
+ const MIGRATION_RACE_WAIT_MS = 10_000;
156
+ /** Polling interval while waiting for a concurrent migrator. */
157
+ const MIGRATION_RACE_POLL_MS = 100;
158
+
159
+ /**
160
+ * Pattern used to detect "table does not exist" errors across the dialects
161
+ * EmDash supports. The phrasing differs by driver:
162
+ *
163
+ * - better-sqlite3: `no such table: _emdash_migrations`
164
+ * - D1: `D1_ERROR: no such table: _emdash_migrations: SQLITE_ERROR`
165
+ * - PostgreSQL: `relation "_emdash_migrations" does not exist`
166
+ * (also occasionally `table "_emdash_migrations" does not exist`)
167
+ *
168
+ * We deliberately match on the migration table name (rather than using the
169
+ * generic `isMissingTableError` helper) so an unexpected missing-table error
170
+ * naming a different table — implausible today since
171
+ * `getAppliedMigrationCount` only references `MIGRATION_TABLE`, but cheap
172
+ * insurance against future edits — is not silently swallowed. The pattern is
173
+ * built from `MIGRATION_TABLE` so a rename cannot drift.
174
+ */
175
+ const MIGRATION_TABLE_MISSING_PATTERN = new RegExp(
176
+ `(?:no such table:\\s*${escapeRegExp(MIGRATION_TABLE)}\\b` +
177
+ `|(?:relation|table)\\s+"?${escapeRegExp(MIGRATION_TABLE)}"?\\s+does(?:n't| not) exist\\b)`,
178
+ "i",
179
+ );
180
+
181
+ /**
182
+ * Read the count of applied migrations.
183
+ *
184
+ * Returns `null` only when the migration table does not exist yet (which is
185
+ * the normal state on a fresh database before the first migration runs).
186
+ * Any other error is rethrown so callers — particularly
187
+ * `waitForConcurrentMigrator` — don't silently mask connection failures,
188
+ * permission errors, or other unexpected driver problems behind a 10s wait
189
+ * and a bogus "we're done" verdict.
190
+ */
191
+ async function getAppliedMigrationCount(db: Kysely<Database>): Promise<number | null> {
139
192
  try {
140
193
  const result = await sql<{ count: number }>`
141
194
  SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
142
195
  `.execute(db);
143
- if (result.rows[0]?.count === MIGRATION_COUNT) {
144
- return { applied: [] };
196
+ return Number(result.rows[0]?.count ?? 0);
197
+ } catch (error) {
198
+ if (MIGRATION_TABLE_MISSING_PATTERN.test(deepErrorMessage(error))) {
199
+ return null;
200
+ }
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Wait for a concurrent migrator to finish applying all migrations.
207
+ *
208
+ * Resolves to `true` once the migration table contains at least
209
+ * `MIGRATION_COUNT` rows (i.e. every migration this build knows about has
210
+ * been recorded), `false` if the deadline elapses first. We use `>=` rather
211
+ * than `===` so that an old isolate observing a database that has already
212
+ * been migrated by a newer build still treats the wait as settled instead
213
+ * of timing out.
214
+ */
215
+ async function waitForConcurrentMigrator(db: Kysely<Database>): Promise<boolean> {
216
+ const deadline = Date.now() + MIGRATION_RACE_WAIT_MS;
217
+ while (Date.now() < deadline) {
218
+ const count = await getAppliedMigrationCount(db);
219
+ if (count !== null && count >= MIGRATION_COUNT) {
220
+ return true;
221
+ }
222
+ await new Promise((resolve) => setTimeout(resolve, MIGRATION_RACE_POLL_MS));
223
+ }
224
+ const finalCount = await getAppliedMigrationCount(db);
225
+ return finalCount !== null && finalCount >= MIGRATION_COUNT;
226
+ }
227
+
228
+ /** Extract the deepest error message available from a thrown value. */
229
+ function deepErrorMessage(error: unknown): string {
230
+ if (error instanceof Error) {
231
+ const own = error.message ?? "";
232
+ if (error.cause) {
233
+ const causeMsg = deepErrorMessage(error.cause);
234
+ return own ? `${own}: ${causeMsg}` : causeMsg;
145
235
  }
236
+ return own;
237
+ }
238
+ if (typeof error === "string") return error;
239
+ try {
240
+ return JSON.stringify(error);
146
241
  } catch {
147
- // Table doesn't exist yet (first run). Fall through to the Migrator
148
- // which will create it.
242
+ return String(error);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Run all pending migrations.
248
+ *
249
+ * Includes a fast-path: if the migration table already exists and contains
250
+ * at least MIGRATION_COUNT rows, all migrations this build knows about have
251
+ * been applied and we can skip the Kysely Migrator entirely. This avoids
252
+ * the expensive `pragma_table_info` introspection that Kysely runs for
253
+ * every table in the database (twice!) just to check if the migration
254
+ * tables exist. On D1 with ~57 tables, that's ~116 queries saved per init.
255
+ *
256
+ * Concurrent-migration safety: the Kysely Migrator's `acquireMigrationLock`
257
+ * is a no-op for SQLite (and therefore D1), so two callers running this
258
+ * concurrently against the same database will both try to apply pending
259
+ * migrations. SQLite serializes the writes, but the loser still surfaces a
260
+ * `UNIQUE constraint failed: _emdash_migrations.name` error. We treat that
261
+ * specific error as benign: another caller is already applying the same
262
+ * schema. We wait for the concurrent migrator to finish, then return
263
+ * success. This matches the user-observable expectation that running
264
+ * migrations twice in a row is a no-op.
265
+ */
266
+ export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
267
+ // Fast path: check if all migrations are already applied.
268
+ // A single cheap query vs the Migrator's full schema introspection.
269
+ // We use `>=` rather than `===` so a database with extra rows from a
270
+ // newer build (e.g. mid-deploy old isolate, or downgrade) still skips
271
+ // the migrator instead of falling through to the race-recovery path
272
+ // unnecessarily.
273
+ const initialCount = await getAppliedMigrationCount(db);
274
+ if (initialCount !== null && initialCount >= MIGRATION_COUNT) {
275
+ return { applied: [] };
149
276
  }
150
277
 
151
278
  const migrator = new Migrator({
@@ -160,17 +287,23 @@ export async function runMigrations(db: Kysely<Database>): Promise<{ applied: st
160
287
  const applied = results?.filter((r) => r.status === "Success").map((r) => r.migrationName) ?? [];
161
288
 
162
289
  if (error) {
163
- // Kysely sometimes wraps errors with an empty message. Check cause and
164
- // failed migration results for the real error.
165
- let msg = error instanceof Error ? error.message : JSON.stringify(error);
166
- if (!msg && error instanceof Error && error.cause) {
167
- msg = error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause);
168
- }
290
+ // Walk error.cause to get the underlying driver message Kysely
291
+ // often wraps with an empty top-level message.
292
+ const msg = deepErrorMessage(error);
169
293
  const failedMigration = results?.find((r) => r.status === "Error");
170
- if (failedMigration) {
171
- msg = `${msg || "unknown error"} (migration: ${failedMigration.migrationName})`;
294
+
295
+ // Concurrent-migration race: another caller is applying (or just
296
+ // applied) the same migration. Wait for it to finish, then verify
297
+ // the schema is fully migrated and treat as success.
298
+ if (MIGRATION_RACE_PATTERN.test(msg)) {
299
+ const settled = await waitForConcurrentMigrator(db);
300
+ if (settled) {
301
+ return { applied };
302
+ }
172
303
  }
173
- throw new Error(`Migration failed: ${msg}`);
304
+
305
+ const failedSuffix = failedMigration ? ` (migration: ${failedMigration.migrationName})` : "";
306
+ throw new Error(`Migration failed: ${msg || "unknown error"}${failedSuffix}`);
174
307
  }
175
308
 
176
309
  return { applied };
@@ -917,8 +917,14 @@ export class ContentRepository {
917
917
  * Syncs the draft revision's data into the content table columns so the
918
918
  * content table always reflects the published version.
919
919
  * If no draft revision exists, creates one from current data and publishes it.
920
+ *
921
+ * `publishedAt` (optional) overrides the publication timestamp. If omitted,
922
+ * the existing `published_at` is preserved (idempotent re-publish keeps the
923
+ * original date) and falls back to the current time on first publish. Pass
924
+ * an explicit value to backdate a publish (e.g. when migrating content from
925
+ * another CMS).
920
926
  */
921
- async publish(type: string, id: string): Promise<ContentItem> {
927
+ async publish(type: string, id: string, publishedAt?: string): Promise<ContentItem> {
922
928
  const tableName = getTableName(type);
923
929
  const now = new Date().toISOString();
924
930
 
@@ -956,17 +962,35 @@ export class ContentRepository {
956
962
  }
957
963
  }
958
964
 
959
- await sql`
960
- UPDATE ${sql.ref(tableName)}
961
- SET live_revision_id = ${revisionToPublish},
962
- draft_revision_id = NULL,
963
- status = 'published',
964
- scheduled_at = NULL,
965
- published_at = COALESCE(published_at, ${now}),
966
- updated_at = ${now}
967
- WHERE id = ${id}
968
- AND deleted_at IS NULL
969
- `.execute(this.db);
965
+ if (publishedAt !== undefined) {
966
+ // Caller supplied an explicit timestamp, so we overwrite published_at
967
+ // directly (used to backdate a publish, e.g. for content migrations).
968
+ await sql`
969
+ UPDATE ${sql.ref(tableName)}
970
+ SET live_revision_id = ${revisionToPublish},
971
+ draft_revision_id = NULL,
972
+ status = 'published',
973
+ scheduled_at = NULL,
974
+ published_at = ${publishedAt},
975
+ updated_at = ${now}
976
+ WHERE id = ${id}
977
+ AND deleted_at IS NULL
978
+ `.execute(this.db);
979
+ } else {
980
+ // No timestamp supplied — preserve existing published_at on
981
+ // idempotent re-publish, fall back to `now` on first publish.
982
+ await sql`
983
+ UPDATE ${sql.ref(tableName)}
984
+ SET live_revision_id = ${revisionToPublish},
985
+ draft_revision_id = NULL,
986
+ status = 'published',
987
+ scheduled_at = NULL,
988
+ published_at = COALESCE(published_at, ${now}),
989
+ updated_at = ${now}
990
+ WHERE id = ${id}
991
+ AND deleted_at IS NULL
992
+ `.execute(this.db);
993
+ }
970
994
 
971
995
  const updated = await this.findById(type, id);
972
996
  if (!updated) {
@@ -28,6 +28,9 @@ export const REFERRER_MAX_LENGTH = 512;
28
28
  /** Max stored length for the `User-Agent` header — truncated on insert. */
29
29
  export const USER_AGENT_MAX_LENGTH = 256;
30
30
 
31
+ /** Pattern to escape LIKE wildcards: %, _, and backslash */
32
+ const LIKE_ESCAPE_RE = /[\\%_]/g;
33
+
31
34
  /**
32
35
  * Truncate a header-derived string to `max` chars, preserving `null`/`undefined`
33
36
  * as `null`. Empty strings stay empty (the caller decides whether to coerce).
@@ -162,9 +165,15 @@ export class RedirectRepository {
162
165
  .limit(limit + 1);
163
166
 
164
167
  if (opts.search) {
165
- const term = `%${opts.search}%`;
168
+ // Escape LIKE wildcards in the search term to prevent injection.
169
+ // Must include ESCAPE clause for SQLite to recognize backslash as escape char.
170
+ const escaped = opts.search.replace(LIKE_ESCAPE_RE, (c) => `\\${c}`);
171
+ const term = `%${escaped}%`;
166
172
  query = query.where((eb) =>
167
- eb.or([eb("source", "like", term), eb("destination", "like", term)]),
173
+ eb.or([
174
+ sql<boolean>`source LIKE ${term} ESCAPE '\\'`,
175
+ sql<boolean>`destination LIKE ${term} ESCAPE '\\'`,
176
+ ]),
168
177
  );
169
178
  }
170
179
 
@@ -502,7 +511,9 @@ export class RedirectRepository {
502
511
  .limit(limit + 1);
503
512
 
504
513
  if (opts.search) {
505
- query = query.where("path", "like", `%${opts.search}%`);
514
+ const escaped = opts.search.replace(LIKE_ESCAPE_RE, (c) => `\\${c}`);
515
+ const term = `%${escaped}%`;
516
+ query = query.where(sql<boolean>`path LIKE ${term} ESCAPE '\\'`);
506
517
  }
507
518
 
508
519
  if (opts.cursor) {
@@ -289,6 +289,32 @@ export class TaxonomyRepository {
289
289
  return Number(result?.count || 0);
290
290
  }
291
291
 
292
+ /**
293
+ * Batch count entries for multiple taxonomy term IDs.
294
+ * Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.
295
+ * Returns a Map from term ID to count.
296
+ */
297
+ async countEntriesForTerms(termIds: string[]): Promise<Map<string, number>> {
298
+ if (termIds.length === 0) return new Map();
299
+
300
+ const { chunks, SQL_BATCH_SIZE } = await import("../../utils/chunks.js");
301
+
302
+ const counts = new Map<string, number>();
303
+ for (const chunk of chunks(termIds, SQL_BATCH_SIZE)) {
304
+ const rows = await this.db
305
+ .selectFrom("content_taxonomies")
306
+ .select(["taxonomy_id", (eb) => eb.fn.count("entry_id").as("count")])
307
+ .where("taxonomy_id", "in", chunk)
308
+ .groupBy("taxonomy_id")
309
+ .execute();
310
+
311
+ for (const row of rows) {
312
+ counts.set(row.taxonomy_id, Number(row.count || 0));
313
+ }
314
+ }
315
+ return counts;
316
+ }
317
+
292
318
  /**
293
319
  * Convert database row to Taxonomy object
294
320
  */
package/src/db/libsql.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Loaded at runtime via virtual module.
6
6
  */
7
7
 
8
+ import { LibsqlDialect } from "@libsql/kysely-libsql";
8
9
  import type { Dialect } from "kysely";
9
10
 
10
11
  import type { LibsqlConfig } from "./adapters.js";
@@ -13,9 +14,6 @@ import type { LibsqlConfig } from "./adapters.js";
13
14
  * Create a libSQL dialect from config
14
15
  */
15
16
  export function createDialect(config: LibsqlConfig): Dialect {
16
- // Dynamic import to avoid loading @libsql/kysely-libsql at config time
17
- const { LibsqlDialect } = require("@libsql/kysely-libsql");
18
-
19
17
  return new LibsqlDialect({
20
18
  url: config.url,
21
19
  authToken: config.authToken,
package/src/db/sqlite.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * Loaded at runtime via virtual module.
6
6
  */
7
7
 
8
- import type { Dialect } from "kysely";
8
+ import BetterSqlite3 from "better-sqlite3";
9
+ import { type Dialect, SqliteDialect } from "kysely";
9
10
 
10
11
  import type { SqliteConfig } from "./adapters.js";
11
12
 
@@ -13,10 +14,6 @@ import type { SqliteConfig } from "./adapters.js";
13
14
  * Create a SQLite dialect from config
14
15
  */
15
16
  export function createDialect(config: SqliteConfig): Dialect {
16
- // Dynamic import to avoid loading better-sqlite3 at config time
17
- const BetterSqlite3 = require("better-sqlite3");
18
- const { SqliteDialect } = require("kysely");
19
-
20
17
  // Parse URL to get file path
21
18
  const url = config.url;
22
19
  const filePath = url.startsWith("file:") ? url.slice(5) : url;