emdash 0.4.0 → 0.6.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 (212) 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-B4MsLM-w.mjs} +27 -12
  4. package/dist/apply-B4MsLM-w.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 +208 -34
  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 +34 -9
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +1 -1
  14. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +5 -3
  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 +460 -180
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +8 -8
  22. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  23. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  24. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  25. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  26. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  27. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  28. package/dist/chunks-HGz06Soa.mjs +19 -0
  29. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  30. package/dist/cli/index.mjs +9 -8
  31. package/dist/cli/index.mjs.map +1 -1
  32. package/dist/client/cf-access.d.mts +1 -1
  33. package/dist/client/index.d.mts +1 -1
  34. package/dist/client/index.mjs +1 -1
  35. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  36. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  37. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  38. package/dist/connection-2igzM-AT.mjs.map +1 -0
  39. package/dist/database/instrumentation.d.mts +45 -0
  40. package/dist/database/instrumentation.d.mts.map +1 -0
  41. package/dist/database/instrumentation.mjs +61 -0
  42. package/dist/database/instrumentation.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs.map +1 -1
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/postgres.d.mts +1 -1
  47. package/dist/db/sqlite.d.mts +1 -1
  48. package/dist/db-errors-D0UT85nC.mjs +41 -0
  49. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  50. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  51. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  52. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  53. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  54. package/dist/{index-CRg3PWfZ.d.mts → index-BYv0mB9g.d.mts} +135 -19
  55. package/dist/index-BYv0mB9g.d.mts.map +1 -0
  56. package/dist/index.d.mts +11 -11
  57. package/dist/index.mjs +20 -18
  58. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  59. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  60. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  61. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  62. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  63. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  64. package/dist/media/index.d.mts +1 -1
  65. package/dist/media/index.mjs +1 -1
  66. package/dist/media/local-runtime.d.mts +7 -7
  67. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  68. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  69. package/dist/page/index.d.mts +11 -2
  70. package/dist/page/index.d.mts.map +1 -1
  71. package/dist/page/index.mjs +23 -1
  72. package/dist/page/index.mjs.map +1 -1
  73. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  74. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  75. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  76. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  77. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  78. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  79. package/dist/{query-B6Vu0d2i.mjs → query-Bk_3vKvU.mjs} +78 -11
  80. package/dist/query-Bk_3vKvU.mjs.map +1 -0
  81. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  82. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  83. package/dist/request-cache-DiR961CV.mjs +79 -0
  84. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  85. package/dist/request-context.d.mts +19 -16
  86. package/dist/request-context.d.mts.map +1 -1
  87. package/dist/request-context.mjs.map +1 -1
  88. package/dist/{runner-DYv3rX8P.d.mts → runner-Fl2NcUUz.d.mts} +2 -2
  89. package/dist/{runner-DYv3rX8P.d.mts.map → runner-Fl2NcUUz.d.mts.map} +1 -1
  90. package/dist/runtime.d.mts +6 -6
  91. package/dist/runtime.mjs +1 -1
  92. package/dist/{search-B5p9D36n.mjs → search-DI4bM2w9.mjs} +110 -209
  93. package/dist/search-DI4bM2w9.mjs.map +1 -0
  94. package/dist/seed/index.d.mts +2 -2
  95. package/dist/seed/index.mjs +8 -7
  96. package/dist/seo/index.d.mts +1 -1
  97. package/dist/storage/local.d.mts +1 -1
  98. package/dist/storage/local.mjs +1 -1
  99. package/dist/storage/s3.d.mts +1 -1
  100. package/dist/storage/s3.mjs +1 -1
  101. package/dist/taxonomies-DbrKzDju.mjs +308 -0
  102. package/dist/taxonomies-DbrKzDju.mjs.map +1 -0
  103. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  104. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  105. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  106. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  107. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  108. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  109. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  110. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  111. package/dist/{types-B6BzlZxx.d.mts → types-8xrvl_68.d.mts} +1 -1
  112. package/dist/{types-B6BzlZxx.d.mts.map → types-8xrvl_68.d.mts.map} +1 -1
  113. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  114. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  115. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  116. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  117. package/dist/{types-gLYVCXCQ.d.mts → types-CnZYHyLW.d.mts} +55 -5
  118. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  119. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  120. package/dist/types-DDS4MxsT.mjs.map +1 -0
  121. package/dist/{types-BYWYxLcp.d.mts → types-DgrIP0tF.d.mts} +9 -2
  122. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  123. package/dist/{validate-CcNRWH6I.d.mts → validate-CaLH1Ia2.d.mts} +5 -52
  124. package/dist/validate-CaLH1Ia2.d.mts.map +1 -0
  125. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  126. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  127. package/dist/version-Uaf2ynPX.mjs +7 -0
  128. package/dist/{version-DlTDRdpv.mjs.map → version-Uaf2ynPX.mjs.map} +1 -1
  129. package/package.json +10 -5
  130. package/src/after.ts +62 -0
  131. package/src/api/handlers/oauth-authorization.ts +2 -32
  132. package/src/api/handlers/oauth-clients.ts +40 -4
  133. package/src/api/handlers/taxonomies.ts +13 -0
  134. package/src/api/oauth/redirect-uri.ts +34 -0
  135. package/src/api/openapi/document.ts +126 -118
  136. package/src/api/schemas/auth.ts +7 -0
  137. package/src/api/schemas/media.ts +26 -15
  138. package/src/api/schemas/schema.ts +1 -0
  139. package/src/astro/integration/font-provider.ts +176 -0
  140. package/src/astro/integration/index.ts +42 -0
  141. package/src/astro/integration/routes.ts +17 -1
  142. package/src/astro/integration/runtime.ts +63 -0
  143. package/src/astro/integration/virtual-modules.ts +41 -39
  144. package/src/astro/integration/vite-config.ts +16 -5
  145. package/src/astro/middleware/auth.ts +39 -6
  146. package/src/astro/middleware/request-context.ts +15 -3
  147. package/src/astro/middleware.ts +340 -263
  148. package/src/astro/routes/admin.astro +10 -5
  149. package/src/astro/routes/api/auth/invite/register-options.ts +78 -0
  150. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  151. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  152. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  153. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +1 -1
  154. package/src/astro/routes/api/media/upload-url.ts +10 -2
  155. package/src/astro/routes/api/media.ts +10 -7
  156. package/src/astro/routes/api/oauth/register.ts +178 -0
  157. package/src/astro/routes/api/oauth/token.ts +15 -0
  158. package/src/astro/routes/api/openapi.json.ts +15 -5
  159. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  160. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  161. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  162. package/src/astro/routes/api/search/index.ts +5 -0
  163. package/src/astro/routes/api/search/suggest.ts +3 -0
  164. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  165. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +6 -4
  166. package/src/bylines/index.ts +22 -45
  167. package/src/components/EmDashHead.astro +23 -7
  168. package/src/components/Table.astro +73 -41
  169. package/src/components/index.ts +2 -12
  170. package/src/components/marks.ts +20 -0
  171. package/src/database/connection.ts +23 -1
  172. package/src/database/instrumentation.ts +98 -0
  173. package/src/db/adapters.ts +15 -0
  174. package/src/emdash-runtime.ts +309 -91
  175. package/src/index.ts +6 -0
  176. package/src/loader.ts +19 -24
  177. package/src/menus/index.ts +6 -3
  178. package/src/page/index.ts +1 -1
  179. package/src/page/seo-contributions.ts +36 -0
  180. package/src/plugins/context.ts +1 -0
  181. package/src/plugins/email-console.ts +9 -2
  182. package/src/plugins/types.ts +8 -0
  183. package/src/query.ts +104 -7
  184. package/src/request-cache.ts +106 -0
  185. package/src/request-context.ts +19 -0
  186. package/src/schema/query.ts +5 -2
  187. package/src/schema/registry.ts +243 -166
  188. package/src/schema/types.ts +13 -2
  189. package/src/schema/zod-generator.ts +4 -0
  190. package/src/search/fts-manager.ts +19 -5
  191. package/src/search/query.ts +4 -3
  192. package/src/seed/apply.ts +15 -1
  193. package/src/settings/index.ts +24 -5
  194. package/src/taxonomies/index.ts +324 -124
  195. package/src/utils/db-errors.ts +46 -0
  196. package/src/virtual-modules.d.ts +31 -10
  197. package/src/widgets/index.ts +54 -25
  198. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  199. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  200. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  201. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  202. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  203. package/dist/index-CRg3PWfZ.d.mts.map +0 -1
  204. package/dist/loader-BYzwzORf.mjs.map +0 -1
  205. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  206. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  207. package/dist/search-B5p9D36n.mjs.map +0 -1
  208. package/dist/types-BYWYxLcp.d.mts.map +0 -1
  209. package/dist/types-gLYVCXCQ.d.mts.map +0 -1
  210. package/dist/types-xxCWI3j0.mjs.map +0 -1
  211. package/dist/validate-CcNRWH6I.d.mts.map +0 -1
  212. package/dist/version-DlTDRdpv.mjs +0 -7
