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
@@ -275,9 +275,10 @@ export async function getSuggestions(
275
275
  const ftsTable = ftsManager.getFtsTableName(collection);
276
276
  const contentTable = ftsManager.getContentTableName(collection);
277
277
 
278
- // Use prefix search for autocomplete
279
- const prefixQuery = `${escapeQuery(query)}*`;
280
- if (!prefixQuery || prefixQuery === "*") {
278
+ // Use prefix search for autocomplete. `escapeQuery` already appends `*`
279
+ // to each term for prefix matching, so we must not append another one.
280
+ const prefixQuery = escapeQuery(query);
281
+ if (!prefixQuery) {
281
282
  continue;
282
283
  }
283
284
 
package/src/seed/apply.ts CHANGED
@@ -14,6 +14,7 @@ import { BylineRepository } from "../database/repositories/byline.js";
14
14
  import { ContentRepository } from "../database/repositories/content.js";
15
15
  import { MediaRepository } from "../database/repositories/media.js";
16
16
  import { RedirectRepository } from "../database/repositories/redirect.js";
17
+ import { RevisionRepository } from "../database/repositories/revision.js";
17
18
  import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
18
19
  import { withTransaction } from "../database/transaction.js";
19
20
  import type { Database } from "../database/types.js";
@@ -371,6 +372,7 @@ export async function applySeed(
371
372
  await withTransaction(db, async (trx) => {
372
373
  const trxContentRepo = new ContentRepository(trx);
373
374
  const trxBylineRepo = new BylineRepository(trx);
375
+ const trxRevisionRepo = new RevisionRepository(trx);
374
376
 
375
377
  await trxContentRepo.update(collectionSlug, existing.id, {
376
378
  status,
@@ -386,6 +388,23 @@ export async function applySeed(
386
388
  true,
387
389
  );
388
390
  await applyContentTaxonomies(trx, collectionSlug, existing.id, entry, true);
391
+
392
+ // Seed is declarative — when status is "published", promote to a live
393
+ // revision so the admin UI shows "Unpublish" instead of "Save & Publish"
394
+ // and `live_revision_id` is populated for downstream queries.
395
+ //
396
+ // Create a fresh revision from the updated data and stage it as the
397
+ // draft so `publish()` picks it up instead of re-syncing stale data
398
+ // from an existing live revision.
399
+ if (status === "published") {
400
+ const draft = await trxRevisionRepo.create({
401
+ collection: collectionSlug,
402
+ entryId: existing.id,
403
+ data: resolvedData,
404
+ });
405
+ await trxContentRepo.setDraftRevision(collectionSlug, existing.id, draft.id);
406
+ await trxContentRepo.publish(collectionSlug, existing.id);
407
+ }
389
408
  });
390
409
 
391
410
  seedIdMap.set(entry.id, existing.id);
@@ -434,6 +453,13 @@ export async function applySeed(
434
453
  await applyContentBylines(trxBylineRepo, collectionSlug, item.id, entry, seedBylineIdMap);
435
454
  await applyContentTaxonomies(trx, collectionSlug, item.id, entry, false);
436
455
 
456
+ // Seed is declarative — when status is "published", promote to a live
457
+ // revision so the admin UI shows "Unpublish" instead of "Save & Publish"
458
+ // and `live_revision_id` is populated for downstream queries.
459
+ if (status === "published") {
460
+ await trxContentRepo.publish(collectionSlug, item.id);
461
+ }
462
+
437
463
  return item;
438
464
  });
439
465
 
@@ -792,7 +818,15 @@ async function applyContentTaxonomies(
792
818
  .execute();
793
819
  }
794
820
 
795
- if (!entry.taxonomies) return;
821
+ if (!entry.taxonomies) {
822
+ // In update mode we may have just deleted rows above; invalidate so
823
+ // hydration doesn't serve stale "has terms" cached value.
824
+ if (isUpdate) {
825
+ const { invalidateTermCache } = await import("../taxonomies/index.js");
826
+ invalidateTermCache();
827
+ }
828
+ return;
829
+ }
796
830
 
797
831
  for (const [taxonomyName, termSlugs] of Object.entries(entry.taxonomies)) {
798
832
  const termRepo = new TaxonomyRepository(db);
@@ -804,6 +838,12 @@ async function applyContentTaxonomies(
804
838
  }
805
839
  }
806
840
  }
841
+
842
+ // Seed writes directly to content_taxonomies. Clear the cache so
843
+ // the worker lifetime cached "has any term assignments" probe
844
+ // re-runs on the next read.
845
+ const { invalidateTermCache } = await import("../taxonomies/index.js");
846
+ invalidateTermCache();
807
847
  }
808
848
 
809
849
  /**
@@ -11,6 +11,7 @@ import { MediaRepository } from "../database/repositories/media.js";
11
11
  import { OptionsRepository } from "../database/repositories/options.js";
12
12
  import type { Database } from "../database/types.js";
13
13
  import { getDb } from "../loader.js";
14
+ import { peekRequestCache, requestCached } from "../request-cache.js";
14
15
  import type { Storage } from "../storage/types.js";
15
16
  import type { SiteSettings, SiteSettingKey, MediaReference } from "./types.js";
16
17
 
@@ -75,8 +76,24 @@ async function resolveMediaReference(
75
76
  export async function getSiteSetting<K extends SiteSettingKey>(
76
77
  key: K,
77
78
  ): Promise<SiteSettings[K] | undefined> {
78
- const db = await getDb();
79
- return getSiteSettingWithDb(key, db);
79
+ // If `getSiteSettings()` has already been called in this request,
80
+ // read from that (request-cached) batch rather than firing a second
81
+ // options-table query. Common layout: a Base template pulls the
82
+ // whole settings object up-front, then `EmDashHead` or a plugin
83
+ // asks for one key — no reason the singular call should round-trip
84
+ // again.
85
+ const primed = peekRequestCache<Partial<SiteSettings>>("siteSettings");
86
+ if (primed) {
87
+ const settings = await primed;
88
+ return settings[key];
89
+ }
90
+
91
+ // Otherwise cache per-key. Templates that pull several settings
92
+ // independently still share the in-flight query for each one.
93
+ return requestCached(`siteSetting:${key}`, async () => {
94
+ const db = await getDb();
95
+ return getSiteSettingWithDb(key, db);
96
+ });
80
97
  }
81
98
 
82
99
  /**
@@ -124,9 +141,11 @@ export async function getSiteSettingWithDb<K extends SiteSettingKey>(
124
141
  * console.log(settings.logo?.url); // "/_emdash/api/media/file/abc123"
125
142
  * ```
126
143
  */
127
- export async function getSiteSettings(): Promise<Partial<SiteSettings>> {
128
- const db = await getDb();
129
- return getSiteSettingsWithDb(db);
144
+ export function getSiteSettings(): Promise<Partial<SiteSettings>> {
145
+ return requestCached("siteSettings", async () => {
146
+ const db = await getDb();
147
+ return getSiteSettingsWithDb(db);
148
+ });
130
149
  }
131
150
 
132
151
  /**
@@ -5,103 +5,125 @@
5
5
  */
6
6
 
7
7
  import { getDb } from "../loader.js";
8
+ import { requestCached, setRequestCacheEntry } from "../request-cache.js";
9
+ import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
10
+ import { isMissingTableError } from "../utils/db-errors.js";
8
11
  import type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from "./types.js";
9
12
 
13
+ /**
14
+ * No-op — kept for API compatibility.
15
+ *
16
+ * Used to invalidate a worker-lifetime "has any term assignments?" probe.
17
+ * That probe added a query on every cold isolate to save one query on
18
+ * sites with zero term assignments (i.e. the wrong tradeoff), so we
19
+ * dropped it. The batch term join below returns an empty map for empty
20
+ * sites at the same cost as the probe, without the pre-check.
21
+ */
22
+ export function invalidateTermCache(): void {
23
+ // Intentionally empty.
24
+ }
25
+
10
26
  /**
11
27
  * Get all taxonomy definitions
12
28
  */
13
29
  export async function getTaxonomyDefs(): Promise<TaxonomyDef[]> {
14
- const db = await getDb();
30
+ return requestCached("taxonomy-defs:all", async () => {
31
+ const db = await getDb();
15
32
 
16
- const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
33
+ const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
17
34
 
18
- return rows.map((row) => ({
19
- id: row.id,
20
- name: row.name,
21
- label: row.label,
22
- labelSingular: row.label_singular ?? undefined,
23
- hierarchical: row.hierarchical === 1,
24
- collections: row.collections ? JSON.parse(row.collections) : [],
25
- }));
35
+ return rows.map((row) => ({
36
+ id: row.id,
37
+ name: row.name,
38
+ label: row.label,
39
+ labelSingular: row.label_singular ?? undefined,
40
+ hierarchical: row.hierarchical === 1,
41
+ collections: row.collections ? JSON.parse(row.collections) : [],
42
+ }));
43
+ });
26
44
  }
27
45
 
28
46
  /**
29
47
  * Get a single taxonomy definition by name
30
48
  */
31
49
  export async function getTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
32
- const db = await getDb();
50
+ return requestCached(`taxonomy-def:${name}`, async () => {
51
+ const db = await getDb();
33
52
 
34
- const row = await db
35
- .selectFrom("_emdash_taxonomy_defs")
36
- .selectAll()
37
- .where("name", "=", name)
38
- .executeTakeFirst();
53
+ const row = await db
54
+ .selectFrom("_emdash_taxonomy_defs")
55
+ .selectAll()
56
+ .where("name", "=", name)
57
+ .executeTakeFirst();
39
58
 
40
- if (!row) return null;
59
+ if (!row) return null;
41
60
 
42
- return {
43
- id: row.id,
44
- name: row.name,
45
- label: row.label,
46
- labelSingular: row.label_singular ?? undefined,
47
- hierarchical: row.hierarchical === 1,
48
- collections: row.collections ? JSON.parse(row.collections) : [],
49
- };
61
+ return {
62
+ id: row.id,
63
+ name: row.name,
64
+ label: row.label,
65
+ labelSingular: row.label_singular ?? undefined,
66
+ hierarchical: row.hierarchical === 1,
67
+ collections: row.collections ? JSON.parse(row.collections) : [],
68
+ };
69
+ });
50
70
  }
51
71
 
52
72
  /**
53
73
  * Get all terms for a taxonomy (as tree for hierarchical, flat for tags)
54
74
  */
55
75
  export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
56
- const db = await getDb();
57
-
58
- // Get taxonomy definition to check if hierarchical
59
- const def = await getTaxonomyDef(taxonomyName);
60
- if (!def) return [];
61
-
62
- // Get all terms for this taxonomy
63
- const rows = await db
64
- .selectFrom("taxonomies")
65
- .selectAll()
66
- .where("name", "=", taxonomyName)
67
- .orderBy("label", "asc")
68
- .execute();
69
-
70
- // Count entries for each term
71
- const countsResult = await db
72
- .selectFrom("content_taxonomies")
73
- .select(["taxonomy_id"])
74
- .select((eb) => eb.fn.count<number>("entry_id").as("count"))
75
- .groupBy("taxonomy_id")
76
- .execute();
77
-
78
- const counts = new Map<string, number>();
79
- for (const row of countsResult) {
80
- counts.set(row.taxonomy_id, row.count);
81
- }
76
+ return requestCached(`taxonomy-terms:${taxonomyName}`, async () => {
77
+ const db = await getDb();
78
+
79
+ // Get taxonomy definition to check if hierarchical
80
+ const def = await getTaxonomyDef(taxonomyName);
81
+ if (!def) return [];
82
+
83
+ // Get all terms for this taxonomy
84
+ const rows = await db
85
+ .selectFrom("taxonomies")
86
+ .selectAll()
87
+ .where("name", "=", taxonomyName)
88
+ .orderBy("label", "asc")
89
+ .execute();
90
+
91
+ // Count entries for each term
92
+ const countsResult = await db
93
+ .selectFrom("content_taxonomies")
94
+ .select(["taxonomy_id"])
95
+ .select((eb) => eb.fn.count<number>("entry_id").as("count"))
96
+ .groupBy("taxonomy_id")
97
+ .execute();
98
+
99
+ const counts = new Map<string, number>();
100
+ for (const row of countsResult) {
101
+ counts.set(row.taxonomy_id, row.count);
102
+ }
82
103
 
83
- const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
84
- id: row.id,
85
- name: row.name,
86
- slug: row.slug,
87
- label: row.label,
88
- parent_id: row.parent_id,
89
- data: row.data,
90
- }));
104
+ const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
105
+ id: row.id,
106
+ name: row.name,
107
+ slug: row.slug,
108
+ label: row.label,
109
+ parent_id: row.parent_id,
110
+ data: row.data,
111
+ }));
91
112
 
