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

324 lines
16 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The committed kernel schema, and the rule that keeps it committable.
//
// `migrations/postgres/0001_kernel.sql` is `schemaStatements()` over the kernel's own entities,
// written once. It is a snapshot of the *kernel's* tables alone, not of the whole registry, so
// adding a module does not change this file -- a module ships its own numbered migration, which
// is what a forward-only runner needs (it refuses a migration whose text changed after it ran).
// A kit upgrade that changes a kernel table does change it, and that is exactly the moment a
// customer has to see a new migration in review.
import { readFileSync } from "node:fs"
import { authModule } from "@conusai-kit/auth"
import { buildRegistry } from "@conusai-kit/core"
import { type AnyEntityDef, defineEntity, entitiesOf, managedColumnsFor } from "@conusai-kit/data"
import {
columnName,
createTable,
enableRowLevelSecurity,
isNullable,
postgresType,
quoted,
schemaStatements,
} from "@conusai-kit/data-kysely"
import { createMysqlTable, mysqlQuoted, mysqlType } from "@conusai-kit/data-kysely/mysql"
import { createMongoCollection, type MongoCommand, mongoValidator } from "@conusai-kit/data-mongo"
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss"
import {
appUserIdentities,
invitations,
orgMemberships,
projectMemberships,
projects,
tenancyModule,
tenancyRoles,
workspaceMemberships,
} from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { z } from "zod"
import { kernelSchema } from "../apps/worker/src/migrations.js"
import { SPDX_LINE } from "../scripts/licence.mjs"
/**
* The columns the R-wave roles migration (0008) added to tables 0001 created: a role key on the
* memberships and invitations, a project's visibility. 0001 is the snapshot from before them.
*/
const ADDED_BY_0008: ReadonlyArray<readonly [AnyEntityDef, string]> = [
[orgMemberships, "roleKey"],
[workspaceMemberships, "roleKey"],
[invitations, "roleKey"],
[projects, "visibility"],
]
/** The column 0009 added to a table 0001 created: a project's description. */
const ADDED_BY_0009: ReadonlyArray<readonly [AnyEntityDef, string]> = [[projects, "description"]]
/** The column 0010 added to a table 0001 created: when an invitation was revoked or re-issued. */
const ADDED_BY_0010: ReadonlyArray<readonly [AnyEntityDef, string]> = [[invitations, "revokedAt"]]
const fieldsOf = (added: ReadonlyArray<readonly [AnyEntityDef, string]>, entity: AnyEntityDef) =>
added.filter(([changed]) => changed.table === entity.table).map(([, field]) => field)
/** An entity as 0001 created it: without the fields a later migration added, nor their indexes. */
const asOf0001 = (entity: AnyEntityDef): AnyEntityDef =>
without(entity, [
...fieldsOf(ADDED_BY_0008, entity),
...fieldsOf(ADDED_BY_0009, entity),
...fieldsOf(ADDED_BY_0010, entity),
])
/** An entity as 0008 left it: a committed validator is the whole of its table at that point. */
const asOf0008 = (entity: AnyEntityDef): AnyEntityDef =>
without(entity, [...fieldsOf(ADDED_BY_0009, entity), ...fieldsOf(ADDED_BY_0010, entity)])
/** An entity as 0009 left it. */
const asOf0009 = (entity: AnyEntityDef): AnyEntityDef => without(entity, fieldsOf(ADDED_BY_0010, entity))
function without(entity: AnyEntityDef, later: readonly string[]): AnyEntityDef {
if (later.length === 0) return entity
const schema = entity.schema
if (!(schema instanceof z.ZodObject)) throw new Error(`${entity.table}: an entity's schema is an object`)
const managed = managedColumnsFor(entity.tenancy)
const fields = Object.fromEntries(
Object.entries(schema.shape).filter(([field]) => !managed.includes(field) && !later.includes(field)),
)
const before = (groups: readonly (readonly string[])[]) =>
groups.filter((group) => !group.some((field) => later.includes(field))).map((group) => [...group])
return defineEntity({
name: entity.name,
table: entity.table,
tenancy: entity.tenancy,
fields,
unique: before(entity.unique),
indexes: before(entity.indexes),
})
}
// Tenancy and sign-in alone: later kit tables have their own forward migrations, because 0001 is
// never edited once it has run. app_user_identity belongs to 0006 and the custom roles and project
// memberships to 0008, not this historical snapshot.
const LATER_TABLES = new Set([appUserIdentities.table, tenancyRoles.table, projectMemberships.table])
const kernelEntities = [...entitiesOf(buildRegistry([tenancyModule, authModule]), "postgres"), ...KIT_JOBS_ENTITIES]
.filter((entity) => !LATER_TABLES.has(entity.table))
.map(asOf0001)
describe("the kernel migration", () => {
it("matches the committed 0001_kernel.sql", async () => {
// The file leads with the template's SPDX line (AT5.1); the runner reads it as the comment it is.
await expect(`-- ${SPDX_LINE}\n${kernelSchema(kernelEntities)}`).toMatchFileSnapshot(
"../migrations/postgres/0001_kernel.sql",
)
})
it("puts forced row-level security on every tenant table", () => {
const sql = schemaStatements(kernelEntities).join(";\n")
for (const entity of kernelEntities) {
if (entity.tenancy === "global") continue
expect(sql, `${entity.table} is not forced`).toContain(`alter table "${entity.table}" force row level security`)
expect(sql).toContain(`create policy "${entity.table}_tenant" on "${entity.table}"`)
}
})
it("compares a tenant row against the transaction's organization and nothing else", () => {
const sql = schemaStatements(kernelEntities).join(";\n")
// The policy reads NULLIF(current_setting(...), '')::uuid: a pooled connection whose previous
// transaction set app.org_id sees no rows rather than an error (kit-data-kysely's decision 2).
expect(sql).toContain("nullif(current_setting('app.org_id', true), '')::uuid")
})
})
describe("the custom roles migration (R-wave, ADR-062)", () => {
const shapeOf = (entity: AnyEntityDef) => {
if (!(entity.schema instanceof z.ZodObject)) throw new Error(`${entity.table}: an entity's schema is an object`)
return entity.schema.shape
}
it("is 0008_tenancy-roles.sql: the two new tables, the added columns with their indexes, and forced RLS", async () => {
const statements = [
...createTable(tenancyRoles),
...createTable(projectMemberships),
...ADDED_BY_0008.map(([entity, field]) => {
const type = shapeOf(entity)[field]
// Added to tables that already hold rows: a forward migration cannot add a NOT NULL column.
expect(isNullable(type), `${entity.table}.${field}`).toBe(true)
return `alter table ${quoted(entity.table)} add column ${quoted(columnName(field))} ${postgresType(type)}`
}),
...ADDED_BY_0008.flatMap(([entity, field]) =>
createTable(entity).filter(
(statement) => statement.startsWith("create index") && statement.includes(`, ${quoted(columnName(field))})`),
),
),
...enableRowLevelSecurity(tenancyRoles),
...enableRowLevelSecurity(projectMemberships),
]
await expect(
`-- ${SPDX_LINE}\n${statements.map((statement) => `${statement};`).join("\n\n")}\n`,
).toMatchFileSnapshot("../migrations/postgres/0008_tenancy-roles.sql")
})
it("forces row-level security on the organization column of both new tables", () => {
const sql = readFileSync(new URL("../migrations/postgres/0008_tenancy-roles.sql", import.meta.url), "utf8")
for (const table of [tenancyRoles.table, projectMemberships.table]) {
expect(sql).toContain(`alter table "${table}" force row level security`)
expect(sql).toContain(`create policy "${table}_tenant" on "${table}"`)
}
expect(sql).not.toContain("drop ")
})
})
// The same migration on the dialect profiles (ADR-062: every profile has the new tables under its own
// isolation mechanism). Neither engine has row-level security: the wall is the repository's tenant
// predicate on `org_id`, which the shared contract suites prove, and each unique group is scoped by the
// organization column as every kernel table's is.
describe("the custom roles migration on MariaDB and MongoDB", () => {
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
const addedColumns = (entity: AnyEntityDef) =>
ADDED_BY_0008.filter(([added]) => added === entity).map(([, field]) => columnName(field))
it("is profiles/mariadb/migrations/0008_tenancy-roles.sql: the two tables, the added columns and their indexes", async () => {
const statements = [
...createMysqlTable(tenancyRoles),
...createMysqlTable(projectMemberships),
...ADDED_BY_0008.map(([entity, field]) => {
const type = shapeOfEntity(entity)[field]
expect(isNullable(type), `${entity.table}.${field}`).toBe(true)
return `alter table ${mysqlQuoted(entity.table)} add column ${mysqlQuoted(columnName(field))} ${mysqlType(type)}`
}),
...ADDED_BY_0008.flatMap(([entity, field]) =>
createMysqlTable(entity).filter(
(statement) =>
statement.startsWith("create index") &&
new RegExp(`, ${mysqlQuoted(columnName(field))}(\\(191\\))?\\)$`).test(statement),
),
),
]
await expect(
`-- ${SPDX_LINE}\n${statements.map((statement) => `${statement};`).join("\n\n")}\n`,
).toMatchFileSnapshot("../profiles/mariadb/migrations/0008_tenancy-roles.sql")
})
it("scopes both new tables' unique groups by the organization, and drops nothing", () => {
const sql = read("profiles/mariadb/migrations/0008_tenancy-roles.sql")
expect(sql).toContain("unique (`org_id`, `key`)")
expect(sql).toContain("unique (`org_id`, `project_id`, `user_id`)")
expect(sql).not.toContain("drop ")
})
it("is profiles/mongodb/migrations/0008_tenancy-roles.json: two collections, the four validators with the new fields, their indexes", () => {
const changed = [...new Set(ADDED_BY_0008.map(([entity]) => entity))]
const expected: MongoCommand[] = [
...createMongoCollection(tenancyRoles),
...createMongoCollection(projectMemberships),
// A collection's validator refuses a field it does not list (additionalProperties: false), so the
// added fields arrive as each collection's whole current validator.
...changed.map((entity) => ({ collMod: entity.table, validator: mongoValidator(asOf0008(entity)) })),
...changed.flatMap((entity) =>
createMongoCollection(entity).filter(
(command) =>
"createIndex" in command &&
addedColumns(entity).some((column) => Object.keys(command.createIndex.keys).includes(column)),
),
),
]
// Compared as data: the committed file is formatted by Biome, the generator's output is not. It ran before
// a nullable enum listed null, which 0009 adds, and a file that ran is never edited.
expect(JSON.parse(read("profiles/mongodb/migrations/0008_tenancy-roles.json"))).toEqual(
JSON.parse(
JSON.stringify(expected, (_, item) => (Array.isArray(item) ? item.filter((entry) => entry !== null) : item)),
),
)
for (const entity of changed) {
const validator = JSON.stringify(mongoValidator(asOf0008(entity)))
for (const column of addedColumns(entity)) expect(validator, `${entity.table}.${column}`).toContain(`"${column}"`)
}
})
it("is profiles/mongodb/migrations/0009_nullable-enums.json: an invitation with no workspace may have no workspace role", () => {
expect(JSON.parse(read("profiles/mongodb/migrations/0009_nullable-enums.json"))).toEqual([
{ collMod: invitations.table, validator: mongoValidator(asOf0009(invitations)) },
])
const { properties } = mongoValidator(asOf0009(invitations)).$jsonSchema as { properties: Record<string, unknown> }
expect(properties.workspace_role).toEqual({
bsonType: ["string", "null"],
enum: ["viewer", "editor", "admin", null],
})
})
})
// One added column on every profile. Convex takes it from the generated schema (`convex/schema.ts`,
// which the Convex generator writes from the entities), and the Postgres reader's whole-row grant is
// built from the entity after these files ran.
describe("the project description migration", () => {
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
const shape = shapeOfEntity(projects)
it("adds a nullable column to the existing table: a forward migration over rows that exist", () => {
for (const [entity, field] of ADDED_BY_0009) {
expect(isNullable(shapeOfEntity(entity)[field]), `${entity.table}.${field}`).toBe(true)
}
})
it("is 0009_project-description.sql on Postgres", async () => {
const statement = `alter table ${quoted(projects.table)} add column ${quoted(columnName("description"))} ${postgresType(shape.description)}`
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
"../migrations/postgres/0009_project-description.sql",
)
})
it("is profiles/mariadb/migrations/0009_project-description.sql", async () => {
const statement = `alter table ${mysqlQuoted(projects.table)} add column ${mysqlQuoted(columnName("description"))} ${mysqlType(shape.description)}`
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
"../profiles/mariadb/migrations/0009_project-description.sql",
)
})
it("is profiles/mongodb/migrations/0009_project-description.json: the collection's whole current validator", () => {
// A collection's validator refuses a field it does not list (additionalProperties: false).
const expected: MongoCommand[] = [{ collMod: projects.table, validator: mongoValidator(asOf0009(projects)) }]
expect(JSON.parse(read("profiles/mongodb/migrations/0009_project-description.json"))).toEqual(
JSON.parse(JSON.stringify(expected)),
)
expect(JSON.stringify(mongoValidator(projects))).toContain('"description"')
})
})
function shapeOfEntity(entity: AnyEntityDef) {
if (!(entity.schema instanceof z.ZodObject)) throw new Error(`${entity.table}: an entity's schema is an object`)
return entity.schema.shape
}
// An invitation that was revoked keeps its row, so its link can say so. One nullable column on every
// profile; Convex takes it from the generated schema.
describe("the invitation revoked migration", () => {
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
const shape = shapeOfEntity(invitations)
it("adds a nullable column to the existing table: a forward migration over rows that exist", () => {
for (const [entity, field] of ADDED_BY_0010) {
expect(isNullable(shapeOfEntity(entity)[field]), `${entity.table}.${field}`).toBe(true)
}
})
it("is 0010_invitation-revoked.sql on Postgres", async () => {
const statement = `alter table ${quoted(invitations.table)} add column ${quoted(columnName("revokedAt"))} ${postgresType(shape.revokedAt)}`
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
"../migrations/postgres/0010_invitation-revoked.sql",
)
})
it("is profiles/mariadb/migrations/0010_invitation-revoked.sql", async () => {
const statement = `alter table ${mysqlQuoted(invitations.table)} add column ${mysqlQuoted(columnName("revokedAt"))} ${mysqlType(shape.revokedAt)}`
await expect(`-- ${SPDX_LINE}\n${statement};\n`).toMatchFileSnapshot(
"../profiles/mariadb/migrations/0010_invitation-revoked.sql",
)
})
it("is profiles/mongodb/migrations/0010_invitation-revoked.json: the collection's whole current validator", () => {
const expected: MongoCommand[] = [{ collMod: invitations.table, validator: mongoValidator(invitations) }]
expect(JSON.parse(read("profiles/mongodb/migrations/0010_invitation-revoked.json"))).toEqual(
JSON.parse(JSON.stringify(expected)),
)
expect(JSON.stringify(mongoValidator(invitations))).toContain('"revoked_at"')
})
})