Skip to content

Commit 308c297

Browse files
authored
fix: prevent stale extension bundle cache when source has bare specifiers (#876)
## Summary - Extension loaders (models, vaults, drivers, datastores) could serve stale cached bundles even when source files had changed, if the source used bare specifiers like from "zod" - The mtime check correctly detected staleness, but the bare specifier fallback unconditionally returned the cached bundle, bypassing the freshness result - Changed all four loaders to attempt rebundling first, falling back to cached bundle only if rebundling fails (e.g. pulled extensions without a deno.json import map) ## Test plan - 3562 unit tests pass - Verified stale bundle is replaced when source changes (backdated bundle mtime, confirmed rebundle on next run) - Verified pulled extensions with bare specifiers and pre-built bundles still load (@swamp/hetzner-cloud with 11 models in a repo without deno.json) - Verified rebundle failure logs a warning and falls back to cached bundle safely
1 parent 17ae7ae commit 308c297

4 files changed

Lines changed: 77 additions & 93 deletions

File tree

src/domain/datastore/user_datastore_loader.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
installZodGlobal,
2727
rewriteZodImports,
2828
sanitizeDataUrlError,
29-
sourceHasBareSpecifiers,
3029
uint8ArrayToBase64,
3130
} from "../models/bundle.ts";
3231
import { resolveLocalImports } from "../models/local_import_resolver.ts";
@@ -233,33 +232,31 @@ export class UserDatastoreLoader {
233232
}
234233
}
235234
} catch {
236-
if (bundleExists) {
237-
logger
238-
.debug`Using cached datastore bundle for ${relativePath} (freshness check failed — missing dependency)`;
239-
return await Deno.readTextFile(bundlePath);
240-
}
235+
// Freshness check failed (e.g. missing dependency file).
236+
// Fall through to attempt a rebundle rather than using a
237+
// potentially stale cache.
241238
}
242239