@@ -10,6 +10,7 @@ import { sql } from "kysely";
10
10
  import type { Database } from "../database/types.js";
11
11
  import { validateIdentifier } from "../database/validate.js";
12
12
  import { getDb } from "../loader.js";
13
+ import { requestCached } from "../request-cache.js";
13
14
  import { sanitizeHref } from "../utils/url.js";
14
15
  import type { Menu, MenuItem, MenuItemRow } from "./types.js";
15
16
 
@@ -26,9 +27,11 @@ import type { Menu, MenuItem, MenuItemRow } from "./types.js";
26
27
  * }
27
28
  * ```
28
29
  */
29
- export async function getMenu(name: string): Promise<Menu | null> {
30
- const db = await getDb();
31
- return getMenuWithDb(name, db);
30
+ export function getMenu(name: string): Promise<Menu | null> {
31
+ return requestCached(`menu:${name}`, async () => {
32
+ const db = await getDb();
33
+ return getMenuWithDb(name, db);
34
+ });
32
35
  }
33
36
 
34
37
  /**
package/src/page/index.ts CHANGED
@@ -24,7 +24,7 @@ export type { ResolvedPageMetadata } from "./metadata.js";
24
24
 
25
25
  export { resolveFragments, renderFragments } from "./fragments.js";
26
26
 
27
- export { generateBaseSeoContributions } from "./seo-contributions.js";
27
+ export { generateBaseSeoContributions, generateSiteSeoContributions } from "./seo-contributions.js";
28
28
  export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
29
29
 
30
30
  /**
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { PageMetadataContribution, PublicPageContext } from "../plugins/types.js";
13
+ import type { SeoSettings } from "../settings/types.js";
13
14
  import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
14
15
 
15
16
  /**
@@ -134,3 +135,38 @@ export function generateBaseSeoContributions(page: PublicPageContext): PageMetad
134
135
 
135
136
  return contributions;
136
137
  }
138
+
139
+ /**
140
+ * Generate site-level SEO metadata contributions from SiteSettings.seo.
141
+ *
142
+ * These tags apply to every page (search engine ownership verification),
143
+ * so they're sourced from site settings rather than per-page context.
144
+ * Returns an empty array when no relevant settings are configured.
145
+ */
146
+ export function generateSiteSeoContributions(
147
+ seoSettings: SeoSettings | undefined,
148
+ ): PageMetadataContribution[] {
149
+ const contributions: PageMetadataContribution[] = [];
150
+
151
+ if (!seoSettings) {
152
+ return contributions;
153
+ }
154
+
155
+ if (seoSettings.googleVerification) {
156
+ contributions.push({
157
+ kind: "meta",
158
+ name: "google-site-verification",
159
+ content: seoSettings.googleVerification,
160
+ });
161
+ }
162
+
163
+ if (seoSettings.bingVerification) {
164
+ contributions.push({
165
+ kind: "meta",
166
+ name: "msvalidate.01",
167
+ content: seoSettings.bingVerification,
168
+ });
169
+ }
170
+
171
+ return contributions;
172
+ }
@@ -236,6 +236,7 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
236
236
  limit: options?.limit ?? 50,
237
237
  cursor: options?.cursor,
238
238
  orderBy,
239
+ where: options?.where,
239
240
  });
240
241
 
241
242
  const items: ContentItem[] = result.items.map((item) => ({
@@ -24,8 +24,15 @@ export interface StoredEmail {
24
24
  sentAt: string;
25
25
  }
26
26
 
27
- /** In-memory store for dev emails */
28
- const storedEmails: StoredEmail[] = [];
27
+ /**
28
+ * In-memory store for dev emails.
29
+ * Uses globalThis so the same array is shared across Vite SSR module
30
+ * instances (the runtime and the route handler may load separate copies
31
+ * of this module, but globalThis is always the same object).
32
+ */
33
+ const GLOBAL_KEY = "__emdash_dev_emails__" as const;
34
+ const storedEmails: StoredEmail[] = ((globalThis as Record<string, unknown>)[GLOBAL_KEY] ??=
35
+ []) as StoredEmail[];
29
36
 
30
37
  /**
31
38
  * Get all stored dev emails (most recent first).
@@ -209,6 +209,13 @@ export interface ContentItem {
209
209
  publishedAt: string | null;
210
210
  }
211
211
 
212
+ export interface ContentListWhere {
213
+ /** Exact match on `status` (e.g. `"published"`, `"draft"`). */
214
+ status?: string;
215
+ /** Exact match on `locale` (e.g. `"en"`, `"fr-CA"`). */
216
+ locale?: string;
217
+ }
218
+
212
219
  /**
213
220
  * Content list options
214
221
  */
@@ -216,6 +223,7 @@ export interface ContentListOptions {
216
223
  limit?: number;
217
224
  cursor?: string;
218
225
  orderBy?: Record<string, "asc" | "desc">;
226
+ where?: ContentListWhere;
219
227
  }
220
228
 
221
229
  /**
package/src/query.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  */
14
14
 
15
15
  import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js";
16
+ import { requestCached } from "./request-cache.js";
16
17
  import { getRequestContext } from "./request-context.js";
18
+ import { isMissingTableError } from "./utils/db-errors.js";
17
19
  import {
18
20
  createEditable,
19
21
  createNoop,
@@ -269,6 +271,51 @@ function entryEditOptions(entry: { data?: unknown }): EditableOptions {
269
271
  export async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(
270
272
  type: T,
271
273
  filter?: CollectionFilter,
274
+ ): Promise<CollectionResult<D>> {
275
+ // Cache per (type, filter) within a single request. Edit mode and
276
+ // preview are request-scoped and stable, so they don't need to be
277
+ // part of the key. Widgets and layouts frequently request the same
278
+ // collection shape as the page itself (e.g. a "recent posts" list
279
+ // appears on the home page AND in the sidebar) — caching collapses
280
+ // those duplicate queries, along with the bylines and taxonomy-term
281
+ // hydration each call would otherwise re-do.
282
+ return requestCached(collectionCacheKey(type, filter), () =>
283
+ getEmDashCollectionUncached<T, D>(type, filter),
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Build a canonical cache key for `getEmDashCollection`.
289
+ *
290
+ * `JSON.stringify` is insertion-order-sensitive, so two callers passing
291
+ * semantically identical filters with different key orders would miss
292
+ * the cache. We fix the top-level field order and sort `where` keys
293
+ * (order there is irrelevant), while preserving `orderBy` key order
294
+ * because that's the sort priority.
295
+ */
296
+ function collectionCacheKey(type: string, filter?: CollectionFilter): string {
297
+ if (!filter) return `collection:${type}:`;
298
+ const parts = [
299
+ filter.status ?? "",
300
+ filter.limit ?? "",
301
+ filter.cursor ?? "",
302
+ filter.where ? stableStringify(filter.where) : "",
303
+ filter.orderBy ? JSON.stringify(filter.orderBy) : "",
304
+ filter.locale ?? "",
305
+ ];
306
+ return `collection:${type}:${parts.join("|")}`;
307
+ }
308
+
309
+ function stableStringify(value: Record<string, unknown>): string {
310
+ const keys = Object.keys(value).toSorted();
311
+ const ordered: Record<string, unknown> = {};
312
+ for (const k of keys) ordered[k] = value[k];
313
+ return JSON.stringify(ordered);
314
+ }
315
+
316
+ async function getEmDashCollectionUncached<T extends string, D = InferCollectionData<T>>(
317
+ type: T,
318
+ filter?: CollectionFilter,
272
319
  ): Promise<CollectionResult<D>> {
273
320
  // Dynamic import to avoid build-time issues
274
321
  const { getLiveCollection } = await import("astro:content");
@@ -313,8 +360,13 @@ export async function getEmDashCollection<T extends string, D = InferCollectionD
313
360
  };
314
361
  });
315
362
 
316
- // Eagerly hydrate bylines for all entries
317
- await hydrateEntryBylines(type, entriesWithEdit);
363
+ // Eagerly hydrate bylines and taxonomy terms for all entries in parallel.
364
+ // Both are independent queries, so running them concurrently halves the
365
+ // round-trip cost on remote databases (D1 replicas, etc.).
366
+ await Promise.all([
367
+ hydrateEntryBylines(type, entriesWithEdit),
368
+ hydrateEntryTerms(type, entriesWithEdit),
369
+ ]);
318
370
 
319
371
  return { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };
320
372
  }
@@ -386,12 +438,12 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
386
438
  const localeChain =
387
439
  requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];
388
440
 
389
- /** Return a successful EntryResult with bylines hydrated */
441
+ /** Return a successful EntryResult with bylines and taxonomy terms hydrated */
390
442
  async function successResult(
391
443
  wrapped: ContentEntry<D>,
392
444
  opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
393
445
  ): Promise<EntryResult<D>> {
394
- await hydrateEntryBylines(type, [wrapped]);
446
+ await Promise.all([hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped])]);
395
447
  return {
396
448
  entry: wrapped,
397
449
  isPreview: opts.isPreview,
@@ -525,14 +577,59 @@ async function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]):
525
577
  data.byline = credits[0]?.byline ?? null;
526
578
  }
527
579
  } catch (err) {
528
- // Only swallow "table not found" errors from pre-migration databases
529
- const msg = err instanceof Error ? err.message : "";
530
- if (!msg.includes("no such table")) {
580
+ // Only swallow "table not found" errors from pre-migration databases.
581
+ // Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
582
+ // ... does not exist") via the shared helper.
583
+ if (!isMissingTableError(err)) {
584
+ const msg = err instanceof Error ? err.message : String(err);
531
585
  console.warn("[emdash] Failed to hydrate bylines:", msg);
532
586
  }
533
587
  }
534
588
  }
535
589
 
590
+ /**
591
+ * Eagerly hydrate taxonomy term data onto entry.data for one or more entries.
592
+ *
593
+ * Attaches `terms` (Record keyed by taxonomy name with an array of TaxonomyTerm
594
+ * values) to each entry's data object. Uses a single batched JOIN query across
595
+ * all taxonomies so the cost is O(1) regardless of the number of entries or
596
+ * taxonomies on the site.
597
+ *
598
+ * This eliminates the common N+1 pattern where templates loop over list
599
+ * results and call getEntryTerms() per entry. With hydration, the list page
600
+ * stays at a single round-trip for term data.
601
+ *
602
+ * Fails silently if the taxonomy tables don't exist yet (pre-migration).
603
+ */
604
+ async function hydrateEntryTerms<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
605
+ if (entries.length === 0) return;
606
+
607
+ try {
608
+ const { getAllTermsForEntries } = await import("./taxonomies/index.js");
609
+
610
+ const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean);
611
+ if (ids.length === 0) return;
612
+
613
+ const termsMap = await getAllTermsForEntries(type, ids);
614
+
615
+ for (const entry of entries) {
616
+ const data = entryData(entry);
617
+ const dbId = dataStr(data, "id");
618
+ if (!dbId) continue;
619
+
620
+ data.terms = termsMap.get(dbId) ?? {};
621
+ }
622
+ } catch (err) {
623
+ // Only swallow "table not found" errors from pre-migration databases.
624
+ // Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
625
+ // ... does not exist") via the shared helper.
626
+ if (!isMissingTableError(err)) {
627
+ const msg = err instanceof Error ? err.message : String(err);
628
+ console.warn("[emdash] Failed to hydrate terms:", msg);
629
+ }
630
+ }
631
+ }
632
+
536
633
  /**
537
634
  * Translation summary for a single locale variant
538
635
  */
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-request query cache
3
+ *
4
+ * Deduplicates identical database queries within a single page render.
5
+ * Uses the ALS request context as a WeakMap key so the cache is
6
+ * automatically GC'd when the request completes.
7
+ *
8
+ * When no request context is available (e.g. local dev without D1
9
+ * replicas), queries bypass the cache — local SQLite is fast enough
10
+ * that deduplication doesn't matter.
11
+ *
12
+ * The WeakMap is stored on globalThis with a Symbol key to guarantee
13
+ * a singleton even when bundlers duplicate this module across chunks
14
+ * (same pattern as request-context.ts).
15
+ */
16
+
17
+ import type { EmDashRequestContext } from "./request-context.js";
18
+ import { getRequestContext } from "./request-context.js";
19
+
20
+ type CacheStore = WeakMap<EmDashRequestContext, Map<string, Promise<unknown>>>;
21
+
22
+ const STORE_KEY = Symbol.for("emdash:request-cache");
23
+ const g = globalThis as Record<symbol, unknown>;
24
+ const store: CacheStore =
25
+ (g[STORE_KEY] as CacheStore | undefined) ??
26
+ (() => {
27
+ const wm: CacheStore = new WeakMap();
28
+ g[STORE_KEY] = wm;
29
+ return wm;
30
+ })();
31
+
32
+ /**
33
+ * Return a cached result for `key` if one exists in the current
34
+ * request scope, otherwise call `fn`, cache its promise, and return it.
35
+ *
36
+ * Caches the *promise*, not the resolved value, so concurrent calls
37
+ * with the same key share a single in-flight query.
38
+ */
39
+ export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T> {
40
+ const ctx = getRequestContext();
41
+ if (!ctx) return fn();
42
+
43
+ let cache = store.get(ctx);
44
+ if (!cache) {
45
+ cache = new Map();
46
+ store.set(ctx, cache);
47
+ }
48
+
49
+ const existing = cache.get(key);
50
+ if (existing) return existing as Promise<T>;
51
+
52
+ const promise = Promise.resolve()
53
+ .then(fn)
54
+ .catch((error) => {
55
+ cache.delete(key);
56
+ throw error;
57
+ });
58
+ cache.set(key, promise);
59
+ return promise;
60
+ }
61
+
62
+ /**
63
+ * Look up an entry in the request-scoped cache without inserting one.
64
+ *
65
+ * Returns the in-flight or resolved promise if the key exists in the
66
+ * current request, otherwise `undefined`. Callers can use this to
67
+ * opportunistically satisfy a narrower query (e.g. `getSiteSetting("seo")`)
68
+ * from a broader one (`getSiteSettings()`) that's already been loaded
69
+ * by a parent template — avoiding a redundant round-trip.
70
+ *
71
+ * No-ops outside a request context.
72
+ */
73
+ export function peekRequestCache<T>(key: string): Promise<T> | undefined {
74
+ const ctx = getRequestContext();
75
+ if (!ctx) return undefined;
76
+ const cache = store.get(ctx);
77
+ return cache?.get(key) as Promise<T> | undefined;
78
+ }
79
+
80
+ /**
81
+ * Pre-populate the request-scoped cache with a resolved value.
82
+ *
83
+ * Internal helper shared between hydration paths (taxonomy terms,
84
+ * bylines, etc.) that already have the data in hand and want downstream
85
+ * callers using `requestCached(key, ...)` to skip the database entirely.
86
+ * Not exported from the package entrypoint — keep it internal until we
87
+ * have a documented plugin/extension surface for hydration.
88
+ *
89
+ * No-ops outside a request context (local dev without ALS).
90
+ *
91
+ * Does not overwrite an existing entry — if a query for this key is already
92
+ * in flight, its promise wins.
93
+ */
94
+ export function setRequestCacheEntry<T>(key: string, value: T): void {
95
+ const ctx = getRequestContext();
96
+ if (!ctx) return;
97
+
98
+ let cache = store.get(ctx);
99
+ if (!cache) {
100
+ cache = new Map();
101
+ store.set(ctx, cache);
102
+ }
103
+
104
+ if (cache.has(key)) return;
105
+ cache.set(key, Promise.resolve(value));
106
+ }
@@ -17,6 +17,8 @@
17
17
 
18
18
  import { AsyncLocalStorage } from "node:async_hooks";
19
19
 
20
+ import type { QueryRecorder } from "./database/instrumentation.js";
21
+
20
22
  export interface EmDashRequestContext {
21
23
  /** Whether the current request is in visual editing mode */
22
24
  editMode: boolean;
@@ -35,6 +37,23 @@ export interface EmDashRequestContext {
35
37
  * the singleton instance. Also used by the DO preview pattern.
36
38
  */
37
39
  db?: unknown;
40
+ /**
41
+ * Indicates the per-request `db` points at an isolated database
42
+ * instance whose schema may diverge from the configured one
43
+ * (playground, DO preview sessions). When true, schema-derived caches
44
+ * (manifest, taxonomy defs, etc.) must not be reused across requests.
45
+ *
46
+ * Plain D1 Sessions API routing does NOT set this — sessions are just
47
+ * a routing hint over the same schema, so the module-scoped manifest
48
+ * cache remains valid.
49
+ */
50
+ dbIsIsolated?: boolean;
51
+ /**
52
+ * Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.
53
+ * The Kysely `log` hook appends an event per query; middleware flushes
54
+ * to NDJSON after the response.
55
+ */
56
+ queryRecorder?: QueryRecorder;
38
57
  }
39
58
 
40
59
  const ALS_KEY = Symbol.for("emdash:request-context");
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
8
8
 
9
9
  import type { Database } from "../database/types.js";
10
10
  import { getDb } from "../loader.js";
11
+ import { requestCached } from "../request-cache.js";
11
12
  import { SchemaRegistry } from "./registry.js";
12
13
  import type { Collection } from "./types.js";
13
14
 
@@ -25,8 +26,10 @@ import type { Collection } from "./types.js";
25
26
  * ```
26
27
  */
27
28
  export async function getCollectionInfo(slug: string): Promise<Collection | null> {
28
- const db = await getDb();
29
- return getCollectionInfoWithDb(db, slug);
29
+ return requestCached(`collection-info:${slug}`, async () => {
30
+ const db = await getDb();
31
+ return getCollectionInfoWithDb(db, slug);
32
+ });
30
33
  }
31
34
 
32
35
  /**