324 lines
16 KiB
TypeScript
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"')
|
|
})
|
|
})
|