Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 122 additions & 1 deletion src/cli/resolve_datastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* Default: filesystem datastore at `{repoDir}/.swamp/` (full backward compatibility)
*/

import { join } from "@std/path";
import { join, resolve } from "@std/path";
import { getLogger } from "@logtape/logtape";
import type { RepoMarkerData } from "../infrastructure/persistence/repo_marker_repository.ts";
import type { DatastoreConfig } from "../domain/datastore/datastore_config.ts";
Expand All @@ -41,6 +41,16 @@ import { datastoreTypeRegistry } from "../domain/datastore/datastore_type_regist
import { UserError } from "../domain/errors.ts";
import { resolveDatastoreType } from "../domain/extensions/extension_auto_resolver.ts";
import { getAutoResolver } from "../domain/extensions/auto_resolver_context.ts";
import { maybeAutoUpdateDatastoreExtension } from "../libswamp/extensions/datastore_auto_update.ts";
import { FileExtensionUpdateCheckRepository } from "../infrastructure/persistence/extension_update_check_repository.ts";
import { readUpstreamExtensions } from "../infrastructure/persistence/upstream_extensions.ts";
import { ExtensionApiClient } from "../infrastructure/http/extension_api_client.ts";
import { installExtension } from "../libswamp/mod.ts";
import { UserDatastoreLoader } from "../domain/datastore/user_datastore_loader.ts";
import { EmbeddedDenoRuntime } from "../infrastructure/runtime/embedded_deno_runtime.ts";
import { swampPath } from "../infrastructure/persistence/paths.ts";
import { SWAMP_SUBDIRS } from "../infrastructure/persistence/paths.ts";
import { resolveModelsDir } from "./resolve_models_dir.ts";

const logger = getLogger(["swamp", "datastore", "resolve"]);

Expand All @@ -52,6 +62,109 @@ export const RENAMED_DATASTORE_TYPES: Record<string, string> = {
"s3": "@swamp/s3-datastore",
};

/**
* Checks if a @swamp/ datastore extension has an update available and
* auto-pulls if so. Called from all @swamp/ resolution paths, placed
* after resolveDatastoreType() but before createProvider().
*
* Never throws — failures are logged and silently ignored.
*/
async function maybeAutoUpdateSwampDatastore(
type: string,
repoDir: string,
marker: RepoMarkerData | null,
): Promise<void> {
if (!type.startsWith("@swamp/")) return;

try {
const resolvedRepoDir = resolve(repoDir);
const swampDir = join(resolvedRepoDir, ".swamp");
const modelsDir = resolveModelsDir(marker);
const lockfilePath = join(
resolvedRepoDir,
modelsDir,
"upstream_extensions.json",
);
logger.debug("Auto-update check for {type}, lockfile: {path}", {
type,
path: lockfilePath,
});
const serverUrl = Deno.env.get("SWAMP_CLUB_URL") ?? "https://swamp.club";
const extensionClient = new ExtensionApiClient(serverUrl);
const cacheRepository = new FileExtensionUpdateCheckRepository(swampDir);

const result = await maybeAutoUpdateDatastoreExtension(type, {
getInstalledVersion: async (name) => {
const upstream = await readUpstreamExtensions(lockfilePath);
return upstream[name]?.version ?? null;
},
getLatestVersion: async (name) => {
try {
const info = await extensionClient.getExtension(name);
return info?.latestVersion ?? null;
} catch {
return null;
}
},
pullExtension: async (name, version) => {
const resolvedRepoDir = resolve(repoDir);
const datastoresDir = swampPath(
resolvedRepoDir,
SWAMP_SUBDIRS.pulledDatastores,
);

// Pull the extension with the specific version
await installExtension(
{ name, version },
{
getExtension: (n) => extensionClient.getExtension(n),
downloadArchive: (n, v) => extensionClient.downloadArchive(n, v),
getChecksum: (n, v) => extensionClient.getChecksum(n, v),
logger,
lockfilePath,
modelsDir: swampPath(resolvedRepoDir, SWAMP_SUBDIRS.pulledModels),
workflowsDir: swampPath(
resolvedRepoDir,
SWAMP_SUBDIRS.pulledWorkflows,
),
vaultsDir: swampPath(resolvedRepoDir, SWAMP_SUBDIRS.pulledVaults),
driversDir: swampPath(
resolvedRepoDir,
SWAMP_SUBDIRS.pulledDrivers,
),
datastoresDir,
reportsDir: swampPath(
resolvedRepoDir,
SWAMP_SUBDIRS.pulledReports,
),
repoDir: resolvedRepoDir,
force: true,
alreadyPulled: new Set(),
depth: 0,
},
);

// Hot-reload the datastore type from the updated extension
const denoRuntime = new EmbeddedDenoRuntime();
const loader = new UserDatastoreLoader(denoRuntime, resolvedRepoDir);
await loader.loadDatastores(datastoresDir, {
skipAlreadyRegistered: false,
});
},
cacheRepository,
});

if (result?.updated) {
logger.info(
"Updated {name} {from} → {to}",
{ name: type, from: result.previousVersion, to: result.newVersion },
);
}
} catch {
// Never block command execution for auto-update failures
}
}

