Files
windmill/cli/test/datatable_migrations_unit.test.ts
Ruben Fiszel bfc3f5242a feat: sync data table migrations to git, gated by a new object type (#10436)
* fix: auto-sync data table migrations to the linked git repo

* fix: deploy data table migrations when a data table is renamed or deleted

* fix: hold new data table names to the git-sync-safe charset

* fix: reject leading-dot data table names and warn on unsyncable legacy names

* chore: update ee-repo-ref to 15c9eef2a4f867eb90d841aee1ce762f4b725589

This commit updates the EE repository reference after PR #702 was merged in windmill-ee-private.

Previous ee-repo-ref: e2fb073a3d0057683666424e463b2ce423664caa

New ee-repo-ref: 15c9eef2a4f867eb90d841aee1ce762f4b725589

Automated by sync-ee-ref workflow.

* feat: make data table migrations a git-sync object type with its own toggle

* fix: never let an untracked checkout delete data table migrations on push

* fix: confirm ambiguous data table migration deletions instead of dropping them

* fix: settle ambiguous migration deletions before the dry-run preview prints

* fix: restore the split shared-UI comment and count migration records in prompts

* chore: keep the deletion-safety doc block attached to its function

* fix: trust git history, not the working tree, for migration deletions

* fix: scope migration history to HEAD, detect shallow clones and subdir roots

* chore: give the unattested-history case a remedy that applies to it

* chore: pair each unattested-history cause with its own remedy

* fix: treat a sparse checkout as unattested history for migration deletions

* fix: normalize the sparse-checkout boolean and give it a remedy that works

* chore: describe both shapes of unattested migration history

* chore: update ee-repo-ref to a786cd42b5aaf0aa6789fbb723d956560f93b1b3

This commit updates the EE repository reference after PR #703 was merged in windmill-ee-private.

Previous ee-repo-ref: 4f312642b5d8fd37ab5e20473a011d6f1d299cf6

New ee-repo-ref: a786cd42b5aaf0aa6789fbb723d956560f93b1b3

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-31 23:33:47 +02:00

182 lines
6.9 KiB
TypeScript

/**
* Unit tests for datatable-migration path parsing and local validation.
*
* These exercise pure logic with no backend:
* - `parseDatatableMigrationPath` recognizes only the
* `migrations/datatable/<dt>/<timestamp>_<name>.(up|down).sql` shape.
* - `validateLocalMigrations` rejects the two invalid on-disk states a push
* must catch: two up (or two down) files sharing a timestamp, and a
* `.down.sql` with no matching `.up.sql`.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { parseDatatableMigrationPath } from "../src/types.ts";
import { validateLocalMigrations } from "../src/commands/datatable_migrations.ts";
import { untrackedDatatableMigrationDeletions } from "../src/commands/sync/sync.ts";
describe("parseDatatableMigrationPath", () => {
test("parses up and down files of the new layout", () => {
expect(
parseDatatableMigrationPath(
"migrations/datatable/mydt/20260101000001_create_users.up.sql",
),
).toEqual({
datatable: "mydt",
timestamp: 20260101000001,
name: "create_users",
kind: "up",
});
expect(
parseDatatableMigrationPath(
"migrations/datatable/my-dt/42_x.down.sql",
),
).toEqual({ datatable: "my-dt", timestamp: 42, name: "x", kind: "down" });
});
test("rejects unrelated, legacy and malformed paths", () => {
for (
const p of [
// legacy top-level layout
"datatable_migrations/mydt/20260101000001_x.up.sql",
// wrong sub-namespace / depth
"migrations/ducklake/mydt/1_x.up.sql",
"migrations/datatable/1_x.up.sql",
"migrations/datatable/mydt/sub/1_x.up.sql",
// not a migration file
"migrations/datatable/mydt/notes.txt",
"migrations/datatable/mydt/x.up.sql", // no numeric timestamp prefix
// unrelated workspace files
"f/foo/bar.script.yaml",
"u/admin/script.ts",
]
) {
expect(parseDatatableMigrationPath(p)).toBeUndefined();
}
});
});
describe("validateLocalMigrations", () => {
let prevCwd: string;
let tmp: string;
beforeEach(() => {
prevCwd = process.cwd();
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dtmig-"));
process.chdir(tmp);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tmp, { recursive: true, force: true });
});
function write(datatable: string, file: string) {
const dir = path.join(tmp, "migrations", "datatable", datatable);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), "-- sql\n");
}
test("accepts up+down pairs and up-only migrations", () => {
write("mydt", "20260101000001_create_users.up.sql");
write("mydt", "20260101000001_create_users.down.sql");
write("mydt", "20260101000002_add_email.up.sql"); // down is optional
expect(validateLocalMigrations()).toEqual([]);
});
test("flags two up files sharing a timestamp", () => {
write("mydt", "20260101000003_foo.up.sql");
write("mydt", "20260101000003_bar.up.sql");
const errors = validateLocalMigrations();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("20260101000003");
});
test("flags two down files sharing a timestamp", () => {
write("mydt", "20260101000004_a.up.sql");
write("mydt", "20260101000004_a.down.sql");
write("mydt", "20260101000004_b.down.sql");
const errors = validateLocalMigrations();
// duplicate down + the b.down orphan (no b.up)
expect(errors.some((e) => e.includes("down") && e.includes("20260101000004"))).toBe(true);
});
test("flags a down file with no matching up", () => {
write("mydt", "20260101000005_orphan.down.sql");
const errors = validateLocalMigrations();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("20260101000005_orphan");
});
test("only validates the requested datatables", () => {
write("bad", "20260101000006_x.up.sql");
write("bad", "20260101000006_y.up.sql"); // duplicate, but in 'bad'
write("good", "20260101000007_ok.up.sql");
expect(validateLocalMigrations(new Set(["good"]))).toEqual([]);
expect(validateLocalMigrations(new Set(["bad"])).length).toBe(1);
});
test("returns no errors when the migrations folder is absent", () => {
expect(validateLocalMigrations()).toEqual([]);
});
});
// =============================================================================
// untrackedDatatableMigrationDeletions — the push-side safety net.
//
// Migrations bypass the repo's path filters, so a clone made before they were
// synced sees every server-side migration as remote-only, and
// `pushMigrationFromDisk` reads a missing `.up.sql` as "delete it". What the repo
// has ever committed under migrations/datatable/ is the durable answer to "did we
// track this?" — the working tree is not, because creating a migration locally
// makes the directory appear without anything having been tracked.
// =============================================================================
describe("untrackedDatatableMigrationDeletions", () => {
const A_UP = "migrations/datatable/mydb/20260101000000_a.up.sql";
const A_DOWN = "migrations/datatable/mydb/20260101000000_a.down.sql";
const changes = [
{ name: "deleted", path: A_UP },
{ name: "deleted", path: A_DOWN },
{ name: "deleted", path: "f/foo/bar.script.yaml" },
{ name: "added", path: "migrations/datatable/mydb/20260102000000_b.up.sql" },
];
test("trusts a deletion the repository has committed before", () => {
expect(
untrackedDatatableMigrationDeletions(changes, { kind: "known", paths: new Set([A_UP, A_DOWN]) }),
).toEqual([]);
});
test("flags migrations this repository has never recorded", () => {
expect(
untrackedDatatableMigrationDeletions(changes, { kind: "known", paths: new Set() }).map((c) => c.path),
).toEqual([A_UP, A_DOWN]);
});
test("a locally created migration does not vouch for unrelated ones", () => {
// `wmill datatable migrate new` makes migrations/datatable/ exist without the
// checkout having tracked anything, so only the recorded paths count.
const recorded = {
kind: "known" as const,
paths: new Set(["migrations/datatable/mydb/20260102000000_b.up.sql"]),
};
expect(
untrackedDatatableMigrationDeletions(changes, recorded).map((c) => c.path),
).toEqual([A_UP, A_DOWN]);
});
test("trusts nothing when the history cannot be consulted", () => {
// A shallow clone or a non-repository can't prove a path was never tracked,
// so absence is not read as permission to delete.
expect(
untrackedDatatableMigrationDeletions(changes, {
kind: "unknown",
reason: "this is a shallow clone, so its history is truncated",
remedy: "Fetch the full history (for actions/checkout, fetch-depth: 0)",
}).map((c) => c.path),
).toEqual([A_UP, A_DOWN]);
});
});