173 lines
7.6 KiB
TypeScript
173 lines
7.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { TABLES, assertProtocolRecord, assertProtocolRecords } from "../protocol/index.mjs";
|
|
import {
|
|
CLOUD_CORE_MIGRATIONS,
|
|
CLOUD_CORE_MIGRATION_ID,
|
|
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE_COLUMNS,
|
|
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS,
|
|
CLOUD_TRANSACTIONAL_REALTIME_MIGRATION_ID,
|
|
CLOUD_TRANSACTIONAL_REALTIME_SCHEMA_VERSION,
|
|
FROZEN_CLOUD_CORE_TABLES,
|
|
assertFrozenCloudCoreTables,
|
|
requiredRuntimeDurableColumns
|
|
} from "./schema.ts";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
|
|
test("cloud core schema keeps the frozen L0 table names", () => {
|
|
assert.deepEqual(FROZEN_CLOUD_CORE_TABLES, TABLES);
|
|
assertFrozenCloudCoreTables();
|
|
assert.equal(CLOUD_CORE_MIGRATIONS[0].connectsToDatabase, false);
|
|
});
|
|
|
|
test("initial migration skeleton declares every frozen table", async () => {
|
|
const sql = await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8");
|
|
|
|
for (const table of TABLES) {
|
|
assert.match(sql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`), `missing ${table}`);
|
|
}
|
|
|
|
assert.match(sql, /\brequest_id\b/);
|
|
assert.match(sql, /\bactor\b/);
|
|
assert.match(sql, /\bsource\b/);
|
|
assert.match(sql, /\boperation\b/);
|
|
assert.match(sql, /\btarget\b/);
|
|
assert.match(sql, /\bresult\b/);
|
|
assert.match(sql, /\btimestamp\b/);
|
|
});
|
|
|
|
test("versioned migration chain exposes columns required by the durable runtime adapter", async () => {
|
|
const sql = await readMigrationChain();
|
|
|
|
assert.ok(requiredRuntimeDurableColumns().length > 0);
|
|
for (const [table, columns] of Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS)) {
|
|
const tableMatch = sql.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\s*\\(([\\s\\S]*?)\\);`, "u"));
|
|
assert.ok(tableMatch, `missing ${table}`);
|
|
for (const column of columns) {
|
|
const declared = new RegExp(`\\b${column}\\b`).test(tableMatch[1]);
|
|
const added = new RegExp(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${column}\\b`, "u").test(sql);
|
|
assert.equal(declared || added, true, `missing ${table}.${column}`);
|
|
}
|
|
}
|
|
|
|
const migrationMatch = sql.match(
|
|
new RegExp(`CREATE TABLE IF NOT EXISTS ${CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE}\\s*\\(([\\s\\S]*?)\\);`, "u")
|
|
);
|
|
assert.ok(migrationMatch, `missing ${CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE}`);
|
|
for (const column of CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE_COLUMNS) {
|
|
assert.match(migrationMatch[1], new RegExp(`\\b${column}\\b`), `missing migration ledger column ${column}`);
|
|
}
|
|
assert.match(sql, new RegExp(`'${CLOUD_CORE_MIGRATION_ID}'`, "u"));
|
|
assert.match(sql, new RegExp(`'${CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION}'`, "u"));
|
|
assert.equal(CLOUD_CORE_MIGRATIONS[0].runtimeDurableMigrationTable, CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE);
|
|
});
|
|
|
|
test("initial migration declares Workbench fact backfill sources", async () => {
|
|
const sql = await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8");
|
|
|
|
for (const [target, source] of [
|
|
["workbench_sessions", "agent_sessions"],
|
|
["workbench_trace_events", "agent_trace_events"],
|
|
["workbench_turns", "workbench_projection_state"],
|
|
["workbench_projection_checkpoints", "workbench_projection_state"]
|
|
]) {
|
|
assert.match(sql, new RegExp(`INSERT INTO ${target} \\(`, "u"), `missing ${target} backfill`);
|
|
assert.match(sql, new RegExp(`FROM ${source}\\b`, "u"), `missing ${source} source for ${target}`);
|
|
}
|
|
|
|
assert.match(sql, /ON CONFLICT \(session_id\) DO NOTHING/u);
|
|
assert.match(sql, /ON CONFLICT \(trace_id\) DO NOTHING/u);
|
|
const traceBackfillMatch = sql.match(/INSERT INTO workbench_trace_events \([\s\S]*?ON CONFLICT DO NOTHING;/u);
|
|
assert.ok(traceBackfillMatch, "missing workbench_trace_events idempotent backfill");
|
|
assert.match(traceBackfillMatch[0], /ROW_NUMBER\(\) OVER \(PARTITION BY trace_id ORDER BY occurred_at, id\)/u);
|
|
assert.match(traceBackfillMatch[0], /durable_projection_seq/u);
|
|
assert.match(sql, /duplicate_trace_source_events/u);
|
|
assert.match(sql, /workbench_trace_events:/u);
|
|
assert.match(sql, /trace_event_projected_seq_conflicts/u);
|
|
assert.match(sql, /COUNT\(\*\) <> COUNT\(DISTINCT projected_seq\)/u);
|
|
assert.match(sql, /DROP INDEX IF EXISTS idx_workbench_trace_events_trace_projected_seq/u);
|
|
assert.match(sql, /CREATE UNIQUE INDEX idx_workbench_trace_events_trace_projected_seq/u);
|
|
});
|
|
|
|
test("v8 migration upgrades the existing Workbench outbox before enforcing realtime identity", async () => {
|
|
const sql = await readFile(path.join(repoRoot, "internal/db/migrations/0008_workbench_kafka_realtime.sql"), "utf8");
|
|
|
|
for (const column of ["outbox_event_id", "entity_family", "entity_id"]) {
|
|
assert.match(
|
|
sql,
|
|
new RegExp(`ALTER TABLE workbench_projection_outbox ADD COLUMN IF NOT EXISTS ${column} TEXT`, "u"),
|
|
`missing additive upgrade for workbench_projection_outbox.${column}`
|
|
);
|
|
assert.match(
|
|
sql,
|
|
new RegExp(`ALTER TABLE workbench_projection_outbox ALTER COLUMN ${column} SET NOT NULL`, "u"),
|
|
`missing post-backfill constraint for workbench_projection_outbox.${column}`
|
|
);
|
|
}
|
|
|
|
assert.match(sql, /SET outbox_event_id = COALESCE\(outbox_event_id, 'legacy:' \|\| outbox_seq::text\)/u);
|
|
assert.match(sql, /entity_family = COALESCE\(entity_family, 'legacy'\)/u);
|
|
assert.match(sql, /CREATE UNIQUE INDEX idx_workbench_projection_outbox_event_id ON workbench_projection_outbox\(outbox_event_id\)/u);
|
|
assert.match(sql, /CREATE TRIGGER trg_notify_hwlab_workbench_projection/u);
|
|
assert.match(sql, new RegExp(`'${CLOUD_TRANSACTIONAL_REALTIME_MIGRATION_ID}'`, "u"));
|
|
assert.match(sql, new RegExp(`'${CLOUD_TRANSACTIONAL_REALTIME_SCHEMA_VERSION}'`, "u"));
|
|
assert.match(sql, /ON CONFLICT \(id\) DO UPDATE SET\s+schema_version = EXCLUDED\.schema_version/u);
|
|
});
|
|
|
|
async function readMigrationChain() {
|
|
const sources = await Promise.all(CLOUD_CORE_MIGRATIONS.map((migration) => readFile(path.join(repoRoot, migration.path), "utf8")));
|
|
return sources.join("\n");
|
|
}
|
|
|
|
test("protocol record guards catch schema drift before runtime writes", () => {
|
|
assertProtocolRecord("gatewaySession", {
|
|
gatewaySessionId: "gws_01J00000000000000000000000",
|
|
projectId: "prj_01J00000000000000000000000",
|
|
serviceId: "hwlab-gateway",
|
|
gatewayId: "gtw_01J00000000000000000000000",
|
|
endpoint: "http://127.0.0.1:7101",
|
|
status: "connected",
|
|
environment: "dev",
|
|
startedAt: "2026-05-21T00:00:00.000Z",
|
|
lastSeenAt: "2026-05-21T00:00:00.000Z"
|
|
});
|
|
|
|
assertProtocolRecords("boxCapability", [
|
|
{
|
|
capabilityId: "cap_01J00000000000000000000000",
|
|
resourceId: "res_01J00000000000000000000000",
|
|
projectId: "prj_01J00000000000000000000000",
|
|
name: "shell.exec",
|
|
direction: "bidirectional",
|
|
valueType: "object",
|
|
mutatesState: true,
|
|
createdAt: "2026-05-21T00:00:00.000Z",
|
|
updatedAt: "2026-05-21T00:00:00.000Z"
|
|
}
|
|
]);
|
|
|
|
assert.throws(
|
|
() =>
|
|
assertProtocolRecord("evidenceRecord", {
|
|
evidenceId: "evd_01J00000000000000000000000",
|
|
projectId: "prj_01J00000000000000000000000",
|
|
operationId: "op_01J00000000000000000000000",
|
|
kind: "trace",
|
|
uri: "memory://hwlab/evidence/evd_01J00000000000000000000000",
|
|
sha256: "0".repeat(64),
|
|
serviceId: "hwlab-cloud-api",
|
|
environment: "dev",
|
|
createdAt: "2026-05-21T00:00:00.000Z",
|
|
evidenceType: "trace"
|
|
}),
|
|
/evidenceRecord.evidenceType is not part of the frozen protocol schema/
|
|
);
|
|
});
|