/**
* Parses the SWAMP_DATASTORE env var format into a DatastoreConfig.
*
Expand Down Expand Up @@ -96,6 +209,7 @@ export async function parseDatastoreEnvVar(

// Auto-resolve the extension if not already loaded
await resolveDatastoreType(renamedTo, getAutoResolver());
await maybeAutoUpdateSwampDatastore(renamedTo, repoDir ?? ".", null);

const typeInfo = datastoreTypeRegistry.get(renamedTo);
if (typeInfo?.createProvider) {
Expand Down Expand Up @@ -136,6 +250,7 @@ export async function parseDatastoreEnvVar(
// Auto-resolve extension types
if (type.startsWith("@")) {
await resolveDatastoreType(type, getAutoResolver());
await maybeAutoUpdateSwampDatastore(type, repoDir ?? ".", null);
}

// Custom datastore type: value is JSON config
Expand Down Expand Up @@ -235,6 +350,11 @@ export async function resolveDatastoreConfig(

// Auto-resolve the extension if not already loaded
await resolveDatastoreType(renamedTo, getAutoResolver());
await maybeAutoUpdateSwampDatastore(
renamedTo,
repoDir ?? ".",
marker ?? null,
);

const typeInfo = datastoreTypeRegistry.get(renamedTo);
if (typeInfo?.createProvider) {
Expand Down Expand Up @@ -296,6 +416,7 @@ export async function resolveDatastoreConfig(
// Auto-resolve extension types
if (dsType.startsWith("@")) {
await resolveDatastoreType(dsType, getAutoResolver());
await maybeAutoUpdateSwampDatastore(dsType, repoDir ?? ".", marker);
}

// Custom datastore type from YAML config
Expand Down
59 changes: 59 additions & 0 deletions src/domain/extensions/extension_update_check_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { CHECK_INTERVAL_MS } from "../update/update_check_cache.ts";

/** Cached update check result for a single extension. */
export interface ExtensionUpdateCheckEntry {
checkedAt: string; // ISO 8601
latestVersion: string;
}

/**
* Map of extension name → last update check result.
*
* Stored in .swamp/extension-update-checks.json. For S3-backed datastores
* this file is synced to the remote, so all machines sharing the datastore
* share the 24h cooldown — avoiding redundant registry checks.
*/
export type ExtensionUpdateCheckMap = Record<string, ExtensionUpdateCheckEntry>;

/** Port for reading/writing per-extension update check cache. */
export interface ExtensionUpdateCheckRepository {
read(): Promise<ExtensionUpdateCheckMap>;
write(data: ExtensionUpdateCheckMap): Promise<void>;
}

/**
* Returns true if the cache entry for the given extension is stale
* (older than 24 hours) or does not exist.
*/
export function isExtensionCheckStale(
cache: ExtensionUpdateCheckMap,
extensionName: string,
now: Date,
): boolean {
const entry = cache[extensionName];
if (!entry) return true;

const checkedAt = new Date(entry.checkedAt);
if (isNaN(checkedAt.getTime())) return true;

return now.getTime() - checkedAt.getTime() >= CHECK_INTERVAL_MS;
}
79 changes: 79 additions & 0 deletions src/domain/extensions/extension_update_check_cache_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { assertEquals } from "@std/assert";
import { isExtensionCheckStale } from "./extension_update_check_cache.ts";
import { CHECK_INTERVAL_MS } from "../update/update_check_cache.ts";

Deno.test("isExtensionCheckStale: returns true for missing entry", () => {
assertEquals(
isExtensionCheckStale({}, "@swamp/s3-datastore", new Date()),
true,
);
});

Deno.test("isExtensionCheckStale: returns true for stale entry", () => {
const now = new Date();
const staleTime = new Date(now.getTime() - CHECK_INTERVAL_MS - 1000);
const cache = {
"@swamp/s3-datastore": {
checkedAt: staleTime.toISOString(),
latestVersion: "2026.03.15.1",
},
};
assertEquals(isExtensionCheckStale(cache, "@swamp/s3-datastore", now), true);
});

Deno.test("isExtensionCheckStale: returns false for fresh entry", () => {
const now = new Date();
const freshTime = new Date(now.getTime() - 1000); // 1 second ago
const cache = {
"@swamp/s3-datastore": {
checkedAt: freshTime.toISOString(),
latestVersion: "2026.03.15.1",
},
};
assertEquals(isExtensionCheckStale(cache, "@swamp/s3-datastore", now), false);
});

Deno.test("isExtensionCheckStale: returns true for invalid date", () => {
const cache = {
"@swamp/s3-datastore": {
checkedAt: "not-a-date",
latestVersion: "2026.03.15.1",
},
};
assertEquals(
isExtensionCheckStale(cache, "@swamp/s3-datastore", new Date()),
true,
);
});

Deno.test("isExtensionCheckStale: different extensions are independent", () => {
const now = new Date();
const freshTime = new Date(now.getTime() - 1000);
const cache = {
"@swamp/s3-datastore": {
checkedAt: freshTime.toISOString(),
latestVersion: "2026.03.15.1",
},
};
assertEquals(isExtensionCheckStale(cache, "@swamp/s3-datastore", now), false);
assertEquals(isExtensionCheckStale(cache, "@swamp/other-store", now), true);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { join } from "@std/path";
import type {
ExtensionUpdateCheckMap,
ExtensionUpdateCheckRepository,
} from "../../domain/extensions/extension_update_check_cache.ts";

const CACHE_FILENAME = "extension-update-checks.json";

/**
* File-based implementation of ExtensionUpdateCheckRepository.
* Reads/writes .swamp/extension-update-checks.json.
*/
export class FileExtensionUpdateCheckRepository
implements ExtensionUpdateCheckRepository {
private readonly filePath: string;

constructor(swampDir: string) {
this.filePath = join(swampDir, CACHE_FILENAME);
}

async read(): Promise<ExtensionUpdateCheckMap> {
try {
const content = await Deno.readTextFile(this.filePath);
return JSON.parse(content) as ExtensionUpdateCheckMap;
} catch {
return {};
}
}

async write(data: ExtensionUpdateCheckMap): Promise<void> {
await Deno.writeTextFile(
this.filePath,
JSON.stringify(data, null, 2) + "\n",
);
}
}
Loading
Loading