From cf105cc9847495667dfc04bdb3e3ec069df87311 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:59:53 +0200 Subject: [PATCH] Keep provider updates within their lock --- packages/contrail/README.md | 2 +- packages/contrail/src/cli/commands/connect.ts | 56 ++++++++++++++++--- packages/contrail/tests/connect.test.ts | 56 ++++++++++++++++++- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/packages/contrail/README.md b/packages/contrail/README.md index 3c90680..6fd56c2 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -116,7 +116,7 @@ const config = { The returned cursor is opaque. Compare the complete `{ source, epoch, cursor }` value for equality; never order cursors from different epochs. Consumers can read the position before and after a query, retry if it changed, then poll it as a refetch/invalidation signal. -Connect an independent consumer with `contrail connect `. A repeated connection requires `--update`; provider files and the lock are staged and swapped without deleting consumer-owned Lexicons. +Connect an independent consumer with `contrail connect `. A repeated connection to the same endpoint and provider-owned output root requires `--update`; provider files and the lock are staged and swapped without deleting consumer-owned Lexicons. Switching providers or output roots requires removing the existing connection deliberately, so stale Lexicons cannot remain under a broad generator glob. ## Runtime record validation diff --git a/packages/contrail/src/cli/commands/connect.ts b/packages/contrail/src/cli/commands/connect.ts index aebefc1..b0d18b2 100644 --- a/packages/contrail/src/cli/commands/connect.ts +++ b/packages/contrail/src/cli/commands/connect.ts @@ -50,6 +50,34 @@ export interface ProviderLock { lexiconRoot: string; } +async function readProviderLock(path: string): Promise { + let source: string; + try { + source = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch { + throw new Error("existing Contrail provider lock is not valid JSON"); + } + const lock = value as Partial; + if ( + !value || + typeof value !== "object" || + lock.format !== "contrail.provider-lock" || + lock.version !== 1 || + typeof lock.endpoint !== "string" || + typeof lock.lexiconRoot !== "string" + ) { + throw new Error("existing Contrail provider lock is malformed"); + } + return lock as ProviderLock; +} + async function readJson(response: Response, label: string): Promise { if (!response.ok) { throw new Error( @@ -136,14 +164,27 @@ export async function connectPublicService(options: { const endpoint = normalizePublicServiceEndpoint(options.endpoint); const projectRoot = resolve(options.root); const lockPath = resolveInsideRoot(projectRoot, options.lock); - if (!options.update) { - try { - await readFile(lockPath, "utf8"); + const outputRoot = resolveInsideRoot(projectRoot, options.out); + const providerKey = new URL(endpoint).host.replace(/[^a-zA-Z0-9.-]/g, "_"); + const providerRoot = resolveInsideRoot(outputRoot, providerKey); + const existingLock = await readProviderLock(lockPath); + if (existingLock && !options.update) { + throw new Error( + "a Contrail provider lock already exists; rerun with --update", + ); + } + if (existingLock) { + if (normalizePublicServiceEndpoint(existingLock.endpoint) !== endpoint) { throw new Error( - "a Contrail provider lock already exists; rerun with --update", + `provider lock targets ${existingLock.endpoint}; remove the existing connection before switching endpoints`, + ); + } + if ( + resolveInsideRoot(projectRoot, existingLock.lexiconRoot) !== providerRoot + ) { + throw new Error( + `provider lock owns ${existingLock.lexiconRoot}; reuse its output path or remove the existing connection`, ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } const fetcher = options.fetcher ?? fetch; @@ -200,9 +241,6 @@ export async function connectPublicService(options: { ); } - const outputRoot = resolveInsideRoot(projectRoot, options.out); - const providerKey = new URL(endpoint).host.replace(/[^a-zA-Z0-9.-]/g, "_"); - const providerRoot = resolveInsideRoot(outputRoot, providerKey); await mkdir(outputRoot, { recursive: true }); const stagedProvider = await mkdtemp(join(outputRoot, `.${providerKey}-`)); for (const document of lexicons) { diff --git a/packages/contrail/tests/connect.test.ts b/packages/contrail/tests/connect.test.ts index bcc6b4b..58a4714 100644 --- a/packages/contrail/tests/connect.test.ts +++ b/packages/contrail/tests/connect.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { connectPublicService } from "../src/cli/commands/connect"; +import { + connectPublicService, + type ProviderLock, +} from "../src/cli/commands/connect"; import { contractFromManifest, digestLexiconDocuments, @@ -30,6 +33,19 @@ const sourceLexicon = { defs: { main: { type: "record" } }, }; +function providerLock(): ProviderLock { + return { + format: "contrail.provider-lock", + version: 1, + endpoint, + namespace: "atmo.rsvp", + contractDigest: `sha256:${"a".repeat(64)}`, + lexiconDigest: `sha256:${"b".repeat(64)}`, + methods: [method], + lexiconRoot: "lexicons/pulled/api.atmo.rsvp", + }; +} + async function serviceFixture(values = [methodLexicon, sourceLexicon]) { const { digest } = await digestLexiconDocuments(values); const manifest: PublicServiceManifest = { @@ -131,6 +147,44 @@ describe("contrail connect", () => { ).toBe("keep"); }); + it("refuses to repoint an existing lock or abandon its owned output", async () => { + const root = await temporaryRoot(); + const lockPath = join(root, "contrail.lock.json"); + const fetcher = vi.fn(); + await writeFile( + lockPath, + `${JSON.stringify({ + ...providerLock(), + endpoint: "https://old.example.com", + })}\n`, + ); + + await expect( + connectPublicService({ + endpoint, + root, + out: "lexicons/pulled", + lock: "contrail.lock.json", + fetcher, + update: true, + }), + ).rejects.toThrow("remove the existing connection before switching endpoints"); + expect(fetcher).not.toHaveBeenCalled(); + + await writeFile(lockPath, `${JSON.stringify(providerLock())}\n`); + await expect( + connectPublicService({ + endpoint, + root, + out: "different-lexicons", + lock: "contrail.lock.json", + fetcher, + update: true, + }), + ).rejects.toThrow("reuse its output path"); + expect(fetcher).not.toHaveBeenCalled(); + }); + it("preserves the previous provider and lock when an update fails validation", async () => { const root = await temporaryRoot(); const fixture = await serviceFixture(); -- 2.51.2