Skip to content

Commit e18a59d

Browse files
committed
Ship migrations as SQL files applied like Interchange's runMigrations
runArtifactMigrations now takes the same (config, { schema }) arguments as @intx/db's runMigrations and applies the idempotent SQL files under migrations/, rewriting the "public". foreign-key references to the host schema that holds tenant and principal. The package's own tables stay in the artifacts schema. One advisory-locked transaction still serializes concurrent boots. The embedded TypeScript DDL, the checksum ledger, the adopt path and their errors are gone. 0001 creates the final 0.1.0 shape, so a database 0.1.0 migrated no-ops, and 0002 drops the old ledger table.
1 parent 7b44feb commit e18a59d

13 files changed

Lines changed: 329 additions & 1418 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ always called out under their own heading.
124124

125125
### Breaking
126126

127+
- `runArtifactMigrations(config, { schema })` takes the same arguments as
128+
Interchange's `runMigrations`: a `DBConfig` and the host schema holding
129+
`tenant` and `principal`. It applies the SQL files shipped under
130+
`migrations/`, all idempotent, with no ledger. The `adopt` option,
131+
`RunArtifactMigrationsOptions`, `MigrationChecksumError` and
132+
`MigrationAdoptError` are removed, and the `artifacts.migrations` ledger
133+
table is dropped on the next boot.
127134
- The drizzle tables (`artifact`, `artifactVersion`, `upload`,
128135
`mailAttachmentRef`) are no longer exported from the package entry. Hosts
129136
reach artifacts through the routes and functions; `ARTIFACTS_SCHEMA` and the

‎CONTRIBUTING.md‎

Lines changed: 35 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ make this package uninstallable outside the project that defines it.
7070

7171
## Migrations
7272

73-
Shipped migrations are immutable. Each ledger row records a checksum of the migration's
74-
rendered SQL, so editing one that has already been applied fails with
75-
`MigrationChecksumError` on the next boot rather than letting fresh and existing
76-
databases diverge. Add a new migration instead.
73+
Migrations are SQL files under `migrations/`, applied in filename order on every
74+
boot, so every statement must be idempotent (`IF NOT EXISTS`, `IF EXISTS`). There
75+
is no ledger: a schema change is a new file whose statements are safe to re-run,
76+
never an edit that assumes it runs once.
7777

78-
`schema.ts` and `migrations.ts` must agree — every query goes through the drizzle
78+
`schema.ts` and `migrations/` must agree — every query goes through the drizzle
7979
table objects, and a test asserts the migrations create exactly the tables
8080
`schema.ts` declares, no more and no less. Change one, change the other, in the
8181
same commit.
@@ -235,20 +235,20 @@ which store is installed.
235235
### Data model
236236

237237
Four physical tables — `artifact`, `artifact_version`, `upload`,
238-
`mail_attachment_ref` — plus this package's own migration ledger.
238+
`mail_attachment_ref`.
239239

240240
**Hard control-plane foreign keys, by design.** `tenant_id` is `NOT NULL` and
241-
references `public.tenant(id)` (`ON DELETE CASCADE` — a deleted tenant takes its
241+
references the host's `tenant(id)` (`ON DELETE CASCADE` — a deleted tenant takes its
242242
artifacts with it) and `principal_id` / `owner_principal_id` reference
243-
`public.principal(id)` (`ON DELETE SET NULL` — a removed principal detaches its
243+
the host's `principal(id)` (`ON DELETE SET NULL` — a removed principal detaches its
244244
artifacts rather than destroying them). This package is coupled to Interchange:
245245
it mounts on Interchange-shaped hosts only, and the host's own migrations must
246246
have run before `runArtifactMigrations`. The internal key —
247247
`artifact_version.artifact_id` — cascades with its artifact.
248248

249249
**Cheap row-local CHECKs.** `artifact.version` and `artifact_version.version`
250250
must be ≥ 1; `upload.size` and `mail_attachment_ref.size` must be ≥ 0. These are
251-
single-column constraints applied by a ledgered migration — free at write time.
251+
single-column constraints — free at write time.
252252

