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
@@ -6,19 +6,16 @@
6
6
  */
7
7
 
8
8
  import { defineMiddleware } from "astro:middleware";
9
- import { Kysely } from "kysely";
9
+ import type { Kysely } from "kysely";
10
10
  // Import from virtual modules (populated by integration at build time)
11
11
  // @ts-ignore - virtual module
12
12
  import virtualConfig from "virtual:emdash/config";
13
13
  // @ts-ignore - virtual module
14
14
  import {
15
15
  createDialect as virtualCreateDialect,
16
- isSessionEnabled as virtualIsSessionEnabled,
17
- getD1Binding as virtualGetD1Binding,
18
- getDefaultConstraint as virtualGetDefaultConstraint,
19
- getBookmarkCookieName as virtualGetBookmarkCookieName,
20
- createSessionDialect as virtualCreateSessionDialect,
16
+ createRequestScopedDb as virtualCreateRequestScopedDb,
21
17
  } from "virtual:emdash/dialect";
18
+ import type { RequestScopedDbOpts } from "virtual:emdash/dialect";
22
19
  // @ts-ignore - virtual module
23
20
  import { mediaProviders as virtualMediaProviders } from "virtual:emdash/media-providers";
24
21
  // @ts-ignore - virtual module
@@ -33,6 +30,11 @@ import { sandboxedPlugins as virtualSandboxedPlugins } from "virtual:emdash/sand
33
30
  // @ts-ignore - virtual module
34
31
  import { createStorage as virtualCreateStorage } from "virtual:emdash/storage";
35
32
 
