feat: establish core identity foundation
Adds Core-owned identity contracts, a Core-owned Better Auth database schema (ekklesia_core_user/session/account/verification), a first-class Core-owned migration source that coexists with module-owned migrations under the exact existing migration system, and an explicit Better Auth construction boundary. No authentication endpoint or sign-in UI is exposed yet (ADR 0010). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
df0f20d8af
commit
679a5d225c
@@ -1,10 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverCoreMigrations,
|
||||
type MigrationApproval,
|
||||
type MigrationApprovalProvider,
|
||||
MigrationRunnerError,
|
||||
@@ -16,6 +19,12 @@ import type {
|
||||
ModuleMigrationAggregate,
|
||||
} from "../src/modules/migration-discovery.js";
|
||||
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const historyTable = "public.ekklesia_core_migrations";
|
||||
const migrationLockNamespace = 1_162_562_380;
|
||||
@@ -1236,3 +1245,215 @@ $body$;\n`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const coreIdentityTables = Object.freeze([
|
||||
"ekklesia_core_user",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_account",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
async function loadRealCoreMigrations(): Promise<readonly DiscoveredModuleMigration[]> {
|
||||
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
|
||||
if (migrations.length === 0) {
|
||||
throw harnessError("The committed Core identity migration is missing.");
|
||||
}
|
||||
return migrations;
|
||||
}
|
||||
|
||||
interface IndexRow extends QueryResultRow {
|
||||
readonly indexname: string;
|
||||
readonly tablename: string;
|
||||
}
|
||||
|
||||
async function coreIdentityIndexNames(client: Client): Promise<readonly string[]> {
|
||||
const result = await safeQuery<IndexRow>(
|
||||
client,
|
||||
`SELECT tablename, indexname FROM pg_catalog.pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = ANY($1::text[])
|
||||
ORDER BY tablename, indexname`,
|
||||
[coreIdentityTables],
|
||||
);
|
||||
return Object.freeze(result.rows.map((row) => row.indexname));
|
||||
}
|
||||
|
||||
describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
||||
it("plans the committed Core identity migration on a fresh database without mutation", async () => {
|
||||
await withIsolatedDatabase("core_fresh_plan", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const inventory = aggregate(moduleFixture("core", coreMigrations));
|
||||
const before = await databaseState(database.client);
|
||||
|
||||
const plan = await planModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
|
||||
expect(plan.entries.map((entry) => entry.status)).toEqual(
|
||||
coreMigrations.map(() => "pending"),
|
||||
);
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
expect(await relationExists(database.client, historyTable)).toBe(false);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the committed Core identity migration and creates exactly the expected tables and indexes", async () => {
|
||||
await withIsolatedDatabase("core_apply", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const inventory = aggregate(moduleFixture("core", coreMigrations));
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
|
||||
expect(result.applied.map((entry) => entry.identity)).toEqual(
|
||||
coreMigrations.map((entry) => entry.identity),
|
||||
);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
|
||||
}
|
||||
expect(await coreIdentityIndexNames(database.client)).toEqual([
|
||||
"ekklesia_core_account_pkey",
|
||||
"ekklesia_core_account_user_id_idx",
|
||||
"ekklesia_core_session_pkey",
|
||||
"ekklesia_core_session_token_unique",
|
||||
"ekklesia_core_session_user_id_idx",
|
||||
"ekklesia_core_user_email_unique",
|
||||
"ekklesia_core_user_pkey",
|
||||
"ekklesia_core_verification_identifier_idx",
|
||||
"ekklesia_core_verification_pkey",
|
||||
]);
|
||||
|
||||
const history = await readHistory(database.client);
|
||||
expect(history).toHaveLength(coreMigrations.length);
|
||||
expect(history.map(({ applied_at: _appliedAt, ...row }) => row)).toEqual(
|
||||
coreMigrations.map((entry, index) => ({
|
||||
applied_order: index + 1,
|
||||
checksum: entry.checksum,
|
||||
identity: entry.identity,
|
||||
migration_index: entry.index,
|
||||
migration_tag: entry.tag,
|
||||
module_id: "core",
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("performs a genuine no-op on a second Core identity apply", async () => {
|
||||
await withIsolatedDatabase("core_no_op", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const inventory = aggregate(moduleFixture("core", coreMigrations));
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({ aggregate: inventory, database: migrationDatabase });
|
||||
const before = await databaseState(database.client);
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
it("coexists with a fixture module migration, executing Core first as documented", async () => {
|
||||
await withIsolatedDatabase("core_and_module", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const people = peopleInitial();
|
||||
const inventory = aggregate(
|
||||
moduleFixture("core", coreMigrations),
|
||||
moduleFixture("people", [people]),
|
||||
);
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
|
||||
expect(result.applied.map((entry) => entry.identity)).toEqual([
|
||||
...coreMigrations.map((entry) => entry.identity),
|
||||
people.identity,
|
||||
]);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
|
||||
}
|
||||
expect(await relationExists(database.client, "public.people_records")).toBe(true);
|
||||
const history = await readHistory(database.client);
|
||||
expect(history.map((row) => row.module_id)).toEqual([
|
||||
...coreMigrations.map(() => "core"),
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not corrupt previously valid module-only history when Core is introduced later", async () => {
|
||||
await withIsolatedDatabase("core_added_later", async (database) => {
|
||||
const people = peopleInitial();
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({
|
||||
aggregate: aggregate(moduleFixture("people", [people])),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
const historyBeforeCore = await readHistory(database.client);
|
||||
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate(
|
||||
moduleFixture("core", coreMigrations),
|
||||
moduleFixture("people", [people]),
|
||||
),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result.applied.map((entry) => entry.identity)).toEqual(
|
||||
coreMigrations.map((entry) => entry.identity),
|
||||
);
|
||||
const historyAfterCore = await readHistory(database.client);
|
||||
const peopleRowBefore = historyBeforeCore.find((row) => row.module_id === "people");
|
||||
const peopleRowAfter = historyAfterCore.find((row) => row.module_id === "people");
|
||||
expect(peopleRowAfter).toEqual(peopleRowBefore);
|
||||
expect(historyAfterCore).toHaveLength(historyBeforeCore.length + coreMigrations.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not damage the committed Core identity migration when a later module migration fails", async () => {
|
||||
await withIsolatedDatabase("core_rollback_safety", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({
|
||||
aggregate: aggregate(moduleFixture("core", coreMigrations)),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
const historyAfterCore = await readHistory(database.client);
|
||||
|
||||
const failing = migration("broken", 0, {
|
||||
sql: `CREATE TABLE "broken_records" ("id" bigint PRIMARY KEY);\n--> statement-breakpoint\nSELECT 1 / 0;\n`,
|
||||
tag: "0000_broken",
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
applyModuleMigrations({
|
||||
aggregate: aggregate(
|
||||
moduleFixture("core", coreMigrations),
|
||||
moduleFixture("broken", [failing]),
|
||||
),
|
||||
database: migrationDatabase,
|
||||
}),
|
||||
"MIGRATION_EXECUTION_FAILED",
|
||||
);
|
||||
|
||||
expect(error.migrationIdentity).toBe(failing.identity);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
|
||||
}
|
||||
expect(await relationExists(database.client, "public.broken_records")).toBe(false);
|
||||
expect(await readHistory(database.client)).toEqual(historyAfterCore);
|
||||
await expectMigrationLockAvailable(database.client);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user