From 4b11832ef93ec75abada7681d923bd2b55283357 Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 26 Mar 2026 15:27:18 +0100 Subject: [PATCH] fix: prevent stale extension bundle cache when source has bare specifiers ## 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 --- src/domain/datastore/user_datastore_loader.ts | 41 ++++++++-------- src/domain/drivers/user_driver_loader.ts | 41 ++++++++-------- src/domain/models/user_model_loader.ts | 47 ++++++++----------- src/domain/vaults/user_vault_loader.ts | 41 ++++++++-------- 4 files changed, 77 insertions(+), 93 deletions(-) diff --git a/src/domain/datastore/user_datastore_loader.ts b/src/domain/datastore/user_datastore_loader.ts index 0476dc96..2107fba4 100644 --- a/src/domain/datastore/user_datastore_loader.ts +++ b/src/domain/datastore/user_datastore_loader.ts @@ -26,7 +26,6 @@ import { installZodGlobal, rewriteZodImports, sanitizeDataUrlError, - sourceHasBareSpecifiers, uint8ArrayToBase64, } from "../models/bundle.ts"; import { resolveLocalImports } from "../models/local_import_resolver.ts"; @@ -233,33 +232,31 @@ export class UserDatastoreLoader { } } } catch { - if (bundleExists) { - logger - .debug`Using cached datastore bundle for ${relativePath} (freshness check failed — missing dependency)`; - return await Deno.readTextFile(bundlePath); - } + // Freshness check failed (e.g. missing dependency file). + // Fall through to attempt a rebundle rather than using a + // potentially stale cache. } - // If the source uses bare specifiers (e.g., from "zod" instead of - // from "npm:zod@4") and a cached bundle exists, use it — re-bundling - // would fail without a deno.json import map to resolve the specifiers. - if (bundleExists) { - const source = await Deno.readTextFile(absolutePath); - if (sourceHasBareSpecifiers(source)) { + // Try to rebundle from source. If bundling fails (e.g. bare specifiers + // without a deno.json import map) and a cached bundle exists, fall back + // to the cache. The old bundle file is untouched on failure since + // bundleExtension returns the JS string in memory before we write. + try { + const js = await bundleExtension(absolutePath, denoPath); + const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); + await assertSafePath(bundlePath, bundleBoundary); + await Deno.mkdir(dirname(bundlePath), { recursive: true }); + await Deno.writeTextFile(bundlePath, js); + logger.debug`Wrote datastore bundle cache: ${bundlePath}`; + return js; + } catch (bundleError) { + if (bundleExists) { logger - .debug`Using cached datastore bundle for ${relativePath} (source has bare specifiers)`; + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; return await Deno.readTextFile(bundlePath); } + throw bundleError; } - - // Bundle and write to cache - const js = await bundleExtension(absolutePath, denoPath); - const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); - await assertSafePath(bundlePath, bundleBoundary); - await Deno.mkdir(dirname(bundlePath), { recursive: true }); - await Deno.writeTextFile(bundlePath, js); - logger.debug`Wrote datastore bundle cache: ${bundlePath}`; - return js; } // No repo dir — just bundle without caching diff --git a/src/domain/drivers/user_driver_loader.ts b/src/domain/drivers/user_driver_loader.ts index 9e5d72ed..aa39747c 100644 --- a/src/domain/drivers/user_driver_loader.ts +++ b/src/domain/drivers/user_driver_loader.ts @@ -26,7 +26,6 @@ import { installZodGlobal, rewriteZodImports, sanitizeDataUrlError, - sourceHasBareSpecifiers, uint8ArrayToBase64, } from "../models/bundle.ts"; import { resolveLocalImports } from "../models/local_import_resolver.ts"; @@ -232,33 +231,31 @@ export class UserDriverLoader { } } } catch { - if (bundleExists) { - logger - .debug`Using cached driver bundle for ${relativePath} (freshness check failed — missing dependency)`; - return await Deno.readTextFile(bundlePath); - } + // Freshness check failed (e.g. missing dependency file). + // Fall through to attempt a rebundle rather than using a + // potentially stale cache. } - // If the source uses bare specifiers (e.g., from "zod" instead of - // from "npm:zod@4") and a cached bundle exists, use it — re-bundling - // would fail without a deno.json import map to resolve the specifiers. - if (bundleExists) { - const source = await Deno.readTextFile(absolutePath); - if (sourceHasBareSpecifiers(source)) { + // Try to rebundle from source. If bundling fails (e.g. bare specifiers + // without a deno.json import map) and a cached bundle exists, fall back + // to the cache. The old bundle file is untouched on failure since + // bundleExtension returns the JS string in memory before we write. + try { + const js = await bundleExtension(absolutePath, denoPath); + const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); + await assertSafePath(bundlePath, bundleBoundary); + await Deno.mkdir(dirname(bundlePath), { recursive: true }); + await Deno.writeTextFile(bundlePath, js); + logger.debug`Wrote driver bundle cache: ${bundlePath}`; + return js; + } catch (bundleError) { + if (bundleExists) { logger - .debug`Using cached driver bundle for ${relativePath} (source has bare specifiers)`; + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; return await Deno.readTextFile(bundlePath); } + throw bundleError; } - - // Bundle and write to cache - const js = await bundleExtension(absolutePath, denoPath); - const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); - await assertSafePath(bundlePath, bundleBoundary); - await Deno.mkdir(dirname(bundlePath), { recursive: true }); - await Deno.writeTextFile(bundlePath, js); - logger.debug`Wrote driver bundle cache: ${bundlePath}`; - return js; } // No repo dir — just bundle without caching diff --git a/src/domain/models/user_model_loader.ts b/src/domain/models/user_model_loader.ts index 340e074f..1c3beac9 100644 --- a/src/domain/models/user_model_loader.ts +++ b/src/domain/models/user_model_loader.ts @@ -26,7 +26,6 @@ import { installZodGlobal, rewriteZodImports, sanitizeDataUrlError, - sourceHasBareSpecifiers, uint8ArrayToBase64, } from "./bundle.ts"; import { resolveLocalImports } from "./local_import_resolver.ts"; @@ -428,10 +427,7 @@ export class UserModelLoader { ); // Check mtime-based cache against all local dependencies. - // If the bundle exists but freshness cannot be determined (e.g. a - // dependency file is missing because the extension was pushed with an - // older swamp that had a single-line import regex), fall back to the - // cached bundle rather than attempting a re-bundle that will also fail. + // If the bundle is newer than all source files, use it directly. let bundleExists = false; try { const bundleStat = await Deno.stat(bundlePath); @@ -458,34 +454,31 @@ export class UserModelLoader { } } } catch { - if (bundleExists) { - logger - .debug`Using cached bundle for ${relativePath} (freshness check failed — missing dependency)`; - return await Deno.readTextFile(bundlePath); - } + // Freshness check failed (e.g. missing dependency file). + // Fall through to attempt a rebundle rather than using a + // potentially stale cache. } - // If the source uses bare specifiers (e.g., from "zod" instead of - // from "npm:zod@4") and a cached bundle exists, use it — re-bundling - // would fail without a deno.json import map to resolve the specifiers. - // This handles pulled extensions that were built with a deno.json project. - if (bundleExists) { - const source = await Deno.readTextFile(absolutePath); - if (sourceHasBareSpecifiers(source)) { + // Try to rebundle from source. If bundling fails (e.g. bare specifiers + // without a deno.json import map) and a cached bundle exists, fall back + // to the cache. The old bundle file is untouched on failure since + // bundleExtension returns the JS string in memory before we write. + try { + const js = await bundleExtension(absolutePath, denoPath); + const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); + await assertSafePath(bundlePath, bundleBoundary); + await Deno.mkdir(dirname(bundlePath), { recursive: true }); + await Deno.writeTextFile(bundlePath, js); + logger.debug`Wrote bundle cache: ${bundlePath}`; + return js; + } catch (bundleError) { + if (bundleExists) { logger - .debug`Using cached bundle for ${relativePath} (source has bare specifiers)`; + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; return await Deno.readTextFile(bundlePath); } + throw bundleError; } - - // Bundle and write to cache - const js = await bundleExtension(absolutePath, denoPath); - const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); - await assertSafePath(bundlePath, bundleBoundary); - await Deno.mkdir(dirname(bundlePath), { recursive: true }); - await Deno.writeTextFile(bundlePath, js); - logger.debug`Wrote bundle cache: ${bundlePath}`; - return js; } // No repo dir — just bundle without caching diff --git a/src/domain/vaults/user_vault_loader.ts b/src/domain/vaults/user_vault_loader.ts index 40451445..a9d0d478 100644 --- a/src/domain/vaults/user_vault_loader.ts +++ b/src/domain/vaults/user_vault_loader.ts @@ -26,7 +26,6 @@ import { installZodGlobal, rewriteZodImports, sanitizeDataUrlError, - sourceHasBareSpecifiers, uint8ArrayToBase64, } from "../models/bundle.ts"; import { resolveLocalImports } from "../models/local_import_resolver.ts"; @@ -239,33 +238,31 @@ export class UserVaultLoader { } } } catch { - if (bundleExists) { - logger - .debug`Using cached vault bundle for ${relativePath} (freshness check failed — missing dependency)`; - return await Deno.readTextFile(bundlePath); - } + // Freshness check failed (e.g. missing dependency file). + // Fall through to attempt a rebundle rather than using a + // potentially stale cache. } - // If the source uses bare specifiers (e.g., from "zod" instead of - // from "npm:zod@4") and a cached bundle exists, use it — re-bundling - // would fail without a deno.json import map to resolve the specifiers. - if (bundleExists) { - const source = await Deno.readTextFile(absolutePath); - if (sourceHasBareSpecifiers(source)) { + // Try to rebundle from source. If bundling fails (e.g. bare specifiers + // without a deno.json import map) and a cached bundle exists, fall back + // to the cache. The old bundle file is untouched on failure since + // bundleExtension returns the JS string in memory before we write. + try { + const js = await bundleExtension(absolutePath, denoPath); + const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); + await assertSafePath(bundlePath, bundleBoundary); + await Deno.mkdir(dirname(bundlePath), { recursive: true }); + await Deno.writeTextFile(bundlePath, js); + logger.debug`Wrote vault bundle cache: ${bundlePath}`; + return js; + } catch (bundleError) { + if (bundleExists) { logger - .debug`Using cached vault bundle for ${relativePath} (source has bare specifiers)`; + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; return await Deno.readTextFile(bundlePath); } + throw bundleError; } - - // Bundle and write to cache - const js = await bundleExtension(absolutePath, denoPath); - const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR); - await assertSafePath(bundlePath, bundleBoundary); - await Deno.mkdir(dirname(bundlePath), { recursive: true }); - await Deno.writeTextFile(bundlePath, js); - logger.debug`Wrote vault bundle cache: ${bundlePath}`; - return js; } // No repo dir — just bundle without caching