Files
template-app/test/module-migrations.test.ts
T

145 lines
7.2 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 { DOCUMENTS_ENTITIES, documentsModule } from "@app/modules/documents"
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 { 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)
}
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 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",
)
})
})
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's validator is the current one without skipped_pages, and 0002 carries the current one.
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 withoutSkippedPages = structuredClone(createDocument)
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: createDocument.validator }])
})
})