33
+ import {
34
+ createRecorder,
35
+ flushRecorder,
36
+ isInstrumentationEnabled,
37
+ } from "../database/instrumentation.js";
36
38
  import {
37
39
  EmDashRuntime,
38
40
  type RuntimeDependencies,
@@ -43,7 +45,7 @@ import { setI18nConfig } from "../i18n/config.js";
43
45
  import type { Database, Storage } from "../index.js";
44
46
  import type { SandboxRunner } from "../plugins/sandbox/types.js";
45
47
  import type { ResolvedPlugin } from "../plugins/types.js";
46
- import { runWithContext } from "../request-context.js";
48
+ import { getRequestContext, runWithContext } from "../request-context.js";
47
49
  import type { EmDashConfig } from "./integration/runtime.js";
48
50
  import type { EmDashHandlers } from "./types.js";
49
51
 
@@ -128,9 +130,17 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies {
128
130
  }
129
131
 
130
132
  /**
131
- * Get or create the runtime instance
133
+ * Get or create the runtime instance.
134
+ *
135
+ * When `initTimings` is provided, any timing samples recorded during a
136
+ * genuine cold init are appended. Subsequent warm calls (hitting the
137
+ * cached instance) push nothing — callers should treat an empty array
138
+ * as "warm, nothing to report".
132
139
  */
133
- async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
140
+ async function getRuntime(
141
+ config: EmDashConfig,
142
+ initTimings?: Array<{ name: string; dur: number; desc?: string }>,
143
+ ): Promise<EmDashRuntime> {
134
144
  // Return cached instance if available
135
145
  if (runtimeInstance) {
136
146
  return runtimeInstance;
@@ -142,13 +152,13 @@ async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
142
152
  if (runtimeInitializing) {
143
153
  // Poll until the initializing request finishes
144
154
  await new Promise((resolve) => setTimeout(resolve, 50));
145
- return getRuntime(config);
155
+ return getRuntime(config, initTimings);
146
156
  }
147
157
 
148
158
  runtimeInitializing = true;
149
159
  try {
150
160
  const deps = buildDependencies(config);
151
- const runtime = await EmDashRuntime.create(deps);
161
+ const runtime = await EmDashRuntime.create(deps, initTimings);
152
162
  runtimeInstance = runtime;
153
163
  return runtime;
154
164
  } finally {
@@ -156,18 +166,44 @@ async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
156
166
  }
157
167
  }
158
168
 
169
+ /**
170
+ * Astro attaches AstroCookies to outgoing responses via a well-known global
171
+ * symbol. Cloning a Response (`new Response(body, init)`) drops non-header
172
+ * metadata, so any middleware that wraps the response must explicitly forward
173
+ * this symbol or `cookies.set()` calls will be silently dropped.
174
+ */
175
+ const ASTRO_COOKIES_SYMBOL = Symbol.for("astro.cookies");
176
+
159
177
  /**
160
178
  * Baseline security headers applied to all responses.
161
179
  * Admin routes get additional headers (strict CSP) from auth middleware.
162
180
  */
163
- function setBaselineSecurityHeaders(response: Response): Response {
181
+ function finalizeResponse(
182
+ response: Response,
183
+ serverTimings?: Array<{ name: string; dur: number; desc?: string }>,
184
+ ): Response {
164
185
  const res = new Response(response.body, response);
186
+ const astroCookies = Reflect.get(response, ASTRO_COOKIES_SYMBOL);
187
+ if (astroCookies !== undefined) {
188
+ Reflect.set(res, ASTRO_COOKIES_SYMBOL, astroCookies);
189
+ }
165
190
  res.headers.set("X-Content-Type-Options", "nosniff");
166
191
  res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
167
192
  res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
168
193
  if (!res.headers.has("Content-Security-Policy")) {
169
194
  res.headers.set("X-Frame-Options", "SAMEORIGIN");
170
195
  }
196
+ if (serverTimings && serverTimings.length > 0) {
197
+ res.headers.set(
198
+ "Server-Timing",
199
+ serverTimings
200
+ .map((t) => {
201
+ const dur = Math.round(t.dur);
202
+ return t.desc ? `${t.name};dur=${dur};desc="${t.desc}"` : `${t.name};dur=${dur}`;
203
+ })
204
+ .join(", "),
205
+ );
206
+ }
171
207
  return res;
172
208
  }
173
209
 
@@ -175,279 +211,320 @@ function setBaselineSecurityHeaders(response: Response): Response {
175
211
  const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
176
212
  const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
177
213
 
214
+ /**
215
+ * Ask the configured database adapter for a per-request scoped Kysely. The
216
+ * adapter encapsulates any per-request semantics (D1 sessions, read-replica
217
+ * routing, bookmark cookies, etc.); core just forwards the cookie jar and
218
+ * request flags and wraps next() in ALS if a scope was returned.
219
+ */
220
+ function createRequestScopedDb(
221
+ opts: RequestScopedDbOpts,
222
+ ): { db: Kysely<Database>; commit: () => void } | null {
223
+ if (typeof virtualCreateRequestScopedDb !== "function") return null;
224
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- adapter returns Kysely<unknown>; cast to Database since core owns that type
225
+ const fn = virtualCreateRequestScopedDb as (
226
+ o: RequestScopedDbOpts,
227
+ ) => { db: Kysely<Database>; commit: () => void } | null;
228
+ return fn(opts);
229
+ }
230
+
178
231
  export const onRequest = defineMiddleware(async (context, next) => {
179
232
  const { request, locals, cookies } = context;
180
233
  const url = context.url;
181
234
 
182
- // Process /_emdash routes and public routes with an active session
183
- // (logged-in editors need the runtime for toolbar/visual editing on public pages)
184
- const isEmDashRoute = url.pathname.startsWith("/_emdash");
185
- const isPublicRuntimeRoute =
186
- PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
187
-
188
- // Check for edit mode cookie - editors viewing public pages need the runtime
189
- // so auth middleware can verify their session for visual editing
190
- const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true";
191
- const hasPreviewToken = url.searchParams.has("_preview");
235
+ const queryRecorder = isInstrumentationEnabled()
236
+ ? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
237
+ : undefined;
238
+
239
+ const run = async (): Promise<Response> => {
240
+ // Process /_emdash routes and public routes with an active session
241
+ // (logged-in editors need the runtime for toolbar/visual editing on public pages)
242
+ const isEmDashRoute = url.pathname.startsWith("/_emdash");
243
+ const isPublicRuntimeRoute =
244
+ PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
245
+
246
+ // Check for edit mode cookie - editors viewing public pages need the runtime
247
+ // so auth middleware can verify their session for visual editing
248
+ const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true";
249
+ const hasPreviewToken = url.searchParams.has("_preview");
250
+
251
+ // Playground mode: the playground middleware stashes the per-session DO database
252
+ // on locals.__playgroundDb. When present, use runWithContext() to make it
253
+ // available to getDb() and the runtime's db getter via the correct ALS instance.
254
+ const playgroundDb = locals.__playgroundDb;
255
+
256
+ // Read the Astro session user once up-front. Both the anonymous fast path
257
+ // and the full doInit path need this, and the session store is network-backed
258
+ // (KV / Durable Object) so we want to avoid re-fetching on the hot path.
259
+ // Skipped entirely for prerendered requests — they have no session.
260
+ const sessionUser = context.isPrerendered ? null : await context.session?.get("user");
192
261
 
193
- // Playground mode: the playground middleware stashes the per-session DO database
194
- // on locals.__playgroundDb. When present, use runWithContext() to make it
195
- // available to getDb() and the runtime's db getter via the correct ALS instance.
196
- const playgroundDb = locals.__playgroundDb;
262
+ if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
263
+ if (!sessionUser && !playgroundDb) {
264
+ const timings: Array<{ name: string; dur: number; desc?: string }> = [];
265
+ const mwStart = performance.now();
266
+
267
+ // On a fresh deployment the database may be completely empty.
268
+ // Public pages call getSiteSettings() / getMenu() via getDb(), which
269
+ // bypasses runtime init and would crash with "no such table: options".
270
+ // Do a one-time lightweight probe using the same getDb() instance the
271
+ // page will use: if the migrations table doesn't exist, no migrations
272
+ // have ever run -- redirect to the setup wizard.
273
+ if (!setupVerified) {
274
+ const t0 = performance.now();
275
+ try {
276
+ const { getDb } = await import("../loader.js");
277
+ const db = await getDb();
278
+ await db
279
+ .selectFrom("_emdash_migrations" as keyof Database)
280
+ .selectAll()
281
+ .limit(1)
282
+ .execute();
283
+ setupVerified = true;
284
+ } catch {
285
+ // Table doesn't exist -> fresh database, redirect to setup
286
+ return context.redirect("/_emdash/admin/setup");
287
+ }
288
+ timings.push({ name: "setup", dur: performance.now() - t0, desc: "Setup probe" });
289
+ }
197
290
 
198
- if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
199
- const sessionUser = context.isPrerendered ? null : await context.session?.get("user");
200
- if (!sessionUser && !playgroundDb) {
201
- // On a fresh deployment the database may be completely empty.
202
- // Public pages call getSiteSettings() / getMenu() via getDb(), which
203
- // bypasses runtime init and would crash with "no such table: options".
204
- // Do a one-time lightweight probe using the same getDb() instance the
205
- // page will use: if the migrations table doesn't exist, no migrations
206
- // have ever run -- redirect to the setup wizard.
207
- if (!setupVerified) {
208
- try {
209
- const { getDb } = await import("../loader.js");
210
- const db = await getDb();
211
- await db
212
- .selectFrom("_emdash_migrations" as keyof Database)
213
- .selectAll()
214
- .limit(1)
215
- .execute();
216
- setupVerified = true;
217
- } catch {
218
- // Table doesn't exist -> fresh database, redirect to setup
219
- return context.redirect("/_emdash/admin/setup");
291
+ // Initialize the runtime for page:metadata and page:fragments hooks.
292
+ // The runtime is a cached singleton after the first request,
293
+ // getRuntime() is just a null-check. This enables SEO plugins to
294
+ // contribute meta tags for all visitors, not just logged-in editors.
295
+ const config = getConfig();
296
+ if (config) {
297
+ // Sub-phase timings are populated only on the cold init. Warm
298
+ // requests hit the cached runtime and leave this empty.
299
+ const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
300
+ const t0 = performance.now();
301
+ try {
302
+ const runtime = await getRuntime(config, initSubTimings);
303
+ setupVerified = true;
304
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
305
+ locals.emdash = {
306
+ collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
307
+ collectPageFragments: runtime.collectPageFragments.bind(runtime),
308
+ } as EmDashHandlers;
309
+ } catch {
310
+ // Non-fatal — EmDashHead will fall back to base SEO contributions
311
+ }
312
+ timings.push({ name: "rt", dur: performance.now() - t0, desc: "Runtime init" });
313
+ // Append cold-only sub-phase timings so the breakdown is visible
314
+ // in Server-Timing (rt.db, rt.fts, rt.plugins, rt.site,
315
+ // rt.sandbox, rt.market, rt.hooks, rt.cron).
316
+ for (const sub of initSubTimings) timings.push(sub);
220
317
  }
221
- }
222
318
 
223
- // Initialize the runtime for page:metadata and page:fragments hooks.
224
- // The runtime is a cached singleton after the first request,
225
- // getRuntime() is just a null-check. This enables SEO plugins to
226
- // contribute meta tags for all visitors, not just logged-in editors.
227
- const config = getConfig();
228
- if (config) {
229
- try {
230
- const runtime = await getRuntime(config);
231
- setupVerified = true;
232
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
233
- locals.emdash = {
234
- collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
235
- collectPageFragments: runtime.collectPageFragments.bind(runtime),
236
- } as EmDashHandlers;
237
- } catch {
238
- // Non-fatal — EmDashHead will fall back to base SEO contributions
319
+ // Even on the anonymous fast path we ask the adapter for a per-request
320
+ // scoped db. For D1 with read replication this routes anonymous reads
321
+ // to the nearest replica; for other adapters it's a no-op.
322
+ const anonScoped = createRequestScopedDb({
323
+ config: config?.database?.config,
324
+ isAuthenticated: false,
325
+ isWrite: request.method !== "GET" && request.method !== "HEAD",
326
+ cookies,
327
+ url,
328
+ });
329
+ const runAnon = async () => {
330
+ const t0 = performance.now();
331
+ const response = await next();
332
+ timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
333
+ timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
334
+ return finalizeResponse(response, timings);
335
+ };
336
+ if (anonScoped) {
337
+ const parent = getRequestContext();
338
+ const ctx = parent
339
+ ? { ...parent, db: anonScoped.db }
340
+ : { editMode: false, db: anonScoped.db };
341
+ return runWithContext(ctx, async () => {
342
+ const response = await runAnon();
343
+ anonScoped.commit();
344
+ return response;
345
+ });
239
346
  }
347
+ return runAnon();
240
348
  }
241
-
242
- const response = await next();
243
- return setBaselineSecurityHeaders(response);
244
349
  }
245
- }
246
-
247
- const config = getConfig();
248
- if (!config) {
249
- console.error("EmDash: No configuration found");
250
- return next();
251
- }
252
350
 
253
- // In playground mode, wrap the entire runtime init + request handling in
254
- // runWithContext so that getDatabase() and all init queries use the real
255
- // DO database via the same AsyncLocalStorage instance as the loader.
256
- const doInit = async () => {
257
- try {
258
- // Get or create runtime
259
- const runtime = await getRuntime(config);
260
-
261
- // Runtime init runs migrations, so the DB is guaranteed set up
262
- setupVerified = true;
263
-
264
- // Get manifest (cached after first call)
265
- const manifest = await runtime.getManifest();
266
-
267
- // Attach to locals for route handlers
268
- locals.emdashManifest = manifest;
269
- locals.emdash = {
270
- // Content handlers
271
- handleContentList: runtime.handleContentList.bind(runtime),
272
- handleContentGet: runtime.handleContentGet.bind(runtime),
273
- handleContentCreate: runtime.handleContentCreate.bind(runtime),
274
- handleContentUpdate: runtime.handleContentUpdate.bind(runtime),
275
- handleContentDelete: runtime.handleContentDelete.bind(runtime),
276
-
277
- // Trash handlers
278
- handleContentListTrashed: runtime.handleContentListTrashed.bind(runtime),
279
- handleContentRestore: runtime.handleContentRestore.bind(runtime),
280
- handleContentPermanentDelete: runtime.handleContentPermanentDelete.bind(runtime),
281
- handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime),
282
- handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime),
283
-
284
- // Duplicate handler
285
- handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime),
286
-
287
- // Publishing & Scheduling handlers
288
- handleContentPublish: runtime.handleContentPublish.bind(runtime),
289
- handleContentUnpublish: runtime.handleContentUnpublish.bind(runtime),
290
- handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
291
- handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
292
- handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
293
- handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
294
- handleContentCompare: runtime.handleContentCompare.bind(runtime),
295
- handleContentTranslations: runtime.handleContentTranslations.bind(runtime),
296
-
297
- // Media handlers
298
- handleMediaList: runtime.handleMediaList.bind(runtime),
299
- handleMediaGet: runtime.handleMediaGet.bind(runtime),
300
- handleMediaCreate: runtime.handleMediaCreate.bind(runtime),
301
- handleMediaUpdate: runtime.handleMediaUpdate.bind(runtime),
302
- handleMediaDelete: runtime.handleMediaDelete.bind(runtime),
303
-
304
- // Revision handlers
305
- handleRevisionList: runtime.handleRevisionList.bind(runtime),
306
- handleRevisionGet: runtime.handleRevisionGet.bind(runtime),
307
- handleRevisionRestore: runtime.handleRevisionRestore.bind(runtime),
308
-
309
- // Plugin routes
310
- handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime),
311
- getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime),
312
-
313
- // Media provider methods
314
- getMediaProvider: runtime.getMediaProvider.bind(runtime),
315
- getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
316
-
317
- // Page contribution methods (for EmDashHead/EmDashBodyStart/EmDashBodyEnd)
318
- collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
319
- collectPageFragments: runtime.collectPageFragments.bind(runtime),
320
-
321
- // Direct access (for advanced use cases)
322
- storage: runtime.storage,
323
- db: runtime.db,
324
- hooks: runtime.hooks,
325
- email: runtime.email,
326
- configuredPlugins: runtime.configuredPlugins,
327
-
328
- // Configuration (for checking database type, auth mode, etc.)
329
- config,
330
-
331
- // Manifest invalidation (call after schema changes)
332
- invalidateManifest: runtime.invalidateManifest.bind(runtime),
333
-
334
- // Sandbox runner (for marketplace plugin install/update)
335
- getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
336
-
337
- // Sync marketplace plugin states (after install/update/uninstall)
338
- syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime),
339
-
340
- // Update plugin enabled/disabled status and rebuild hook pipeline
341
- setPluginStatus: runtime.setPluginStatus.bind(runtime),
342
- };
343
- } catch (error) {
344
- console.error("EmDash middleware error:", error);
351
+ const config = getConfig();
352
+ if (!config) {
353
+ console.error("EmDash: No configuration found");
354
+ return finalizeResponse(await next());
345
355
  }
346
356
 
347
- // =========================================================================
348
- // D1 Read Replica Session Management
349
- //
350
- // When D1 sessions are enabled, we create a per-request D1 session and
351
- // Kysely instance. The session is wrapped in ALS so `runtime.db` (a getter)
352
- // picks up the per-request instance instead of the singleton.
353
- //
354
- // After the response, we extract the bookmark from the session and set
355
- // it as a cookie for authenticated users (read-your-writes consistency).
356
- // =========================================================================
357
- const dbConfig = config?.database?.config;
358
- const sessionEnabled =
359
- dbConfig &&
360
- typeof virtualIsSessionEnabled === "function" &&
361
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module functions are untyped
362
- (virtualIsSessionEnabled as (config: unknown) => boolean)(dbConfig);
363
-
364
- if (
365
- sessionEnabled &&
366
- typeof virtualGetD1Binding === "function" &&
367
- virtualCreateSessionDialect
368
- ) {
369
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module functions are untyped
370
- const d1Binding = (virtualGetD1Binding as (config: unknown) => unknown)(dbConfig);
371
-
372
- if (d1Binding && typeof d1Binding === "object" && "withSession" in d1Binding) {
373
- const isAuthenticated = context.isPrerendered
374
- ? false
375
- : !!(await context.session?.get("user"));
376
- const isWrite = request.method !== "GET" && request.method !== "HEAD";
377
-
378
- // Determine session constraint:
379
- // - Config says "primary-first" → always "first-primary"
380
- // - Authenticated writes → "first-primary" (need to hit primary)
381
- // - Authenticated reads with bookmark → resume from bookmark
382
- // - Otherwise → "first-unconstrained" (nearest replica)
383
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module functions are untyped
384
- const configConstraint = (virtualGetDefaultConstraint as (config: unknown) => string)(
385
- dbConfig,
386
- );
387
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module functions are untyped
388
- const cookieName = (virtualGetBookmarkCookieName as (config: unknown) => string)(dbConfig);
389
-
390
- let constraint: string = configConstraint;
391
- if (isAuthenticated && isWrite) {
392
- constraint = "first-primary";
393
- } else if (isAuthenticated) {
394
- const bookmarkCookie = context.cookies.get(cookieName);
395
- if (bookmarkCookie?.value) {
396
- constraint = bookmarkCookie.value;
397
- }
398
- }
357
+ // In playground mode, wrap the entire runtime init + request handling in
358
+ // runWithContext so that getDatabase() and all init queries use the real
359
+ // DO database via the same AsyncLocalStorage instance as the loader.
360
+ const doInit = async () => {
361
+ const timings: Array<{ name: string; dur: number; desc?: string }> = [];
362
+ const mwStart = performance.now();
363
+
364
+ try {
365
+ // Get or create runtime. Sub-phase timings (rt.db, rt.fts, rt.plugins,
366
+ // rt.site, rt.sandbox, rt.market, rt.hooks, rt.cron) are populated
367
+ // only on the cold init — subsequent warm calls find the cached
368
+ // instance and `initSubTimings` stays empty.
369
+ const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
370
+ let t0 = performance.now();
371
+ const runtime = await getRuntime(config, initSubTimings);
372
+ timings.push({ name: "rt", dur: performance.now() - t0, desc: "Runtime init" });
373
+ // Forward any sub-phase samples so cold-start breakdown is visible
374
+ // in Server-Timing. Each phase appears prefixed "rt." to distinguish
375
+ // from the aggregate "rt" timing above.
376
+ for (const sub of initSubTimings) timings.push(sub);
377
+
378
+ // Runtime init runs migrations, so the DB is guaranteed set up
379
+ setupVerified = true;
380
+
381
+ // Get manifest (cached after first call)
382
+ t0 = performance.now();
383
+ const manifest = await runtime.getManifest();
384
+ timings.push({ name: "manifest", dur: performance.now() - t0, desc: "Manifest" });
385
+
386
+ // Attach to locals for route handlers
387
+ locals.emdashManifest = manifest;
388
+ locals.emdash = {
389
+ // Content handlers
390
+ handleContentList: runtime.handleContentList.bind(runtime),
391
+ handleContentGet: runtime.handleContentGet.bind(runtime),
392
+ handleContentCreate: runtime.handleContentCreate.bind(runtime),
393
+ handleContentUpdate: runtime.handleContentUpdate.bind(runtime),
394
+ handleContentDelete: runtime.handleContentDelete.bind(runtime),
395
+
396
+ // Trash handlers
397
+ handleContentListTrashed: runtime.handleContentListTrashed.bind(runtime),
398
+ handleContentRestore: runtime.handleContentRestore.bind(runtime),
399
+ handleContentPermanentDelete: runtime.handleContentPermanentDelete.bind(runtime),
400
+ handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime),
401
+ handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime),
402
+
403
+ // Duplicate handler
404
+ handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime),
405
+
406
+ // Publishing & Scheduling handlers
407
+ handleContentPublish: runtime.handleContentPublish.bind(runtime),
408
+ handleContentUnpublish: runtime.handleContentUnpublish.bind(runtime),
409
+ handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
410
+ handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
411
+ handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
412
+ handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
413
+ handleContentCompare: runtime.handleContentCompare.bind(runtime),
414
+ handleContentTranslations: runtime.handleContentTranslations.bind(runtime),
415
+
416
+ // Media handlers
417
+ handleMediaList: runtime.handleMediaList.bind(runtime),
418
+ handleMediaGet: runtime.handleMediaGet.bind(runtime),
419
+ handleMediaCreate: runtime.handleMediaCreate.bind(runtime),
420
+ handleMediaUpdate: runtime.handleMediaUpdate.bind(runtime),
421
+ handleMediaDelete: runtime.handleMediaDelete.bind(runtime),
422
+
423
+ // Revision handlers
424
+ handleRevisionList: runtime.handleRevisionList.bind(runtime),
425
+ handleRevisionGet: runtime.handleRevisionGet.bind(runtime),
426
+ handleRevisionRestore: runtime.handleRevisionRestore.bind(runtime),
427
+
428
+ // Plugin routes
429
+ handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime),
430
+ getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime),
431
+
432
+ // Media provider methods
433
+ getMediaProvider: runtime.getMediaProvider.bind(runtime),
434
+ getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
435
+
436
+ // Page contribution methods (for EmDashHead/EmDashBodyStart/EmDashBodyEnd)
437
+ collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
438
+ collectPageFragments: runtime.collectPageFragments.bind(runtime),
439
+
440
+ // Lazy search index health check — search endpoints call this
441
+ // before querying so a crash-corrupted index gets repaired on
442
+ // first use rather than stalling every cold start.
443
+ ensureSearchHealthy: runtime.ensureSearchHealthy.bind(runtime),
444
+
445
+ // Direct access (for advanced use cases)
446
+ storage: runtime.storage,
447
+ db: runtime.db,
448
+ hooks: runtime.hooks,
449
+ email: runtime.email,
450
+ configuredPlugins: runtime.configuredPlugins,
451
+
452
+ // Configuration (for checking database type, auth mode, etc.)
453
+ config,
454
+
455
+ // Manifest invalidation (call after schema changes)
456
+ invalidateManifest: runtime.invalidateManifest.bind(runtime),
457
+
458
+ // Sandbox runner (for marketplace plugin install/update)
459
+ getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
460
+
461
+ // Sync marketplace plugin states (after install/update/uninstall)
462
+ syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime),
463
+
464
+ // Update plugin enabled/disabled status and rebuild hook pipeline
465
+ setPluginStatus: runtime.setPluginStatus.bind(runtime),
466
+ };
467
+ } catch (error) {
468
+ console.error("EmDash middleware error:", error);
469
+ }
399
470
 
400
- // Create the D1 session and per-request Kysely instance.
401
- // D1DatabaseSession has the same prepare()/batch() interface as D1Database,
402
- // so createSessionDialect passes it straight to D1Dialect.
403
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1 binding with Sessions API, checked via "withSession" in d1Binding above
404
- const withSession = (d1Binding as { withSession: (c: string) => unknown }).withSession;
405
- const session = withSession.call(d1Binding, constraint);
406
- const sessionDialect =
407
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module functions are untyped
408
- (virtualCreateSessionDialect as (db: unknown) => import("kysely").Dialect)(session);
409
- const sessionDb = new Kysely<Database>({ dialect: sessionDialect });
410
-
411
- // Wrap the request in ALS with the per-request db
412
- return runWithContext({ editMode: false, db: sessionDb }, async () => {
413
- const response = setBaselineSecurityHeaders(await next());
414
-
415
- // Set bookmark cookie for authenticated users only they need
416
- // read-your-writes consistency across requests. Anonymous visitors
417
- // don't write, so they get "first-unconstrained" every time.
418
- if (
419
- isAuthenticated &&
420
- session &&
421
- typeof session === "object" &&
422
- "getBookmark" in session
423
- ) {
424
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1DatabaseSession with getBookmark()
425
- const getBookmark = (session as { getBookmark: () => string | null }).getBookmark;
426
- const newBookmark = getBookmark.call(session);
427
- if (newBookmark) {
428
- response.headers.append(
429
- "Set-Cookie",
430
- `${cookieName}=${newBookmark}; Path=/; HttpOnly; SameSite=Lax; Secure`,
431
- );
432
- }
433
- }
471
+ // Ask the adapter for a request-scoped db. When it returns one, we stash
472
+ // it in ALS so the runtime's db getter and loader's getDb() pick it up,
473
+ // then call commit() after next() so the adapter can persist any
474
+ // per-request state (e.g. a D1 bookmark cookie for read-your-writes).
475
+ const scoped = createRequestScopedDb({
476
+ config: config?.database?.config,
477
+ isAuthenticated: !!sessionUser,
478
+ isWrite: request.method !== "GET" && request.method !== "HEAD",
479
+ cookies: context.cookies,
480
+ url,
481
+ });
482
+
483
+ const renderAndFinalize = async () => {
484
+ const t0 = performance.now();
485
+ const response = await next();
486
+ timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
487
+ timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
488
+ return finalizeResponse(response, timings);
489
+ };
434
490
 
491
+ if (scoped) {
492
+ const parent = getRequestContext();
493
+ const ctx = parent ? { ...parent, db: scoped.db } : { editMode: false, db: scoped.db };
494
+ return runWithContext(ctx, async () => {
495
+ const response = await renderAndFinalize();
496
+ scoped.commit();
435
497
  return response;
436
498
  });
437
499
  }
438
- }
439
500
 
440
- const response = await next();
441
- return setBaselineSecurityHeaders(response);
442
- }; // end doInit
501
+ return renderAndFinalize();
502
+ }; // end doInit
503
+
504
+ if (playgroundDb) {
505
+ // Read the edit-mode cookie to determine if visual editing is active.
506
+ // Default to false -- editing is opt-in via the playground toolbar toggle.
507
+ const editMode = context.cookies.get("emdash-edit-mode")?.value === "true";
508
+ // Playground DBs are per-session isolated instances whose schema is
509
+ // independent of the configured one — flag as isolated so schema-
510
+ // derived caches (manifest, taxonomy defs) rebuild against it.
511
+ const parent = getRequestContext();
512
+ const ctx = parent
513
+ ? { ...parent, editMode, db: playgroundDb, dbIsIsolated: true }
514
+ : { editMode, db: playgroundDb, dbIsIsolated: true };
515
+ return runWithContext(ctx, doInit);
516
+ }
517
+ return doInit();
518
+ };
443
519
 
444
- if (playgroundDb) {
445
- // Read the edit-mode cookie to determine if visual editing is active.
446
- // Default to false -- editing is opt-in via the playground toolbar toggle.
447
- const editMode = context.cookies.get("emdash-edit-mode")?.value === "true";
448
- return runWithContext({ editMode, db: playgroundDb }, doInit);
520
+ if (queryRecorder) {
521
+ try {
522
+ return await runWithContext({ editMode: false, queryRecorder }, run);
523
+ } finally {
524
+ flushRecorder(queryRecorder);
525
+ }
449
526
  }
450
- return doInit();
527
+ return run();
451
528
  });
452
529
 
453
530
  export default onRequest;