92
- // If hierarchical, build tree. Otherwise return flat
93
- if (def.hierarchical) {
94
- return buildTree(flatTerms, counts);
95
- }
113
+ // If hierarchical, build tree. Otherwise return flat
114
+ if (def.hierarchical) {
115
+ return buildTree(flatTerms, counts);
116
+ }
96
117
 
97
- return flatTerms.map((term) => ({
98
- id: term.id,
99
- name: term.name,
100
- slug: term.slug,
101
- label: term.label,
102
- children: [],
103
- count: counts.get(term.id) ?? 0,
104
- }));
118
+ return flatTerms.map((term) => ({
119
+ id: term.id,
120
+ name: term.name,
121
+ slug: term.slug,
122
+ label: term.label,
123
+ children: [],
124
+ count: counts.get(term.id) ?? 0,
125
+ }));
126
+ });
105
127
  }
106
128
 
107
129
  /**
@@ -160,34 +182,36 @@ export async function getTerm(taxonomyName: string, slug: string): Promise<Taxon
160
182
  /**
161
183
  * Get terms assigned to an entry
162
184
  */
163
- export async function getEntryTerms(
185
+ export function getEntryTerms(
164
186
  collection: string,
165
187
  entryId: string,
166
188
  taxonomyName?: string,
167
189
  ): Promise<TaxonomyTerm[]> {
168
- const db = await getDb();
169
-
170
- let query = db
171
- .selectFrom("content_taxonomies")
172
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
173
- .selectAll("taxonomies")
174
- .where("content_taxonomies.collection", "=", collection)
175
- .where("content_taxonomies.entry_id", "=", entryId);
176
-
177
- if (taxonomyName) {
178
- query = query.where("taxonomies.name", "=", taxonomyName);
179
- }
190
+ return requestCached(`terms:${collection}:${entryId}:${taxonomyName ?? "*"}`, async () => {
191
+ const db = await getDb();
192
+
193
+ let query = db
194
+ .selectFrom("content_taxonomies")
195
+ .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
196
+ .selectAll("taxonomies")
197
+ .where("content_taxonomies.collection", "=", collection)
198
+ .where("content_taxonomies.entry_id", "=", entryId);
199
+
200
+ if (taxonomyName) {
201
+ query = query.where("taxonomies.name", "=", taxonomyName);
202
+ }
180
203
 
181
- const rows = await query.execute();
204
+ const rows = await query.execute();
182
205
 
183
- return rows.map((row) => ({
184
- id: row.id,
185
- name: row.name,
186
- slug: row.slug,
187
- label: row.label,
188
- parentId: row.parent_id ?? undefined,
189
- children: [],
190
- }));
206
+ return rows.map((row) => ({
207
+ id: row.id,
208
+ name: row.name,
209
+ slug: row.slug,
210
+ label: row.label,
211
+ parentId: row.parent_id ?? undefined,
212
+ children: [],
213
+ }));
214
+ });
191
215
  }
192
216
 
193
217
  /**
@@ -208,53 +232,229 @@ export async function getTermsForEntries(
208
232
  ): Promise<Map<string, TaxonomyTerm[]>> {
209
233
  const result = new Map<string, TaxonomyTerm[]>();
210
234
 
211
- // Initialize all entry IDs with empty arrays
212
- for (const id of entryIds) {
235
+ // Initialize all entry IDs with empty arrays so callers can always
236
+ // expect the key to be present.
237
+ const uniqueIds = [...new Set(entryIds)];
238
+ for (const id of uniqueIds) {
213
239
  result.set(id, []);
214
240
  }
215
241
 
216
- if (entryIds.length === 0) {
242
+ if (uniqueIds.length === 0) {
217
243
  return result;
218
244
  }
219
245
 
220
246
  const db = await getDb();
221
247
 
222
- const rows = await db
223
- .selectFrom("content_taxonomies")
224
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
225
- .select([
226
- "content_taxonomies.entry_id",
227
- "taxonomies.id",
228
- "taxonomies.name",
229
- "taxonomies.slug",
230
- "taxonomies.label",
231
- "taxonomies.parent_id",
232
- ])
233
- .where("content_taxonomies.collection", "=", collection)
234
- .where("content_taxonomies.entry_id", "in", entryIds)
235
- .where("taxonomies.name", "=", taxonomyName)
236
- .execute();
248
+ // Chunk the IN clause so we stay below D1's ~100 bound-parameter limit
249
+ // (and equivalent limits on other dialects). Matches getContentBylinesMany.
250
+ //
251
+ // Sites with no term assignments get back empty rows for one query —
252
+ // the previous "has any term assignments" probe spent a round-trip on
253
+ // every request to save that single query on empty sites, which is
254
+ // backwards. Pre-migration databases (content_taxonomies missing) fall
255
+ // through to the `isMissingTableError` catch and return empties.
256
+ for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
257
+ let rows;
258
+ try {
259
+ rows = await db
260
+ .selectFrom("content_taxonomies")
261
+ .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
262
+ .select([
263
+ "content_taxonomies.entry_id",
264
+ "taxonomies.id",
265
+ "taxonomies.name",
266
+ "taxonomies.slug",
267
+ "taxonomies.label",
268
+ "taxonomies.parent_id",
269
+ ])
270
+ .where("content_taxonomies.collection", "=", collection)
271
+ .where("content_taxonomies.entry_id", "in", chunk)
272
+ .where("taxonomies.name", "=", taxonomyName)
273
+ .execute();
274
+ } catch (error) {
275
+ if (isMissingTableError(error)) return result;
276
+ throw error;
277
+ }
237
278
 
238
- for (const row of rows) {
239
- const entryId = row.entry_id;
240
- const term: TaxonomyTerm = {
241
- id: row.id,
242
- name: row.name,
243
- slug: row.slug,
244
- label: row.label,
245
- parentId: row.parent_id ?? undefined,
246
- children: [],
247
- };
279
+ for (const row of rows) {
280
+ const entryId = row.entry_id;
281
+ const term: TaxonomyTerm = {
282
+ id: row.id,
283
+ name: row.name,
284
+ slug: row.slug,
285
+ label: row.label,
286
+ parentId: row.parent_id ?? undefined,
287
+ children: [],
288
+ };
289
+
290
+ const terms = result.get(entryId);
291
+ if (terms) {
292
+ terms.push(term);
293
+ }
294
+ }
295
+ }
296
+
297
+ return result;
298
+ }
299
+
300
+ /**
301
+ * Batch-fetch terms for multiple entries across ALL taxonomies in a single query.
302
+ *
303
+ * Returns a Map keyed by entry ID, where each value is a Record keyed by
304
+ * taxonomy name with the matching terms as an array. Used by
305
+ * getEmDashCollection to eagerly hydrate `entry.data.terms` and avoid
306
+ * the N+1 pattern that callers hit when they loop and call getEntryTerms.
307
+ *
308
+ * Pre-migration databases (content_taxonomies missing) return an empty
309
+ * Map — the join falls through to the `isMissingTableError` branch.
310
+ */
311
+ export async function getAllTermsForEntries(
312
+ collection: string,
313
+ entryIds: string[],
314
+ ): Promise<Map<string, Record<string, TaxonomyTerm[]>>> {
315
+ const result = new Map<string, Record<string, TaxonomyTerm[]>>();
316
+
317
+ // Initialize unique entry IDs with empty objects so callers can always
318
+ // expect the key to be present. Deduping also reduces wasted bound
319
+ // parameters when a caller accidentally passes duplicates.
320
+ const uniqueIds = [...new Set(entryIds)];
321
+ for (const id of uniqueIds) {
322
+ result.set(id, {});
323
+ }
324
+
325
+ if (uniqueIds.length === 0) {
326
+ return result;
327
+ }
328
+
329
+ const db = await getDb();
248
330
 
249
- const terms = result.get(entryId);
250
- if (terms) {
251
- terms.push(term);
331
+ // Look up which taxonomies apply to this collection. Used below to
332
+ // seed empty arrays for taxonomies the entry has no terms in — so
333
+ // callers (including the pre-populated getEntryTerms cache) get a
334
+ // deterministic `[]` back rather than a cache miss that triggers a DB
335
+ // round-trip just to confirm "no terms".
336
+ const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection);
337
+
338
+ // Chunk the IN clause to stay below D1's ~100 bound-parameter limit
339
+ // (and equivalent limits on other dialects). Matches getContentBylinesMany.
340
+ //
341
+ // Previously we did a separate "has any assignments" probe to skip the
342
+ // join on empty sites. That traded one query per request for a query
343
+ // saved only on empty sites — backwards. Now the join runs directly
344
+ // (returning zero rows cheaply) and pre-migration databases are caught
345
+ // by the `isMissingTableError` branch below.
346
+ for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
347
+ let rows;
348
+ try {
349
+ rows = await db
350
+ .selectFrom("content_taxonomies")
351
+ .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
352
+ .select([
353
+ "content_taxonomies.entry_id",
354
+ "taxonomies.id",
355
+ "taxonomies.name",
356
+ "taxonomies.slug",
357
+ "taxonomies.label",
358
+ "taxonomies.parent_id",
359
+ ])
360
+ .where("content_taxonomies.collection", "=", collection)
361
+ .where("content_taxonomies.entry_id", "in", chunk)
362
+ .orderBy("taxonomies.label", "asc")
363
+ .execute();
364
+ } catch (error) {
365
+ if (isMissingTableError(error)) {
366
+ for (const id of uniqueIds) {
367
+ primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames);
368
+ }
369
+ return result;
370
+ }
371
+ throw error;
252
372
  }
373
+
374
+ for (const row of rows) {
375
+ const entryId = row.entry_id;
376
+ const term: TaxonomyTerm = {
377
+ id: row.id,
378
+ name: row.name,
379
+ slug: row.slug,
380
+ label: row.label,
381
+ parentId: row.parent_id ?? undefined,
382
+ children: [],
383
+ };
384
+
385
+ const byTaxonomy = result.get(entryId);
386
+ if (!byTaxonomy) continue;
387
+ const existing = byTaxonomy[row.name];
388
+ if (existing) {
389
+ existing.push(term);
390
+ } else {
391
+ byTaxonomy[row.name] = [term];
392
+ }
393
+ }
394
+ }
395
+
396
+ // Prime the request-scoped cache so legacy callers of getEntryTerms
397
+ // (which still work per-entry) hit the in-memory cache instead of
398
+ // re-querying. This is what gives us the N+1 win in existing templates
399
+ // without requiring them to be rewritten.
400
+ for (const [entryId, byTaxonomy] of result) {
401
+ primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames);
253
402
  }
