emdash 0.3.0 → 0.4.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 (162) hide show
  1. package/dist/{adapters-BLMa4JGD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
  2. package/dist/{adapters-BLMa4JGD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
  3. package/dist/{apply-Bqoekfbe.mjs → apply-Cma_PiF6.mjs} +37 -22
  4. package/dist/apply-Cma_PiF6.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +9 -8
  7. package/dist/astro/index.mjs.map +1 -1
  8. package/dist/astro/middleware/auth.d.mts +5 -5
  9. package/dist/astro/middleware/auth.mjs +2 -2
  10. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  11. package/dist/astro/middleware/redirect.mjs +19 -7
  12. package/dist/astro/middleware/redirect.mjs.map +1 -1
  13. package/dist/astro/middleware/request-context.mjs +12 -2
  14. package/dist/astro/middleware/request-context.mjs.map +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +45 -42
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +8 -8
  20. package/dist/{byline-BGj9p9Ht.mjs → byline-WuOq9MFJ.mjs} +3 -2
  21. package/dist/byline-WuOq9MFJ.mjs.map +1 -0
  22. package/dist/{bylines-BihaoIDY.mjs → bylines-C_Wsnz4L.mjs} +36 -4
  23. package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
  24. package/dist/cache-E3Dts-yT.mjs +56 -0
  25. package/dist/cache-E3Dts-yT.mjs.map +1 -0
  26. package/dist/cli/index.mjs +11 -11
  27. package/dist/cli/index.mjs.map +1 -1
  28. package/dist/client/cf-access.d.mts +1 -1
  29. package/dist/client/index.d.mts +1 -1
  30. package/dist/client/index.mjs +1 -1
  31. package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
  32. package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
  33. package/dist/db/index.d.mts +3 -3
  34. package/dist/db/index.mjs +1 -1
  35. package/dist/db/libsql.d.mts +1 -1
  36. package/dist/db/postgres.d.mts +1 -1
  37. package/dist/db/sqlite.d.mts +1 -1
  38. package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
  39. package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
  40. package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
  41. package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
  42. package/dist/{index-Cff7AimE.d.mts → index-CRg3PWfZ.d.mts} +32 -30
  43. package/dist/index-CRg3PWfZ.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +17 -17
  46. package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
  47. package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
  48. package/dist/{loader-BmYdf3Dr.mjs → loader-BYzwzORf.mjs} +1 -1
  49. package/dist/{loader-BmYdf3Dr.mjs.map → loader-BYzwzORf.mjs.map} +1 -1
  50. package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
  51. package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/index.mjs +1 -1
  54. package/dist/media/local-runtime.d.mts +7 -7
  55. package/dist/{mode-C2EzN1uE.mjs → mode-CyPLdO3C.mjs} +1 -1
  56. package/dist/{mode-C2EzN1uE.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
  57. package/dist/page/index.d.mts +1 -1
  58. package/dist/patterns-CrCYkMBb.mjs +93 -0
  59. package/dist/patterns-CrCYkMBb.mjs.map +1 -0
  60. package/dist/{placeholder-SvFCKbz_.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
  61. package/dist/{placeholder-SvFCKbz_.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
  62. package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
  63. package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
  64. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  65. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  66. package/dist/{query-sesiOndV.mjs → query-B6Vu0d2i.mjs} +34 -15
  67. package/dist/{query-sesiOndV.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
  68. package/dist/{redirect-DUAk-Yl_.mjs → redirect-7lGhLBNZ.mjs} +2 -92
  69. package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
  70. package/dist/{registry-DU18yVo0.mjs → registry-BgnP3ysR.mjs} +19 -35
  71. package/dist/registry-BgnP3ysR.mjs.map +1 -0
  72. package/dist/{runner-Biufrii2.mjs → runner-Cd-_WyDo.mjs} +16 -4
  73. package/dist/runner-Cd-_WyDo.mjs.map +1 -0
  74. package/dist/{runner-EAtf0ZIe.d.mts → runner-DYv3rX8P.d.mts} +10 -3
  75. package/dist/runner-DYv3rX8P.d.mts.map +1 -0
  76. package/dist/runtime.d.mts +6 -6
  77. package/dist/runtime.mjs +1 -1
  78. package/dist/{search-BXB-jfu2.mjs → search-B5p9D36n.mjs} +102 -53
  79. package/dist/search-B5p9D36n.mjs.map +1 -0
  80. package/dist/seed/index.d.mts +2 -2
  81. package/dist/seed/index.mjs +8 -8
  82. package/dist/seo/index.d.mts +1 -1
  83. package/dist/storage/local.d.mts +1 -1
  84. package/dist/storage/local.mjs +1 -1
  85. package/dist/storage/s3.d.mts +1 -1
  86. package/dist/storage/s3.mjs +1 -1
  87. package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
  88. package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
  89. package/dist/transaction-Cn2rjY78.mjs +28 -0
  90. package/dist/transaction-Cn2rjY78.mjs.map +1 -0
  91. package/dist/{transport-yxiQsi8I.mjs → transport-BtcQ-Z7T.mjs} +1 -1
  92. package/dist/{transport-yxiQsi8I.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
  93. package/dist/{transport-BFGblqwG.d.mts → transport-CKQA_G44.d.mts} +1 -1
  94. package/dist/{transport-BFGblqwG.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
  95. package/dist/{types-DRjfYOEv.d.mts → types-B6BzlZxx.d.mts} +1 -1
  96. package/dist/{types-DRjfYOEv.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
  97. package/dist/{types-CaKte3hR.d.mts → types-BYWYxLcp.d.mts} +10 -4
  98. package/dist/types-BYWYxLcp.d.mts.map +1 -0
  99. package/dist/{types-BbsYgi_R.d.mts → types-BmkQR1En.d.mts} +1 -1
  100. package/dist/{types-BbsYgi_R.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
  101. package/dist/{types-C1-PVaS_.d.mts → types-DNZpaCBk.d.mts} +1 -1
  102. package/dist/{types-C1-PVaS_.d.mts.map → types-DNZpaCBk.d.mts.map} +1 -1
  103. package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
  104. package/dist/{types-Bec-r_3_.mjs.map → types-Dz9_WMS6.mjs.map} +1 -1
  105. package/dist/{types-DPfzHnjW.d.mts → types-gLYVCXCQ.d.mts} +1 -1
  106. package/dist/{types-DPfzHnjW.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
  107. package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
  108. package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
  109. package/dist/{validate-bfg9OR6N.d.mts → validate-CcNRWH6I.d.mts} +4 -4
  110. package/dist/{validate-bfg9OR6N.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
  111. package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
  112. package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
  113. package/dist/version-DlTDRdpv.mjs +7 -0
  114. package/dist/{version-REAapfsU.mjs.map → version-DlTDRdpv.mjs.map} +1 -1
  115. package/package.json +7 -5
  116. package/src/api/handlers/content.ts +36 -25
  117. package/src/api/handlers/menus.ts +19 -16
  118. package/src/astro/integration/index.ts +2 -3
  119. package/src/astro/integration/runtime.ts +8 -14
  120. package/src/astro/integration/vite-config.ts +7 -0
  121. package/src/astro/middleware/redirect.ts +30 -15
  122. package/src/astro/middleware.ts +11 -19
  123. package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
  124. package/src/astro/routes/api/admin/bylines/index.ts +2 -0
  125. package/src/astro/routes/api/redirects/[id].ts +3 -0
  126. package/src/astro/routes/api/redirects/index.ts +2 -0
  127. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
  128. package/src/astro/routes/api/schema/collections/index.ts +1 -0
  129. package/src/bylines/index.ts +48 -0
  130. package/src/cleanup.ts +3 -3
  131. package/src/cli/commands/bundle-utils.ts +5 -5
  132. package/src/database/migrations/011_sections.ts +2 -2
  133. package/src/database/migrations/runner.ts +23 -2
  134. package/src/database/repositories/byline.ts +2 -1
  135. package/src/emdash-runtime.ts +18 -8
  136. package/src/index.ts +2 -0
  137. package/src/mcp/server.ts +40 -67
  138. package/src/plugins/context.ts +28 -4
  139. package/src/plugins/cron.ts +29 -4
  140. package/src/plugins/hooks.ts +22 -10
  141. package/src/plugins/index.ts +1 -0
  142. package/src/plugins/manager.ts +6 -2
  143. package/src/plugins/marketplace.ts +33 -3
  144. package/src/plugins/routes.ts +3 -3
  145. package/src/plugins/types.ts +7 -0
  146. package/src/query.ts +37 -14
  147. package/src/redirects/cache.ts +68 -0
  148. package/src/search/fts-manager.ts +20 -11
  149. package/src/search/query.ts +8 -9
  150. package/src/seed/apply.ts +49 -28
  151. package/src/visual-editing/toolbar.ts +11 -1
  152. package/dist/apply-Bqoekfbe.mjs.map +0 -1
  153. package/dist/byline-BGj9p9Ht.mjs.map +0 -1
  154. package/dist/bylines-BihaoIDY.mjs.map +0 -1
  155. package/dist/index-Cff7AimE.d.mts.map +0 -1
  156. package/dist/redirect-DUAk-Yl_.mjs.map +0 -1
  157. package/dist/registry-DU18yVo0.mjs.map +0 -1
  158. package/dist/runner-Biufrii2.mjs.map +0 -1
  159. package/dist/runner-EAtf0ZIe.d.mts.map +0 -1
  160. package/dist/search-BXB-jfu2.mjs.map +0 -1
  161. package/dist/types-CaKte3hR.d.mts.map +0 -1
  162. package/dist/version-REAapfsU.mjs +0 -7
@@ -160,20 +160,15 @@ async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
160
160
  * Baseline security headers applied to all responses.
161
161
  * Admin routes get additional headers (strict CSP) from auth middleware.
162
162
  */
163
- function setBaselineSecurityHeaders(response: Response): void {
164
- // Prevent MIME type sniffing
165
- response.headers.set("X-Content-Type-Options", "nosniff");
166
- // Control referrer information
167
- response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
168
- // Restrict access to sensitive browser APIs
169
- response.headers.set(
170
- "Permissions-Policy",
171
- "camera=(), microphone=(), geolocation=(), payment=()",
172
- );
173
- // Prevent clickjacking (non-admin routes; admin CSP uses frame-ancestors)
174
- if (!response.headers.has("Content-Security-Policy")) {
175
- response.headers.set("X-Frame-Options", "SAMEORIGIN");
163
+ function setBaselineSecurityHeaders(response: Response): Response {
164
+ const res = new Response(response.body, response);
165
+ res.headers.set("X-Content-Type-Options", "nosniff");
166
+ res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
167
+ res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
168
+ if (!res.headers.has("Content-Security-Policy")) {
169
+ res.headers.set("X-Frame-Options", "SAMEORIGIN");
176
170
  }
171
+ return res;
177
172
  }
178
173
 
179
174
  /** Public routes that require the runtime (sitemap, robots.txt, etc.) */
@@ -245,8 +240,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
245
240
  }
246
241
 
247
242
  const response = await next();
248
- setBaselineSecurityHeaders(response);
249
- return response;
243
+ return setBaselineSecurityHeaders(response);
250
244
  }
251
245
  }
252
246
 
@@ -416,8 +410,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
416
410
 
417
411
  // Wrap the request in ALS with the per-request db
418
412
  return runWithContext({ editMode: false, db: sessionDb }, async () => {
419
- const response = await next();
420
- setBaselineSecurityHeaders(response);
413
+ const response = setBaselineSecurityHeaders(await next());
421
414
 
422
415
  // Set bookmark cookie for authenticated users only — they need
423
416
  // read-your-writes consistency across requests. Anonymous visitors
@@ -445,8 +438,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
445
438
  }
446
439
 
447
440
  const response = await next();
448
- setBaselineSecurityHeaders(response);
449
- return response;
441
+ return setBaselineSecurityHeaders(response);
450
442
  }; // end doInit
451
443
 
452
444
  if (playgroundDb) {
@@ -5,6 +5,7 @@ import { requirePerm } from "#api/authorize.js";
5
5
  import { apiError, apiSuccess, handleError } from "#api/error.js";
6
6
  import { isParseError, parseBody } from "#api/parse.js";
7
7
  import { bylineUpdateBody } from "#api/schemas.js";
8
+ import { invalidateBylineCache } from "#bylines/index.js";
8
9
  import { BylineRepository } from "#db/repositories/byline.js";
9
10
 
10
11
  export const prerender = false;
@@ -61,6 +62,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
61
62
  });
62
63
 
63
64
  if (!byline) return apiError("NOT_FOUND", "Byline not found", 404);
65
+ invalidateBylineCache();
64
66
  return apiSuccess(byline);
65
67
  } catch (error) {
66
68
  return handleError(error, "Failed to update byline", "BYLINE_UPDATE_ERROR");
@@ -80,6 +82,7 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
80
82
  const repo = new BylineRepository(emdash.db);
81
83
  const deleted = await repo.delete(params.id!);
82
84
  if (!deleted) return apiError("NOT_FOUND", "Byline not found", 404);
85
+ invalidateBylineCache();
83
86
  return apiSuccess({ deleted: true });
84
87
  } catch (error) {
85
88
  return handleError(error, "Failed to delete byline", "BYLINE_DELETE_ERROR");
@@ -5,6 +5,7 @@ import { requirePerm } from "#api/authorize.js";
5
5
  import { apiError, apiSuccess, handleError } from "#api/error.js";
6
6
  import { isParseError, parseBody, parseQuery } from "#api/parse.js";
7
7
  import { bylineCreateBody, bylinesListQuery } from "#api/schemas.js";
8
+ import { invalidateBylineCache } from "#bylines/index.js";
8
9
  import { BylineRepository } from "#db/repositories/byline.js";
9
10
 
10
11
  export const prerender = false;
@@ -65,6 +66,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
65
66
  isGuest: body.isGuest,
66
67
  });
67
68
 
69
+ invalidateBylineCache();
68
70
  return apiSuccess(byline, 201);
69
71
  } catch (error) {
70
72
  return handleError(error, "Failed to create byline", "BYLINE_CREATE_ERROR");
@@ -17,6 +17,7 @@ import {
17
17
  } from "#api/handlers/redirects.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { updateRedirectBody } from "#api/schemas.js";
20
+ import { invalidateRedirectCache } from "#redirects/cache.js";
20
21
 
21
22
  export const prerender = false;
22
23
 
@@ -57,6 +58,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
57
58
  if (isParseError(body)) return body;
58
59
 
59
60
  const result = await handleRedirectUpdate(db, id, body);
61
+ invalidateRedirectCache();
60
62
  return unwrapResult(result);
61
63
  } catch (error) {
62
64
  return handleError(error, "Failed to update redirect", "REDIRECT_UPDATE_ERROR");
@@ -77,6 +79,7 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
77
79
 
78
80
  try {
79
81
  const result = await handleRedirectDelete(db, id);
82
+ invalidateRedirectCache();
80
83
  return unwrapResult(result);
81
84
  } catch (error) {
82
85
  return handleError(error, "Failed to delete redirect", "REDIRECT_DELETE_ERROR");
@@ -12,6 +12,7 @@ import { handleError, unwrapResult } from "#api/error.js";
12
12
  import { handleRedirectCreate, handleRedirectList } from "#api/handlers/redirects.js";
13
13
  import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
14
  import { createRedirectBody, redirectsListQuery } from "#api/schemas.js";
15
+ import { invalidateRedirectCache } from "#redirects/cache.js";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -45,6 +46,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
45
46
  if (isParseError(body)) return body;
46
47
 
47
48
  const result = await handleRedirectCreate(db, body);
49
+ invalidateRedirectCache();
48
50
  return unwrapResult(result, 201);
49
51
  } catch (error) {
50
52
  return handleError(error, "Failed to create redirect", "REDIRECT_CREATE_ERROR");
@@ -59,6 +59,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
59
59
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- parseBody validates via Zod
60
60
  body as UpdateCollectionInput,
61
61
  );
62
+ emdash!.invalidateManifest();
62
63
  return unwrapResult(result);
63
64
  };
64
65
 
@@ -76,5 +77,6 @@ export const DELETE: APIRoute = async ({ params, url, locals }) => {
76
77
  const result = await handleSchemaCollectionDelete(emdash!.db, slug, {
77
78
  force,
78
79
  });
80
+ emdash!.invalidateManifest();
79
81
  return unwrapResult(result);
80
82
  };
@@ -43,5 +43,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
43
43
 
44
44
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Zod schema output narrowed to CreateCollectionInput
45
45
  const result = await handleSchemaCollectionCreate(emdash!.db, body as CreateCollectionInput);
46
+ emdash!.invalidateManifest();
46
47
  return unwrapResult(result, 201);
47
48
  };
@@ -14,6 +14,48 @@ import { validateIdentifier } from "../database/validate.js";
14
14
  import { getDb } from "../loader.js";
15
15
  import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
16
16
 
17
+ /**
18
+ * Cached result of "does any byline exist in the database?"
19
+ * null = not yet checked, true/false = cached result.
20
+ * Invalidated when bylines are created or deleted.
21
+ */
22
+ let hasBylines: boolean | null = null;
23
+
24
+ /**
25
+ * Invalidate the cached "has any bylines" check.
26
+ * Call this when bylines are created, updated, or deleted.
27
+ */
28
+ export function invalidateBylineCache(): void {
29
+ hasBylines = null;
30
+ }
31
+
32
+ /**
33
+ * Check if any bylines exist in the database. Result is cached
34
+ * for the lifetime of the worker/process and invalidated on writes.
35
+ */
36
+ async function hasAnyBylines(): Promise<boolean> {
37
+ if (hasBylines !== null) return hasBylines;
38
+
39
+ try {
40
+ const db = await getDb();
41
+ const result = await sql<{ id: string }>`
42
+ SELECT id FROM _emdash_bylines LIMIT 1
43
+ `.execute(db);
44
+ hasBylines = result.rows.length > 0;
45
+ } catch (error: unknown) {
46
+ // Only treat "no such table" as a safe false -- anything else should
47
+ // not be cached so the next request retries.
48
+ const message = error instanceof Error ? error.message : "";
49
+ if (message.includes("no such table")) {
50
+ hasBylines = false;
51
+ } else {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ return hasBylines;
57
+ }
58
+
17
59
  /**
18
60
  * Get a byline by ID.
19
61
  *
@@ -134,6 +176,12 @@ export async function getBylinesForEntries(
134
176
  return result;
135
177
  }
136
178
 
179
+ // Skip DB queries entirely when no bylines have been created.
180
+ // The cache is invalidated when bylines are created/deleted.
181
+ if (!(await hasAnyBylines())) {
182
+ return result;
183
+ }
184
+
137
185
  const db = await getDb();
138
186
  const repo = new BylineRepository(db);
139
187
 
package/src/cleanup.ts CHANGED
@@ -121,11 +121,11 @@ export async function runSystemCleanup(
121
121
  * them down to REVISION_KEEP_COUNT.
122
122
  */
123
123
  async function pruneExcessiveRevisions(db: Kysely<Database>): Promise<number> {
124
- const entries = await sql<{ collection: string; entry_id: string; cnt: number }>`
125
- SELECT collection, entry_id, COUNT(*) as cnt
124
+ const entries = await sql<{ collection: string; entry_id: string }>`
125
+ SELECT collection, entry_id
126
126
  FROM revisions
127
127
  GROUP BY collection, entry_id
128
- HAVING cnt > ${REVISION_PRUNE_THRESHOLD}
128
+ HAVING COUNT(*) > ${REVISION_PRUNE_THRESHOLD}
129
129
  `.execute(db);
130
130
 
131
131
  if (entries.rows.length === 0) return 0;
@@ -196,11 +196,7 @@ export async function resolveSourceEntry(
196
196
  ): Promise<string | undefined> {
197
197
  const cleaned = distPath.replace(LEADING_DOT_SLASH_RE, "");
198
198
 
199
- // Try the path directly (might be source already)
200
- const direct = resolve(pluginDir, cleaned);
201
- if (await fileExists(direct)) return direct;
202
-
203
- // Convert dist path to src: dist/foo.mjs → src/foo.ts
199
+ // Prefer source over dist dist/foo.mjs src/foo.ts
204
200
  const srcPath = cleaned.replace(DIST_PREFIX_RE, "src/").replace(MJS_EXT_RE, ".ts");
205
201
  const srcFull = resolve(pluginDir, srcPath);
206
202
  if (await fileExists(srcFull)) return srcFull;
@@ -210,6 +206,10 @@ export async function resolveSourceEntry(
210
206
  const tsxFull = resolve(pluginDir, tsxPath);
211
207
  if (await fileExists(tsxFull)) return tsxFull;
212
208
 
209
+ // Fall back to direct path (might be source already, or pre-compiled plugin)
210
+ const direct = resolve(pluginDir, cleaned);
211
+ if (await fileExists(direct)) return direct;
212
+
213
213
  return undefined;
214
214
  }
215
215
 
@@ -58,8 +58,8 @@ export async function up(db: Kysely<unknown>): Promise<void> {
58
58
  }
59
59
 
60
60
  export async function down(db: Kysely<unknown>): Promise<void> {
61
- await db.schema.dropIndex("idx_content_taxonomies_term").execute();
62
- await db.schema.dropIndex("idx_media_mime_type").execute();
61
+ await db.schema.dropIndex("idx_sections_source").execute();
62
+ await db.schema.dropIndex("idx_sections_category").execute();
63
63
  await db.schema.dropTable("_emdash_sections").execute();
64
64
  await db.schema.dropTable("_emdash_section_categories").execute();
65
65
  }
@@ -1,4 +1,4 @@
1
- import { type Kysely, type Migration, type MigrationProvider, Migrator } from "kysely";
1
+ import { type Kysely, type Migration, type MigrationProvider, Migrator, sql } from "kysely";
2
2
 
3
3
  import type { Database } from "../types.js";
4
4
  // Import migrations statically for bundling
@@ -122,9 +122,30 @@ export async function getMigrationStatus(db: Kysely<Database>): Promise<Migratio
122
122
  }
123
123
 
124
124
  /**
125
- * Run all pending migrations
125
+ * Run all pending migrations.
126
+ *
127
+ * Includes a fast-path: if the migration table already exists and contains
128
+ * exactly MIGRATION_COUNT rows, all migrations have been applied and we can
129
+ * skip the Kysely Migrator entirely. This avoids the expensive
130
+ * `pragma_table_info` introspection that Kysely runs for every table in the
131
+ * database (twice!) just to check if the migration tables exist.
132
+ * On D1 with ~57 tables, that's ~116 queries saved per init.
126
133
  */
127
134
  export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
135
+ // Fast path: check if all migrations are already applied.
136
+ // A single cheap query vs the Migrator's full schema introspection.
137
+ try {
138
+ const result = await sql<{ count: number }>`
139
+ SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
140
+ `.execute(db);
141
+ if (result.rows[0]?.count === MIGRATION_COUNT) {
142
+ return { applied: [] };
143
+ }
144
+ } catch {
145
+ // Table doesn't exist yet (first run). Fall through to the Migrator
146
+ // which will create it.
147
+ }
148
+
128
149
  const migrator = new Migrator({
129
150
  db,
130
151
  provider: new StaticMigrationProvider(),
@@ -3,6 +3,7 @@ import { ulid } from "ulidx";
3
3
 
4
4
  import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
5
5
  import { listTablesLike } from "../dialect-helpers.js";
6
+ import { withTransaction } from "../transaction.js";
6
7
  import type { BylineTable, Database } from "../types.js";
7
8
  import { validateIdentifier } from "../validate.js";
8
9
  import {
@@ -197,7 +198,7 @@ export class BylineRepository {
197
198
  const existing = await this.findById(id);
198
199
  if (!existing) return false;
199
200
 
200
- await this.db.transaction().execute(async (trx) => {
201
+ await withTransaction(this.db, async (trx) => {
201
202
  await trx.deleteFrom("_emdash_content_bylines").where("byline_id", "=", id).execute();
202
203
 
203
204
  await trx.deleteFrom("_emdash_bylines").where("id", "=", id).execute();
@@ -37,6 +37,7 @@ import type {
37
37
  PageMetadataContribution,
38
38
  PageFragmentContribution,
39
39
  } from "./plugins/types.js";
40
+ import { invalidateUrlPatternCache } from "./query.js";
40
41
  import type { FieldType } from "./schema/types.js";
41
42
  import { hashString } from "./utils/hash.js";
42
43
  import { COMMIT, VERSION } from "./version.js";
@@ -57,6 +58,7 @@ const VALID_LINK_REL = new Set([
57
58
  "alternate",
58
59
  "author",
59
60
  "license",
61
+ "nlweb",
60
62
  "site.standard.document",
61
63
  ]);
62
64
 
@@ -1367,11 +1369,12 @@ export class EmDashRuntime {
1367
1369
  }
1368
1370
 
1369
1371
  /**
1370
- * Invalidate the cached manifest (no-op now that we don't cache).
1371
- * Kept for API compatibility.
1372
+ * Invalidate cached data derived from the manifest/schema.
1373
+ * Called when collections are created, updated, or deleted.
1372
1374
  */
1373
1375
  invalidateManifest(): void {
1374
- // No-op - manifest is rebuilt on each request
1376
+ // Invalidate the URL pattern cache used by resolveEmDashPath
1377
+ invalidateUrlPatternCache();
1375
1378
  }
1376
1379
 
1377
1380
  // =========================================================================
@@ -1613,7 +1616,7 @@ export class EmDashRuntime {
1613
1616
 
1614
1617
  // Run afterDelete hooks (fire-and-forget)
1615
1618
  if (result.success) {
1616
- this.runAfterDeleteHooks(id, collection);
1619
+ this.runAfterDeleteHooks(id, collection, false);
1617
1620
  }
1618
1621
 
1619
1622
  return result;
@@ -1635,7 +1638,14 @@ export class EmDashRuntime {
1635
1638
  }
1636
1639
 
1637
1640
  async handleContentPermanentDelete(collection: string, id: string) {
1638
- return handleContentPermanentDelete(this.db, collection, id);
1641
+ const result = await handleContentPermanentDelete(this.db, collection, id);
1642
+
1643
+ // Run afterDelete hooks so plugins (e.g. AI Search) can clean up
1644
+ if (result.success) {
1645
+ this.runAfterDeleteHooks(id, collection, true);
1646
+ }
1647
+
1648
+ return result;
1639
1649
  }
1640
1650
 
1641
1651
  async handleContentCountTrashed(collection: string) {
@@ -2010,11 +2020,11 @@ export class EmDashRuntime {
2010
2020
  }
2011
2021
  }
2012
2022
 
2013
- private runAfterDeleteHooks(id: string, collection: string): void {
2023
+ private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void {
2014
2024
  // Trusted plugins
2015
2025
  if (this.hooks.hasHooks("content:afterDelete")) {
2016
2026
  this.hooks
2017
- .runContentAfterDelete(id, collection)
2027
+ .runContentAfterDelete(id, collection, permanent)
2018
2028
  .catch((err) => console.error("EmDash afterDelete hook error:", err));
2019
2029
  }
2020
2030
 
@@ -2024,7 +2034,7 @@ export class EmDashRuntime {
2024
2034
  if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2025
2035
 
2026
2036
  plugin
2027
- .invokeHook("content:afterDelete", { id, collection })
2037
+ .invokeHook("content:afterDelete", { id, collection, permanent })
2028
2038
  .catch((err) =>
2029
2039
  console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err),
2030
2040
  );
package/src/index.ts CHANGED
@@ -213,6 +213,8 @@ export type {
213
213
  ResolvedHook,
214
214
  ResolvedPluginHooks,
215
215
  ContentHookEvent,
216
+ ContentDeleteEvent,
217
+ ContentPublishStateChangeEvent,
216
218
  MediaUploadEvent,
217
219
  HookResult,
218
220
  PluginRoute,
package/src/mcp/server.ts CHANGED
@@ -1257,26 +1257,8 @@ export function createMcpServer(): McpServer {
1257
1257
  requireScope(extra, "content:read");
1258
1258
  const ec = getEmDash(extra);
1259
1259
  try {
1260
- const rows = (await ec.db
1261
- .selectFrom("_emdash_taxonomy_defs" as never)
1262
- .selectAll()
1263
- .execute()) as Array<{
1264
- id: string;
1265
- name: string;
1266
- label: string;
1267
- label_singular: string | null;
1268
- hierarchical: number;
1269
- collections: string | null;
1270
- }>;
1271
- const taxonomies = rows.map((row) => ({
1272
- id: row.id,
1273
- name: row.name,
1274
- label: row.label,
1275
- labelSingular: row.label_singular ?? undefined,
1276
- hierarchical: row.hierarchical === 1,
1277
- collections: row.collections ? JSON.parse(row.collections) : [],
1278
- }));
1279
- return jsonResult(taxonomies);
1260
+ const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1261
+ return unwrap(await handleTaxonomyList(ec.db));
1280
1262
  } catch (error) {
1281
1263
  return errorResult(error);
1282
1264
  }
@@ -1302,32 +1284,44 @@ export function createMcpServer(): McpServer {
1302
1284
  requireScope(extra, "content:read");
1303
1285
  const ec = getEmDash(extra);
1304
1286
  try {
1305
- const taxonomy = (await ec.db
1306
- .selectFrom("_emdash_taxonomy_defs" as never)
1307
- .select("id" as never)
1308
- .where("name" as never, "=", args.taxonomy as never)
1309
- .executeTakeFirst()) as { id: string } | undefined;
1310
-
1287
+ // Verify taxonomy exists via handler layer
1288
+ const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1289
+ const listResult = await handleTaxonomyList(ec.db);
1290
+ if (!listResult.success) return unwrap(listResult);
1291
+
1292
+ const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
1293
+ .taxonomies;
1294
+ const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
1311
1295
  if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
1312
1296
 
1297
+ // Paginated term query via repository (avoids N+1 of handleTermList)
1298
+ const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
1299
+ const repo = new TaxonomyRepository(ec.db);
1313
1300
  const limit = Math.min(args.limit ?? 50, 100);
1314
- let query = ec.db
1315
- .selectFrom("_emdash_taxonomy_terms" as never)
1316
- .selectAll()
1317
- .where("taxonomy_id" as never, "=", taxonomy.id as never)
1318
- .orderBy("label" as never, "asc")
1319
- .limit(limit + 1);
1301
+ const terms = await repo.findByName(args.taxonomy);
1320
1302
 
1303
+ // Manual cursor pagination over the sorted results
1304
+ let startIdx = 0;
1321
1305
  if (args.cursor) {
1322
- query = query.where("id" as never, ">" as never, args.cursor as never);
1306
+ const cursorIdx = terms.findIndex((t) => t.id === args.cursor);
1307
+ if (cursorIdx >= 0) startIdx = cursorIdx + 1;
1323
1308
  }
1324
1309
 
1325
- const rows = (await query.execute()) as Array<{ id: string }>;
1326
- const hasMore = rows.length > limit;
1327
- const items = hasMore ? rows.slice(0, limit) : rows;
1328
- const nextCursor = hasMore ? items.at(-1)?.id : undefined;
1329
-
1330
- return jsonResult({ items, nextCursor });
1310
+ const page = terms.slice(startIdx, startIdx + limit);
1311
+ const hasMore = startIdx + limit < terms.length;
1312
+ const nextCursor = hasMore ? page.at(-1)?.id : undefined;
1313
+
1314
+ return jsonResult({
1315
+ items: page.map((t) => ({
1316
+ id: t.id,
1317
+ name: t.name,
1318
+ slug: t.slug,
1319
+ label: t.label,
1320
+ parentId: t.parentId,
1321
+ description: typeof t.data?.description === "string" ? t.data.description : undefined,
1322
+ })),
1323
+ nextCursor,
1324
+ });
1331
1325
  } catch (error) {
1332
1326
  return errorResult(error);
1333
1327
  }
@@ -1354,36 +1348,15 @@ export function createMcpServer(): McpServer {
1354
1348
  requireRole(extra, Role.EDITOR);
1355
1349
  const ec = getEmDash(extra);
1356
1350
  try {
1357
- const { ulid } = await import("ulidx");
1358
-
1359
- const taxonomy = (await ec.db
1360
- .selectFrom("_emdash_taxonomy_defs" as never)
1361
- .select("id" as never)
1362
- .where("name" as never, "=", args.taxonomy as never)
1363
- .executeTakeFirst()) as { id: string } | undefined;
1364
-
1365
- if (!taxonomy) return errorResult(`Taxonomy '${args.taxonomy}' not found`);
1366
-
1367
- const id = ulid();
1368
- await ec.db
1369
- .insertInto("_emdash_taxonomy_terms" as never)
1370
- .values({
1371
- id,
1372
- taxonomy_id: taxonomy.id,
1351
+ const { handleTermCreate } = await import("../api/handlers/taxonomies.js");
1352
+ return unwrap(
1353
+ await handleTermCreate(ec.db, args.taxonomy, {
1373
1354
  slug: args.slug,
1374
1355
  label: args.label,
1375
- parent_id: args.parentId ?? null,
1376
- description: args.description ?? null,
1377
- } as never)
1378
- .execute();
1379
-
1380
- const term = await ec.db
1381
- .selectFrom("_emdash_taxonomy_terms" as never)
1382
- .selectAll()
1383
- .where("id" as never, "=", id as never)
1384
- .executeTakeFirstOrThrow();
1385
-
1386
- return jsonResult(term);
1356
+ parentId: args.parentId,
1357
+ description: args.description,
1358
+ }),
1359
+ );
1387
1360
  } catch (error) {
1388
1361
  return errorResult(error);
1389
1362
  }
@@ -202,9 +202,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
202
202
  const result: ContentItem = {
203
203
  id: item.id,
204
204
  type: item.type,
205
+ slug: item.slug,
206
+ status: item.status,
205
207
  data: item.data,
206
208
  createdAt: item.createdAt,
207
209
  updatedAt: item.updatedAt,
210
+ locale: item.locale,
211
+ publishedAt: item.publishedAt,
208
212
  };
209
213
 
210
214
  if (await seoRepo.isEnabled(collection)) {
@@ -237,9 +241,13 @@ export function createContentAccess(db: Kysely<Database>): ContentAccess {
237
241
  const items: ContentItem[] = result.items.map((item) => ({
238
242
  id: item.id,
239
243
  type: item.type,
244
+ slug: item.slug,
245
+ status: item.status,
240
246
  data: item.data,
241
247
  createdAt: item.createdAt,
242
248
  updatedAt: item.updatedAt,
249
+ locale: item.locale,
250
+ publishedAt: item.publishedAt,
243
251
  }));
244
252
 
245
253
  if (items.length > 0 && (await seoRepo.isEnabled(collection))) {
@@ -294,9 +302,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
294
302
  const result: ContentItem = {
295
303
  id: item.id,
296
304
  type: item.type,
305
+ slug: item.slug,
306
+ status: item.status,
297
307
  data: item.data,
298
308
  createdAt: item.createdAt,
299
309
  updatedAt: item.updatedAt,
310
+ locale: item.locale,
311
+ publishedAt: item.publishedAt,
300
312
  };
301
313
 
302
314
  if (hasSeo) {
@@ -336,9 +348,13 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
336
348
  const result: ContentItem = {
337
349
  id: item.id,
338
350
  type: item.type,
351
+ slug: item.slug,
352
+ status: item.status,
339
353
  data: item.data,
340
354
  createdAt: item.createdAt,
341
355
  updatedAt: item.updatedAt,
356
+ locale: item.locale,
357
+ publishedAt: item.publishedAt,
342
358
  };
343
359
 
344
360
  if (hasSeo) {
@@ -439,12 +455,13 @@ export function createMediaAccessWithWrite(
439
455
  );
440
456
  }
441
457
 
442
- const mediaId = ulid();
458
+ // Generate a storage key with a unique prefix
459
+ const keyPrefix = ulid();
443
460
  // Extract extension from basename (ignore path separators)
444
461
  const basename = filename.split("/").pop() ?? filename;
445
462
  const dotIdx = basename.lastIndexOf(".");
446
463
  const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : "";
447
- const storageKey = `${mediaId}${ext}`;
464
+ const storageKey = `${keyPrefix}${ext}`;
448
465
 
449
466
  // Upload to storage first
450
467
  await storage.upload({
@@ -454,8 +471,9 @@ export function createMediaAccessWithWrite(
454
471
  });
455
472
 
456
473
  // Create DB record — clean up storage on failure
474
+ let media;
457
475
  try {
458
- await mediaRepo.create({
476
+ media = await mediaRepo.create({
459
477
  filename: basename,
460
478
  mimeType: contentType,
461
479
  size: bytes.byteLength,
@@ -472,7 +490,7 @@ export function createMediaAccessWithWrite(
472
490
  }
473
491
 
474
492
  return {
475
- mediaId,
493
+ mediaId: media.id,
476
494
  storageKey,
477
495
  url: `/_emdash/api/media/file/${storageKey}`,
478
496
  };
@@ -491,11 +509,17 @@ export function createMediaAccessWithWrite(
491
509
  /** Maximum number of redirects to follow in plugin HTTP access */
492
510
  const MAX_PLUGIN_REDIRECTS = 5;
493
511
 
512
+ /**
513
+ * Check if a hostname matches any pattern in the allowed list.
514
+ * Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com",
515
+ * "api.example.com" matches exactly.
516
+ */
494
517
  function isHostAllowed(host: string, allowedHosts: string[]): boolean {
495
518
  return allowedHosts.some((pattern) => {
496
519
  if (pattern === "*") return true;
497
520
  if (pattern.startsWith("*.")) {
498
521
  const suffix = pattern.slice(1); // ".example.com"
522
+ // Match subdomains (foo.example.com) and bare domain (example.com)
499
523
  return host.endsWith(suffix) || host === pattern.slice(2);
500
524
  }
501
525
  return host === pattern;