mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
T01-T07: monorepo scaffold, Hono server, SQLite migrations, Vite/React shell
Scaffold (T01-T04): - npm workspaces monorepo with @rigged/daemon and @rigged/ui - Shared tsconfig.base.json with strict mode - Hono server with /healthz endpoint, guarded index.ts (no side-effect on import) - SQLite connection factory (WAL, FK enforcement) and migration runner - Vite/React shell with dev proxy to daemon Schema (T05-T07, QA-approved HIGH risk): - 001_core_schema: rigs, nodes (id=opaque PK, logical_id=spec name), edges with same-rig trigger enforcing source/target/rig_id consistency - 002_bindings_sessions: bindings (optional, 1:1 with node) and sessions (status includes detached). No resume_token (Phase 2). - 003_events: append-only event log with seq AUTOINCREMENT for SSE replay. events.node_id is intentionally NOT an FK — events survive node deletion. 42 tests, all passing. tsc clean in both packages. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
f3a97b0277
commit
f3742e5366
26 files changed
+5214
No files matched your search
Generated
+4116
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "rigged",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
"packages/ui"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"test": "npm run test --workspaces",
|
||||
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@rigged/daemon",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"ulid": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
/**
|
||||
* Create a SQLite database connection.
|
||||
* Defaults to in-memory for tests. Pass a file path for persistence.
|
||||
*/
|
||||
export function createDb(filePath?: string): Database.Database {
|
||||
const db = new Database(filePath ?? ":memory:");
|
||||
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export interface Migration {
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations against a database.
|
||||
* Tracks applied migrations in a schema_migrations table.
|
||||
* Skips already-applied migrations. Applies new ones in order.
|
||||
*/
|
||||
export function migrate(db: Database.Database, migrations: Migration[]): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const applied = new Set(
|
||||
(
|
||||
db
|
||||
.prepare("SELECT name FROM schema_migrations")
|
||||
.all() as { name: string }[]
|
||||
).map((r) => r.name)
|
||||
);
|
||||
|
||||
const sorted = [...migrations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const applyOne = db.transaction((migration: Migration) => {
|
||||
db.exec(migration.sql);
|
||||
db.prepare("INSERT INTO schema_migrations (name) VALUES (?)").run(
|
||||
migration.name
|
||||
);
|
||||
});
|
||||
|
||||
for (const migration of sorted) {
|
||||
if (!applied.has(migration.name)) {
|
||||
applyOne(migration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
export const coreSchema: Migration = {
|
||||
name: "001_core_schema.sql",
|
||||
sql: `
|
||||
-- rigs: top-level topology container
|
||||
CREATE TABLE rigs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- nodes: logical identity within a rig
|
||||
-- id = opaque DB primary key (ulid). All FKs reference this.
|
||||
-- logical_id = logical name from rig spec (e.g. "orchestrator"). Human-facing.
|
||||
CREATE TABLE nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
rig_id TEXT NOT NULL REFERENCES rigs(id) ON DELETE CASCADE,
|
||||
logical_id TEXT NOT NULL,
|
||||
role TEXT,
|
||||
runtime TEXT,
|
||||
model TEXT,
|
||||
cwd TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(rig_id, logical_id)
|
||||
);
|
||||
|
||||
-- edges: relationships between nodes
|
||||
CREATE TABLE edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
rig_id TEXT NOT NULL REFERENCES rigs(id) ON DELETE CASCADE,
|
||||
source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Edge integrity: source and target must belong to the same rig.
|
||||
-- SQLite CHECK cannot cross-reference tables, so we use a trigger.
|
||||
CREATE TRIGGER edge_same_rig_insert
|
||||
BEFORE INSERT ON edges
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'edge source and target must belong to the same rig')
|
||||
WHERE (SELECT rig_id FROM nodes WHERE id = NEW.source_id)
|
||||
!= (SELECT rig_id FROM nodes WHERE id = NEW.target_id);
|
||||
SELECT RAISE(ABORT, 'edge rig_id must match source node rig_id')
|
||||
WHERE NEW.rig_id != (SELECT rig_id FROM nodes WHERE id = NEW.source_id);
|
||||
END;
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
export const bindingsSessionsSchema: Migration = {
|
||||
name: "002_bindings_sessions.sql",
|
||||
sql: `
|
||||
-- bindings: how a node attaches to physical surfaces
|
||||
-- A node MAY have zero or one binding (unbound = not yet materialized)
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL UNIQUE REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- sessions: live harness execution state
|
||||
-- NOTE: resume_token deferred to Phase 2 migration
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
export const eventsSchema: Migration = {
|
||||
name: "003_events.sql",
|
||||
sql: `
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rig_id TEXT REFERENCES rigs(id) ON DELETE CASCADE,
|
||||
-- INTENTIONALLY NOT AN FK to nodes(id).
|
||||
-- Events are an append-only history log. They must survive node deletion
|
||||
-- so the full timeline is preserved for replay and audit. An FK with
|
||||
-- CASCADE would destroy history; SET NULL would lose the node reference.
|
||||
node_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Primary query pattern: "all events for rig X after sequence N" (SSE replay)
|
||||
CREATE INDEX idx_events_rig_seq ON events(rig_id, seq);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { app } from "./server.js";
|
||||
|
||||
export function startServer(port?: number) {
|
||||
const p = port ?? parseInt(process.env["RIGGED_PORT"] ?? "7433", 10);
|
||||
return serve({ fetch: app.fetch, port: p }, (info) => {
|
||||
console.log(`rigged daemon listening on http://localhost:${info.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Only start the server when this file is executed directly (not imported).
|
||||
const isDirectRun =
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${process.argv[1]}`;
|
||||
|
||||
if (isDirectRun) {
|
||||
startServer();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
app.get("/healthz", (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
describe("createDb", () => {
|
||||
it("creates an in-memory database with WAL and FK pragmas", () => {
|
||||
const db = createDb();
|
||||
const walMode = db.pragma("journal_mode", { simple: true });
|
||||
// In-memory databases may report "memory" instead of "wal" — that's fine.
|
||||
// WAL is set but only takes effect on file-backed DBs.
|
||||
expect(walMode).toBeDefined();
|
||||
|
||||
const fkEnabled = db.pragma("foreign_keys", { simple: true });
|
||||
expect(fkEnabled).toBe(1);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates a file-backed database when path is given", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rigged-test-"));
|
||||
const dbPath = path.join(tmpDir, "test.sqlite");
|
||||
const db = createDb(dbPath);
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates schema_migrations table on first run", () => {
|
||||
migrate(db, []);
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'"
|
||||
)
|
||||
.all();
|
||||
expect(tables).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("runs zero migrations without error", () => {
|
||||
expect(() => migrate(db, [])).not.toThrow();
|
||||
});
|
||||
|
||||
it("applies a trivial migration", () => {
|
||||
const migrations = [
|
||||
{
|
||||
name: "001_test.sql",
|
||||
sql: "CREATE TABLE test_table (id TEXT PRIMARY KEY);",
|
||||
},
|
||||
];
|
||||
migrate(db, migrations);
|
||||
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='test_table'"
|
||||
)
|
||||
.all();
|
||||
expect(tables).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tracks applied migrations in schema_migrations", () => {
|
||||
const migrations = [
|
||||
{
|
||||
name: "001_test.sql",
|
||||
sql: "CREATE TABLE test_table (id TEXT PRIMARY KEY);",
|
||||
},
|
||||
];
|
||||
migrate(db, migrations);
|
||||
|
||||
const applied = db
|
||||
.prepare("SELECT name FROM schema_migrations ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
expect(applied).toEqual([{ name: "001_test.sql" }]);
|
||||
});
|
||||
|
||||
it("refuses to re-run already-applied migrations", () => {
|
||||
const migrations = [
|
||||
{
|
||||
name: "001_test.sql",
|
||||
sql: "CREATE TABLE test_table (id TEXT PRIMARY KEY);",
|
||||
},
|
||||
];
|
||||
migrate(db, migrations);
|
||||
// Running again should not throw (idempotent) and should not re-execute SQL
|
||||
expect(() => migrate(db, migrations)).not.toThrow();
|
||||
|
||||
const applied = db
|
||||
.prepare("SELECT name FROM schema_migrations ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies migrations in order", () => {
|
||||
const migrations = [
|
||||
{
|
||||
name: "001_first.sql",
|
||||
sql: "CREATE TABLE first_table (id TEXT PRIMARY KEY);",
|
||||
},
|
||||
{
|
||||
name: "002_second.sql",
|
||||
sql: "CREATE TABLE second_table (id TEXT PRIMARY KEY, first_ref TEXT REFERENCES first_table(id));",
|
||||
},
|
||||
];
|
||||
migrate(db, migrations);
|
||||
|
||||
const applied = db
|
||||
.prepare("SELECT name FROM schema_migrations ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
expect(applied).toEqual([
|
||||
{ name: "001_first.sql" },
|
||||
{ name: "002_second.sql" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("only applies new migrations on subsequent runs", () => {
|
||||
const first = [
|
||||
{
|
||||
name: "001_first.sql",
|
||||
sql: "CREATE TABLE first_table (id TEXT PRIMARY KEY);",
|
||||
},
|
||||
];
|
||||
migrate(db, first);
|
||||
|
||||
const both = [
|
||||
...first,
|
||||
{
|
||||
name: "002_second.sql",
|
||||
sql: "CREATE TABLE second_table (id TEXT PRIMARY KEY);",
|
||||
},
|
||||
];
|
||||
migrate(db, both);
|
||||
|
||||
const applied = db
|
||||
.prepare("SELECT name FROM schema_migrations ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
expect(applied).toHaveLength(2);
|
||||
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='second_table'"
|
||||
)
|
||||
.all();
|
||||
expect(tables).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
|
||||
function seedRigWithNode(db: Database.Database) {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id, role, runtime) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("node-1", "rig-1", "dev1-impl", "worker", "claude-code");
|
||||
}
|
||||
|
||||
describe("002_bindings_sessions", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates bindings and sessions tables", () => {
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
const names = tables.map((t) => t.name);
|
||||
expect(names).toContain("bindings");
|
||||
expect(names).toContain("sessions");
|
||||
});
|
||||
|
||||
describe("bindings", () => {
|
||||
it("can insert a binding for a node", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)"
|
||||
).run("bind-1", "node-1", "r01-dev1-impl");
|
||||
|
||||
const binding = db
|
||||
.prepare("SELECT * FROM bindings WHERE node_id = ?")
|
||||
.get("node-1") as { tmux_session: string };
|
||||
expect(binding.tmux_session).toBe("r01-dev1-impl");
|
||||
});
|
||||
|
||||
it("binding is optional — node can exist without binding", () => {
|
||||
seedRigWithNode(db);
|
||||
const binding = db
|
||||
.prepare("SELECT * FROM bindings WHERE node_id = ?")
|
||||
.get("node-1");
|
||||
expect(binding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces one binding per node (UNIQUE node_id)", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)"
|
||||
).run("bind-1", "node-1", "r01-dev1-impl");
|
||||
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)"
|
||||
)
|
||||
.run("bind-2", "node-1", "r01-dev1-impl-2")
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("allows updating a binding", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)"
|
||||
).run("bind-1", "node-1", "r01-dev1-impl");
|
||||
|
||||
db.prepare(
|
||||
"UPDATE bindings SET cmux_surface = ? WHERE node_id = ?"
|
||||
).run("surface-42", "node-1");
|
||||
|
||||
const binding = db
|
||||
.prepare("SELECT * FROM bindings WHERE node_id = ?")
|
||||
.get("node-1") as { cmux_surface: string | null };
|
||||
expect(binding.cmux_surface).toBe("surface-42");
|
||||
});
|
||||
|
||||
it("cascades on node delete", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)"
|
||||
).run("bind-1", "node-1", "r01-dev1-impl");
|
||||
|
||||
db.prepare("DELETE FROM nodes WHERE id = ?").run("node-1");
|
||||
const bindings = db.prepare("SELECT * FROM bindings").all();
|
||||
expect(bindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions", () => {
|
||||
it("can insert a session for a node", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, node_id, session_name, status) VALUES (?, ?, ?, ?)"
|
||||
).run("sess-1", "node-1", "r01-dev1-impl", "running");
|
||||
|
||||
const session = db
|
||||
.prepare("SELECT * FROM sessions WHERE id = ?")
|
||||
.get("sess-1") as { session_name: string; status: string };
|
||||
expect(session.session_name).toBe("r01-dev1-impl");
|
||||
expect(session.status).toBe("running");
|
||||
});
|
||||
|
||||
it("defaults status to unknown", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)"
|
||||
).run("sess-1", "node-1", "r01-dev1-impl");
|
||||
|
||||
const session = db
|
||||
.prepare("SELECT * FROM sessions WHERE id = ?")
|
||||
.get("sess-1") as { status: string };
|
||||
expect(session.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("supports detached status", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, node_id, session_name, status) VALUES (?, ?, ?, ?)"
|
||||
).run("sess-1", "node-1", "r01-dev1-impl", "running");
|
||||
|
||||
db.prepare("UPDATE sessions SET status = ? WHERE id = ?").run(
|
||||
"detached",
|
||||
"sess-1"
|
||||
);
|
||||
|
||||
const session = db
|
||||
.prepare("SELECT * FROM sessions WHERE id = ?")
|
||||
.get("sess-1") as { status: string };
|
||||
expect(session.status).toBe("detached");
|
||||
});
|
||||
|
||||
it("cascades on node delete", () => {
|
||||
seedRigWithNode(db);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)"
|
||||
).run("sess-1", "node-1", "r01-dev1-impl");
|
||||
|
||||
db.prepare("DELETE FROM nodes WHERE id = ?").run("node-1");
|
||||
const sessions = db.prepare("SELECT * FROM sessions").all();
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does NOT include resume_token (deferred to Phase 2)", () => {
|
||||
const columns = db
|
||||
.prepare("PRAGMA table_info(sessions)")
|
||||
.all() as { name: string }[];
|
||||
const columnNames = columns.map((c) => c.name);
|
||||
expect(columnNames).not.toContain("resume_token");
|
||||
});
|
||||
|
||||
it("enforces FK: session must reference valid node", () => {
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)"
|
||||
)
|
||||
.run("sess-1", "nonexistent", "r01-dev1-impl")
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
|
||||
describe("001_core_schema", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates rigs, nodes, and edges tables", () => {
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
const names = tables.map((t) => t.name);
|
||||
expect(names).toContain("rigs");
|
||||
expect(names).toContain("nodes");
|
||||
expect(names).toContain("edges");
|
||||
});
|
||||
|
||||
it("can insert a rig", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
const rig = db.prepare("SELECT * FROM rigs WHERE id = ?").get("rig-1") as {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
expect(rig.name).toBe("test-rig");
|
||||
});
|
||||
|
||||
it("can insert nodes referencing a rig", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id, role, runtime) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("node-1", "rig-1", "orchestrator", "orchestrator", "claude-code");
|
||||
|
||||
const node = db
|
||||
.prepare("SELECT * FROM nodes WHERE id = ?")
|
||||
.get("node-1") as { logical_id: string; role: string };
|
||||
expect(node.logical_id).toBe("orchestrator");
|
||||
expect(node.role).toBe("orchestrator");
|
||||
});
|
||||
|
||||
it("enforces FK: node must reference valid rig", () => {
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
)
|
||||
.run("node-1", "nonexistent-rig", "worker")
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("enforces unique (rig_id, logical_id)", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "worker");
|
||||
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
)
|
||||
.run("node-2", "rig-1", "worker")
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("can insert edges referencing valid nodes", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "orchestrator");
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-2", "rig-1", "worker");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO edges (id, rig_id, source_id, target_id, kind) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("edge-1", "rig-1", "node-1", "node-2", "delegates_to");
|
||||
|
||||
const edge = db
|
||||
.prepare("SELECT * FROM edges WHERE id = ?")
|
||||
.get("edge-1") as { kind: string };
|
||||
expect(edge.kind).toBe("delegates_to");
|
||||
});
|
||||
|
||||
it("enforces FK: edge must reference valid nodes", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "orchestrator");
|
||||
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO edges (id, rig_id, source_id, target_id, kind) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run("edge-1", "rig-1", "node-1", "nonexistent", "delegates_to")
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects edges where source and target belong to different rigs", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"rig-one"
|
||||
);
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-2",
|
||||
"rig-two"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "worker-a");
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-2", "rig-2", "worker-b");
|
||||
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO edges (id, rig_id, source_id, target_id, kind) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run("edge-1", "rig-1", "node-1", "node-2", "delegates_to")
|
||||
).toThrow(/same rig/);
|
||||
});
|
||||
|
||||
it("rejects edges where rig_id does not match source node rig_id", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"rig-one"
|
||||
);
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-2",
|
||||
"rig-two"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "worker-a");
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-2", "rig-1", "worker-b");
|
||||
|
||||
// Nodes are in rig-1 but edge claims rig-2
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO edges (id, rig_id, source_id, target_id, kind) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run("edge-1", "rig-2", "node-1", "node-2", "delegates_to")
|
||||
).toThrow(/rig_id must match/);
|
||||
});
|
||||
|
||||
it("cascade deletes: deleting a rig removes its nodes and edges", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "orchestrator");
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-2", "rig-1", "worker");
|
||||
db.prepare(
|
||||
"INSERT INTO edges (id, rig_id, source_id, target_id, kind) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("edge-1", "rig-1", "node-1", "node-2", "delegates_to");
|
||||
|
||||
db.prepare("DELETE FROM rigs WHERE id = ?").run("rig-1");
|
||||
|
||||
const nodes = db.prepare("SELECT * FROM nodes").all();
|
||||
const edges = db.prepare("SELECT * FROM edges").all();
|
||||
expect(nodes).toHaveLength(0);
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
|
||||
describe("003_events", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema]);
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-1",
|
||||
"test-rig"
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates events table", () => {
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='events'"
|
||||
)
|
||||
.all();
|
||||
expect(tables).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("can insert an event with type and JSON payload", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "node.added", JSON.stringify({ logicalId: "worker" }));
|
||||
|
||||
const event = db.prepare("SELECT * FROM events WHERE rig_id = ?").get(
|
||||
"rig-1"
|
||||
) as { seq: number; type: string; payload: string };
|
||||
expect(event.type).toBe("node.added");
|
||||
expect(JSON.parse(event.payload)).toEqual({ logicalId: "worker" });
|
||||
expect(typeof event.seq).toBe("number");
|
||||
});
|
||||
|
||||
it("seq is auto-incrementing and monotonic", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "event.a", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "event.b", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "event.c", "{}");
|
||||
|
||||
const events = db
|
||||
.prepare("SELECT seq FROM events ORDER BY seq")
|
||||
.all() as { seq: number }[];
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events[0]!.seq).toBeLessThan(events[1]!.seq);
|
||||
expect(events[1]!.seq).toBeLessThan(events[2]!.seq);
|
||||
});
|
||||
|
||||
it("primary invariant: query WHERE seq > N returns correct replay set", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", `event.${i}`, JSON.stringify({ index: i }));
|
||||
}
|
||||
|
||||
const allEvents = db
|
||||
.prepare("SELECT seq FROM events ORDER BY seq")
|
||||
.all() as { seq: number }[];
|
||||
const seq2 = allEvents[1]!.seq; // second event
|
||||
|
||||
// Replay after seq2: should get events 3, 4, 5 (indices 2, 3, 4)
|
||||
const replay = db
|
||||
.prepare(
|
||||
"SELECT seq, type, payload FROM events WHERE rig_id = ? AND seq > ? ORDER BY seq"
|
||||
)
|
||||
.all("rig-1", seq2) as { seq: number; type: string; payload: string }[];
|
||||
|
||||
expect(replay).toHaveLength(3);
|
||||
expect(JSON.parse(replay[0]!.payload)).toEqual({ index: 2 });
|
||||
expect(JSON.parse(replay[1]!.payload)).toEqual({ index: 3 });
|
||||
expect(JSON.parse(replay[2]!.payload)).toEqual({ index: 4 });
|
||||
|
||||
// Monotonic order
|
||||
expect(replay[0]!.seq).toBeLessThan(replay[1]!.seq);
|
||||
expect(replay[1]!.seq).toBeLessThan(replay[2]!.seq);
|
||||
});
|
||||
|
||||
it("query by rig_id filters correctly", () => {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run(
|
||||
"rig-2",
|
||||
"other-rig"
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "event.a", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-2", "event.b", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "event.c", "{}");
|
||||
|
||||
const rig1Events = db
|
||||
.prepare("SELECT * FROM events WHERE rig_id = ? ORDER BY seq")
|
||||
.all("rig-1") as { type: string }[];
|
||||
expect(rig1Events).toHaveLength(2);
|
||||
expect(rig1Events[0]!.type).toBe("event.a");
|
||||
expect(rig1Events[1]!.type).toBe("event.c");
|
||||
});
|
||||
|
||||
it("node_id is nullable — rig-level events work", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "rig.created", "{}");
|
||||
|
||||
const event = db.prepare("SELECT node_id FROM events WHERE seq = 1").get() as {
|
||||
node_id: string | null;
|
||||
};
|
||||
expect(event.node_id).toBeNull();
|
||||
});
|
||||
|
||||
// -- Explicit contract: events.node_id is intentionally NOT an FK.
|
||||
// -- Events are an append-only log. They must survive node deletion.
|
||||
// -- See 003_events.ts for the design rationale.
|
||||
|
||||
it("allows event with node_id referencing a nonexistent node (orphan refs intentional)", () => {
|
||||
// node_id is plain TEXT, not an FK — orphan refs are allowed by design
|
||||
// because the event log records history, not current state
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO events (rig_id, node_id, type, payload) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
.run("rig-1", "nonexistent-node", "node.added", "{}")
|
||||
).not.toThrow();
|
||||
|
||||
const event = db
|
||||
.prepare("SELECT node_id FROM events WHERE node_id = ?")
|
||||
.get("nonexistent-node") as { node_id: string };
|
||||
expect(event.node_id).toBe("nonexistent-node");
|
||||
});
|
||||
|
||||
it("events survive node deletion (append-only history preserved)", () => {
|
||||
// Create a node, emit events for it, delete the node — events must remain
|
||||
db.prepare(
|
||||
"INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, ?)"
|
||||
).run("node-1", "rig-1", "worker");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, node_id, type, payload) VALUES (?, ?, ?, ?)"
|
||||
).run("rig-1", "node-1", "node.added", '{"logicalId":"worker"}');
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, node_id, type, payload) VALUES (?, ?, ?, ?)"
|
||||
).run("rig-1", "node-1", "session.status_changed", '{"status":"running"}');
|
||||
|
||||
// Delete the node
|
||||
db.prepare("DELETE FROM nodes WHERE id = ?").run("node-1");
|
||||
|
||||
// Events must still exist with their original node_id intact
|
||||
const events = db
|
||||
.prepare("SELECT node_id, type FROM events WHERE node_id = ? ORDER BY seq")
|
||||
.all("node-1") as { node_id: string; type: string }[];
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]!.node_id).toBe("node-1");
|
||||
expect(events[0]!.type).toBe("node.added");
|
||||
expect(events[1]!.node_id).toBe("node-1");
|
||||
expect(events[1]!.type).toBe("session.status_changed");
|
||||
});
|
||||
|
||||
it("cascades on rig delete", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO events (rig_id, type, payload) VALUES (?, ?, ?)"
|
||||
).run("rig-1", "event.a", "{}");
|
||||
|
||||
db.prepare("DELETE FROM rigs WHERE id = ?").run("rig-1");
|
||||
const events = db.prepare("SELECT * FROM events").all();
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { app } from "../src/server.js";
|
||||
|
||||
describe("Hono server", () => {
|
||||
it("GET /healthz returns 200 with status ok", async () => {
|
||||
const res = await app.request("/healthz");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("GET /unknown returns 404", async () => {
|
||||
const res = await app.request("/unknown");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "test"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rigged</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@rigged/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function App() {
|
||||
return <div>Rigged</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { App } from "../src/App.js";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders without crash", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("Rigged")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "test"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:7433",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts", "test/**/*.test.tsx"],
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user