emdash 0.5.0 → 0.7.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 (252) hide show
  1. package/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
  2. package/dist/adapters-Di31kZ28.d.mts.map +1 -0
  3. package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
  4. package/dist/apply-5uslYdUu.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 +203 -33
  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 +30 -4
  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.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +11 -4
  16. package/dist/astro/middleware/request-context.mjs.map +1 -1
  17. package/dist/astro/middleware/setup.mjs +1 -1
  18. package/dist/astro/middleware.d.mts.map +1 -1
  19. package/dist/astro/middleware.mjs +467 -186
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +17 -9
  22. package/dist/astro/types.d.mts.map +1 -1
  23. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  24. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  25. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  26. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  27. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  28. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  29. package/dist/chunks-HGz06Soa.mjs +19 -0
  30. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  31. package/dist/cli/index.mjs +12 -11
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/client/cf-access.d.mts +1 -1
  34. package/dist/client/index.d.mts +1 -1
  35. package/dist/client/index.mjs +1 -1
  36. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  37. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  38. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  39. package/dist/connection-2igzM-AT.mjs.map +1 -0
  40. package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
  41. package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
  42. package/dist/database/instrumentation.d.mts +45 -0
  43. package/dist/database/instrumentation.d.mts.map +1 -0
  44. package/dist/database/instrumentation.mjs +61 -0
  45. package/dist/database/instrumentation.mjs.map +1 -0
  46. package/dist/db/index.d.mts +3 -3
  47. package/dist/db/index.mjs +1 -1
  48. package/dist/db/index.mjs.map +1 -1
  49. package/dist/db/libsql.d.mts +1 -1
  50. package/dist/db/postgres.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts +1 -1
  52. package/dist/db-errors-D0UT85nC.mjs +41 -0
  53. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  54. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  55. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  56. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  57. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  58. package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
  59. package/dist/index-De6_Xv3v.d.mts.map +1 -0
  60. package/dist/index.d.mts +11 -11
  61. package/dist/index.mjs +23 -21
  62. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  63. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  64. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  65. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  66. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  67. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  68. package/dist/media/index.d.mts +1 -1
  69. package/dist/media/index.mjs +1 -1
  70. package/dist/media/local-runtime.d.mts +7 -7
  71. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  72. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  73. package/dist/page/index.d.mts +11 -2
  74. package/dist/page/index.d.mts.map +1 -1
  75. package/dist/page/index.mjs +23 -1
  76. package/dist/page/index.mjs.map +1 -1
  77. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  78. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  79. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  80. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  81. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  82. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  83. package/dist/{query-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
  84. package/dist/query-g4Ug-9j9.mjs.map +1 -0
  85. package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
  86. package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
  87. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  88. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  89. package/dist/request-cache-DiR961CV.mjs +79 -0
  90. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  91. package/dist/request-context.d.mts +19 -16
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs.map +1 -1
  94. package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
  95. package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
  96. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  97. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  98. package/dist/runtime.d.mts +6 -6
  99. package/dist/runtime.mjs +1 -1
  100. package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
  101. package/dist/search-B0effn3j.mjs.map +1 -0
  102. package/dist/seed/index.d.mts +2 -2
  103. package/dist/seed/index.mjs +10 -9
  104. package/dist/seo/index.d.mts +1 -1
  105. package/dist/storage/local.d.mts +1 -1
  106. package/dist/storage/local.mjs +1 -1
  107. package/dist/storage/s3.d.mts +1 -1
  108. package/dist/storage/s3.mjs +1 -1
  109. package/dist/taxonomies-K2z0Uhnj.mjs +308 -0
  110. package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
  111. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  112. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  113. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  114. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  115. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  116. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  117. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  118. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  119. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  120. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  121. package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
  122. package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
  123. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  124. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  125. package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
  126. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  127. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  128. package/dist/types-DDS4MxsT.mjs.map +1 -0
  129. package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
  130. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  131. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  132. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  133. package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
  134. package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
  135. package/dist/version-BnTKdfam.mjs +7 -0
  136. package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
  137. package/package.json +10 -5
  138. package/src/after.ts +62 -0
  139. package/src/api/handlers/content.ts +2 -0
  140. package/src/api/handlers/oauth-authorization.ts +2 -32
  141. package/src/api/handlers/oauth-clients.ts +40 -4
  142. package/src/api/handlers/taxonomies.ts +13 -0
  143. package/src/api/oauth/redirect-uri.ts +34 -0
  144. package/src/api/openapi/document.ts +126 -118
  145. package/src/api/schemas/content.ts +8 -0
  146. package/src/api/schemas/media.ts +26 -15
  147. package/src/api/schemas/schema.ts +1 -0
  148. package/src/astro/integration/font-provider.ts +178 -0
  149. package/src/astro/integration/index.ts +44 -0
  150. package/src/astro/integration/routes.ts +6 -0
  151. package/src/astro/integration/runtime.ts +117 -0
  152. package/src/astro/integration/virtual-modules.ts +41 -39
  153. package/src/astro/integration/vite-config.ts +16 -5
  154. package/src/astro/middleware/auth.ts +33 -1
  155. package/src/astro/middleware/request-context.ts +15 -3
  156. package/src/astro/middleware.ts +340 -263
  157. package/src/astro/routes/admin.astro +21 -10
  158. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  159. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  160. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  161. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  162. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  163. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  164. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  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 +19 -1
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  172. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  173. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
  174. package/src/astro/routes/api/manifest.ts +7 -0
  175. package/src/astro/routes/api/media/upload-url.ts +10 -2
  176. package/src/astro/routes/api/media.ts +10 -7
  177. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  178. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  179. package/src/astro/routes/api/oauth/register.ts +178 -0
  180. package/src/astro/routes/api/oauth/token.ts +15 -0
  181. package/src/astro/routes/api/openapi.json.ts +15 -5
  182. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  183. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  184. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  185. package/src/astro/routes/api/search/index.ts +5 -0
  186. package/src/astro/routes/api/search/suggest.ts +3 -0
  187. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  188. package/src/astro/routes/api/setup/admin.ts +32 -8
  189. package/src/astro/routes/api/setup/index.ts +5 -2
  190. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  191. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
  192. package/src/astro/types.ts +9 -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/bylines/index.ts +22 -45
  197. package/src/components/EmDashHead.astro +23 -7
  198. package/src/database/connection.ts +23 -1
  199. package/src/database/instrumentation.ts +98 -0
  200. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  201. package/src/database/migrations/runner.ts +2 -0
  202. package/src/database/repositories/content.ts +39 -0
  203. package/src/database/repositories/options.ts +25 -0
  204. package/src/database/repositories/redirect.ts +111 -8
  205. package/src/database/types.ts +9 -0
  206. package/src/db/adapters.ts +15 -0
  207. package/src/emdash-runtime.ts +312 -92
  208. package/src/import/registry.ts +4 -3
  209. package/src/import/ssrf.ts +253 -12
  210. package/src/index.ts +6 -0
  211. package/src/loader.ts +19 -24
  212. package/src/mcp/server.ts +76 -3
  213. package/src/menus/index.ts +6 -3
  214. package/src/page/index.ts +1 -1
  215. package/src/page/seo-contributions.ts +36 -0
  216. package/src/plugins/context.ts +15 -3
  217. package/src/plugins/manager.ts +6 -0
  218. package/src/plugins/request-meta.ts +66 -15
  219. package/src/plugins/routes.ts +3 -1
  220. package/src/query.ts +104 -7
  221. package/src/request-cache.ts +106 -0
  222. package/src/request-context.ts +19 -0
  223. package/src/schema/query.ts +5 -2
  224. package/src/schema/registry.ts +243 -166
  225. package/src/schema/types.ts +13 -2
  226. package/src/schema/zod-generator.ts +4 -0
  227. package/src/search/fts-manager.ts +19 -5
  228. package/src/search/query.ts +4 -3
  229. package/src/seed/apply.ts +41 -1
  230. package/src/settings/index.ts +24 -5
  231. package/src/taxonomies/index.ts +324 -124
  232. package/src/utils/db-errors.ts +46 -0
  233. package/src/virtual-modules.d.ts +31 -10
  234. package/src/visual-editing/toolbar.ts +6 -1
  235. package/src/widgets/index.ts +54 -25
  236. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  237. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  238. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  239. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  240. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  241. package/dist/index-CCWzlriB.d.mts.map +0 -1
  242. package/dist/loader-BYzwzORf.mjs.map +0 -1
  243. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  244. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  245. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  246. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  247. package/dist/search-Cn1SYvYF.mjs.map +0 -1
  248. package/dist/types-C3ronwXb.d.mts.map +0 -1
  249. package/dist/types-DeG21anB.d.mts.map +0 -1
  250. package/dist/types-xxCWI3j0.mjs.map +0 -1
  251. package/dist/validate-Db1yNL3i.d.mts.map +0 -1
  252. package/dist/version-CMMjTuqu.mjs +0 -7
@@ -11,6 +11,32 @@ import { currentTimestampValue } from "../dialect-helpers.js";
11
11
  import type { Database, RedirectTable } from "../types.js";
12
12
  import { encodeCursor, decodeCursor, type FindManyResult } from "./types.js";
13
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // Bounded 404 logging
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Hard cap on rows stored in `_emdash_404_log`. When exceeded, the oldest
20
+ * rows (by `last_seen_at`) are evicted on insert. Prevents an unauthenticated
21
+ * attacker from growing the table without bound by requesting unique URLs.
22
+ */
23
+ export const MAX_404_LOG_ROWS = 10_000;
24
+
25
+ /** Max stored length for the `Referer` header — truncated on insert. */
26
+ export const REFERRER_MAX_LENGTH = 512;
27
+
28
+ /** Max stored length for the `User-Agent` header — truncated on insert. */
29
+ export const USER_AGENT_MAX_LENGTH = 256;
30
+
31
+ /**
32
+ * Truncate a header-derived string to `max` chars, preserving `null`/`undefined`
33
+ * as `null`. Empty strings stay empty (the caller decides whether to coerce).
34
+ */
35
+ function truncateOrNull(value: string | null | undefined, max: number): string | null {
36
+ if (value === null || value === undefined) return null;
37
+ return value.length > max ? value.slice(0, max) : value;
38
+ }
39
+
14
40
  // ---------------------------------------------------------------------------
15
41
  // Types
16
42
  // ---------------------------------------------------------------------------
@@ -369,22 +395,97 @@ export class RedirectRepository {
369
395
 
370
396
  // --- 404 log ------------------------------------------------------------
371
397
 
398
+ /**
399
+ * Record a 404 hit for `entry.path`.
400
+ *
401
+ * Dedups by path: repeat hits increment `hits` and refresh `last_seen_at`
402
+ * on the existing row instead of inserting a new one. Referrer and
403
+ * user-agent are truncated to bounded lengths so a malicious client can't
404
+ * blow up storage with huge headers. When the table would exceed
405
+ * MAX_404_LOG_ROWS, the oldest entries (by `last_seen_at`) are evicted.
406
+ *
407
+ * This is called from the public redirect middleware on every 404 and
408
+ * must never throw for an unauthenticated caller — failures bubble up to
409
+ * the middleware, which swallows them.
410
+ */
372
411
  async log404(entry: {
373
412
  path: string;
374
413
  referrer?: string | null;
375
414
  userAgent?: string | null;
376
415
  ip?: string | null;
377
416
  }): Promise<void> {
417
+ const now = new Date().toISOString();
418
+ const referrer = truncateOrNull(entry.referrer, REFERRER_MAX_LENGTH);
419
+ const userAgent = truncateOrNull(entry.userAgent, USER_AGENT_MAX_LENGTH);
420
+ const ip = entry.ip ?? null;
421
+
422
+ // Atomic upsert by path. The UNIQUE index on `path` makes this safe
423
+ // under concurrency: two requests for the same new path can't both
424
+ // insert — the second one hits the conflict branch and increments
425
+ // hits instead of failing with a uniqueness error.
378
426
  await this.db
379
427
  .insertInto("_emdash_404_log")
380
428
  .values({
381
429
  id: ulid(),
382
430
  path: entry.path,
383
- referrer: entry.referrer ?? null,
384
- user_agent: entry.userAgent ?? null,
385
- ip: entry.ip ?? null,
386
- created_at: new Date().toISOString(),
431
+ referrer,
432
+ user_agent: userAgent,
433
+ ip,
434
+ hits: 1,
435
+ last_seen_at: now,
436
+ created_at: now,
387
437
  })
438
+ .onConflict((oc) =>
439
+ oc.column("path").doUpdateSet({
440
+ hits: sql`hits + 1`,
441
+ last_seen_at: now,
442
+ referrer,
443
+ user_agent: userAgent,
444
+ ip,
445
+ }),
446
+ )
447
+ .execute();
448
+
449
+ // Enforce the row cap. Cheap when the table is under cap (single
450
+ // COUNT(*) query); evicts oldest rows if we're over. Updates (dedup
451
+ // hits) don't grow the table so this is a no-op for repeat paths.
452
+ await this.enforce404Cap();
453
+ }
454
+
455
+ /**
456
+ * Delete the oldest rows from `_emdash_404_log` if the row count exceeds
457
+ * MAX_404_LOG_ROWS. "Oldest" is by `last_seen_at`, so a path that keeps
458
+ * getting hit stays in the table even if it was first seen long ago.
459
+ *
460
+ * Private — callers use `log404`, which invokes this after every upsert.
461
+ */
462
+ private async enforce404Cap(): Promise<void> {
463
+ const countRow = await this.db
464
+ .selectFrom("_emdash_404_log")
465
+ .select((eb) => eb.fn.countAll<number>().as("c"))
466
+ .executeTakeFirst();
467
+ const count = Number(countRow?.c ?? 0);
468
+ if (count <= MAX_404_LOG_ROWS) return;
469
+
470
+ const excess = count - MAX_404_LOG_ROWS;
471
+
472
+ // Evict the oldest rows in a single SQL statement. Using a subquery
473
+ // (rather than materialising the victim IDs in JS and passing them
474
+ // back as bind parameters) keeps the statement bounded regardless of
475
+ // how far over cap the table is — important for existing installs
476
+ // that crossed the threshold before this cap was introduced.
477
+ await this.db
478
+ .deleteFrom("_emdash_404_log")
479
+ .where(
480
+ "id",
481
+ "in",
482
+ this.db
483
+ .selectFrom("_emdash_404_log")
484
+ .select("id")
485
+ .orderBy("last_seen_at", "asc")
486
+ .orderBy("id", "asc")
487
+ .limit(excess),
488
+ )
388
489
  .execute();
389
490
  }
390
491
 
@@ -438,6 +539,10 @@ export class RedirectRepository {
438
539
  }
439
540
 
440
541
  async get404Summary(limit = 50): Promise<NotFoundSummary[]> {
542
+ // Since rows are now deduped by path, each path has exactly one row
543
+ // with `hits` as the running count and `last_seen_at` as the latest
544
+ // timestamp. The subquery for `top_referrer` collapses to a simple
545
+ // pick of the row's stored referrer (the most recent one seen).
441
546
  const rows = await sql<{
442
547
  path: string;
443
548
  count: number;
@@ -446,14 +551,12 @@ export class RedirectRepository {
446
551
  }>`
447
552
  SELECT
448
553
  path,
449
- COUNT(*) as count,
450
- MAX(created_at) as last_seen,
554
+ SUM(hits) as count,
555
+ MAX(last_seen_at) as last_seen,
451
556
  (
452
557
  SELECT referrer FROM _emdash_404_log AS inner_log
453
558
  WHERE inner_log.path = _emdash_404_log.path
454
559
  AND referrer IS NOT NULL AND referrer != ''
455
- GROUP BY referrer
456
- ORDER BY COUNT(*) DESC
457
560
  LIMIT 1
458
561
  ) as top_referrer
459
562
  FROM _emdash_404_log
@@ -466,6 +466,15 @@ export interface NotFoundLogTable {
466
466
  referrer: string | null;
467
467
  user_agent: string | null;
468
468
  ip: string | null;
469
+ hits: number;
470
+ /**
471
+ * Migration 035 adds this as a nullable column (SQLite can't add a
472
+ * NOT NULL column with a non-constant default to an existing table).
473
+ * The `log404` upsert always writes a value, so new and updated rows
474
+ * always have one, but existing rows pre-migration were backfilled
475
+ * without a NOT NULL constraint. Typed as nullable to match the schema.
476
+ */
477
+ last_seen_at: string | null;
469
478
  created_at: string;
470
479
  }
471
480
 
@@ -33,6 +33,21 @@ export interface DatabaseDescriptor {
33
33
  entrypoint: string;
34
34
  config: unknown;
35
35
  type: DatabaseDialectType;
36
+ /**
37
+ * When true, the adapter's runtime entrypoint MUST export a named
38
+ * `createRequestScopedDb` function matching the signature declared in
39
+ * `virtual:emdash/dialect`. The virtual-module generator re-exports it
40
+ * by name, so a missing export becomes a build-time bundler error.
41
+ *
42
+ * The function is called once per request and decides — based on its own
43
+ * runtime config (e.g. whether the user opted into D1 sessions) — whether
44
+ * to return a per-request Kysely or null. Use this for features like D1
45
+ * read-replica sessions, bookmark cookies, or any per-request DB handle.
46
+ *
47
+ * When false or absent, the generator emits a stub that returns null and
48
+ * the middleware takes its default (singleton) path.
49
+ */
50
+ supportsRequestScope?: boolean;
36
51
  }
37
52
 
38
53
  export interface SqliteConfig {