diff --git a/src/domain/datastore/user_datastore_loader.ts b/src/domain/datastore/user_datastore_loader.ts index 8fa7e8b1..2311c823 100644 --- a/src/domain/datastore/user_datastore_loader.ts +++ b/src/domain/datastore/user_datastore_loader.ts @@ -251,7 +251,7 @@ export class UserDatastoreLoader { }, null, ); - if (newestSourceMtime && bundleStat.mtime > newestSourceMtime) { + if (newestSourceMtime && bundleStat.mtime >= newestSourceMtime) { logger.debug`Using cached datastore bundle for ${relativePath}`; return await Deno.readTextFile(bundlePath); } diff --git a/src/domain/drivers/user_driver_loader.ts b/src/domain/drivers/user_driver_loader.ts index 45b2a6e5..49a4dd06 100644 --- a/src/domain/drivers/user_driver_loader.ts +++ b/src/domain/drivers/user_driver_loader.ts @@ -276,7 +276,7 @@ export class UserDriverLoader { }, null, ); - if (newestSourceMtime && bundleStat.mtime > newestSourceMtime) { + if (newestSourceMtime && bundleStat.mtime >= newestSourceMtime) { logger.debug`Using cached driver bundle for ${relativePath}`; return await Deno.readTextFile(bundlePath); } diff --git a/src/domain/models/user_model_loader.ts b/src/domain/models/user_model_loader.ts index b20933a4..60413166 100644 --- a/src/domain/models/user_model_loader.ts +++ b/src/domain/models/user_model_loader.ts @@ -1156,7 +1156,7 @@ export class UserModelLoader { }, null, ); - if (newestSourceMtime && bundleStat.mtime > newestSourceMtime) { + if (newestSourceMtime && bundleStat.mtime >= newestSourceMtime) { logger.debug`Using cached bundle for ${relativePath}`; return await Deno.readTextFile(bundlePath); } diff --git a/src/domain/models/user_model_loader_test.ts b/src/domain/models/user_model_loader_test.ts index b2b722ab..adc0b905 100644 --- a/src/domain/models/user_model_loader_test.ts +++ b/src/domain/models/user_model_loader_test.ts @@ -2539,6 +2539,72 @@ export const model = { } }); +Deno.test("UserModelLoader bundleWithCache uses cache when source and bundle have equal mtimes", async () => { + const ts = Date.now(); + const modelCode = ` +import { z } from "npm:zod@4"; + +export const model = { + type: "@user/equal-mtime-${ts}", + version: "2026.02.09.1", + methods: { + run: { + description: "Run", + arguments: z.object({}), + execute: async () => ({ dataHandles: [] }), + }, + }, +}; +`; + + const repoDir = await Deno.makeTempDir({ + prefix: "swamp_equal_mtime_repo_", + }); + const modelsDir = await Deno.makeTempDir({ + prefix: "swamp_equal_mtime_models_", + }); + + try { + // Write model and load to produce cached bundle + await Deno.writeTextFile(join(modelsDir, "model.ts"), modelCode); + const loader1 = new UserModelLoader(testDenoRuntime, repoDir); + await loader1.loadModels(modelsDir); + + const ns = bundleNamespace(modelsDir, repoDir); + const bundlePath = join(repoDir, ".swamp", "bundles", ns, "model.js"); + const cachedBundle = await Deno.readTextFile(bundlePath); + + // Set source and bundle to the exact same mtime + const sharedMtime = new Date("2026-01-01T00:00:00Z"); + await Deno.utime(join(modelsDir, "model.ts"), sharedMtime, sharedMtime); + await Deno.utime(bundlePath, sharedMtime, sharedMtime); + + // Load again — should use cached bundle, not rebundle + const loader2 = new UserModelLoader(testDenoRuntime, repoDir); + await loader2.loadModels(modelsDir); + + const bundleAfter = await Deno.readTextFile(bundlePath); + + // Bundle content should be identical (cache was used, not rebundled) + assertEquals( + cachedBundle, + bundleAfter, + "Bundle content should be unchanged when source and bundle have equal mtimes", + ); + + // Bundle mtime should still be the shared time (not updated by a rebundle) + const bundleStatAfter = await Deno.stat(bundlePath); + assertEquals( + bundleStatAfter.mtime?.getTime(), + sharedMtime.getTime(), + "Bundle mtime should be unchanged — cache was used, no rebundle occurred", + ); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(modelsDir, { recursive: true }); + } +}); + Deno.test("UserModelLoader: accepts optional DatastorePathResolver", () => { // Verify the constructor accepts a resolver without errors const mockResolver = { diff --git a/src/domain/reports/user_report_loader.ts b/src/domain/reports/user_report_loader.ts index 5bd1719e..edc90854 100644 --- a/src/domain/reports/user_report_loader.ts +++ b/src/domain/reports/user_report_loader.ts @@ -686,7 +686,7 @@ export class UserReportLoader { }, null, ); - if (newestSourceMtime && bundleStat.mtime > newestSourceMtime) { + if (newestSourceMtime && bundleStat.mtime >= newestSourceMtime) { logger.debug`Using cached report bundle for ${relativePath}`; return await Deno.readTextFile(bundlePath); } diff --git a/src/domain/vaults/user_vault_loader.ts b/src/domain/vaults/user_vault_loader.ts index a5210fd1..a6a0f0fc 100644 --- a/src/domain/vaults/user_vault_loader.ts +++ b/src/domain/vaults/user_vault_loader.ts @@ -277,7 +277,7 @@ export class UserVaultLoader { }, null, ); - if (newestSourceMtime && bundleStat.mtime > newestSourceMtime) { + if (newestSourceMtime && bundleStat.mtime >= newestSourceMtime) { logger.debug`Using cached vault bundle for ${relativePath}`; return await Deno.readTextFile(bundlePath); }