emdash 0.40.0 → 0.40.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 (200) hide show
  1. package/dist/api/route-utils.mjs +6 -6
  2. package/dist/api/schemas/index.d.mts +1 -1
  3. package/dist/{api-cFFZpyS0.mjs → api-CuyNEHZG.mjs} +3 -3
  4. package/dist/{api-cFFZpyS0.mjs.map → api-CuyNEHZG.mjs.map} +1 -1
  5. package/dist/{apply-CoT603H3.mjs → apply-npY1TAhG.mjs} +237 -34
  6. package/dist/apply-npY1TAhG.mjs.map +1 -0
  7. package/dist/astro/index.d.mts +3 -3
  8. package/dist/astro/index.mjs +9 -9
  9. package/dist/astro/index.mjs.map +1 -1
  10. package/dist/astro/middleware/auth.d.mts +3 -3
  11. package/dist/astro/middleware/redirect.mjs +1 -1
  12. package/dist/astro/middleware.d.mts +3 -3
  13. package/dist/astro/middleware.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +19 -18
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/routes/api/admin/media-usage/progress.mjs +2 -2
  17. package/dist/astro/routes/api/admin/media-usage/repair.mjs +2 -2
  18. package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +8 -8
  19. package/dist/astro/routes/api/admin/plugins/_id_/settings.mjs +8 -8
  20. package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +8 -8
  21. package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +11 -11
  22. package/dist/astro/routes/api/admin/plugins/index.mjs +8 -8
  23. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +8 -8
  24. package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +11 -11
  25. package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +8 -8
  26. package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +8 -8
  27. package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +12 -12
  28. package/dist/astro/routes/api/admin/plugins/registry/install.mjs +9 -9
  29. package/dist/astro/routes/api/admin/plugins/registry/verify.mjs +9 -9
  30. package/dist/astro/routes/api/admin/plugins/updates.mjs +8 -8
  31. package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +8 -8
  32. package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +8 -8
  33. package/dist/astro/routes/api/admin/transfer/approvals/_id_/approve.mjs +3 -3
  34. package/dist/astro/routes/api/admin/transfer/approvals/_id_/deny.mjs +3 -3
  35. package/dist/astro/routes/api/admin/transfer/approvals/index.mjs +3 -3
  36. package/dist/astro/routes/api/admin/transfer/capabilities.mjs +3 -3
  37. package/dist/astro/routes/api/admin/transfer/exports/_id_/advance.mjs +3 -3
  38. package/dist/astro/routes/api/admin/transfer/exports/_id_/archive.mjs +3 -3
  39. package/dist/astro/routes/api/admin/transfer/exports/_id_/files/_...path_.mjs +3 -3
  40. package/dist/astro/routes/api/admin/transfer/exports/_id_/index.mjs +3 -3
  41. package/dist/astro/routes/api/admin/transfer/exports/_id_/manifest.mjs +3 -3
  42. package/dist/astro/routes/api/admin/transfer/exports/index.mjs +3 -3
  43. package/dist/astro/routes/api/admin/transfer/imports/_id_/abandon.mjs +3 -3
  44. package/dist/astro/routes/api/admin/transfer/imports/_id_/advance.mjs +3 -3
  45. package/dist/astro/routes/api/admin/transfer/imports/_id_/analyze.mjs +3 -3
  46. package/dist/astro/routes/api/admin/transfer/imports/_id_/cancel.mjs +3 -3
  47. package/dist/astro/routes/api/admin/transfer/imports/_id_/execute.mjs +3 -3
  48. package/dist/astro/routes/api/admin/transfer/imports/_id_/files/_...path_.mjs +3 -3
  49. package/dist/astro/routes/api/admin/transfer/imports/_id_/index.mjs +3 -3
  50. package/dist/astro/routes/api/admin/transfer/imports/_id_/missing.mjs +3 -3
  51. package/dist/astro/routes/api/admin/transfer/imports/_id_/plan.mjs +3 -3
  52. package/dist/astro/routes/api/admin/transfer/imports/_id_/receipt.mjs +3 -3
  53. package/dist/astro/routes/api/admin/transfer/imports/index.mjs +3 -3
  54. package/dist/astro/routes/api/dashboard.mjs +2 -2
  55. package/dist/astro/routes/api/health.mjs +1 -1
  56. package/dist/astro/routes/api/import/probe.mjs +3 -3
  57. package/dist/astro/routes/api/import/wordpress/execute.d.mts +3 -3
  58. package/dist/astro/routes/api/import/wordpress/media.d.mts +3 -3
  59. package/dist/astro/routes/api/import/wordpress/rewrite-urls.d.mts +3 -3
  60. package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +3 -3
  61. package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +3 -3
  62. package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +3 -3
  63. package/dist/astro/routes/api/manifest.mjs +1 -1
  64. package/dist/astro/routes/api/mcp.mjs +13 -33
  65. package/dist/astro/routes/api/mcp.mjs.map +1 -1
  66. package/dist/astro/routes/api/media/_id_/confirm.mjs +1 -1
  67. package/dist/astro/routes/api/media/_id_/upload.mjs +1 -1
  68. package/dist/astro/routes/api/media/_id_/usage.mjs +2 -2
  69. package/dist/astro/routes/api/media/_id_.mjs +2 -2
  70. package/dist/astro/routes/api/media/asset/_id_/_filename_.mjs +1 -1
  71. package/dist/astro/routes/api/media/upload-url.mjs +1 -1
  72. package/dist/astro/routes/api/media.mjs +2 -2
  73. package/dist/astro/routes/api/redirects/404s/index.mjs +2 -2
  74. package/dist/astro/routes/api/redirects/404s/summary.mjs +2 -2
  75. package/dist/astro/routes/api/redirects/_id_.mjs +2 -2
  76. package/dist/astro/routes/api/redirects/index.mjs +2 -2
  77. package/dist/astro/routes/api/schema/block-types/_slug_/index.mjs +8 -8
  78. package/dist/astro/routes/api/schema/block-types/_slug_/versions/_version_/activate.mjs +8 -8
  79. package/dist/astro/routes/api/schema/block-types/index.mjs +8 -8
  80. package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +8 -8
  81. package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +8 -8
  82. package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +8 -8
  83. package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +8 -8
  84. package/dist/astro/routes/api/schema/collections/index.mjs +8 -8
  85. package/dist/astro/routes/api/schema/collections/reorder.mjs +8 -8
  86. package/dist/astro/routes/api/schema/orphans/_slug_.mjs +8 -8
  87. package/dist/astro/routes/api/schema/orphans/index.mjs +8 -8
  88. package/dist/astro/routes/api/settings/backups/archives/_name_.mjs +2 -2
  89. package/dist/astro/routes/api/settings/backups/archives/index.mjs +2 -2
  90. package/dist/astro/routes/api/settings/backups/export.mjs +2 -2
  91. package/dist/astro/routes/api/settings/backups/index.mjs +2 -2
  92. package/dist/astro/routes/api/settings.mjs +3 -3
  93. package/dist/astro/routes/api/setup/dev-bypass.mjs +4 -4
  94. package/dist/astro/routes/api/setup/index.d.mts.map +1 -1
  95. package/dist/astro/routes/api/setup/index.mjs +35 -10
  96. package/dist/astro/routes/api/setup/index.mjs.map +1 -1
  97. package/dist/astro/routes/api/snapshot.mjs +2 -2
  98. package/dist/astro/routes/api/snapshot.mjs.map +1 -1
  99. package/dist/astro/routes/robots.txt.mjs +2 -2
  100. package/dist/astro/routes/sitemap-_collection_.xml.mjs +2 -2
  101. package/dist/astro/routes/sitemap.xml.mjs +2 -2
  102. package/dist/astro/types.d.mts +7 -3
  103. package/dist/astro/types.d.mts.map +1 -1
  104. package/dist/{backup-CBu0LrXf.mjs → backup-BQRj3afm.mjs} +3 -3
  105. package/dist/{backup-CBu0LrXf.mjs.map → backup-BQRj3afm.mjs.map} +1 -1
  106. package/dist/cli/index.mjs +6 -6
  107. package/dist/client/index.d.mts +14 -14
  108. package/dist/{config-5ii2haYu.d.mts → config-BjEm_3xL.d.mts} +25 -16
  109. package/dist/config-BjEm_3xL.d.mts.map +1 -0
  110. package/dist/{context-BeGccdUX.mjs → context-BV_7t-Ua.mjs} +6 -6
  111. package/dist/{context-BeGccdUX.mjs.map → context-BV_7t-Ua.mjs.map} +1 -1
  112. package/dist/{dashboard-DjrB-xxZ.mjs → dashboard-BP-iJqpd.mjs} +2 -2
  113. package/dist/{dashboard-DjrB-xxZ.mjs.map → dashboard-BP-iJqpd.mjs.map} +1 -1
  114. package/dist/db/libsql-migrations.mjs +2 -2
  115. package/dist/db/postgres-migrations.mjs +2 -2
  116. package/dist/db/sqlite-migrations.mjs +2 -2
  117. package/dist/{direct-executor-BmuFJJZV.mjs → direct-executor-D2lNadwo.mjs} +2 -2
  118. package/dist/{direct-executor-BmuFJJZV.mjs.map → direct-executor-D2lNadwo.mjs.map} +1 -1
  119. package/dist/{emdash-runtime-DAjghyEG.mjs → emdash-runtime-CtfnZmPi.mjs} +56 -56
  120. package/dist/emdash-runtime-CtfnZmPi.mjs.map +1 -0
  121. package/dist/{entry-lock-DVhCt49A.d.mts → entry-lock-Cf5OoIgN.d.mts} +18 -18
  122. package/dist/{entry-lock-DVhCt49A.d.mts.map → entry-lock-Cf5OoIgN.d.mts.map} +1 -1
  123. package/dist/{hreflang-DMFBL2eb.mjs → hreflang-CbVI4paR.mjs} +2 -2
  124. package/dist/{hreflang-DMFBL2eb.mjs.map → hreflang-CbVI4paR.mjs.map} +1 -1
  125. package/dist/{identity-IyNWwXr6.mjs → identity-Be5kzutJ.mjs} +2 -2
  126. package/dist/{identity-IyNWwXr6.mjs.map → identity-Be5kzutJ.mjs.map} +1 -1
  127. package/dist/{import-C3JZplWq.mjs → import-YzfKCaco.mjs} +2 -2
  128. package/dist/{import-C3JZplWq.mjs.map → import-YzfKCaco.mjs.map} +1 -1
  129. package/dist/index.d.mts +4 -4
  130. package/dist/index.mjs +16 -16
  131. package/dist/{manifest-builder-B8zR-73J.mjs → manifest-builder-BmSAOOEF.mjs} +2 -2
  132. package/dist/{manifest-builder-B8zR-73J.mjs.map → manifest-builder-BmSAOOEF.mjs.map} +1 -1
  133. package/dist/media/local-runtime.d.mts +3 -3
  134. package/dist/media/local-runtime.mjs +3 -3
  135. package/dist/{media-Qp5S3eH3.mjs → media-CPXTbb1R.mjs} +20 -2
  136. package/dist/media-CPXTbb1R.mjs.map +1 -0
  137. package/dist/{media-h7tOiDLj.mjs → media-JXRWDhw3.mjs} +77 -4
  138. package/dist/media-JXRWDhw3.mjs.map +1 -0
  139. package/dist/{media-usage-My-Vi-ex.mjs → media-usage-De-zeR9E.mjs} +2 -2
  140. package/dist/{media-usage-My-Vi-ex.mjs.map → media-usage-De-zeR9E.mjs.map} +1 -1
  141. package/dist/{menus-TsSGmSR4.mjs → menus-Cm68rsAq.mjs} +4 -4
  142. package/dist/{menus-TsSGmSR4.mjs.map → menus-Cm68rsAq.mjs.map} +1 -1
  143. package/dist/migrations/index.mjs +2 -2
  144. package/dist/page/index.mjs +2 -2
  145. package/dist/plugin-test-runtime.d.mts +3 -3
  146. package/dist/plugin-test-runtime.mjs +18 -18
  147. package/dist/plugin-utils.d.mts +3 -3
  148. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  149. package/dist/plugins/host.d.mts +1 -1
  150. package/dist/plugins/host.mjs +6 -6
  151. package/dist/{redirect-iJf5fltD.mjs → redirect-B29UjdUn.mjs} +10 -14
  152. package/dist/redirect-B29UjdUn.mjs.map +1 -0
  153. package/dist/{redirects-CYu1u1aK.mjs → redirects-Ca2MPfEG.mjs} +2 -2
  154. package/dist/{redirects-CYu1u1aK.mjs.map → redirects-Ca2MPfEG.mjs.map} +1 -1
  155. package/dist/{routes-CUsM40Cc.mjs → routes-vrR95WR6.mjs} +2 -2
  156. package/dist/{routes-CUsM40Cc.mjs.map → routes-vrR95WR6.mjs.map} +1 -1
  157. package/dist/runtime.d.mts +3 -3
  158. package/dist/seed/index.mjs +4 -4
  159. package/dist/seo/index.mjs +3 -3
  160. package/dist/{settings-CgjRWGW9.mjs → settings-B4vDl1fK.mjs} +2 -2
  161. package/dist/{settings-CgjRWGW9.mjs.map → settings-B4vDl1fK.mjs.map} +1 -1
  162. package/dist/{settings-2xNYr90o.mjs → settings-Dyj6v8C4.mjs} +2 -2
  163. package/dist/{settings-2xNYr90o.mjs.map → settings-Dyj6v8C4.mjs.map} +1 -1
  164. package/dist/{snapshot-CugwUqKQ.mjs → snapshot-PawoX-WG.mjs} +180 -3
  165. package/dist/snapshot-PawoX-WG.mjs.map +1 -0
  166. package/dist/{storage-query-Bdv7A62k.d.mts → storage-query-FXJH5pI8.d.mts} +6 -1
  167. package/dist/storage-query-FXJH5pI8.d.mts.map +1 -0
  168. package/dist/{transfer-BhUCrgDc.mjs → transfer-DgeCvHnj.mjs} +4 -4
  169. package/dist/{transfer-BhUCrgDc.mjs.map → transfer-DgeCvHnj.mjs.map} +1 -1
  170. package/dist/validate-CM79QXYE.d.mts.map +1 -1
  171. package/dist/version-DXsxRrob.mjs +7 -0
  172. package/dist/{version-DUQDHgrf.mjs.map → version-DXsxRrob.mjs.map} +1 -1
  173. package/package.json +5 -5
  174. package/src/api/handlers/index.ts +1 -0
  175. package/src/api/handlers/media-upload.ts +17 -71
  176. package/src/api/handlers/media.ts +97 -3
  177. package/src/api/handlers/snapshot.ts +257 -3
  178. package/src/astro/integration/vite-config.ts +10 -4
  179. package/src/astro/middleware.ts +1 -0
  180. package/src/astro/routes/api/setup/index.ts +66 -23
  181. package/src/astro/routes/api/snapshot.ts +2 -1
  182. package/src/astro/types.ts +5 -0
  183. package/src/cleanup.ts +10 -0
  184. package/src/components/Columns.astro +3 -1
  185. package/src/components/Cover.astro +3 -1
  186. package/src/database/repositories/media.ts +40 -1
  187. package/src/database/repositories/redirect.ts +17 -34
  188. package/src/emdash-runtime.ts +28 -0
  189. package/src/index.ts +1 -0
  190. package/src/mcp/server.ts +13 -48
  191. package/src/seed/apply.ts +322 -41
  192. package/dist/apply-CoT603H3.mjs.map +0 -1
  193. package/dist/config-5ii2haYu.d.mts.map +0 -1
  194. package/dist/emdash-runtime-DAjghyEG.mjs.map +0 -1
  195. package/dist/media-Qp5S3eH3.mjs.map +0 -1
  196. package/dist/media-h7tOiDLj.mjs.map +0 -1
  197. package/dist/redirect-iJf5fltD.mjs.map +0 -1
  198. package/dist/snapshot-CugwUqKQ.mjs.map +0 -1
  199. package/dist/storage-query-Bdv7A62k.d.mts.map +0 -1
  200. package/dist/version-DUQDHgrf.mjs +0 -7
