145 lines
7.2 KiB
TypeScript
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 }])
|
|
})
|
|
})
|