mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
bfc3f5242a
* 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>
182 lines
6.9 KiB
TypeScript
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]);
|
|
});
|
|
});
|