@nextsparkjs/core 0.1.0-beta.193 → 0.1.0-beta.194

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 (49) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/api/auth/permissions.d.ts +10 -0
  3. package/dist/lib/api/auth/permissions.d.ts.map +1 -1
  4. package/dist/lib/api/auth/permissions.js +7 -0
  5. package/dist/lib/api/rate-limit.d.ts.map +1 -1
  6. package/dist/lib/api/rate-limit.js +3 -0
  7. package/dist/lib/api/request-origin.d.ts +26 -0
  8. package/dist/lib/api/request-origin.d.ts.map +1 -0
  9. package/dist/lib/api/request-origin.js +46 -0
  10. package/dist/lib/auth.d.ts.map +1 -1
  11. package/dist/lib/auth.js +8 -12
  12. package/dist/lib/entities/external-api-generator.d.ts.map +1 -1
  13. package/dist/lib/entities/external-api-generator.js +3 -1
  14. package/dist/lib/services/subscription.service.d.ts.map +1 -1
  15. package/dist/lib/services/subscription.service.js +3 -0
  16. package/dist/lib/utils/cors.d.ts +7 -0
  17. package/dist/lib/utils/cors.d.ts.map +1 -1
  18. package/dist/lib/utils/cors.js +8 -0
  19. package/dist/routes/api/v1/users/[id]/meta/[key]/route.d.ts.map +1 -1
  20. package/dist/routes/api/v1/users/[id]/meta/[key]/route.js +13 -0
  21. package/dist/routes/api/v1/users/[id]/route.d.ts.map +1 -1
  22. package/dist/routes/api/v1/users/[id]/route.js +17 -0
  23. package/dist/templates/projects/blog/package.json +3 -3
  24. package/dist/templates/projects/crm/package.json +3 -3
  25. package/dist/templates/projects/productivity/package.json +3 -3
  26. package/package.json +8 -5
  27. package/scripts/build/auth-readiness.mjs +4 -0
  28. package/scripts/build/registry/__tests__/build-output.test.mjs +1 -1
  29. package/scripts/build/registry/__tests__/project-mode-root-first.test.mjs +6 -6
  30. package/scripts/build/registry/__tests__/write-places.test.mjs +1 -1
  31. package/scripts/build/registry/contracts/__tests__/contracts-wire.test.mjs +1 -1
  32. package/scripts/build/registry/contracts/__tests__/contracts.test.mjs +1 -1
  33. package/scripts/build/registry/contracts/render.mjs +3 -3
  34. package/scripts/build/registry/host/README.md +2 -2
  35. package/scripts/build/registry/host/__tests__/facade-emitter.test.mjs +9 -9
  36. package/scripts/build/registry/host/__tests__/host-mode.test.mjs +1 -1
  37. package/scripts/build/registry/host/__tests__/host-project-composition.test.mjs +1 -1
  38. package/scripts/build/registry/host/__tests__/host-project.test.mjs +2 -2
  39. package/scripts/build/registry/host/__tests__/host-robustness.test.mjs +65 -2
  40. package/scripts/build/registry/host/facade-emitter.mjs +2 -2
  41. package/scripts/build/registry/host/generation.mjs +54 -0
  42. package/scripts/build/registry/host/next-route-exports.json +2 -2
  43. package/scripts/build/registry/host/prepare.mjs +3 -0
  44. package/scripts/db/migration-time-limit.mjs +216 -34
  45. package/scripts/db/run-migrations.mjs +11 -179
  46. package/scripts/db/verify-theme-migrations.mjs +1 -1
  47. package/templates/projects/blog/package.json +3 -3
  48. package/templates/projects/crm/package.json +3 -3
  49. package/templates/projects/productivity/package.json +3 -3
