Files
template-app/test/module-migrations.test.ts
T
2026-11-14 09:56:45 +00:00

240 lines
12 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Every table of a module the kit ships has a committed, forward-only migration, and each is exactly
// `schemaStatements()` over that module's own entities (decision AT-D20: the entity definitions are
// the one source of a column, so a hand-written column cannot drift from the schema a module validates
// its rows with). A kit upgrade that changes a table changes a snapshot here, which is the moment a
// customer must see a new migration in review.
//
// `0002` and `0003` are the grants the migrate job builds at run time (apps/worker/src/migrations.ts),
// so the files of the kit's modules take the next numbers.
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { appRoot, moduleMigrationFolder } from "@app/config"
import { ACCOUNT_ENTITIES, accountModule } from "@app/modules/account"
import { DOCUMENTS_ENTITIES, documentsModule } from "@app/modules/documents" // kit:documents
import { authModule } from "@conusai-kit/auth"
import { buildRegistry } from "@conusai-kit/core"
import { entitiesOf } from "@conusai-kit/data"
import { schemaStatements } from "@conusai-kit/data-kysely"
import { mysqlSchemaStatements } from "@conusai-kit/data-kysely/mysql"
import { MONGO_KERNEL_COMMANDS, mongoSchemaCommands } from "@conusai-kit/data-mongo"
import { filesModule } from "@conusai-kit/files"
import { liveSchemaStatements } from "@conusai-kit/live-server"
import { proposalsModule } from "@conusai-kit/proposals"
import { tenancyModule } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { SPDX_LINE } from "../scripts/licence.mjs"
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own two. */
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
const kernel = schemaStatements([]).length
return `${schemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
/** The same for a module's MariaDB folder (spec §5.3: one numbered SQL folder per dialect). */
function mysqlTablesOf(entities: Parameters<typeof mysqlSchemaStatements>[0]): string {
const kernel = mysqlSchemaStatements([]).length
return `${mysqlSchemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
type MongoCommands = ReturnType<typeof mongoSchemaCommands>
/** The same for a module's MongoDB folder: `mongoSchemaCommands` leads with the kernel's own collections. */
function mongoCommandsOf(entities: Parameters<typeof mongoSchemaCommands>[0]): MongoCommands {
return mongoSchemaCommands(entities).slice(MONGO_KERNEL_COMMANDS.length)
}
/** A file of the account module, which the package ships: the folder is the one the migrate job reads. */
const accountMigration = (profile: "mariadb" | "mongodb", name: string): string => {
const path = accountModule.migrations?.[profile] ?? ""
return readFileSync(join(moduleMigrationFolder({ module: "account", path }, appRoot()), name), "utf8")
}
// kit:documents:begin
/**
* A validator as the generator wrote it before a nullable enum listed null: the files that ran then stay what
* they were, and a later file carries the current validator.
*/
const withoutListedNull = <T>(value: T): T =>
JSON.parse(JSON.stringify(value, (_, item) => (Array.isArray(item) ? item.filter((entry) => entry !== null) : item)))
// kit:documents:end
const entitiesOfModule = (module: Parameters<typeof buildRegistry>[0][number]) =>
entitiesOf(buildRegistry([tenancyModule, module]), "postgres").filter(
(entity) => !entitiesOf(buildRegistry([tenancyModule]), "postgres").some((own) => own.table === entity.table),
)
describe("the account module's dialect migrations (data-subject rights on every profile)", () => {
// First shipped with both columns 0003 added on Postgres (steps, provider), so each dialect's 0001
// is the ledger and the revocation outbox as they stood then; what `identity.erase` added since
// (identity_subjects, identity_note, and the `held` state) is each dialect's 0002: a migration that
// has run is never edited, and the files together are exactly the entities.
it("ships the MariaDB tables as mysqlSchemaStatements generates them", () => {
const mysqlMigration = (name: string) => accountMigration("mariadb", name)
const added = ",\n `identity_subjects` json,\n `identity_note` text\n"
const generated = mysqlTablesOf(ACCOUNT_ENTITIES)
expect(generated).toContain(added)
expect(generated.replace(added, "\n")).toBe(mysqlMigration("0001_account.sql"))
expect(mysqlMigration("0002_identity_erasure.sql")).toBe(
"alter table `account_deletion` add column `identity_subjects` json;\n\nalter table `account_deletion` add column `identity_note` text;\n",
)
})
it("ships the MongoDB collections as mongoSchemaCommands generates them", () => {
// Compared as data: the committed file is formatted by Biome, the generator's output is not.
const mongoMigration = (name: string): MongoCommands => JSON.parse(accountMigration("mongodb", name))
const current = mongoCommandsOf(ACCOUNT_ENTITIES)
const create = current.find(
(command) => "createCollection" in command && command.createCollection === "account_deletion",
)
if (!create || !("createCollection" in create)) throw new Error("no createCollection for account_deletion")
const first = mongoMigration("0001_account.json")
expect(JSON.stringify(first)).not.toContain("identity_subjects")
expect(
first.filter((command) => !("createCollection" in command && command.createCollection === "account_deletion")),
).toEqual(current.filter((command) => command !== create))
const shipped = structuredClone(create.validator)
const fields = shipped.$jsonSchema.properties as Record<string, unknown>
fields.identity_note = { bsonType: ["string", "null"] }
expect(mongoMigration("0002_identity_erasure.json")).toEqual([{ collMod: "account_deletion", validator: shipped }])
expect(mongoMigration("0003_identity_note_enum.json")).toEqual([
{ collMod: "account_deletion", validator: create.validator },
])
})
it("is declared by the manifest for every profile, so the migrate job finds it", () => {
expect(accountModule.migrations).toEqual({
postgres: "@conusai-kit/account/migrations/postgres",
mariadb: "@conusai-kit/account/migrations/mariadb",
mongodb: "@conusai-kit/account/migrations/mongodb",
})
for (const profile of ["postgres", "mariadb", "mongodb"] as const) {
expect(
entitiesOf(buildRegistry([tenancyModule, authModule, accountModule]), profile).map(({ table }) => table),
).toEqual(expect.arrayContaining(["account_deletion", "account_session_revocation"]))
}
})
})
describe("the live change log (umbrella TS6.2)", () => {
it("0007_live.sql is @conusai-kit/live-server's schema: the log, its policies, the capture and the prune", async () => {
const sql = `${liveSchemaStatements()
.map((statement) => `${statement};`)
.join("\n\n")}\n`
await expect(`-- ${SPDX_LINE}\n${sql}`).toMatchFileSnapshot("../migrations/postgres/0007_live.sql")
})
})
describe("the migrations of the modules the kit ships", () => {
it("0004_files.sql creates the files table", async () => {
await expect(`-- ${SPDX_LINE}\n${tablesOf(entitiesOfModule(filesModule))}`).toMatchFileSnapshot(
"../migrations/postgres/0004_files.sql",
)
})
it("0005_proposals.sql creates the proposals table", async () => {
await expect(`-- ${SPDX_LINE}\n${tablesOf(entitiesOfModule(proposalsModule))}`).toMatchFileSnapshot(
"../migrations/postgres/0005_proposals.sql",
)
})
})
// kit:documents:begin
describe("the migration of the documents module", () => {
const migration = (name: string) =>
readFileSync(new URL(`../modules/documents/migrations/${name}`, import.meta.url), "utf8")
// A migration that has run is never edited, so a column added later is a file of its own: the first
// file stays what it was, the second adds the column, and together they are exactly the entities.
it("0001 is its two tables as first shipped, and 0002 adds the one column since", () => {
expect(migration("0002_skipped_pages.sql")).toBe(
'alter table "documents_document" add column "skipped_pages" jsonb;\n',
)
const generated = tablesOf(DOCUMENTS_ENTITIES)
expect(generated).toContain(' "skipped_pages" jsonb,\n')
expect(generated.replace(' "skipped_pages" jsonb,\n', "")).toBe(migration("0001_documents.sql"))
})
it("is declared by the manifest, so the migrate job finds it", () => {
expect(documentsModule.migrations).toEqual({
postgres: "migrations",
mariadb: "adapters/kysely/mysql/migrations",
mongodb: "adapters/mongo/migrations",
})
const sql = migration("0001_documents.sql")
for (const table of ["documents_document", "documents_line"]) {
expect(sql).toContain(`create table "${table}"`)
expect(sql).toContain(`alter table "${table}" force row level security`)
}
})
it("ships the same migrations in the MariaDB dialect (AT3.1)", () => {
const mysqlMigration = (name: string) =>
readFileSync(new URL(`../modules/documents/adapters/kysely/mysql/migrations/${name}`, import.meta.url), "utf8")
expect(mysqlMigration("0002_skipped_pages.sql")).toBe(
"alter table `documents_document` add column `skipped_pages` json;\n",
)
const generated = mysqlTablesOf(DOCUMENTS_ENTITIES)
expect(generated).toContain(" `skipped_pages` json,\n")
expect(generated.replace(" `skipped_pages` json,\n", "")).toBe(mysqlMigration("0001_documents.sql"))
})
it("ships the same migrations as MongoDB commands: 0001 the collections as first shipped, 0002 the added field (AT3.2)", () => {
const mongoMigration = (name: string): MongoCommands =>
JSON.parse(
readFileSync(new URL(`../modules/documents/adapters/mongo/migrations/${name}`, import.meta.url), "utf8"),
)
const current = mongoCommandsOf(DOCUMENTS_ENTITIES)
const createDocument = current.find(
(command) => "createCollection" in command && command.createCollection === "documents_document",
)
if (!createDocument || !("createCollection" in createDocument))
throw new Error("no createCollection for documents_document")
// A migration that has run is never edited, so a field added later is a collMod of its own:
// 0001/0002 predate processing_exhausted; 0003 adds null, and 0004 carries the current model.
const first = mongoMigration("0001_documents.json")
const created = first.find(
(command) => "createCollection" in command && command.createCollection === "documents_document",
)
if (!created || !("createCollection" in created)) throw new Error("0001 does not create documents_document")
expect(JSON.stringify(created.validator)).not.toContain("skipped_pages")
const historical = structuredClone(createDocument)
const failure = historical.validator.$jsonSchema.properties as { failure_code: { enum: unknown[] } }
failure.failure_code.enum = failure.failure_code.enum.filter((code) => code !== "processing_exhausted")
const withoutSkippedPages = withoutListedNull(historical)
const properties = withoutSkippedPages.validator.$jsonSchema.properties as Record<string, unknown>
expect(JSON.stringify(properties)).toContain("skipped_pages")
delete properties.skipped_pages
expect(JSON.stringify(created.validator)).toBe(JSON.stringify(withoutSkippedPages.validator))
// The other collection and every index are exactly the generated ones.
const rest = (commands: MongoCommands) =>
JSON.stringify(
commands.filter(
(command) => !("createCollection" in command && command.createCollection === "documents_document"),
),
)
expect(rest(first)).toBe(rest(current))
const second = mongoMigration("0002_skipped_pages.json")
expect(second).toEqual([{ collMod: "documents_document", validator: withoutListedNull(historical.validator) }])
// The third lets a nullable failure code be null, which the validators before it refused.
expect(mongoMigration("0003_nullable_failure_code.json")).toEqual([
{ collMod: "documents_document", validator: historical.validator },
])
// The latest cumulative validator, rather than every historical version, matches today's model.
expect(mongoMigration("0004_processing_exhausted.json")).toEqual([
{ collMod: "documents_document", validator: createDocument.validator },
])
})
})
// kit:documents:end