253253
**Principal↔tenant alignment is host-owned.** The package FKs each column into
254254
the control plane independently; it does **not** enforce that `principal_id` (or
@@ -302,9 +302,7 @@ path that promises it.
302302
Separately, this package also has no way to confirm existing tenants are
303303
already free of duplicate `(title, kind)` rows, which would make even a
304304
scoped constraint risky to backfill. That is not the main reason for
305-
rejecting the constraint, and it is not by itself decisive. See
306-
`0003_schema_invariants` for this repo's own pattern for guarding a
307-
migration against exactly that kind of bad existing data.
305+
rejecting the constraint, and it is not by itself decisive.
308306

309307
Instead, `findOrVersionArtifact(db, args)` (in `artifacts.ts`) closes the
310308
race with a transaction-scoped advisory lock keyed by
@@ -336,55 +334,35 @@ behind it.
336334

337335
### Migration runner
338336

339-
`runArtifactMigrations(db)` is idempotent and safe to call unconditionally on
340-
every boot of every replica.
337+
`runArtifactMigrations(config, { schema })` takes the same arguments as
338+
Interchange's `runMigrations`, and a host calls it right after that, with the
339+
same values. `schema` is where the host's `tenant` and `principal` tables live;
340+
the runner rewrites the `"public".` foreign-key references in the SQL files to
341+
it. It is idempotent and safe to call on every boot of every replica.
341342

342-
- The whole run is one transaction whose first statements are
343-
`SET LOCAL client_min_messages = warning` and a **transaction-scoped**
344-
advisory lock. A transaction pins one pooled connection, so the lock, the
345-
ledger read and the DDL are the same session; the lock releases on commit or
346-
rollback, so there is no unlock call to lose on an error path.
343+
- The whole run is one transaction whose first statement takes a
344+
**transaction-scoped** advisory lock, so the lock releases on commit or
345+
rollback and there is no unlock call to lose on an error path.
347346
`CREATE TABLE IF NOT EXISTS` is not itself race-safe, so the lock — not the
348347
`IF NOT EXISTS` — is what makes concurrent cold starts safe.
349-
- Lowering `client_min_messages` is why a re-run prints **nothing**: every
350-
statement is `IF NOT EXISTS`, and on the second boot Postgres answers each with
351-
a NOTICE that postgres.js would otherwise dump to the console, making a clean
352-
re-boot look like a wall of errors. `SET LOCAL` scopes it to the transaction
353-
and stops at NOTICE — WARNING and above still reach the host.
354-
- Each migration applies inside a nested transaction (a savepoint) together with
355-
its ledger row, so a migration can never be recorded as applied with only some
356-
of its statements run.
357-
- The ledger is this package's own table, `artifacts.migrations`,
358-
never shared with a host's. Each row records a **checksum of the migration's
359-
rendered SQL**, so editing a shipped migration fails with
360-
`MigrationChecksumError` on the next boot instead of letting existing and
361-
fresh databases diverge silently. Ship a new migration instead. The column is
362-
`NOT NULL`, so the guarantee is unconditional: there is no unrecorded row for
363-
the runner to adopt and wave through.
348+
- The runner opens its own single-connection client and discards NOTICEs, so a
349+
re-run, where Postgres answers every `IF NOT EXISTS` with a NOTICE, prints
350+
nothing.
364351
- Event timestamps (`created_at`, `updated_at`, `archived_at`) are
365-
**`timestamptz`**. The initial create migration still lays them down as
366-
zoneless `timestamp`; a follow-on migration retypes them with
367-
`USING col AT TIME ZONE 'UTC'`, treating existing walls as the UTC clocks the
368-
package always assumed. List keyset cursors project through
369-
`AT TIME ZONE 'UTC'` and compare with `::timestamptz`, so paging and date
370-
filters stay on the absolute instant under any session `TimeZone`. Rollback is
371-
the reverse cast (`TYPE timestamp USING col AT TIME ZONE 'UTC'`) plus a new
372-
ledgered migration — never edit a shipped one.
373-
- A later ledgered migration sets `artifact.tenant_id NOT NULL` and adds the
374-
version/size CHECKs. If null-tenant rows still exist, that migration raises
375-
before altering the column so the operator can clean them up first.
376-
- Empty ledger + pre-existing package objects fails closed
377-
(`MigrationAdoptError`). `{ adopt: true }` records checksums without re-DDL
378-
only after shape validation: tables, column types, required nullability, and
379-
the named CHECK constraints. Column presence alone is not enough.
380-
381-
**The package owns its own Postgres schema.** Every table, index and the ledger
382-
live in `artifacts`, created by the runner and qualified in every
383-
DDL statement and every query — nothing resolves through `search_path`, so the
384-
package shares a database with the host's control plane without ever being able
385-
to collide with (or silently adopt) a host table of the same name. The coupling
386-
to the host is explicit instead: `tenant_id` and the principal columns are hard
387-
FKs into `public.tenant` / `public.principal` (see the data model).
352+
**`timestamptz`**. List keyset cursors project through `AT TIME ZONE 'UTC'`
353+
and compare with `::timestamptz`, so paging and date filters stay on the
354+
absolute instant under any session `TimeZone`.
355+
- Releases up to 0.1.0 kept a checksum ledger in `artifacts.migrations`.
356+
`0002_drop_migration_ledger.sql` removes it; a database migrated by 0.1.0
357+
already has the shape `0001_artifacts.sql` creates, so its statements no-op.
358+
359+
**The package owns its own Postgres schema.** Every table and index lives in
360+
`artifacts`, created by the runner and qualified in every DDL statement and
361+
every query — nothing resolves through `search_path`, so the package shares a
362+
database with the host's control plane without ever being able to collide with
363+
(or silently adopt) a host table of the same name. The coupling to the host is
364+
explicit instead: `tenant_id` and the principal columns are hard FKs into the
365+
host schema's `tenant` / `principal` (see the data model).
388366

389367
### Boundaries
390368

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ npm add @corbits/artifacts
1010

1111
Requires Node 24 or newer and `@intx/*` 0.4.0 or newer.
1212

13-
Open a database handle and apply this package's migrations at boot, before mounting any routes. A host that already has a drizzle handle passes that instead of calling `createArtifactDb`.
13+
At boot, right after Interchange's `runMigrations`, apply this package's migrations with the same `config` and `schema`. The tables go in their own `artifacts` Postgres schema, with tenant and principal foreign keys pointing into `schema`. Then open a database handle for the routes; a host that already has a drizzle handle passes that instead of calling `createArtifactDb`.
1414

1515
```ts
16+
import { runMigrations } from "@intx/db";
1617
import { createArtifactDb, runArtifactMigrations } from "@corbits/artifacts";
1718

19+
// `config` is the host's `DBConfig` from `@intx/db`.
20+
await runMigrations(config, { schema: "public" });
21+
await runArtifactMigrations(config, { schema: "public" });
22+
1823
const { db, close } = createArtifactDb(process.env.DATABASE_URL!);
19-
await runArtifactMigrations(db);
2024

2125
// on shutdown
2226
await close();

‎bun.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎examples/reference-host/src/index.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ import {
2222
type RequireGrant,
2323
type TenantEnv,
2424
} from "@intx/hub-api";
25-
import { createDB, createGrantStore, runMigrations, schema as intxSchema } from "@intx/db";
25+
import {
26+
createDB,
27+
createGrantStore,
28+
runMigrations,
29+
schema as intxSchema,
30+
type DBConfig,
31+
} from "@intx/db";
2632
// Interchange owns its id scheme; the host mints its OWN control-plane rows
2733
// with it rather than inventing a second one.
2834
import { generateId } from "@intx/hub-common";
@@ -50,7 +56,7 @@ export const DATABASE_URL =
5056

5157
const EPOCH = new Date(0);
5258

53-
function parsePostgresUrl(raw: string) {
59+
function parsePostgresUrl(raw: string): DBConfig {
5460
const url = new URL(raw);
5561
return {
5662
host: url.hostname,
@@ -103,6 +109,8 @@ export type Session = { userId: string } | null;
103109

104110
export type ReferenceHost = {
105111
db: ArtifactDb;
112+
/** The `DBConfig` the host migrates with. */
113+
config: DBConfig;
106114
/** Interchange tenant id every artifact in this host is scoped to. */
107115
tenantId: string;
108116
/** Principal id of the agent Alice owns. */
@@ -130,13 +138,14 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
130138
// ONE pool. The artifact module mounts on the handle the host already has
131139
// from `createDB` — the seam takes any drizzle postgres-js instance, so there
132140
// is no second connection to the same database.
133-
const hub = createDB(parsePostgresUrl(DATABASE_URL));
141+
const config = parsePostgresUrl(DATABASE_URL);
142+
const hub = createDB(config);
134143
const db: ArtifactDb = hub.db;
135144

136145
// This host resets and truncates its database on boot — refuse to run
137146
// against anything that doesn't look like a throwaway database unless
138147
// explicitly opted in.
139-
const { database } = parsePostgresUrl(DATABASE_URL);
148+
const { database } = config;
140149
if (
141150
!database.startsWith("artifact_") &&
142151
process.env.ARTIFACT_REFERENCE_ALLOW_RESET !== "1"
@@ -156,8 +165,8 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
156165
// principal stand-ins as FK targets; on a shared dev database those look
157166
// present by name but lack Interchange's columns, so detect by shape
158167
// (`tenant.slug`), drop the stand-ins, and migrate for real.
159-
// `runArtifactMigrations` needs no such guard — carrying its own ledger is
160-
// precisely why it can be called unconditionally on every boot.
168+
// `runArtifactMigrations` needs no such guard: every statement is idempotent,
169+
// so it runs unconditionally on every boot.
161170
const [hostSchema] = await db.execute<{ present: boolean }>(sql`
162171
SELECT EXISTS (
163172
SELECT 1 FROM information_schema.columns
@@ -168,9 +177,9 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
168177
await db.execute(sql`DROP TABLE IF EXISTS "public"."principal" CASCADE`);
169178
await db.execute(sql`DROP TABLE IF EXISTS "public"."tenant" CASCADE`);
170179
await db.execute(sql`DROP SCHEMA IF EXISTS "artifacts" CASCADE`);
171-
await runMigrations(parsePostgresUrl(DATABASE_URL), { schema: "public" });
180+
await runMigrations(config, { schema: "public" });
172181
}
173-
await runArtifactMigrations(db);
182+
await runArtifactMigrations(config, { schema: "public" });
174183
await db.execute(
175184
sql`TRUNCATE TABLE "artifacts"."artifact", "artifacts"."artifact_version", "artifacts"."upload", "artifacts"."mail_attachment_ref" CASCADE`,
176185
);
@@ -362,6 +371,7 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
362371

363372
return {
364373
db,
374+
config,
365375
tenantId: tenant.id,
366376
agentPrincipal,
367377
scope: () => ({

‎examples/reference-host/test/acceptance.test.ts‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -679,15 +679,8 @@ describe("a skill-draft is invisible over the mounted host", () => {
679679
});
680680

681681
describe("the migration runner is re-runnable", () => {
682-
test("re-running applies nothing new and destroys no data", async () => {
683-
const before = await host.db.execute<{ id: string }>(
684-
sql`SELECT "id" FROM "artifacts"."migrations"`,
685-
);
686-
await runArtifactMigrations(host.db);
687-
const after = await host.db.execute<{ id: string }>(
688-
sql`SELECT "id" FROM "artifacts"."migrations"`,
689-
);
690-
expect(after.length).toBe(before.length);
682+
test("re-running destroys no data", async () => {
683+
await runArtifactMigrations(host.config, { schema: "public" });
691684

692685
const survived = await json<{ artifacts: unknown[] }>(
693686
await host.request("/api/artifacts?limit=100"),

‎migrations/0001_artifacts.sql‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
CREATE SCHEMA IF NOT EXISTS "artifacts";
2+
--> statement-breakpoint
3+
CREATE TABLE IF NOT EXISTS "artifacts"."artifact" (
4+
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text,
5+
"tenant_id" text NOT NULL REFERENCES "public"."tenant"("id") ON DELETE CASCADE,
6+
"principal_id" text REFERENCES "public"."principal"("id") ON DELETE SET NULL,
7+
"owner_principal_id" text REFERENCES "public"."principal"("id") ON DELETE SET NULL,
8+
"kind" text NOT NULL,
9+
"title" text NOT NULL,
10+
"content" text NOT NULL,
11+
"source" jsonb,
12+
"version" integer NOT NULL DEFAULT 1,
13+
"metadata" jsonb,
14+
"content_sha256" text,
15+
"archived_at" timestamptz,
16+
"created_at" timestamptz NOT NULL DEFAULT now(),
17+
"updated_at" timestamptz NOT NULL DEFAULT now(),
18+
CONSTRAINT "artifact_version_gte_1" CHECK ("version" >= 1)
19+
);
20+
--> statement-breakpoint
21+
CREATE INDEX IF NOT EXISTS "artifact_tenant_updated_id_idx"
22+
ON "artifacts"."artifact" ("tenant_id", "updated_at", "id");
23+
--> statement-breakpoint
24+
CREATE INDEX IF NOT EXISTS "artifact_principal_idx"
25+
ON "artifacts"."artifact" ("principal_id");
26+
--> statement-breakpoint
27+
CREATE INDEX IF NOT EXISTS "artifact_owner_principal_idx"
28+
ON "artifacts"."artifact" ("owner_principal_id");
29+
--> statement-breakpoint
30+
CREATE TABLE IF NOT EXISTS "artifacts"."artifact_version" (
31+
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text,
32+
"artifact_id" text NOT NULL REFERENCES "artifacts"."artifact"("id") ON DELETE CASCADE,
33+
"version" integer NOT NULL,
34+
"title" text NOT NULL,
35+
"content" text NOT NULL,
36+
"author_id" text NOT NULL,
37+
"metadata" jsonb,
38+
"parent_version_ids" text[],
39+
"content_sha256" text,
40+
"created_at" timestamptz NOT NULL DEFAULT now(),
41+
CONSTRAINT "artifact_version_artifact_id_version" UNIQUE ("artifact_id", "version"),
42+
CONSTRAINT "artifact_version_version_gte_1" CHECK ("version" >= 1)
43+
);
44+
--> statement-breakpoint
45+
CREATE TABLE IF NOT EXISTS "artifacts"."upload" (
46+
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text,
47+
"tenant_id" text NOT NULL REFERENCES "public"."tenant"("id") ON DELETE CASCADE,
48+
"principal_id" text REFERENCES "public"."principal"("id") ON DELETE SET NULL,
49+
"filename" text NOT NULL,
50+
"mime_type" text NOT NULL,
51+
"content" bytea NOT NULL,
52+
"size" integer NOT NULL,
53+
"created_at" timestamptz NOT NULL DEFAULT now(),
54+
CONSTRAINT "upload_size_gte_0" CHECK ("size" >= 0)
55+
);
56+
--> statement-breakpoint
57+
CREATE INDEX IF NOT EXISTS "upload_tenant_idx" ON "artifacts"."upload" ("tenant_id");
58+
--> statement-breakpoint
59+
CREATE INDEX IF NOT EXISTS "upload_principal_idx" ON "artifacts"."upload" ("principal_id");
60+
--> statement-breakpoint
61+
CREATE TABLE IF NOT EXISTS "artifacts"."mail_attachment_ref" (
62+
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text,
63+
"tenant_id" text NOT NULL REFERENCES "public"."tenant"("id") ON DELETE CASCADE,
64+
"principal_id" text REFERENCES "public"."principal"("id") ON DELETE SET NULL,
65+
"instance_id" text NOT NULL,
66+
"mail_id" text NOT NULL,
67+
"artifact_id" text NOT NULL,
68+
"name" text NOT NULL,
69+
"mime_type" text NOT NULL,
70+
"size" integer NOT NULL,
71+
"created_at" timestamptz NOT NULL DEFAULT now(),
72+
CONSTRAINT "mail_attachment_ref_mail_id_artifact_id" UNIQUE ("mail_id", "artifact_id"),
73+
CONSTRAINT "mail_attachment_ref_size_gte_0" CHECK ("size" >= 0)
74+
);
75+
--> statement-breakpoint
76+
CREATE INDEX IF NOT EXISTS "mail_attachment_ref_instance_idx"
77+
ON "artifacts"."mail_attachment_ref" ("instance_id");
78+
--> statement-breakpoint
79+
CREATE INDEX IF NOT EXISTS "mail_attachment_ref_tenant_idx"
80+
ON "artifacts"."mail_attachment_ref" ("tenant_id");
81+
--> statement-breakpoint
82+
CREATE INDEX IF NOT EXISTS "mail_attachment_ref_principal_idx"
83+
ON "artifacts"."mail_attachment_ref" ("principal_id");
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS "artifacts"."migrations";

‎package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
},
4646
"files": [
4747
"dist",
48+
"migrations",
4849
"LICENSE",
4950
"README.md"
5051
],
@@ -70,7 +71,8 @@
7071
"hono-openapi": "^1.2.0",
7172
"postgres": "^3.4.9",
7273
"@intx/hub-api": "^0.4.0",
73-
"@intx/agent": "^0.4.0"
74+
"@intx/agent": "^0.4.0",
75+
"@intx/db": "^0.4.0"
7476
},
7577
"peerDependenciesMeta": {
7678
"@intx/agent": {
@@ -88,7 +90,8 @@
8890
"typescript": "5.7.2",
8991
"@intx/hub-api": "0.4.0",
9092
"@intx/authz": "0.4.0",
91-
"@intx/agent": "0.4.0"
93+
"@intx/agent": "0.4.0",
94+
"@intx/db": "0.4.0"
9295
},
9396
"overrides": {
9497
"drizzle-orm": "0.45.2"

0 commit comments

Comments
 (0)