package/src/seed/apply.ts CHANGED
@@ -6,7 +6,15 @@
6
6
  */
7
7
 
8
8
  import { imageSize } from "image-size";
9
- import type { Kysely } from "kysely";
9
+ import type {
10
+ Kysely,
11
+ KyselyPlugin,
12
+ PluginTransformQueryArgs,
13
+ PluginTransformResultArgs,
14
+ QueryResult,
15
+ RootOperationNode,
16
+ UnknownRow,
17
+ } from "kysely";
10
18
  import mime from "mime/lite";
11
19
  import { ulid } from "ulidx";
12
20
 
@@ -23,6 +31,7 @@ import {
23
31
  saveTaxonomyStructure,
24
32
  } from "../database/repositories/taxonomy-def.js";
25
33
  import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
34
+ import type { ContentItem } from "../database/repositories/types.js";
26
35
  import { withTransaction } from "../database/transaction.js";
27
36
  import type { Database } from "../database/types.js";
28
37
  import type { MediaValue } from "../fields/types.js";
@@ -37,10 +46,12 @@ import { FTSManager } from "../search/fts-manager.js";
37
46
  import { invalidateSiteSettingsCache, setSiteSettings } from "../settings/index.js";
38
47
  import type { SiteSettings } from "../settings/types.js";