243-
// If the source uses bare specifiers (e.g., from "zod" instead of
244-
// from "npm:zod@4") and a cached bundle exists, use it — re-bundling
245-
// would fail without a deno.json import map to resolve the specifiers.
246-
if (bundleExists) {
247-
const source = await Deno.readTextFile(absolutePath);
248-
if (sourceHasBareSpecifiers(source)) {
240+
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
241+
// without a deno.json import map) and a cached bundle exists, fall back
242+
// to the cache. The old bundle file is untouched on failure since
243+
// bundleExtension returns the JS string in memory before we write.
244+
try {
245+
const js = await bundleExtension(absolutePath, denoPath);
246+
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
247+
await assertSafePath(bundlePath, bundleBoundary);
248+
await Deno.mkdir(dirname(bundlePath), { recursive: true });
249+
await Deno.writeTextFile(bundlePath, js);
250+
logger.debug`Wrote datastore bundle cache: ${bundlePath}`;
251+
return js;
252+
} catch (bundleError) {
253+
if (bundleExists) {
249254
logger
250-
.debug`Using cached datastore bundle for ${relativePath} (source has bare specifiers)`;
255+
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`;
251256
return await Deno.readTextFile(bundlePath);
252257
}
258+
throw bundleError;
253259
}
254-
255-
// Bundle and write to cache
256-
const js = await bundleExtension(absolutePath, denoPath);
257-
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
258-
await assertSafePath(bundlePath, bundleBoundary);
259-
await Deno.mkdir(dirname(bundlePath), { recursive: true });
260-
await Deno.writeTextFile(bundlePath, js);
261-
logger.debug`Wrote datastore bundle cache: ${bundlePath}`;
262-
return js;
263260
}
264261

265262
// No repo dir — just bundle without caching

src/domain/drivers/user_driver_loader.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
installZodGlobal,
2727
rewriteZodImports,
2828
sanitizeDataUrlError,
29-
sourceHasBareSpecifiers,
3029
uint8ArrayToBase64,
3130
} from "../models/bundle.ts";
3231
import { resolveLocalImports } from "../models/local_import_resolver.ts";
@@ -232,33 +231,31 @@ export class UserDriverLoader {
232231
}
233232
}
234233
} catch {
235-
if (bundleExists) {
236-
logger
237-
.debug`Using cached driver bundle for ${relativePath} (freshness check failed — missing dependency)`;
238-
return await Deno.readTextFile(bundlePath);
239-
}
234+
// Freshness check failed (e.g. missing dependency file).
235+
// Fall through to attempt a rebundle rather than using a
236+
// potentially stale cache.
240237
}
241238

242-
// If the source uses bare specifiers (e.g., from "zod" instead of
243-
// from "npm:zod@4") and a cached bundle exists, use it — re-bundling
244-
// would fail without a deno.json import map to resolve the specifiers.
245-
if (bundleExists) {
246-
const source = await Deno.readTextFile(absolutePath);
247-
if (sourceHasBareSpecifiers(source)) {
239+
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
240+
// without a deno.json import map) and a cached bundle exists, fall back
241+
// to the cache. The old bundle file is untouched on failure since
242+
// bundleExtension returns the JS string in memory before we write.
243+
try {
244+
const js = await bundleExtension(absolutePath, denoPath);
245+
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
246+
await assertSafePath(bundlePath, bundleBoundary);
247+
await Deno.mkdir(dirname(bundlePath), { recursive: true });
248+
await Deno.writeTextFile(bundlePath, js);
249+
logger.debug`Wrote driver bundle cache: ${bundlePath}`;
250+
return js;
251+
} catch (bundleError) {
252+
if (bundleExists) {
248253
logger
249-
.debug`Using cached driver bundle for ${relativePath} (source has bare specifiers)`;
254+
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`;
250255
return await Deno.readTextFile(bundlePath);
251256
}
257+
throw bundleError;
252258
}
253-
254-
// Bundle and write to cache
255-
const js = await bundleExtension(absolutePath, denoPath);
256-
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
257-
await assertSafePath(bundlePath, bundleBoundary);
258-
await Deno.mkdir(dirname(bundlePath), { recursive: true });
259-
await Deno.writeTextFile(bundlePath, js);
260-
logger.debug`Wrote driver bundle cache: ${bundlePath}`;
261-
return js;
262259
}
263260

264261
// No repo dir — just bundle without caching

src/domain/models/user_model_loader.ts

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
installZodGlobal,
2727
rewriteZodImports,
2828
sanitizeDataUrlError,
29-
sourceHasBareSpecifiers,
3029
uint8ArrayToBase64,
3130
} from "./bundle.ts";
3231
import { resolveLocalImports } from "./local_import_resolver.ts";
@@ -428,10 +427,7 @@ export class UserModelLoader {
428427
);
429428

430429
// Check mtime-based cache against all local dependencies.
431-
// If the bundle exists but freshness cannot be determined (e.g. a
432-
// dependency file is missing because the extension was pushed with an
433-
// older swamp that had a single-line import regex), fall back to the
434-
// cached bundle rather than attempting a re-bundle that will also fail.
430+
// If the bundle is newer than all source files, use it directly.
435431
let bundleExists = false;
436432
try {
437433
const bundleStat = await Deno.stat(bundlePath);
@@ -458,34 +454,31 @@ export class UserModelLoader {
458454
}
459455
}
460456
} catch {
461-
if (bundleExists) {
462-
logger
463-
.debug`Using cached bundle for ${relativePath} (freshness check failed — missing dependency)`;
464-
return await Deno.readTextFile(bundlePath);
465-
}
457+
// Freshness check failed (e.g. missing dependency file).
458+
// Fall through to attempt a rebundle rather than using a
459+
// potentially stale cache.
466460
}
467461

