diff --git a/README.md b/README.md index caf9fec..fa755c0 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,11 @@ Applied idempotently, inside one advisory-locked transaction so concurrent hub replicas cannot race the same DDL: ```ts -import { applyAgentTokenMigrations } from "@corbits/agent-token/migrations"; +import { runMigrations } from "@intx/db"; +import { runAgentTokenMigrations } from "@corbits/agent-token/migrations"; -await applyAgentTokenMigrations(databaseUrl); +await runMigrations(dbConfig, { schema: "public" }); +await runAgentTokenMigrations(dbConfig, { schema: "public" }); ``` ## License diff --git a/migrations/0001_agent_token.sql b/migrations/0001_agent_token.sql new file mode 100644 index 0000000..c5587ef --- /dev/null +++ b/migrations/0001_agent_token.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA IF NOT EXISTS "agent_token"; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "agent_token"."token" ( + "id" text PRIMARY KEY, + "tenant_id" text NOT NULL REFERENCES "public"."tenant"("id") ON DELETE CASCADE, + "definition_id" text NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL UNIQUE, + "created_at" timestamptz NOT NULL DEFAULT now(), + "revoked_at" timestamptz +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "agent_token_token_tenant_id_idx" ON "agent_token"."token" ("tenant_id"); diff --git a/package.json b/package.json index e3ccbf2..bfe81a0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "files": [ "dist", + "migrations", "README.md", "LICENSE" ], diff --git a/src/agent-token.drizzle.test.ts b/src/agent-token.drizzle.test.ts index f0607e1..5f7ed2f 100644 --- a/src/agent-token.drizzle.test.ts +++ b/src/agent-token.drizzle.test.ts @@ -1,6 +1,6 @@ // DB-gated: skipped when no DATABASE_URL is reachable. Migrations run into // a scratch schema so this test never touches a real tenant table; -// `applyAgentTokenMigrations` is told that scratch schema so its +// `runAgentTokenMigrations` is told that scratch schema so its // `tenant_id` FK targets it. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; @@ -9,7 +9,7 @@ import type { TenantEnv } from "@intx/hub-api"; import { eq } from "drizzle-orm"; import { Hono, type MiddlewareHandler } from "hono"; -import { applyAgentTokenMigrations } from "./schema"; +import { runAgentTokenMigrations } from "./migrations"; import { hashAgentToken, mintAgentToken, revokeAgentToken, verifyAgentToken } from "./tokens"; import { mountAgentTokens } from "./mount"; import { createAgentTokenVerifier, requireAgentToken } from "./middleware"; @@ -79,7 +79,7 @@ describeIfDb("agent tokens", () => { beforeAll(async () => { await runMigrations(target, { schema: SCHEMA }); - await applyAgentTokenMigrations(databaseUrl ?? "", { tenantSchema: SCHEMA }); + await runAgentTokenMigrations(target, { schema: SCHEMA }); }); afterAll(async () => { diff --git a/src/migrations.ts b/src/migrations.ts index 36e977a..9904336 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -1 +1,72 @@ -export { applyAgentTokenMigrations } from "./schema.js"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { DBConfig } from "@intx/db"; +import postgres from "postgres"; + +// The packed layout keeps /migrations next to /dist and /src. +const MIGRATIONS_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "migrations", +); + +function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +/** + * Applies the shipped SQL idempotently, inside one advisory-locked + * transaction so concurrent hub replicas cannot race the same DDL. `schema` + * is where the host's Interchange tables live, as passed to Interchange's + * `runMigrations`; the `tenant_id` FK is pointed at its `tenant` table. + * Every file re-runs on every boot, with no record of what already ran, so + * each migration must be idempotent. + */ +export async function runAgentTokenMigrations( + config: DBConfig, + options: { schema: string }, +): Promise { + if (options.schema.length === 0) { + throw new Error("runAgentTokenMigrations: schema name must not be empty"); + } + const schemaIdent = quoteIdentifier(options.schema); + const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort(); + if (files.length === 0) { + throw new Error(`runAgentTokenMigrations: no .sql files found in ${MIGRATIONS_DIR}`); + } + const statements: string[] = []; + for (const file of files) { + const raw = await readFile(path.join(MIGRATIONS_DIR, file), "utf8"); + for (const stmt of raw + .replace(/"public"\.(?=")/g, `${schemaIdent}.`) + .split("--> statement-breakpoint")) { + if (stmt.trim().length > 0) statements.push(stmt); + } + } + + const client = postgres({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + ssl: config.ssl, + max: 1, + onnotice: () => undefined, + }); + try { + await client.begin(async (tx) => { + await tx.unsafe(`SELECT pg_advisory_xact_lock(hashtext('corbits_agent_token'))`); + for (const stmt of statements) await tx.unsafe(stmt); + }); + } catch (error) { + throw new Error( + `@corbits/agent-token migration failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } finally { + await client.end({ timeout: 5 }); + } +} diff --git a/src/schema.ts b/src/schema.ts index ce16b99..2b2654b 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -2,7 +2,6 @@ // its agent definitions. Kept on its own Postgres schema, with a real FK // back to Interchange's `tenant` table. import { pgTable, pgSchema, text, timestamp } from "drizzle-orm/pg-core"; -import postgres from "postgres"; const hostTenant = pgTable("tenant", { id: text("id").primaryKey() }); @@ -20,48 +19,3 @@ export const agentTokenTable = agentTokenSchema.table("token", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), revokedAt: timestamp("revoked_at", { withTimezone: true }), }); - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -/** Builds the FK against `tenantSchema.tenant` — `"public"` in every real - * deployment, a scratch schema in tests. */ -function agentTokenMigrationSql(tenantSchema: string): string { - const tenantTable = `${quoteIdentifier(tenantSchema)}."tenant"`; - return ` - CREATE SCHEMA IF NOT EXISTS "agent_token"; - CREATE TABLE IF NOT EXISTS "agent_token"."token" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL REFERENCES ${tenantTable}("id") ON DELETE CASCADE, - "definition_id" text NOT NULL, - "name" text NOT NULL, - "token_hash" text NOT NULL UNIQUE, - "created_at" timestamptz NOT NULL DEFAULT now(), - "revoked_at" timestamptz - ); - CREATE INDEX IF NOT EXISTS "agent_token_token_tenant_id_idx" ON "agent_token"."token" ("tenant_id"); - `; -} - -/** Applies the migration idempotently, inside one advisory-locked - * transaction so concurrent hub replicas cannot race the same DDL. */ -export async function applyAgentTokenMigrations( - databaseUrl: string, - options?: { tenantSchema?: string }, -): Promise { - const client = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await client.begin(async (tx) => { - await tx.unsafe(`SELECT pg_advisory_xact_lock(hashtext('corbits_agent_token'))`); - await tx.unsafe(agentTokenMigrationSql(options?.tenantSchema ?? "public")); - }); - } catch (error) { - throw new Error( - `@corbits/agent-token migration failed: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } finally { - await client.end({ timeout: 5 }); - } -}