emdash 0.10.0 → 0.11.1

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 (148) hide show
  1. package/dist/{apply-UsrFuO7l.mjs → apply-Ded_1vng.mjs} +36 -25
  2. package/dist/{apply-UsrFuO7l.mjs.map → apply-Ded_1vng.mjs.map} +1 -1
  3. package/dist/astro/index.d.mts +5 -5
  4. package/dist/astro/index.mjs +1 -1
  5. package/dist/astro/middleware/auth.d.mts +5 -5
  6. package/dist/astro/middleware/redirect.mjs +2 -2
  7. package/dist/astro/middleware.d.mts.map +1 -1
  8. package/dist/astro/middleware.mjs +83 -33
  9. package/dist/astro/middleware.mjs.map +1 -1
  10. package/dist/astro/types.d.mts +10 -7
  11. package/dist/astro/types.d.mts.map +1 -1
  12. package/dist/{byline-C3vnhIpU.mjs → byline-gFn1r0vA.mjs} +2 -2
  13. package/dist/{byline-C3vnhIpU.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
  14. package/dist/{bylines-esI7ioa9.mjs → bylines-DTFI8nDM.mjs} +4 -4
  15. package/dist/{bylines-esI7ioa9.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
  16. package/dist/{cache-fTzxgMFJ.mjs → cache-BAJbeoZ8.mjs} +2 -2
  17. package/dist/{cache-fTzxgMFJ.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
  18. package/dist/{chunks-Da2-b-oA.mjs → chunks-BK1oZS-l.mjs} +2 -2
  19. package/dist/{chunks-Da2-b-oA.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
  20. package/dist/cli/index.mjs +102 -27
  21. package/dist/cli/index.mjs.map +1 -1
  22. package/dist/{content-C7G4QXkK.mjs → content-CERxPUN0.mjs} +2 -2
  23. package/dist/{content-C7G4QXkK.mjs.map → content-CERxPUN0.mjs.map} +1 -1
  24. package/dist/database/instrumentation.d.mts +6 -4
  25. package/dist/database/instrumentation.d.mts.map +1 -1
  26. package/dist/database/instrumentation.mjs +19 -7
  27. package/dist/database/instrumentation.mjs.map +1 -1
  28. package/dist/db/index.d.mts +2 -2
  29. package/dist/db/index.mjs +1 -1
  30. package/dist/{index-DjPMOfO0.d.mts → index-BogfvE-z.d.mts} +32 -24
  31. package/dist/index-BogfvE-z.d.mts.map +1 -0
  32. package/dist/index.d.mts +7 -7
  33. package/dist/index.mjs +19 -19
  34. package/dist/{load-sXRuM7Us.mjs → load-DR1VwFXR.mjs} +2 -2
  35. package/dist/{load-sXRuM7Us.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
  36. package/dist/{loader-Bx2_9-5e.mjs → loader-ou_PXAjg.mjs} +2 -2
  37. package/dist/{loader-Bx2_9-5e.mjs.map → loader-ou_PXAjg.mjs.map} +1 -1
  38. package/dist/media/local-runtime.d.mts +5 -5
  39. package/dist/media/local-runtime.mjs +1 -1
  40. package/dist/{media-D8FbNsl0.mjs → media-1fFhub9c.mjs} +21 -9
  41. package/dist/media-1fFhub9c.mjs.map +1 -0
  42. package/dist/page/index.d.mts +2 -2
  43. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  44. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  45. package/dist/{query-Bo-msrmu.mjs → query-8c_meo_K.mjs} +10 -10
  46. package/dist/{query-Bo-msrmu.mjs.map → query-8c_meo_K.mjs.map} +1 -1
  47. package/dist/{registry-Beb7wxFc.mjs → registry-Do34mz_P.mjs} +6 -5
  48. package/dist/registry-Do34mz_P.mjs.map +1 -0
  49. package/dist/{request-cache-C-tIpYIw.mjs → request-cache-D4I69LeL.mjs} +6 -2
  50. package/dist/request-cache-D4I69LeL.mjs.map +1 -0
  51. package/dist/request-context.d.mts +27 -1
  52. package/dist/request-context.d.mts.map +1 -1
  53. package/dist/request-context.mjs +16 -3
  54. package/dist/request-context.mjs.map +1 -1
  55. package/dist/{runner-DMnlIkh4.mjs → runner-DIcU2UCC.mjs} +174 -152
  56. package/dist/runner-DIcU2UCC.mjs.map +1 -0
  57. package/dist/{runner-Clwe4Mme.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
  58. package/dist/{runner-Clwe4Mme.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
  59. package/dist/runtime.d.mts +5 -5
  60. package/dist/runtime.mjs +1 -1
  61. package/dist/{search-DkN-BqsS.mjs → search-DuWhx4NG.mjs} +172 -30
  62. package/dist/search-DuWhx4NG.mjs.map +1 -0
  63. package/dist/seed/index.d.mts +2 -2
  64. package/dist/seed/index.mjs +10 -10
  65. package/dist/{taxonomies-CTtewrSQ.mjs → taxonomies-Bw76xAxo.mjs} +6 -6
  66. package/dist/{taxonomies-CTtewrSQ.mjs.map → taxonomies-Bw76xAxo.mjs.map} +1 -1
  67. package/dist/{taxonomy-DSxx2K2L.mjs → taxonomy-D6NvlKo8.mjs} +3 -3
  68. package/dist/{taxonomy-DSxx2K2L.mjs.map → taxonomy-D6NvlKo8.mjs.map} +1 -1
  69. package/dist/{types-Eg829jj9.mjs → types-56BKbld_.mjs} +1 -1
  70. package/dist/types-56BKbld_.mjs.map +1 -0
  71. package/dist/{types-Dtx1mSMX.d.mts → types-BQx6ZXpR.d.mts} +2 -1
  72. package/dist/types-BQx6ZXpR.d.mts.map +1 -0
  73. package/dist/{types-Dl1fgFjn.d.mts → types-BTe41zL6.d.mts} +4 -3
  74. package/dist/types-BTe41zL6.d.mts.map +1 -0
  75. package/dist/types-DiI8NOG_.mjs +16 -0
  76. package/dist/types-DiI8NOG_.mjs.map +1 -0
  77. package/dist/{types-D19uBYWn.d.mts → types-IjUrQMVe.d.mts} +21 -245
  78. package/dist/types-IjUrQMVe.d.mts.map +1 -0
  79. package/dist/{validate-DHGwADqO.d.mts → validate-CcVQQpmH.d.mts} +7 -3
  80. package/dist/validate-CcVQQpmH.d.mts.map +1 -0
  81. package/dist/{validate-CBIbxM3L.mjs → validate-UK4Ja1uo.mjs} +3 -3
  82. package/dist/{validate-CBIbxM3L.mjs.map → validate-UK4Ja1uo.mjs.map} +1 -1
  83. package/dist/{validation-B1NYiEos.mjs → validation-Vc5DQkJa.mjs} +4 -4
  84. package/dist/{validation-B1NYiEos.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
  85. package/dist/version-JjSqv90m.mjs +7 -0
  86. package/dist/{version-CMD42IRC.mjs.map → version-JjSqv90m.mjs.map} +1 -1
  87. package/dist/{zod-generator-BNJDQBSZ.mjs → zod-generator-CHnJUP2l.mjs} +1 -1
  88. package/dist/{zod-generator-BNJDQBSZ.mjs.map → zod-generator-CHnJUP2l.mjs.map} +1 -1
  89. package/package.json +9 -8
  90. package/src/api/errors.ts +5 -0
  91. package/src/api/handlers/content.ts +9 -0
  92. package/src/api/handlers/media-allowlist.ts +40 -0
  93. package/src/api/handlers/media.ts +1 -1
  94. package/src/api/handlers/menus.ts +158 -28
  95. package/src/api/handlers/validate-media-fields.ts +125 -0
  96. package/src/api/schemas/media.ts +23 -3
  97. package/src/api/schemas/schema.ts +11 -2
  98. package/src/astro/middleware.ts +46 -11
  99. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +1 -1
  100. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +1 -1
  101. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  102. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +1 -1
  103. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +1 -1
  104. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +2 -2
  105. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +1 -1
  106. package/src/astro/routes/api/content/[collection]/[id].ts +2 -2
  107. package/src/astro/routes/api/content/[collection]/index.ts +1 -1
  108. package/src/astro/routes/api/media/upload-url.ts +10 -4
  109. package/src/astro/routes/api/media.ts +12 -4
  110. package/src/astro/types.ts +5 -1
  111. package/src/auth/rate-limit.ts +3 -3
  112. package/src/cli/commands/bundle-utils.ts +81 -6
  113. package/src/cli/commands/bundle.ts +18 -15
  114. package/src/cli/commands/export-seed.ts +57 -3
  115. package/src/database/instrumentation.ts +22 -8
  116. package/src/database/migrations/016_api_tokens.ts +18 -3
  117. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  118. package/src/database/migrations/runner.ts +2 -0
  119. package/src/database/repositories/media.ts +40 -10
  120. package/src/database/types.ts +2 -1
  121. package/src/emdash-runtime.ts +16 -3
  122. package/src/fields/file.ts +7 -6
  123. package/src/fields/image.ts +12 -11
  124. package/src/fields/types.ts +3 -0
  125. package/src/index.ts +1 -1
  126. package/src/mcp/server.ts +37 -8
  127. package/src/media/mime.ts +75 -0
  128. package/src/plugins/types.ts +81 -191
  129. package/src/request-cache.ts +6 -2
  130. package/src/request-context.ts +42 -2
  131. package/src/schema/registry.ts +5 -5
  132. package/src/schema/types.ts +3 -2
  133. package/src/seed/apply.ts +25 -8
  134. package/src/seed/types.ts +4 -0
  135. package/dist/index-DjPMOfO0.d.mts.map +0 -1
  136. package/dist/media-D8FbNsl0.mjs.map +0 -1
  137. package/dist/registry-Beb7wxFc.mjs.map +0 -1
  138. package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
  139. package/dist/runner-DMnlIkh4.mjs.map +0 -1
  140. package/dist/search-DkN-BqsS.mjs.map +0 -1
  141. package/dist/types-CoO6mpV3.mjs +0 -68
  142. package/dist/types-CoO6mpV3.mjs.map +0 -1
  143. package/dist/types-D19uBYWn.d.mts.map +0 -1
  144. package/dist/types-Dl1fgFjn.d.mts.map +0 -1
  145. package/dist/types-Dtx1mSMX.d.mts.map +0 -1
  146. package/dist/types-Eg829jj9.mjs.map +0 -1
  147. package/dist/validate-DHGwADqO.d.mts.map +0 -1
  148. package/dist/version-CMD42IRC.mjs +0 -7
@@ -22,7 +22,12 @@ import type {
22
22
 
23
23
  // ── Constants ────────────────────────────────────────────────────────────────
24
24
 
25
- export const MAX_BUNDLE_SIZE = 5 * 1024 * 1024;
25
+ // Bundle size caps per RFC 0001 §"Bundle size limits". These are decompressed
26
+ // sizes; the gzipped tarball is typically a fraction of MAX_BUNDLE_SIZE.
27
+ export const MAX_BUNDLE_SIZE = 256 * 1024;
28
+ export const MAX_FILE_SIZE = 128 * 1024;
29
+ export const MAX_FILE_COUNT = 20;
30
+
26
31
  export const MAX_SCREENSHOTS = 5;
27
32
  export const MAX_SCREENSHOT_WIDTH = 1920;
28
33
  export const MAX_SCREENSHOT_HEIGHT = 1080;
@@ -251,23 +256,93 @@ export function findSourceExports(
251
256
  // ── Directory helpers ────────────────────────────────────────────────────────
252
257
 
253
258
  /**
254
- * Recursively calculate the total size of all files in a directory.
259
+ * One file in a bundle: a tarball-relative path and its byte length.
260
+ * Produced by `collectBundleEntries` (from a staging dir) or by the publish
261
+ * flow (from tarball entries); consumed by `validateBundleSize`.
255
262
  */
256
- export async function calculateDirectorySize(dir: string): Promise<number> {
257
- let total = 0;
263
+ export interface BundleFileEntry {
264
+ name: string;
265
+ bytes: number;
266
+ }
267
+
268
+ /**
269
+ * Recursively walk a staging directory and return a flat list of all files
270
+ * with sizes. Names are relative to `dir` so they match what would appear
271
+ * as the tarball entry name.
272
+ */
273
+ export async function collectBundleEntries(dir: string): Promise<BundleFileEntry[]> {
274
+ const entries: BundleFileEntry[] = [];
275
+ await walkBundle(dir, "", entries);
276
+ return entries;
277
+ }
278
+
279
+ async function walkBundle(dir: string, prefix: string, into: BundleFileEntry[]): Promise<void> {
258
280
  const items = await readdir(dir, { withFileTypes: true });
259
281
  for (const item of items) {
260
282
  const fullPath = join(dir, item.name);
283
+ const relPath = prefix ? `${prefix}/${item.name}` : item.name;
261
284
  if (item.isFile()) {
262
285
  const s = await stat(fullPath);
263
- total += s.size;
286
+ into.push({ name: relPath, bytes: s.size });
264
287
  } else if (item.isDirectory()) {
265
- total += await calculateDirectorySize(fullPath);
288
+ await walkBundle(fullPath, relPath, into);
266
289
  }
267
290
  }
291
+ }
292
+
293
+ /**
294
+ * Sum the byte sizes of all entries.
295
+ */
296
+ export function totalBundleBytes(entries: readonly BundleFileEntry[]): number {
297
+ let total = 0;
298
+ for (const e of entries) total += e.bytes;
268
299
  return total;
269
300
  }
270
301
 
302
+ /**
303
+ * Check a bundle against the three size caps from RFC 0001:
304
+ * - total decompressed ≤ MAX_BUNDLE_SIZE
305
+ * - per-file decompressed ≤ MAX_FILE_SIZE
306
+ * - file count ≤ MAX_FILE_COUNT
307
+ *
308
+ * Returns a list of violation messages (empty if the bundle is within all
309
+ * caps). Messages are deterministic per input — the total/count violations
310
+ * come first, then oversized files in alphabetical order — so the same
311
+ * bundle always produces the same error text.
312
+ */
313
+ export function validateBundleSize(entries: readonly BundleFileEntry[]): string[] {
314
+ const violations: string[] = [];
315
+ const total = totalBundleBytes(entries);
316
+ if (total > MAX_BUNDLE_SIZE) {
317
+ violations.push(
318
+ `Bundle size ${formatBytes(total)} exceeds maximum of ${formatBytes(MAX_BUNDLE_SIZE)}.`,
319
+ );
320
+ }
321
+ if (entries.length > MAX_FILE_COUNT) {
322
+ violations.push(
323
+ `Bundle contains ${entries.length} files, exceeds maximum of ${MAX_FILE_COUNT}.`,
324
+ );
325
+ }
326
+ const oversized = entries
327
+ .filter((e) => e.bytes > MAX_FILE_SIZE)
328
+ .toSorted((a, b) => a.name.localeCompare(b.name));
329
+ for (const e of oversized) {
330
+ violations.push(
331
+ `File ${e.name} is ${formatBytes(e.bytes)}, exceeds per-file maximum of ${formatBytes(MAX_FILE_SIZE)}.`,
332
+ );
333
+ }
334
+ return violations;
335
+ }
336
+
337
+ /**
338
+ * Render a byte count as a human-friendly string (e.g. "256.0 KB").
339
+ */
340
+ export function formatBytes(n: number): string {
341
+ if (n < 1024) return `${n} B`;
342
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
343
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
344
+ }
345
+
271
346
  // ── Tarball creation ─────────────────────────────────────────────────────────
272
347
 
273
348
  /**
@@ -23,20 +23,22 @@ import consola from "consola";
23
23
  import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js";
24
24
  import type { ResolvedPlugin } from "../../plugins/types.js";
25
25
  import {
26
- fileExists,
27
- readImageDimensions,
26
+ collectBundleEntries,
27
+ createTarball,
28
28
  extractManifest,
29
- findNodeBuiltinImports,
29
+ fileExists,
30
30
  findBuildOutput,
31
+ findNodeBuiltinImports,
31
32
  findSourceExports,
32
- resolveSourceEntry,
33
- calculateDirectorySize,
34
- createTarball,
35
- MAX_BUNDLE_SIZE,
33
+ formatBytes,
34
+ ICON_SIZE,
36
35
  MAX_SCREENSHOTS,
37
36
  MAX_SCREENSHOT_WIDTH,
38
37
  MAX_SCREENSHOT_HEIGHT,
39
- ICON_SIZE,
38
+ readImageDimensions,
39
+ resolveSourceEntry,
40
+ totalBundleBytes,
41
+ validateBundleSize,
40
42
  } from "./bundle-utils.js";
41
43
 
42
44
  const TS_EXT_RE = /\.(tsx?|[mc]?js)$/;
@@ -596,15 +598,16 @@ export const bundleCommand = defineCommand({
596
598
  }
597
599
  }
598
600
 
599
- // Calculate total bundle size
600
- const totalSize = await calculateDirectorySize(bundleDir);
601
- if (totalSize > MAX_BUNDLE_SIZE) {
602
- const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
603
- consola.error(`Bundle size ${sizeMB}MB exceeds maximum of 5MB`);
601
+ // Bundle size caps (RFC 0001 §"Bundle size limits").
602
+ const bundleEntries = await collectBundleEntries(bundleDir);
603
+ const sizeViolations = validateBundleSize(bundleEntries);
604
+ if (sizeViolations.length > 0) {
605
+ for (const v of sizeViolations) consola.error(v);
604
606
  hasErrors = true;
605
607
  } else {
606
- const sizeKB = (totalSize / 1024).toFixed(1);
607
- consola.info(`Bundle size: ${sizeKB}KB`);
608
+ consola.info(
609
+ `Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`,
610
+ );
608
611
  }
609
612
 
610
613
  if (hasErrors) {
@@ -32,6 +32,7 @@ import type {
32
32
  SeedWidget,
33
33
  SeedContentEntry,
34
34
  } from "../../seed/types.js";
35
+ import { slugify } from "../../utils/slugify.js";
35
36
 
36
37
  const SETTINGS_PREFIX = "site:";
37
38
 
@@ -101,7 +102,7 @@ export const exportSeedCommand = defineCommand({
101
102
  /**
102
103
  * Export database to seed file format
103
104
  */
104
- async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
105
+ export async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
105
106
  const seed: SeedFile = {
106
107
  $schema: "https://emdashcms.com/seed.schema.json",
107
108
  version: "1",
@@ -317,6 +318,9 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
317
318
  const result: SeedMenu[] = [];
318
319
  // translation_group -> seed-local id of the anchor menu in that group.
319
320
  const groupToSeedId = new Map<string, string>();
321
+ // Shared across menus: translated items reference anchor items in sibling menus.
322
+ const itemGroupToSeedId = new Map<string, string>();
323
+ const usedItemSeedIds = new Set<string>();
320
324
 
321
325
  for (const menu of menus) {
322
326
  const seedId =
@@ -329,7 +333,13 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
329
333
  .orderBy("sort_order", "asc")
330
334
  .execute();
331
335
 
332
- const seedItems = buildMenuItemTree(items);
336
+ const seedItems = buildMenuItemTree(items, {
337
+ i18nEnabled,
338
+ menuName: menu.name,
339
+ menuLocale: menu.locale ?? null,
340
+ itemGroupToSeedId,
341
+ usedItemSeedIds,
342
+ });
333
343
 
334
344
  const seedMenu: SeedMenu = {
335
345
  id: seedId,
@@ -376,7 +386,17 @@ function buildMenuItemTree(
376
386
  target: string | null;
377
387
  title_attr: string | null;
378
388
  css_classes: string | null;
389
+ locale?: string | null;
390
+ translation_group?: string | null;
379
391
  }>,
392
+ i18nCtx: {
393
+ i18nEnabled: boolean;
394
+ menuName: string;
395
+ menuLocale: string | null;
396
+ // translation_group -> seed-local id of the anchor item in that group.
397
+ itemGroupToSeedId: Map<string, string>;
398
+ usedItemSeedIds: Set<string>;
399
+ },
380
400
  ): SeedMenuItem[] {
381
401
  // Build parent -> children map
382
402
  const childMap = new Map<string | null, typeof items>();
@@ -389,10 +409,28 @@ function buildMenuItemTree(
389
409
  childMap.get(parentId)!.push(item);
390
410
  }
391
411
 
412
+ function makeSeedId(item: (typeof items)[number]): string {
413
+ const base = slugify(item.label || "") || item.id;
414
+ const locale = i18nCtx.i18nEnabled ? (item.locale ?? i18nCtx.menuLocale) : null;
415
+ const candidate = locale
416
+ ? `item:${i18nCtx.menuName}:${base}:${locale}`
417
+ : `item:${i18nCtx.menuName}:${base}`;
418
+ if (!i18nCtx.usedItemSeedIds.has(candidate)) {
419
+ i18nCtx.usedItemSeedIds.add(candidate);
420
+ return candidate;
421
+ }
422
+ // Collision fallback: append DB id to disambiguate duplicate labels.
423
+ const fallback = locale
424
+ ? `item:${i18nCtx.menuName}:${base}:${item.id}:${locale}`
425
+ : `item:${i18nCtx.menuName}:${base}:${item.id}`;
426
+ i18nCtx.usedItemSeedIds.add(fallback);
427
+ return fallback;
428
+ }
429
+
392
430
  // Recursively build tree
393
431
  function buildLevel(parentId: string | null): SeedMenuItem[] {
394
432
  const children = childMap.get(parentId) || [];
395
- return children.map((item) => {
433
+ const result = children.map((item) => {
396
434
  const seedItem: SeedMenuItem = {
397
435
  type: item.type,
398
436
  label: item.label || undefined,
@@ -415,6 +453,18 @@ function buildMenuItemTree(
415
453
  seedItem.cssClasses = item.css_classes;
416
454
  }
417
455
 
456
+ if (i18nCtx.i18nEnabled) {
457
+ const itemLocale = item.locale ?? i18nCtx.menuLocale;
458
+ const seedId = makeSeedId(item);
459
+ seedItem.id = seedId;
460
+ if (itemLocale) seedItem.locale = itemLocale;
461
+ if (item.translation_group) {
462
+ const anchor = i18nCtx.itemGroupToSeedId.get(item.translation_group);
463
+ if (anchor && anchor !== seedId) seedItem.translationOf = anchor;
464
+ else if (!anchor) i18nCtx.itemGroupToSeedId.set(item.translation_group, seedId);
465
+ }
466
+ }
467
+
418
468
  // Add children
419
469
  const itemChildren = buildLevel(item.id);
420
470
  if (itemChildren.length > 0) {
@@ -423,6 +473,10 @@ function buildMenuItemTree(
423
473
 
424
474
  return seedItem;
425
475
  });
476
+
477
+ // Sibling order is preserved (maps to sort_order on import). Cross-menu
478
+ // `translationOf` already resolves because exportMenus sorts anchors first.
479
+ return result;
426
480
  }
427
481
 
428
482
  return buildLevel(null);
@@ -83,16 +83,30 @@ export function isInstrumentationEnabled(): boolean {
83
83
 
84
84
  function kyselyLog(event: LogEvent): void {
85
85
  if (event.level !== "query") return;
86
- const rec = getRequestContext()?.queryRecorder;
87
- if (!rec) return;
88
- recordEvent(rec, event.query.sql, event.query.parameters, event.queryDurationMillis);
86
+ const ctx = getRequestContext();
87
+ if (!ctx) return;
88
+ const dur = event.queryDurationMillis;
89
+ if (ctx.metrics) {
90
+ const m = ctx.metrics;
91
+ m.dbCount += 1;
92
+ m.dbTotalMs += dur;
93
+ const finishedAt = performance.now() - m.start;
94
+ const startedAt = finishedAt - dur;
95
+ if (m.dbFirstOffset === null) m.dbFirstOffset = startedAt;
96
+ m.dbLastOffset = finishedAt;
97
+ }
98
+ if (ctx.queryRecorder) {
99
+ recordEvent(ctx.queryRecorder, event.query.sql, event.query.parameters, dur);
100
+ }
89
101
  }
90
102
 
91
103
  /**
92
- * Returns a Kysely `log` option when instrumentation is enabled, or undefined.
93
- * Pass as `new Kysely({ dialect, log: kyselyLogOption() })` so disabled mode
94
- * has zero overhead Kysely skips query timing entirely when `log` is absent.
104
+ * Returns a Kysely `log` callback. Always returns a function so per-request
105
+ * counters (db.count, db.total, db.first, db.last) and the optional NDJSON
106
+ * recorder both get fed. The cost over the previous "undefined when off"
107
+ * behaviour is one `performance.now()` pair per query inside Kysely, which
108
+ * is in the noise compared to any real query.
95
109
  */
96
- export function kyselyLogOption(): Logger | undefined {
97
- return isInstrumentationEnabled() ? kyselyLog : undefined;
110
+ export function kyselyLogOption(): Logger {
111
+ return kyselyLog;
98
112
  }
@@ -9,11 +9,20 @@ import { currentTimestamp } from "../dialect-helpers.js";
9
9
  * 1. _emdash_api_tokens — Personal Access Tokens (ec_pat_...)
10
10
  * 2. _emdash_oauth_tokens — OAuth access/refresh tokens (ec_oat_/ec_ort_...)
11
11
  * 3. _emdash_device_codes — OAuth Device Flow state (RFC 8628)
12
+ *
13
+ * Every CREATE is guarded with `.ifNotExists()` so the migration is safe to
14
+ * re-run against a partially-applied schema. See #954 for the failure mode:
15
+ * if `up()` crashes mid-way (D1 subrequest limit, isolate cancellation,
16
+ * transient connection error), the migration record never gets inserted
17
+ * into `_emdash_migrations`, and the next request retries `up()` from the
18
+ * top. Without these guards, the retry crashed with `table ... already
19
+ * exists` and blocked every subsequent boot of the Worker.
12
20
  */
13
21
  export async function up(db: Kysely<unknown>): Promise<void> {
14
22
  // ── Personal Access Tokens ───────────────────────────────────────
15
23
  await db.schema
16
24
  .createTable("_emdash_api_tokens")
25
+ .ifNotExists()
17
26
  .addColumn("id", "text", (col) => col.primaryKey())
18
27
  .addColumn("name", "text", (col) => col.notNull())
19
28
  .addColumn("token_hash", "text", (col) => col.notNull().unique())
@@ -30,12 +39,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
30
39
 
31
40
  await db.schema
32
41
  .createIndex("idx_api_tokens_token_hash")
42
+ .ifNotExists()
33
43
  .on("_emdash_api_tokens")
34
44
  .column("token_hash")
35
45
  .execute();
36
46
 
37
47
  await db.schema
38
48
  .createIndex("idx_api_tokens_user_id")
49
+ .ifNotExists()
39
50
  .on("_emdash_api_tokens")
40
51
  .column("user_id")
41
52
  .execute();
@@ -43,6 +54,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
43
54
  // ── OAuth Tokens ─────────────────────────────────────────────────
44
55
  await db.schema
45
56
  .createTable("_emdash_oauth_tokens")
57
+ .ifNotExists()
46
58
  .addColumn("token_hash", "text", (col) => col.primaryKey())
47
59
  .addColumn("token_type", "text", (col) => col.notNull()) // 'access' | 'refresh'
48
60
  .addColumn("user_id", "text", (col) => col.notNull())
@@ -58,12 +70,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
58
70
 
59
71
  await db.schema
60
72
  .createIndex("idx_oauth_tokens_user_id")
73
+ .ifNotExists()
61
74
  .on("_emdash_oauth_tokens")
62
75
  .column("user_id")
63
76
  .execute();
64
77
 
65
78
  await db.schema
66
79
  .createIndex("idx_oauth_tokens_expires")
80
+ .ifNotExists()
67
81
  .on("_emdash_oauth_tokens")
68
82
  .column("expires_at")
69
83
  .execute();
@@ -71,6 +85,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
71
85
  // ── Device Codes (OAuth Device Flow, RFC 8628) ───────────────────
72
86
  await db.schema
73
87
  .createTable("_emdash_device_codes")
88
+ .ifNotExists()
74
89
  .addColumn("device_code", "text", (col) => col.primaryKey())
75
90
  .addColumn("user_code", "text", (col) => col.notNull().unique())
76
91
  .addColumn("scopes", "text", (col) => col.notNull()) // JSON array
@@ -83,7 +98,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
83
98
  }
84
99
 
85
100
  export async function down(db: Kysely<unknown>): Promise<void> {
86
- await db.schema.dropTable("_emdash_device_codes").execute();
87
- await db.schema.dropTable("_emdash_oauth_tokens").execute();
88
- await db.schema.dropTable("_emdash_api_tokens").execute();
101
+ await db.schema.dropTable("_emdash_device_codes").ifExists().execute();
102
+ await db.schema.dropTable("_emdash_oauth_tokens").ifExists().execute();
103
+ await db.schema.dropTable("_emdash_api_tokens").ifExists().execute();
89
104
  }
@@ -0,0 +1,18 @@
1
+ import { type Kysely } from "kysely";
2
+
3
+ import { columnExists } from "../dialect-helpers.js";
4
+
5
+ export async function up(db: Kysely<unknown>): Promise<void> {
6
+ if (!(await columnExists(db, "credentials", "algorithm"))) {
7
+ await db.schema
8
+ .alterTable("credentials")
9
+ .addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7))
10
+ .execute();
11
+ }
12
+ }
13
+
14
+ export async function down(db: Kysely<unknown>): Promise<void> {
15
+ if (await columnExists(db, "credentials", "algorithm")) {
16
+ await db.schema.alterTable("credentials").dropColumn("algorithm").execute();
17
+ }
18
+ }
@@ -37,6 +37,7 @@ import * as m033 from "./033_optimize_content_indexes.js";
37
37
  import * as m034 from "./034_published_at_index.js";
38
38
  import * as m035 from "./035_bounded_404_log.js";
39
39
  import * as m036 from "./036_i18n_menus_and_taxonomies.js";
40
+ import * as m037 from "./037_credential_algorithm.js";
40
41
 
41
42
  const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
42
43
  "001_initial": m001,
@@ -74,6 +75,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
74
75
  "034_published_at_index": m034,
75
76
  "035_bounded_404_log": m035,
76
77
  "036_i18n_menus_and_taxonomies": m036,
78
+ "037_credential_algorithm": m037,
77
79
  });
78
80
 
79
81
  /** Total number of registered migrations. Exported for use in tests. */
@@ -1,4 +1,4 @@
1
- import { sql, type Kysely, type SqlBool } from "kysely";
1
+ import { sql, type ExpressionBuilder, type Kysely, type SqlBool } from "kysely";
2
2
  import { ulid } from "ulidx";
3
3
 
4
4
  import type { Database, MediaRow } from "../types.js";
@@ -10,6 +10,35 @@ function escapeLike(value: string): string {
10
10
  return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
11
11
  }
12
12
 
13
+ /**
14
+ * Normalize a mimeType filter (string or array) into a clean string[].
15
+ * Entries that are empty strings are dropped.
16
+ */
17
+ function normalizeMimeFilter(input?: string | readonly string[]): string[] {
18
+ if (!input) return [];
19
+ const arr = Array.isArray(input) ? input : [input];
20
+ return arr
21
+ .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
22
+ .map((entry) =>
23
+ entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(),
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Build a WHERE clause that matches `mime_type` against any of the given
29
+ * filter entries — exact equality for full MIMEs, LIKE prefix for entries
30
+ * ending in "/".
31
+ */
32
+ function mimeMatchExpr(eb: ExpressionBuilder<Database, "media">, filters: string[]) {
33
+ return eb.or(
34
+ filters.map((entry) =>
35
+ entry.endsWith("/")
36
+ ? sql<SqlBool>`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'`
37
+ : eb("mime_type", "=", entry),
38
+ ),
39
+ );
40
+ }
41
+
13
42
  export type MediaStatus = "pending" | "ready" | "failed";
14
43
 
15
44
  export interface MediaItem {
@@ -49,7 +78,8 @@ export interface CreateMediaInput {
49
78
  export interface FindManyMediaOptions {
50
79
  limit?: number;
51
80
  cursor?: string;
52
- mimeType?: string; // Filter by mime type prefix, e.g., "image/"
81
+ /** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */
82
+ mimeType?: string | readonly string[];
53
83
  status?: MediaStatus | "all"; // Filter by status, defaults to "ready"
54
84
  }
55
85
 
@@ -215,9 +245,9 @@ export class MediaRepository {
215
245
  );
216
246
  }
217
247
 
218
- if (options.mimeType) {
219
- const pattern = `${escapeLike(options.mimeType)}%`;
220
- query = query.where(sql<SqlBool>`mime_type LIKE ${pattern} ESCAPE '\\'`);
248
+ const mimeFilters = normalizeMimeFilter(options.mimeType);
249
+ if (mimeFilters.length > 0) {
250
+ query = query.where((eb) => mimeMatchExpr(eb, mimeFilters));
221
251
  }
222
252
 
223
253
  // Default to only showing ready items
@@ -276,12 +306,12 @@ export class MediaRepository {
276
306
  /**
277
307
  * Count media items
278
308
  */
279
- async count(mimeType?: string): Promise<number> {
280
- let query = this.db.selectFrom("media").select((eb) => eb.fn.count("id").as("count"));
309
+ async count(mimeType?: string | readonly string[]): Promise<number> {
310
+ const filters = normalizeMimeFilter(mimeType);
311
+ let query = this.db.selectFrom("media").select((eb) => eb.fn.count<number>("id").as("count"));
281
312
 
282
- if (mimeType) {
283
- const pattern = `${escapeLike(mimeType)}%`;
284
- query = query.where(sql<SqlBool>`mime_type LIKE ${pattern} ESCAPE '\\'`);
313
+ if (filters.length > 0) {
314
+ query = query.where((eb) => mimeMatchExpr(eb, filters));
285
315
  }
286
316
 
287
317
  const result = await query.executeTakeFirst();
@@ -76,7 +76,8 @@ export interface UserTable {
76
76
  export interface CredentialTable {
77
77
  id: string; // Base64url credential ID
78
78
  user_id: string;
79
- public_key: Uint8Array; // COSE public key
79
+ public_key: Uint8Array; // SEC1 or PKIX encoded public key
80
+ algorithm: number;
80
81
  counter: number;
81
82
  device_type: string; // 'singleDevice' | 'multiDevice'
82
83
  backed_up: number; // 0 or 1
@@ -1287,6 +1287,8 @@ export class EmDashRuntime {
1287
1287
  // or arbitrary `Record<string, unknown>` for plugin field widgets that
1288
1288
  // need per-field config (e.g. a checkbox grid receiving its column defs).
1289
1289
  options?: Array<{ value: string; label: string }> | Record<string, unknown>;
1290
+ id?: string;
1291
+ validation?: Record<string, unknown>;
1290
1292
  }
1291
1293
  > = {};
1292
1294
 
@@ -1296,6 +1298,9 @@ export class EmDashRuntime {
1296
1298
  label: field.label,
1297
1299
  required: field.required,
1298
1300
  };
1301
+ // Always include the field's database ID so the admin can forward it
1302
+ // to upload/media-list API calls for MIME allowlist widening.
1303
+ entry.id = field.id;
1299
1304
  if (field.widget) entry.widget = field.widget;
1300
1305
  // Plugin field widgets read their per-field config from `field.options`,
1301
1306
  // which the seed schema types as `Record<string, unknown>`. Pass it
@@ -1312,8 +1317,12 @@ export class EmDashRuntime {
1312
1317
  }));
1313
1318
  }
1314
1319
  // Include full validation for repeater fields (subFields, minItems, maxItems)
1315
- if (field.type === "repeater" && field.validation) {
1316
- (entry as Record<string, unknown>).validation = field.validation;
1320
+ // and for file/image fields (allowedMimeTypes).
1321
+ if (
1322
+ (field.type === "repeater" || field.type === "file" || field.type === "image") &&
1323
+ field.validation
1324
+ ) {
1325
+ entry.validation = { ...field.validation };
1317
1326
  }
1318
1327
  fields[field.slug] = entry;
1319
1328
  }
@@ -1980,7 +1989,11 @@ export class EmDashRuntime {
1980
1989
  // Media Handlers
1981
1990
  // =========================================================================
1982
1991
 
1983
- async handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string }) {
1992
+ async handleMediaList(params: {
1993
+ cursor?: string;
1994
+ limit?: number;
1995
+ mimeType?: string | readonly string[];
1996
+ }) {
1984
1997
  return handleMediaList(this.db, params);
1985
1998
  }
1986
1999
 
@@ -5,13 +5,10 @@ import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js";
5
5
  export interface FileOptions {
6
6
  required?: boolean;
7
7
  maxSize?: number; // In bytes
8
- allowedTypes?: string[]; // MIME types
8
+ allowedTypes?: string[]; // MIME types — exact (image/png) or prefix (image/)
9
9
  helpText?: string;
10
10
  }
11
11
 
12
- /**
13
- * File field - file upload
14
- */
15
12
  export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
16
13
  const fileObjSchema = z.object({
17
14
  id: z.string(),
@@ -21,21 +18,25 @@ export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
21
18
  size: z.number(),
22
19
  });
23
20
 
24
- // Optional vs required
25
21
  const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional();
26
22
 
27
23
  const ui: FieldUIHints = {
28
24
  widget: "file",
29
25
  helpText: options.helpText,
30
26
  maxSize: options.maxSize,
31
- allowedTypes: options.allowedTypes,
32
27
  };
33
28
 
29
+ const validation =
30
+ options.allowedTypes && options.allowedTypes.length > 0
31
+ ? { allowedMimeTypes: [...options.allowedTypes] }
32
+ : undefined;
33
+
34
34
  return {
35
35
  type: "file",
36
36
  columnType: "TEXT",
37
37
  schema,
38
38
  options,
39
39
  ui,
40
+ validation,
40
41
  };
41
42
  }
@@ -2,9 +2,6 @@ import { z } from "astro/zod";
2
2
 
3
3
  import type { FieldDefinition, ImageValue } from "./types.js";
4
4
 
5
- /**
6
- * Image field schema
7
- */
8
5
  const imageSchema = z.object({
9
6
  id: z.string(),
10
7
  src: z.string(),
@@ -13,22 +10,26 @@ const imageSchema = z.object({
13
10
  height: z.number().optional(),
14
11
  });
15
12
 
16
- /**
17
- * Image field
18
- * References media items from the media library
19
- */
20
- export function image(options?: {
13
+ export interface ImageOptions {
21
14
  required?: boolean;
22
15
  maxSize?: number; // in bytes
23
- allowedTypes?: string[]; // MIME types
24
- }): FieldDefinition<ImageValue | undefined> {
16
+ allowedTypes?: string[]; // MIME types — exact or prefix
17
+ }
18
+
19
+ export function image(options: ImageOptions = {}): FieldDefinition<ImageValue | undefined> {
20
+ const validation =
21
+ options.allowedTypes && options.allowedTypes.length > 0
22
+ ? { allowedMimeTypes: [...options.allowedTypes] }
23
+ : undefined;
24
+
25
25
  return {
26
26
  type: "image",
27
27
  columnType: "TEXT",
28
- schema: options?.required === false ? imageSchema.optional() : imageSchema,
28
+ schema: options.required === false ? imageSchema.optional() : imageSchema,
29
29
  options,
30
30
  ui: {
31
31
  widget: "image",
32
32
  },
33
+ validation,
33
34
  };
34
35
  }