@@ -28,7 +28,7 @@ const { target, content } = await emitFacade({
28
28
  Every problem is a `FacadeEmitError` carrying all diagnostics (`code`, `file`, `line`, `exportName`,
29
29
  `message`); nothing is dropped silently.
30
30
 
31
- ## Export table (Next.js 16.3.5)
31
+ ## Export table (Next.js 16.3.6)
32
32
 
33
33
  Source of truth: `next-route-exports.json`. The tests fail when the installed Next.js version differs,
34
34
  when Next's `AppSegmentConfigSchemaKeys` or `HTTP_METHODS` change, and when this rendering is stale.
@@ -71,7 +71,7 @@ export { listSlugs as generateStaticParams }
71
71
  Metadata image kinds accept Next's one-digit variants (`icon1`, `apple-icon9`, `opengraph-image2`,
72
72
  `twitter-image3`), mapped to the base kind.
73
73
 
74
- The emitter resolves `next/package.json` once (project first, then core) and accepts only `~16.3.5`: the table's
74
+ The emitter resolves `next/package.json` once (project first, then core) and accepts only `~16.3.6`: the table's
75
75
  `next` is the verified baseline, and any stable 16.3.x patch at or above it is accepted, while other minors/majors,
76
76
  lower patches and prereleases are refused (`NS_HOST_UNSUPPORTED_NEXT_VERSION`). It loads Next's segment config
77
77
  schema from that same package root.
@@ -595,14 +595,14 @@ test('an aliased magic export is forwarded under its source local name', async (
595
595
  assert.match(content, /^export \{ listSlugs as generateStaticParams \}$/m)
596
596
  })
597
597
 
598
- // --- fix round 3: ~16.3.5 version range --------------------------------------
598
+ // --- fix round 3: ~baseline version range (baseline 16.3.6 since 0.1.0-beta.194) ---
599
599
 
600
600
  test('isSupportedNextVersion accepts ~baseline: same minor, stable patch >= baseline', () => {
601
- assert.equal(ROUTE_EXPORT_TABLE.next, '16.3.5')
602
- for (const version of ['16.3.5', '16.3.6', '16.3.9', '16.3.10', '16.3.123', '16.3.5+build.1', '16.3.7+sha.abc-1']) assert.equal(isSupportedNextVersion(version), true, version)
601
+ assert.equal(ROUTE_EXPORT_TABLE.next, '16.3.6')
602
+ for (const version of ['16.3.6', '16.3.7', '16.3.9', '16.3.10', '16.3.123', '16.3.6+build.1', '16.3.7+sha.abc-1']) assert.equal(isSupportedNextVersion(version), true, version)
603
603
  for (const version of [
604
- '16.3.4', '16.3.0', '16.4.0', '16.2.9', '17.0.0', '15.3.5', '16.3.6-canary.0', '16.3.5-rc.1', '16.3.6-canary.0+build.1',
605
- '16.3.4+build.1', '16.3.5+', '16.3.5+bad..meta', '16.3', 'latest', '', undefined,
604
+ '16.3.5', '16.3.4', '16.3.0', '16.4.0', '16.2.9', '17.0.0', '15.3.6', '16.3.7-canary.0', '16.3.6-rc.1', '16.3.7-canary.0+build.1',
605
+ '16.3.5+build.1', '16.3.6+', '16.3.6+bad..meta', '16.3', 'latest', '', undefined,
606
606
  ]) {
607
607
  assert.equal(isSupportedNextVersion(version), false, String(version))
608
608
  }
@@ -625,7 +625,7 @@ test('a later 16.3 patch is accepted and its own schema internal is used', async
625
625
  }
626
626
  })
627
627
 
628
- for (const version of ['16.3.4', '16.4.0', '17.0.0', '16.3.6-canary.0']) {
628
+ for (const version of ['16.3.5', '16.4.0', '17.0.0', '16.3.6-canary.0']) {
629
629
  test(`${DIAGNOSTICS.UNSUPPORTED_NEXT_VERSION}: next@${version} is refused`, async () => {
630
630
  const dir = await fakeNextProject(version, "exports.AppSegmentConfigSchemaKeys = []\nexports.parseAppSegmentConfig = () => ({})\n")
631
631
  try {
@@ -634,7 +634,7 @@ for (const version of ['16.3.4', '16.4.0', '17.0.0', '16.3.6-canary.0']) {
634
634
  error => {
635
635
  assert.deepEqual(error.diagnostics.map(d => d.code), [DIAGNOSTICS.UNSUPPORTED_NEXT_VERSION])
636
636
  assert.match(error.message, new RegExp(`next@${version.replace(/\./g, '\\.')}`))
637
- assert.match(error.message, /next@~16\.3\.5/)
637
+ assert.match(error.message, /next@~16\.3\.6/)
638
638
  return true
639
639
  }
640
640
  )
@@ -646,9 +646,9 @@ for (const version of ['16.3.4', '16.4.0', '17.0.0', '16.3.6-canary.0']) {
646
646
 
647
647
  // --- fix round 4: build metadata ----------------------------------------------
648
648
 
649
- test('a Next.js version with build metadata (16.3.5+build.1) is accepted end to end', async () => {
649
+ test('a Next.js version with build metadata (16.3.6+build.1) is accepted end to end', async () => {
650
650
  const marker = "exports.AppSegmentConfigSchemaKeys = []\nexports.parseAppSegmentConfig = () => ({})\n"
651
- const dir = await fakeNextProject('16.3.5+build.1', marker)
651
+ const dir = await fakeNextProject('16.3.6+build.1', marker)
652
652
  try {
653
653
  assert.equal(loadNextSegmentConfig(dir).error, undefined)
654
654
  const { content } = await emitFacade({ kind: 'page', target: 'a/page.tsx', specifier: '@/a', file: FILE, source: PAGE_WITH_CONFIG, projectRoot: dir })
@@ -17,7 +17,7 @@ const PREPARE_CLI = join(CORE_ROOT, 'scripts/build/registry/host/prepare-cli.mjs
17
17
  function project({ legacyApp = false } = {}) {
18
18
  const root = mkdtempSync(join(tmpdir(), 'nextspark-host-mode-'))
19
19
  write(root, 'nextspark.config.ts', 'export default { plugins: [] }\n')
20
- write(root, 'package.json', JSON.stringify({ name: 'host-mode', dependencies: { next: '16.3.5' } }))
20
+ write(root, 'package.json', JSON.stringify({ name: 'host-mode', dependencies: { next: '16.3.6' } }))
21
21
  write(root, 'templates/pricing/page.tsx', PAGE('Pricing'))
22
22
  if (legacyApp) write(root, 'src/app/layout.tsx', 'export default function Layout({ children }) { return children }\n')
23
23
  return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }
@@ -36,7 +36,7 @@ const LAYOUT = 'export default function Layout({ children }: { children: React.R
36
36
  function project(extra = {}) {
37
37
  const root = mkdtempSync(join(tmpdir(), 'nextspark-host-composition-'))
38
38
  write(root, 'nextspark.config.ts', "export default { plugins: [], billing: { webhookExtensions: { stripe: './lib/billing/stripe-webhook-extensions' } } }\n")
39
- write(root, 'package.json', JSON.stringify({ name: 'composition', dependencies: { next: '16.3.5' } }))
39
+ write(root, 'package.json', JSON.stringify({ name: 'composition', dependencies: { next: '16.3.6' } }))
40
40
  write(root, 'config/theme.config.ts', "export const compositionThemeConfig = { name: 'composition' }\n")
41
41
  write(root, 'styles/globals.css', '@import "tailwindcss";\n')
42
42
  write(root, 'lib/billing/stripe-webhook-extensions.ts', 'export const stripeWebhookExtensions = {}\n')
@@ -16,7 +16,7 @@ const PREPARE_CLI = join(CORE_ROOT, 'scripts/build/registry/host/prepare-cli.mjs
16
16
  function project() {
17
17
  const root = mkdtempSync(join(tmpdir(), 'nextspark-host-project-'))
18
18
  write(root, 'nextspark.config.ts', 'export default { plugins: [] }\n')
19
- write(root, 'package.json', JSON.stringify({ name: 'host-project', dependencies: { next: '16.3.5' } }))
19
+ write(root, 'package.json', JSON.stringify({ name: 'host-project', dependencies: { next: '16.3.6' } }))
20
20
  write(root, 'templates/pricing/page.tsx', PAGE('Pricing'))
21
21
  write(root, 'api/ping/route.ts', ROUTE)
22
22
  return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }
@@ -41,7 +41,7 @@ test('a real project: the registry build is staged and published with the host,
41
41
 
42
42
  const record = readGeneration(root)
43
43
  assert.equal(Object.keys(record.files).length, first.files.length)
44
- assert.ok(record.versions.core && record.versions.next === '16.3.5')
44
+ assert.ok(record.versions.core && record.versions.next === '16.3.6')
45
45
  assert.ok(record.inputs.files['nextspark.config.ts'] && record.inputs.files['templates/pricing/page.tsx'])
46
46
  assert.deepEqual(await checkHost(config), { ok: true, problems: [] })
47
47
 
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { spawn } from 'node:child_process'
4
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
5
  import { hostname, tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
7
7
 
@@ -139,6 +139,69 @@ test('crash mid-publish (SIGKILL): --check reports the host as not fresh, and th
139
139
  }
140
140
  })
141
141
 
142
+ test('what a killed writer leaves (temporary files, a staging directory) is swept by the next prepare instead of blocking it or piling up', async () => {
143
+ const host = tempHost({ 'templates/about/page.tsx': PAGE('About') })
144
+ try {
145
+ const config = hostConfig(host.root)
146
+ await prepareHost(config, { mode: 'production' })
147
+ const leftovers = [
148
+ 'src/app/(public)/.page.tsx.4242.0a1b2c3d.nextspark-tmp',
149
+ '.nextspark/registries/.example.ts.4242.0a1b2c3d.nextspark-tmp',
150
+ '.nextspark/contracts/index.ts.nextspark-tmp',
151
+ '.nextspark/staging-AbC123/registries/example.ts',
152
+ ]
153
+ const kept = ['.nextspark/my-notes.txt', '.nextspark/staging-notes/keep.txt']
154
+ for (const path of [...leftovers, ...kept]) write(host.hostRoot, path, 'x\n')
155
+
156
+ // A temporary file in src/app used to read as "a file NextSpark did not generate": prepare refused to run
157
+ const published = await prepareHost(config, { mode: 'production' })
158
+ assert.deepEqual(published.written, [])
159
+ for (const path of leftovers) assert.equal(existsSync(join(host.hostRoot, path)), false, `${path} is swept`)
160
+ assert.equal(existsSync(join(host.hostRoot, '.nextspark/staging-AbC123')), false)
161
+ // Only the writers' own names go: anything else is the user's
162
+ for (const path of kept) assert.equal(existsSync(join(host.hostRoot, path)), true, `${path} stays`)
163
+ write(host.hostRoot, 'src/app/(public)/page.tsx.nextspark-tmp', 'x\n')
164
+ await assert.rejects(() => prepareHost(config, { mode: 'production' }), error => error instanceof PrepareError && codes(error).includes(GENERATION_DIAGNOSTICS.FOREIGN))
165
+ assert.equal(existsSync(join(host.hostRoot, 'src/app/(public)/page.tsx.nextspark-tmp')), true)
166
+ } finally {
167
+ host.cleanup()
168
+ }
169
+ })
170
+
171
+ test('a scratch registry file a killed writer left is swept and the next prepare completes', async () => {
172
+ const host = tempHost({ 'templates/about/page.tsx': PAGE('About') })
173
+ try {
174
+ const config = hostConfig(host.root, { registries: manyRegistries(20, 'old') })
175
+ await prepareHost(config, { mode: 'production' })
176
+ const scratch = '.nextspark/registries/.r00003.ts.4242.0a1b2c3d.nextspark-tmp'
177
+ write(host.hostRoot, scratch, 'half written')
178
+ const fresh = hostConfig(host.root, { registries: manyRegistries(20, 'new') })
179
+ await prepareHost(fresh, { mode: 'production' })
180
+ assert.equal(existsSync(join(host.hostRoot, scratch)), false)
181
+ assert.deepEqual(await checkHost(fresh), { ok: true, problems: [] })
182
+ } finally {
183
+ host.cleanup()
184
+ }
185
+ })
186
+
187
+ test('the sweep never enters backups or migrate-rollback, and an unreadable directory under .nextspark does not crash prepare', async () => {
188
+ const host = tempHost({ 'templates/about/page.tsx': PAGE('About') })
189
+ const locked = join(host.hostRoot, '.nextspark/unreadable')
190
+ try {
191
+ const config = hostConfig(host.root)
192
+ await prepareHost(config, { mode: 'production' })
193
+ const kept = ['.nextspark/backups/20260101/src/app/.page.tsx.1.0a1b2c3d.nextspark-tmp', '.nextspark/backups/20260101/x.nextspark-tmp', '.nextspark/migrate-rollback/y.nextspark-tmp']
194
+ for (const path of kept) write(host.hostRoot, path, 'x\n')
195
+ write(host.hostRoot, '.nextspark/unreadable/z.nextspark-tmp', 'x\n')
196
+ if (process.getuid?.() !== 0) chmodSync(locked, 0)
197
+ await prepareHost(config, { mode: 'production' })
198
+ for (const path of kept) assert.equal(existsSync(join(host.hostRoot, path)), true, `${path} stays`)
199
+ } finally {
200
+ try { chmodSync(locked, 0o755) } catch {}
201
+ host.cleanup()
202
+ }
203
+ })
204
+
142
205
  test('registries: a file in place of a generated registry is adopted only when a NextSpark registry build wrote it', async () => {
143
206
  const cases = [
144
207
  ['byte-identical', REGISTRY, 'adopted'],
@@ -251,7 +314,7 @@ test('an npm-installed core ships only dist/: the manifest, variants and route m
251
314
  test('the project config is read again for every generation: enabling a plugin in nextspark.config.ts adds its routes', async () => {
252
315
  const root = mkdtempSync(join(tmpdir(), 'nextspark-config-reload-'))
253
316
  try {
254
- write(root, 'package.json', JSON.stringify({ name: 'reload', dependencies: { next: '16.3.5' } }))
317
+ write(root, 'package.json', JSON.stringify({ name: 'reload', dependencies: { next: '16.3.6' } }))
255
318
  write(root, 'nextspark.config.ts', 'export default { plugins: [] }\n')
256
319
  write(root, 'plugins/search/plugin.config.ts', 'export const searchPluginConfig = {}\n')
257
320
  write(root, 'plugins/search/templates/search/page.tsx', PAGE('Search'))
@@ -284,8 +284,8 @@ export function resolveNextPackage(projectRoot = process.cwd()) {
284
284
  /**
285
285
  * Whether `version` is a Next.js the table applies to: the table's `next` is the verified
286
286
  * baseline, and core pins `next` to `~<baseline>`, so any stable patch of the same minor at or
287
- * above the baseline is accepted (16.3.5, 16.3.9, 16.3.5+build.1), while other minors/majors, lower
288
- * patches and prereleases (16.3.6-canary.0) are refused.
287
+ * above the baseline is accepted (16.3.6, 16.3.9, 16.3.6+build.1), while other minors/majors, lower
288
+ * patches and prereleases (16.3.7-canary.0) are refused.
289
289
  */
290
290
  export function isSupportedNextVersion(version, baseline = ROUTE_EXPORT_TABLE.next) {
291
291
  // Stable versions only; build metadata (`+build.1`) is allowed, as npm's `~` range allows it.
@@ -438,6 +438,60 @@ export function acquireLock(hostRoot, { staleAfterMs = DEFAULT_STALE_LOCK_MS, at
438
438
  throw lockedError(path)
439
439
  }
440
440
 
441
+ // ---------------------------------------------------------------------------
442
+ // What a killed writer leaves behind
443
+ // ---------------------------------------------------------------------------
444
+
445
+ // The names of the scratch files and directories the writers create: `writeAtomically` (below),
446
+ // the contracts publisher (`<file>.nextspark-tmp`) and the registry build's staging directory
447
+ // (`fs.mkdtempSync('.nextspark/staging-')`).
448
+ const TEMPORARY_FILE = /^\..+\.\d+\.[0-9a-f]{8}\.nextspark-tmp$/
449
+ const CONTRACTS_TEMPORARY_FILE = /\.nextspark-tmp$/
450
+ const STAGING_DIR = /^staging-[A-Za-z0-9]{6}$/
451
+
452
+ /**
453
+ * Remove what a writer that was killed (SIGKILL, power loss) between creating a scratch file and
454
+ * renaming it leaves behind: its temporary files under `src/app` and `.nextspark`, and a registry
455
+ * build's staging directory. Call it with the lock held, when no live writer can own one of them.
456
+ * Without it a temporary file in `src/app` is a file "NextSpark did not generate", so the next
457
+ * prepare refuses to run until someone deletes it by hand, and the others stay forever.
458
+ * Only names the writers use are touched, never through a symlink; returns the paths removed.
459
+ */
460
+ export function sweepInterruptedWrites(hostRoot, { app = true } = {}) {
461
+ const fs = projectFiles(hostRoot)
462
+ const removed = []
463
+ const entries = dir => {
464
+ try {
465
+ return lstatOrNull(join(hostRoot, dir))?.isDirectory() ? readdirSync(join(hostRoot, dir), { withFileTypes: true }) : []
466
+ } catch {
467
+ return [] // an unreadable directory is skipped: the build reports it
468
+ }
469
+ }
470
+ const visit = (dir, isTemporary, recursive) => {
471
+ for (const entry of entries(dir)) {
472
+ const path = `${dir}/${entry.name}`
473
+ if (entry.isDirectory()) {
474
+ if (recursive) visit(path, isTemporary, true)
475
+ } else if (entry.isFile() && isTemporary.test(entry.name)) {
476
+ fs.unlinkSync(join(hostRoot, path))
477
+ removed.push(path)
478
+ }
479
+ }
480
+ }
481
+ // Only the places the writers use; never `.nextspark/backups` or `.nextspark/migrate-rollback`.
482
+ // src/app belongs to the host only when it generates it (prepareContractsOnly leaves it alone).
483
+ if (app) visit(APP_DIR, TEMPORARY_FILE, true)
484
+ visit('.nextspark', TEMPORARY_FILE, false)
485
+ visit(REGISTRIES_DIR, TEMPORARY_FILE, true)
486
+ visit('.nextspark/contracts', CONTRACTS_TEMPORARY_FILE, true)
487
+ for (const entry of entries('.nextspark')) {
488
+ if (!entry.isDirectory() || !STAGING_DIR.test(entry.name)) continue
489
+ fs.rmSync(join(hostRoot, '.nextspark', entry.name), { recursive: true, force: true })
490
+ removed.push(`.nextspark/${entry.name}`)
491
+ }
492
+ return removed
493
+ }
494
+
441
495
  // ---------------------------------------------------------------------------
442
496
  // Publication
443
497
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "Route file kinds x exports x emitted facade form, for the generated host (#203). Source of truth for host/facade-emitter.mjs; README.md renders it. Verified against the installed Next.js source listed in verifiedAgainst and by the probe builds recorded in README.md. Re-verify and bump `next` when upgrading Next.js: host/__tests__/facade-emitter.test.mjs fails when the installed version differs.",
3
- "next": "16.3.5",
4
- "supportedNext": "~16.3.5 (same major.minor, stable patch >= the verified baseline in \"next\"; enforced by isSupportedNextVersion)",
3
+ "next": "16.3.6",
4
+ "supportedNext": "~16.3.6 (same major.minor, stable patch >= the verified baseline in \"next\"; enforced by isSupportedNextVersion)",
5
5
  "verifiedAgainst": [
6
6
  "next/dist/build/segment-config/app/app-segment-config.js (AppSegmentConfigSchemaKeys, parseAppSegmentConfig)",
7
7
  "next/dist/build/analysis/get-page-static-info.js (checkExports, getAppPageStaticInfo)",
@@ -58,6 +58,7 @@ import {
58
58
  preflight,
59
59
  publishGeneration,
60
60
  readGeneration,
61
+ sweepInterruptedWrites,
61
62
  validateFiles,
62
63
  writeOwnedFile,
63
64
  } from './generation.mjs'
@@ -209,6 +210,7 @@ export async function predictHost(config, { devStatus = false } = {}) {
209
210
  export async function prepareHost(config, { mode = 'development', devStatus = false, cache, staleAfterMs, reportFailure = false, changed = [] } = {}) {
210
211
  const release = acquireLock(config.hostRoot, { staleAfterMs })
211
212
  try {
213
+ sweepInterruptedWrites(config.hostRoot)
212
214
  return await generateAndPublish(config, { mode, devStatus, cache })
213
215
  } catch (error) {
214
216
  // `nextspark dev`: the failure goes to the browser under the same lock, so no other writer's
@@ -283,6 +285,7 @@ export async function prepareContractsOnly(config, { staleAfterMs } = {}) {
283
285
  if (!config.contracts) throw new PrepareError([{ code: 'NS_CONTRACTS_UNAVAILABLE', message: 'this project has no contracts step' }])
284
286
  const release = acquireLock(config.hostRoot, { staleAfterMs })
285
287
  try {
288
+ sweepInterruptedWrites(config.hostRoot, { app: false })
286
289
  const contracts = await planContractsStep(config)
287
290
  return publishContractsStep(config, contracts)
288
291
  } catch (error) {