39
48
  import type { Storage } from "../storage/types.js";
49
+ import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
40
50
  import type {
41
51
  SeedFile,
42
52
  SeedApplyOptions,
43
53
  SeedApplyResult,
54
+ SeedContentEntry,
44
55
  SeedCollection,
45
56
  SeedTaxonomy,
46
57
  SeedTaxonomyTerm,
@@ -158,14 +169,90 @@ export async function applySeed(
158
169
  seed: SeedFile,
159
170
  options: SeedApplyOptions = {},
160
171
  ): Promise<SeedApplyResult> {
161
- return coalesceObjectCacheWrites(() => applySeedWrites(db, seed, options));
172
+ const { result } = await coalesceObjectCacheWrites(() =>
173
+ applySeedWrites(db, seed, options, null),
174
+ );
175
+ return result;
176
+ }
177
+
178
+ /** Per-call limits for `applySeedWithinBudget`. A limit that is left out is not checked. */
179
+ export interface SeedApplyBudget {
180
+ queries?: number;
181
+ mediaDownloads?: number;
182
+ }
183
+
184
+ /** How many of a seed's items (taxonomy terms, bylines, content entries) exist after a call. */
185
+ export interface SeedApplyProgress {
186
+ done: number;
187
+ total: number;
188
+ }
189
+
190
+ /**
191
+ * Apply a seed over several calls, for a platform that caps the queries or
192
+ * fetches of one request.
193
+ *
194
+ * The limits are thresholds, checked before each taxonomy term, byline and
195
+ * content entry is created and before the phases that follow content. Schema,
196
+ * taxonomy definitions and settings are applied in every call, the item that
197
+ * crosses a threshold finishes, and the phases after content (menus,
198
+ * redirects, widgets, sections) run in one call, so set the limits well below
199
+ * the platform's. A call that stops returns `complete: false`; applying the
200
+ * same seed again continues with the first item not yet created. A call
201
+ * creates at least one item before it stops, so repeated calls finish. A
202
+ * `$media` URL used by entries in different calls is downloaded and stored
203
+ * again, as a new media row, in each of those calls. Requires
204
+ * `onConflict: "skip"`.
205
+ */
206
+ export async function applySeedWithinBudget(
207
+ db: Kysely<Database>,
208
+ seed: SeedFile,
209
+ options: SeedApplyOptions,
210
+ limits: SeedApplyBudget,
211
+ ): Promise<{ result: SeedApplyResult; complete: boolean; progress: SeedApplyProgress }> {
212
+ const budget = new SeedBudget(limits);
213
+ return coalesceObjectCacheWrites(() =>
214
+ applySeedWrites(db.withPlugin(budget), seed, options, budget),
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Counts what a budgeted call spends. As a Kysely plugin it sees every query
220
+ * the call runs, including those inside transactions.
221
+ */
222
+ class SeedBudget implements KyselyPlugin {
223
+ #queries = 0;
224
+ #mediaDownloads = 0;
225
+
226
+ constructor(private readonly limits: SeedApplyBudget) {}
227
+
228
+ transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
229
+ this.#queries++;
230
+ return args.node;
231
+ }
232
+
233
+ transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
234
+ return Promise.resolve(args.result);
235
+ }
236
+
237
+ countMediaDownload(): void {
238
+ this.#mediaDownloads++;
239
+ }
240
+
241
+ isSpent(): boolean {
242
+ const { queries, mediaDownloads } = this.limits;
243
+ return (
244
+ (queries !== undefined && this.#queries >= queries) ||
245
+ (mediaDownloads !== undefined && this.#mediaDownloads >= mediaDownloads)
246
+ );
247
+ }
162
248
  }
163
249
 
164
250
  async function applySeedWrites(
165
251
  db: Kysely<Database>,
166
252
  seed: SeedFile,
167
253
  options: SeedApplyOptions,
168
- ): Promise<SeedApplyResult> {
254
+ budget: SeedBudget | null,
255
+ ): Promise<{ result: SeedApplyResult; complete: boolean; progress: SeedApplyProgress }> {
169
256
  // Validate seed first
170
257
  const validation = validateSeed(seed);
171
258
  if (!validation.valid) {
@@ -179,6 +266,10 @@ async function applySeedWrites(
179
266
  onConflict = "skip",
180
267
  } = options;
181
268
 
269
+ if (budget && onConflict !== "skip") {
270
+ throw new Error('A seed budget requires onConflict: "skip"');
271
+ }
272
+
182
273
  // Result counters
183
274
  const result: SeedApplyResult = {
184
275
  blockTypes: { created: 0, skipped: 0, updated: 0 },
@@ -194,6 +285,13 @@ async function applySeedWrites(
194
285
  content: { created: 0, skipped: 0, updated: 0 },
195
286
  media: { created: 0, skipped: 0 },
196
287
  };
288
+ let complete = true;
289
+ const progress: SeedApplyProgress = { done: 0, total: countSeedItems(seed, includeContent) };
290
+ // With a budget, `onConflict` is "skip", so these counters count creations only.
291
+ const mayCreateItem = (): boolean =>
292
+ !budget ||
293
+ result.taxonomies.terms + result.bylines.created + result.content.created === 0 ||
294
+ !budget.isSpent();
197
295
 
198
296
  // Media context for $media resolution
199
297
  const mediaContext: MediaContext = {
@@ -201,6 +299,7 @@ async function applySeedWrites(
201
299
  storage: storage ?? null,
202
300
  skipMediaDownload,
203
301
  mediaCache: new Map(), // Cache downloaded media by URL to avoid re-downloading
302
+ budget,
204
303
  };
205
304
 
206
305
  // Apply order (critical for foreign keys and references):
@@ -482,36 +581,47 @@ async function applySeedWrites(
482
581
  // Create terms (if provided)
483
582
  if (includeContent && taxonomy.terms && taxonomy.terms.length > 0) {
484
583
  const termRepo = new TaxonomyRepository(db);
584
+ const existingTerms = await findExistingSeedTerms(db, taxonomy.name, taxonomy.terms);
485
585
 
486
586
  if (structure.hierarchical) {
487
- await applyHierarchicalTerms(
587
+ const applied = await applyHierarchicalTerms(
488
588
  termRepo,
489
589
  taxonomy.name,
490
590
  defLocale,
491
591
  taxonomy.terms,
592
+ existingTerms,
492
593
  termSeedIdMap,
493
594
  result,
494
595
  onConflict,
596
+ mayCreateItem,
495
597
  );
598
+ progress.done += applied.processed;
599
+ complete = applied.complete;
496
600
  } else {
497
601
  for (const term of taxonomy.terms) {
498
602
  const termLocale = resolveConfiguredLocale(term.locale ?? defLocale);
499
- const existing = await termRepo.findBySlug(taxonomy.name, term.slug, termLocale);
500
- if (existing) {
603
+ const termKey = seedTermKey(termLocale, term.slug);
604
+ const existingId = existingTerms.get(termKey);
605
+ if (existingId) {
501
606
  if (onConflict === "error") {
502
607
  throw new Error(
503
608
  `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" (${termLocale}) already exists`,
504
609
  );
505
610
  }
506
611
  if (onConflict === "update") {
507
- await termRepo.update(existing.id, {
612
+ await termRepo.update(existingId, {
508
613
  label: term.label,
509
614
  data: term.description ? { description: term.description } : {},
510
615
  });
511
616
  result.taxonomies.terms++;
512
617
  }
513
- if (term.id) termSeedIdMap.set(term.id, existing.id);
618
+ if (term.id) termSeedIdMap.set(term.id, existingId);
619
+ progress.done++;
514
620
  } else {
621
+ if (!mayCreateItem()) {
622
+ complete = false;
623
+ break;
624
+ }
515
625
  const translationOf = term.translationOf
516
626
  ? termSeedIdMap.get(term.translationOf)
517
627
  : undefined;
@@ -523,12 +633,15 @@ async function applySeedWrites(
523
633
  locale: termLocale,
524
634
  translationOf,
525
635
  });
636
+ existingTerms.set(termKey, created.id);
526
637
  if (term.id) termSeedIdMap.set(term.id, created.id);
527
638
  result.taxonomies.terms++;
639
+ progress.done++;
528
640
  }
529
641
  }
530
642
  }
531
643
  }
644
+ if (!complete) break;
532
645
  }
533
646
 
534
647
  // Seeded/updated defs change which taxonomies exist — clear the
@@ -539,11 +652,15 @@ async function applySeedWrites(
539
652
  }
540
653
 
541
654
  // 6. Bylines
542
- if (includeContent && seed.bylines) {
655
+ if (complete && includeContent && seed.bylines) {
543
656
  const bylineRepo = new BylineRepository(db);
657
+ const existingBylines = await findExistingSeedBylines(
658
+ db,
659
+ seed.bylines.map((byline) => byline.slug),
660
+ );
544
661
  for (const byline of seed.bylines) {
545
- const existing = await bylineRepo.findBySlug(byline.slug);
546
- if (existing) {
662
+ const existingId = existingBylines.get(byline.slug);
663
+ if (existingId) {
547
664
  if (onConflict === "error") {
548
665
  throw new Error(`Conflict: byline "${byline.slug}" already exists`);
549
666
  }
@@ -555,7 +672,7 @@ async function applySeedWrites(
555
672
  // one untouched.
556
673
  const avatar = byline.avatar ? await resolveSeedBylineAvatar(db, byline.avatar) : null;
557
674
  try {
558
- const updated = await bylineRepo.update(existing.id, {
675
+ const updated = await bylineRepo.update(existingId, {
559
676
  displayName: byline.displayName,
560
677
  bio: byline.bio ?? null,
561
678
  websiteUrl: byline.websiteUrl ?? null,
@@ -563,7 +680,7 @@ async function applySeedWrites(
563
680
  ...(avatar ? { avatarMediaId: avatar.id } : {}),
564
681
  });
565
682
  // update() returns null (no throw) if the row vanished between
566
- // findBySlug and here; treat that as a failure so the catch
683
+ // the lookup and here; treat that as a failure so the catch
567
684
  // cleans up any freshly-created avatar media instead of leaking it.
568
685
  if (!updated) {
569
686
  throw new Error(`Byline "${byline.slug}" disappeared during update`);
@@ -574,18 +691,24 @@ async function applySeedWrites(
574
691
  if (avatar?.created) await deleteMediaRow(db, avatar.id);
575
692
  throw error;
576
693
  }
577
- seedBylineIdMap.set(byline.id, existing.id);
694
+ seedBylineIdMap.set(byline.id, existingId);
578
695
  result.bylines.updated++;
579
696
  if (avatar?.created) result.media.created++;
697
+ progress.done++;
580
698
  continue;
581
699
  }
582
700
 
583
701
  // skip
584
- seedBylineIdMap.set(byline.id, existing.id);
702
+ seedBylineIdMap.set(byline.id, existingId);
585
703
  result.bylines.skipped++;
704
+ progress.done++;
586
705
  continue;
587
706
  }
588
707
 
708
+ if (!mayCreateItem()) {
709
+ complete = false;
710
+ break;
711
+ }
589
712
  const avatar = byline.avatar ? await resolveSeedBylineAvatar(db, byline.avatar) : null;
590
713
  let createdId: string;
591
714
  try {
@@ -602,14 +725,16 @@ async function applySeedWrites(
602
725
  if (avatar?.created) await deleteMediaRow(db, avatar.id);
603
726
  throw error;
604
727
  }
728
+ existingBylines.set(byline.slug, createdId);
605
729
  seedBylineIdMap.set(byline.id, createdId);
606
730
  result.bylines.created++;
607
731
  if (avatar?.created) result.media.created++;
732
+ progress.done++;
608
733
  }
609
734
  }
610
735
 
611
736
  // 7. Content (created before menus so refs can resolve)
612
- if (includeContent && seed.content) {
737
+ if (complete && includeContent && seed.content) {
613
738
  const contentRepo = new ContentRepository(db);
614
739
  const schemaRegistry = new SchemaRegistry(db);
615
740
  // Settings and fields are all written above, so every entry can share the
@@ -624,19 +749,16 @@ async function applySeedWrites(
624
749
  const resolvedBlockTypes = collectionInfo?.fields.some((field) => field.type === "blocks")
625
750
  ? await resolveBlockTypes(db)
626
751
  : undefined;
752
+ const existingEntries = await findExistingSeedEntries(
753
+ contentRepo,
754
+ collectionSlug,
755
+ entries,
756
+ defaultLocale,
757
+ );
627
758
  for (const entry of entries) {
628
- const entrySlug =
629
- typeof entry.slug === "string" && entry.slug.trim().length > 0 ? entry.slug : null;
630
- // Resolve the entry's locale up front so a non-`en` single-locale
631
- // export (which omits `locale`) is filed under the project default
632
- // rather than `en` (#1421).
633
- const entryLocale = resolveConfiguredLocale(entry.locale ?? defaultLocale);
634
-
635
- // Slugful entries use the existing locale-aware key. Slugless seed
636
- // entries persist their seed ID, which keeps re-application idempotent.
637
- const existing = entrySlug
638
- ? await contentRepo.findBySlug(collectionSlug, entrySlug, entryLocale)
639
- : await contentRepo.findById(collectionSlug, entry.id);
759
+ const { slug: entrySlug, locale: entryLocale } = seedEntryIdentity(entry, defaultLocale);
760
+ const entryKey = seedEntryKey(entry, entrySlug, entryLocale);
761
+ const existing = existingEntries.get(entryKey);
640
762
 
641
763
  if (existing) {
642
764
  if (onConflict === "error") {
@@ -738,6 +860,7 @@ async function applySeedWrites(
738
860
 
739
861
  seedIdMap.set(entry.id, existing.id);
740
862
  result.content.updated++;
863
+ progress.done++;
741
864
  await markSeedContentCollectionStale(collectionSlug);
742
865
  continue;
743
866
  }
@@ -745,9 +868,15 @@ async function applySeedWrites(
745
868
  // skip
746
869
  result.content.skipped++;
747
870
  seedIdMap.set(entry.id, existing.id);
871
+ progress.done++;
748
872
  continue;
749
873
  }
750
874
 
875
+ if (!mayCreateItem()) {
876
+ complete = false;
877
+ break;
878
+ }
879
+
751
880
  // Resolve $ref and $media in data
752
881
  let resolvedData = await resolveReferences(entry.data, seedIdMap, mediaContext, result);
753
882
  if (collectionInfo) {
@@ -828,15 +957,26 @@ async function applySeedWrites(
828
957
  }
829
958
 
830
959
  seedIdMap.set(entry.id, created.id);
960
+ existingEntries.set(entryKey, created);
831
961
  result.content.created++;
962
+ progress.done++;
832
963
  await markSeedContentCollectionStale(collectionSlug);
833
964
  }
965
+ if (!complete) break;
834
966
  }
835
967
  } finally {
836
968
  await retryFailedSeedContentStaleMarks();
837
969
  }
838
970
  }
839
971
 
972
+ if (complete && !mayCreateItem()) {
973
+ complete = false;
974
+ }
975
+ if (!complete) {
976
+ await invalidateSeedCaches();
977
+ return { result, complete, progress };
978
+ }
979
+
840
980
  // 8. Menus and Menu Items (after content so refs can resolve)
841
981
  if (seed.menus) {
842
982
  // seed-local id -> resolved info, used to wire `translationOf` refs.
@@ -1064,31 +1204,165 @@ async function applySeedWrites(
1064
1204
  }
1065
1205
  }
1066
1206
 
1067
- // Invalidate caches that may have been affected by seed data.
1068
- // Seed creates bylines, redirects, and collections, all of which
1069
- // have module-level caches in the hot path.
1207
+ await invalidateSeedCaches();
1208
+
1209
+ return { result, complete, progress };
1210
+ }
1211
+
1212
+ function countSeedItems(seed: SeedFile, includeContent: boolean): number {
1213
+ if (!includeContent) return 0;
1214
+ const terms = (seed.taxonomies ?? []).reduce(
1215
+ (total, taxonomy) => total + (taxonomy.terms?.length ?? 0),
1216
+ 0,
1217
+ );
1218
+ const entries = Object.values(seed.content ?? {}).reduce(
1219
+ (total, collectionEntries) => total + collectionEntries.length,
1220
+ 0,
1221
+ );
1222
+ return terms + (seed.bylines?.length ?? 0) + entries;
1223
+ }
1224
+
1225
+ function seedTermKey(locale: string, slug: string): string {
1226
+ return `${locale}::${slug}`;
1227
+ }
1228
+
1229
+ /**
1230
+ * Find which of a taxonomy's seed terms already exist, as term ids keyed by
1231
+ * `seedTermKey`. Each query covers a batch of slugs.
1232
+ */
1233
+ async function findExistingSeedTerms(
1234
+ db: Kysely<Database>,
1235
+ taxonomyName: string,
1236
+ terms: SeedTaxonomyTerm[],
1237
+ ): Promise<Map<string, string>> {
1238
+ const existing = new Map<string, string>();
1239
+ for (const batch of chunks([...new Set(terms.map((term) => term.slug))], SQL_BATCH_SIZE)) {
1240
+ const rows = await db
1241
+ .selectFrom("taxonomies")
1242
+ .select(["id", "slug", "locale"])
1243
+ .where("name", "=", taxonomyName)
1244
+ .where("slug", "in", batch)
1245
+ .execute();
1246
+ for (const row of rows) existing.set(seedTermKey(row.locale, row.slug), row.id);
1247
+ }
1248
+ return existing;
1249
+ }
1250
+
1251
+ /**
1252
+ * Find which seed bylines already exist, as byline ids keyed by slug. A slug
1253
+ * in several locales resolves to the lowest locale code, as
1254
+ * `BylineRepository.findBySlug` does. Each query covers a batch of slugs.
1255
+ */
1256
+ async function findExistingSeedBylines(
1257
+ db: Kysely<Database>,
1258
+ slugs: string[],
1259
+ ): Promise<Map<string, string>> {
1260
+ const existing = new Map<string, string>();
1261
+ for (const batch of chunks([...new Set(slugs)], SQL_BATCH_SIZE)) {
1262
+ const rows = await db
1263
+ .selectFrom("_emdash_bylines")
1264
+ .select(["id", "slug"])
1265
+ .where("slug", "in", batch)
1266
+ .orderBy("locale", "asc")
1267
+ .execute();
1268
+ for (const row of rows) {
1269
+ if (!existing.has(row.slug)) existing.set(row.slug, row.id);
1270
+ }
1271
+ }
1272
+ return existing;
1273
+ }
1274
+
1275
+ /**
1276
+ * Invalidate caches that may have been affected by seed data.
1277
+ * Seed creates bylines, redirects, and collections, all of which
1278
+ * have module-level caches in the hot path.
1279
+ */
1280
+ async function invalidateSeedCaches(): Promise<void> {
1070
1281
  const { invalidateBylineCache } = await import("../bylines/index.js");
1071
1282
  const { invalidateRedirectCache } = await import("../redirects/cache.js");
1072
1283
  const { invalidateUrlPatternCache } = await import("../query.js");
1073
1284
  invalidateBylineCache();
1074
1285
  invalidateRedirectCache();
1075
1286
  invalidateUrlPatternCache();
1287
+ }
1076
1288
 
1077
- return result;
1289
+ function seedEntryIdentity(
1290
+ entry: SeedContentEntry,
1291
+ defaultLocale: string,
1292
+ ): { slug: string | null; locale: string } {
1293
+ return {
1294
+ slug: typeof entry.slug === "string" && entry.slug.trim().length > 0 ? entry.slug : null,
1295
+ // Resolve the entry's locale up front so a non-`en` single-locale
1296
+ // export (which omits `locale`) is filed under the project default
1297
+ // rather than `en`.
1298
+ locale: resolveConfiguredLocale(entry.locale ?? defaultLocale),
1299
+ };
1078
1300
  }
1079
1301
 
1080
1302
  /**
1081
- * Apply hierarchical taxonomy terms (parents before children)
1303
+ * Slugful entries use the existing locale-aware key. Slugless seed entries
1304
+ * persist their seed ID, which keeps re-application idempotent.
1305
+ */
1306
+ function seedEntryKey(entry: SeedContentEntry, slug: string | null, locale: string): string {
1307
+ return slug === null ? `id:${entry.id}` : `slug:${locale}:${slug}`;
1308
+ }
1309
+
1310
+ /**
1311
+ * Find which of a collection's seed entries already exist, keyed by
1312
+ * `seedEntryKey`. Each query covers a batch of entries.
1313
+ */
1314
+ async function findExistingSeedEntries(
1315
+ repo: ContentRepository,
1316
+ collectionSlug: string,
1317
+ entries: SeedContentEntry[],
1318
+ defaultLocale: string,
1319
+ ): Promise<Map<string, ContentItem>> {
1320
+ const identities = entries.map((entry) => ({
1321
+ entry,
1322
+ ...seedEntryIdentity(entry, defaultLocale),
1323
+ }));
1324
+ const slugsByLocale = new Map<string, string[]>();
1325
+ for (const { slug, locale } of identities) {
1326
+ if (slug === null) continue;
1327
+ const slugs = slugsByLocale.get(locale);
1328
+ if (slugs) slugs.push(slug);
1329
+ else slugsByLocale.set(locale, [slug]);
1330
+ }
1331
+
1332
+ const bySlug = new Map<string, Map<string, ContentItem>>();
1333
+ for (const [locale, slugs] of slugsByLocale) {
1334
+ bySlug.set(locale, await repo.findManyBySlugsInLocale(collectionSlug, slugs, locale));
1335
+ }
1336
+ const byId = await repo.findManyByIds(
1337
+ collectionSlug,
1338
+ identities.filter(({ slug }) => slug === null).map(({ entry }) => entry.id),
1339
+ );
1340
+
1341
+ const existing = new Map<string, ContentItem>();
1342
+ for (const { entry, slug, locale } of identities) {
1343
+ const item = slug === null ? byId.get(entry.id) : bySlug.get(locale)?.get(slug);
1344
+ if (item) existing.set(seedEntryKey(entry, slug, locale), item);
1345
+ }
1346
+ return existing;
1347
+ }
1348
+
1349
+ /**
1350
+ * Apply hierarchical taxonomy terms (parents before children). Stops before
1351
+ * creating a term once `mayCreate` returns false, and returns how many terms
1352
+ * were found or created.
1082
1353
  */
1083
1354
  async function applyHierarchicalTerms(
1084
1355
  termRepo: TaxonomyRepository,
1085
1356
  taxonomyName: string,
1086
1357
  defLocale: string,
1087
1358
  terms: SeedTaxonomyTerm[],
1359
+ existingTerms: Map<string, string>,
1088
1360
  termSeedIdMap: Map<string, string>,
1089
1361
  result: SeedApplyResult,
1090
- onConflict: "skip" | "update" | "error" = "skip",
1091
- ): Promise<void> {
1362
+ onConflict: "skip" | "update" | "error",
1363
+ mayCreate: () => boolean,
1364
+ ): Promise<{ processed: number; complete: boolean }> {
1365
+ let processed = 0;
1092
1366
  // "locale::slug" -> id, so the same slug can resolve per locale.
1093
1367
  const slugToId = new Map<string, string>();
1094
1368
  const resolveTermLocale = (term: SeedTaxonomyTerm) =>
@@ -1111,24 +1385,26 @@ async function applyHierarchicalTerms(
1111
1385
  const parentId = term.parent ? slugToId.get(`${termLocale}::${term.parent}`) : undefined;
1112
1386
  const translationOf = term.translationOf ? termSeedIdMap.get(term.translationOf) : undefined;
1113
1387
 
1114
- const existing = await termRepo.findBySlug(taxonomyName, term.slug, termLocale);
1115
- if (existing) {
1388
+ const termKey = seedTermKey(termLocale, term.slug);
1389
+ const existingId = existingTerms.get(termKey);
1390
+ if (existingId) {
1116
1391
  if (onConflict === "error") {
1117
1392
  throw new Error(
1118
1393
  `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" (${termLocale}) already exists`,
1119
1394
  );
1120
1395
  }
1121
1396
  if (onConflict === "update") {
1122
- await termRepo.update(existing.id, {
1397
+ await termRepo.update(existingId, {
1123
1398
  label: term.label,
1124
1399
  parentId,
1125
1400
  data: term.description ? { description: term.description } : {},
1126
1401
  });
1127
1402
  result.taxonomies.terms++;
1128
1403
  }
1129
- slugToId.set(`${termLocale}::${term.slug}`, existing.id);
1130
- if (term.id) termSeedIdMap.set(term.id, existing.id);
1404
+ slugToId.set(termKey, existingId);
1405
+ if (term.id) termSeedIdMap.set(term.id, existingId);
1131
1406
  } else {
1407
+ if (!mayCreate()) return { processed, complete: false };
1132
1408
  const created = await termRepo.create({
1133
1409
  name: taxonomyName,
1134
1410
  slug: term.slug,
@@ -1138,10 +1414,12 @@ async function applyHierarchicalTerms(
1138
1414
  locale: termLocale,
1139
1415
  translationOf,
1140
1416
  });
1141
- slugToId.set(`${termLocale}::${term.slug}`, created.id);
1417
+ existingTerms.set(termKey, created.id);
1418
+ slugToId.set(termKey, created.id);
1142
1419
  if (term.id) termSeedIdMap.set(term.id, created.id);
1143
1420
  result.taxonomies.terms++;
1144
1421
  }
1422
+ processed++;
1145
1423
 
1146
1424
  processedThisPass.push(term.slug + "::" + termLocale);
1147
1425
  }
@@ -1155,6 +1433,7 @@ async function applyHierarchicalTerms(
1155
1433
  if (remaining.length > 0) {
1156
1434
  console.warn(`Could not process ${remaining.length} terms due to missing parents/translations`);
1157
1435
  }
1436
+ return { processed, complete: true };
1158
1437
  }
1159
1438
 
1160
1439
  /**
@@ -1377,6 +1656,7 @@ interface MediaContext {
1377
1656
  storage: Storage | null;
1378
1657
  skipMediaDownload: boolean;
1379
1658
  mediaCache: Map<string, MediaValue>; // URL -> resolved MediaValue
1659
+ budget: SeedBudget | null;
1380
1660
  }
1381
1661
 
1382
1662
  /**
@@ -1563,6 +1843,7 @@ async function resolveMedia(
1563
1843
 
1564
1844
  // Download the media (ssrfSafeFetch re-validates redirect targets)
1565
1845
  console.log(` 📥 Downloading: ${url}`);
1846
+ ctx.budget?.countMediaDownload();
1566
1847
  const response = await ssrfSafeFetch(url, {
1567
1848
  headers: {
1568
1849
  // Some services like Unsplash require a user-agent