Add D1 migration baseline
This commit is contained in:
@@ -96,6 +96,7 @@ function testEnv(options: TestEnvOptions = {}): Env {
|
||||
|
||||
return {
|
||||
ELY_ENVIRONMENT: "local",
|
||||
ELY_DB: testD1Database(),
|
||||
ELY_KV: {
|
||||
get(key: string): Promise<string | null> {
|
||||
options.kvReads?.push(key);
|
||||
@@ -117,3 +118,34 @@ function testEnv(options: TestEnvOptions = {}): Env {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function testD1Database(): Env["ELY_DB"] {
|
||||
return {
|
||||
prepare() {
|
||||
return testD1PreparedStatement();
|
||||
},
|
||||
batch() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
exec() {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function testD1PreparedStatement(): ReturnType<Env["ELY_DB"]["prepare"]> {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
all() {
|
||||
return Promise.resolve({ results: [] });
|
||||
},
|
||||
run() {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ function testEnv(
|
||||
|
||||
return {
|
||||
ELY_ENVIRONMENT: "local",
|
||||
ELY_DB: testD1Database(),
|
||||
ELY_KV: {
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(values.get(key) ?? null);
|
||||
@@ -376,6 +377,37 @@ function releaseManifestDocument(): string {
|
||||
});
|
||||
}
|
||||
|
||||
function testD1Database(): Env["ELY_DB"] {
|
||||
return {
|
||||
prepare() {
|
||||
return testD1PreparedStatement();
|
||||
},
|
||||
batch() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
exec() {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function testD1PreparedStatement(): ReturnType<Env["ELY_DB"]["prepare"]> {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
all() {
|
||||
return Promise.resolve({ results: [] });
|
||||
},
|
||||
run() {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRegistryDocument(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
const EXPECTED_MIGRATIONS = [
|
||||
"0001_devices.sql",
|
||||
"0002_sync.sql",
|
||||
"0003_plugins.sql",
|
||||
"0004_releases.sql",
|
||||
"0005_audit.sql",
|
||||
];
|
||||
const USER_SCOPED_TABLES = [
|
||||
"user_devices",
|
||||
"device_approvals",
|
||||
"sync_objects",
|
||||
"sync_change_log",
|
||||
"sync_snapshots",
|
||||
"sync_tombstones",
|
||||
];
|
||||
|
||||
describe("D1 migrations", () => {
|
||||
it("keep the PRD migration order explicit", () => {
|
||||
assert.deepEqual(migrationFiles(), EXPECTED_MIGRATIONS);
|
||||
});
|
||||
|
||||
it("replays cleanly and creates the PRD custom tables", () => {
|
||||
withReplayedDatabase((databasePath) => {
|
||||
const tables = sqliteJson<{ name: string }>(
|
||||
databasePath,
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
|
||||
).map((row) => row.name);
|
||||
|
||||
for (const table of [
|
||||
"audit_events",
|
||||
"device_approvals",
|
||||
"plugin_packages",
|
||||
"plugin_registry",
|
||||
"plugin_reviews",
|
||||
"release_manifests",
|
||||
"sync_change_log",
|
||||
"sync_objects",
|
||||
"sync_snapshots",
|
||||
"sync_tombstones",
|
||||
"user_devices",
|
||||
]) {
|
||||
assert.ok(tables.includes(table), table);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps user-scoped D1 tables partitioned by user_id", () => {
|
||||
withReplayedDatabase((databasePath) => {
|
||||
for (const table of USER_SCOPED_TABLES) {
|
||||
assert.ok(tableColumns(databasePath, table).includes("user_id"), table);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sync facts compatible with cursor pull and tombstones", () => {
|
||||
withReplayedDatabase((databasePath) => {
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_objects", [
|
||||
"object_id",
|
||||
"object_type",
|
||||
"logical_clock",
|
||||
"device_id",
|
||||
"deleted_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_change_log", [
|
||||
"change_id",
|
||||
"object_id",
|
||||
"object_type",
|
||||
"operation",
|
||||
"logical_clock",
|
||||
"device_id",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function migrationFiles(): string[] {
|
||||
return readdirSync(MIGRATIONS_DIR).filter((fileName) => fileName.endsWith(".sql")).sort();
|
||||
}
|
||||
|
||||
function withReplayedDatabase(assertions: (databasePath: string) => void): void {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-d1-migrations-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const sql = migrationFiles()
|
||||
.map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"))
|
||||
.join("\n");
|
||||
execFileSync("sqlite3", [databasePath], { input: sql });
|
||||
execFileSync("sqlite3", [databasePath], { input: sql });
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function tableColumns(databasePath: string, table: string): string[] {
|
||||
return sqliteJson<{ name: string }>(databasePath, `PRAGMA table_info(${table})`).map(
|
||||
(row) => row.name,
|
||||
);
|
||||
}
|
||||
|
||||
function requiredColumns(databasePath: string, table: string, columns: string[]): string[] {
|
||||
const existingColumns = new Set(tableColumns(databasePath, table));
|
||||
return columns.filter((column) => !existingColumns.has(column));
|
||||
}
|
||||
|
||||
function sqliteJson<T>(databasePath: string, sql: string): T[] {
|
||||
const output = execFileSync("sqlite3", ["-json", databasePath, sql], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return JSON.parse(output) as T[];
|
||||
}
|
||||
Reference in New Issue
Block a user