254
403
 
255
404
  return result;
256
405
  }
257
406
 
407
+ /**
408
+ * Return the list of taxonomy names applicable to a collection, request-
409
+ * cached so a page render only pays for it once.
410
+ *
411
+ * Returns an empty list when taxonomies haven't been defined yet.
412
+ */
413
+ async function getCollectionTaxonomyNames(collection: string): Promise<string[]> {
414
+ try {
415
+ const defs = await getTaxonomyDefs();
416
+ return defs.filter((d) => d.collections.includes(collection)).map((d) => d.name);
417
+ } catch (error) {
418
+ if (isMissingTableError(error)) return [];
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Pre-populate the request-cache for every getEntryTerms call-shape that
425
+ * could hit this entry:
426
+ *
427
+ * getEntryTerms(collection, entryId) -> key `terms:C:E:*`
428
+ * getEntryTerms(collection, entryId, "tag") -> key `terms:C:E:tag`
429
+ * getEntryTerms(collection, entryId, "category") -> key `terms:C:E:category`
430
+ * ...one per taxonomy that applies to this collection
431
+ *
432
+ * Taxonomies with no rows on this entry are seeded with `[]` so legacy
433
+ * callers short-circuit to the cached empty array instead of re-querying.
434
+ */
435
+ function primeEntryTermsCache(
436
+ collection: string,
437
+ entryId: string,
438
+ byTaxonomy: Record<string, TaxonomyTerm[]>,
439
+ applicableTaxonomyNames: string[],
440
+ ): void {
441
+ // Seed every applicable taxonomy with at least [] so
442
+ // getEntryTerms(collection, id, "tag") doesn't miss the cache when an
443
+ // entry has no tags.
444
+ for (const name of applicableTaxonomyNames) {
445
+ setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, byTaxonomy[name] ?? []);
446
+ }
447
+ // Also seed individual names that show up in data but aren't listed
448
+ // as applicable (e.g. taxonomy reassigned to a different collection
449
+ // since the terms were written).
450
+ for (const [name, terms] of Object.entries(byTaxonomy)) {
451
+ setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, terms);
452
+ }
453
+ // Flattened `*` view — all terms across all taxonomies in one array.
454
+ const allTerms = Object.values(byTaxonomy).flat();
455
+ setRequestCacheEntry(`terms:${collection}:${entryId}:*`, allTerms);
456
+ }
457
+
258
458
  /**
259
459
  * Get entries by term (wraps getEmDashCollection)
260
460
  */