468-
// If the source uses bare specifiers (e.g., from "zod" instead of
469-
// from "npm:zod@4") and a cached bundle exists, use it — re-bundling
470-
// would fail without a deno.json import map to resolve the specifiers.
471-
// This handles pulled extensions that were built with a deno.json project.
472-
if (bundleExists) {
473-
const source = await Deno.readTextFile(absolutePath);
474-
if (sourceHasBareSpecifiers(source)) {
462+
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
463+
// without a deno.json import map) and a cached bundle exists, fall back
464+
// to the cache. The old bundle file is untouched on failure since
465+
// bundleExtension returns the JS string in memory before we write.
466+
try {
467+
const js = await bundleExtension(absolutePath, denoPath);
468+
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
469+
await assertSafePath(bundlePath, bundleBoundary);
470+
await Deno.mkdir(dirname(bundlePath), { recursive: true });
471+
await Deno.writeTextFile(bundlePath, js);
472+
logger.debug`Wrote bundle cache: ${bundlePath}`;
473+
return js;
474+
} catch (bundleError) {
475+
if (bundleExists) {
475476
logger
476-
.debug`Using cached bundle for ${relativePath} (source has bare specifiers)`;
477+
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`;
477478
return await Deno.readTextFile(bundlePath);
478479
}
480+
throw bundleError;
479481
}
480-
481-
// Bundle and write to cache
482-
const js = await bundleExtension(absolutePath, denoPath);
483-
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
484-
await assertSafePath(bundlePath, bundleBoundary);
485-
await Deno.mkdir(dirname(bundlePath), { recursive: true });
486-
await Deno.writeTextFile(bundlePath, js);
487-
logger.debug`Wrote bundle cache: ${bundlePath}`;
488-
return js;
489482
}
490483

491484
// No repo dir — just bundle without caching

src/domain/vaults/user_vault_loader.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
installZodGlobal,
2727
rewriteZodImports,
2828
sanitizeDataUrlError,
29-
sourceHasBareSpecifiers,
3029
uint8ArrayToBase64,
3130
} from "../models/bundle.ts";
3231
import { resolveLocalImports } from "../models/local_import_resolver.ts";
@@ -239,33 +238,31 @@ export class UserVaultLoader {
239238
}
240239
}
241240
} catch {
242-
if (bundleExists) {
243-
logger
244-
.debug`Using cached vault bundle for ${relativePath} (freshness check failed — missing dependency)`;
245-
return await Deno.readTextFile(bundlePath);
246-
}
241+
// Freshness check failed (e.g. missing dependency file).
242+
// Fall through to attempt a rebundle rather than using a
243+
// potentially stale cache.
247244
}
248245

249-
// If the source uses bare specifiers (e.g., from "zod" instead of
250-
// from "npm:zod@4") and a cached bundle exists, use it — re-bundling
251-
// would fail without a deno.json import map to resolve the specifiers.
252-
if (bundleExists) {
253-
const source = await Deno.readTextFile(absolutePath);
254-
if (sourceHasBareSpecifiers(source)) {
246+
// Try to rebundle from source. If bundling fails (e.g. bare specifiers
247+
// without a deno.json import map) and a cached bundle exists, fall back
248+
// to the cache. The old bundle file is untouched on failure since
249+
// bundleExtension returns the JS string in memory before we write.
250+
try {
251+
const js = await bundleExtension(absolutePath, denoPath);
252+
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
253+
await assertSafePath(bundlePath, bundleBoundary);
254+
await Deno.mkdir(dirname(bundlePath), { recursive: true });
255+
await Deno.writeTextFile(bundlePath, js);
256+
logger.debug`Wrote vault bundle cache: ${bundlePath}`;
257+
return js;
258+
} catch (bundleError) {
259+
if (bundleExists) {
255260
logger
256-
.debug`Using cached vault bundle for ${relativePath} (source has bare specifiers)`;
261+
.warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`;
257262
return await Deno.readTextFile(bundlePath);
258263
}
264+
throw bundleError;
259265
}
260-
261-
// Bundle and write to cache
262-
const js = await bundleExtension(absolutePath, denoPath);
263-
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
264-
await assertSafePath(bundlePath, bundleBoundary);
265-
await Deno.mkdir(dirname(bundlePath), { recursive: true });
266-
await Deno.writeTextFile(bundlePath, js);
267-
logger.debug`Wrote vault bundle cache: ${bundlePath}`;
268-
return js;
269266
}
270267

271268
// No repo dir — just bundle without caching

0 commit comments

Comments
 (0)