Skip to content

Commit 3426e5b

Browse files
committed
feat(state): add sqlite schema and migrations
1 parent 1283e9d commit 3426e5b

10 files changed

Lines changed: 370 additions & 0 deletions

File tree

docs/STATE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Estado durable
2+
3+
La persistencia durable de Tanit se mantiene detrás de contratos runtime-neutral. SQLite es infraestructura: `bun:sqlite` sólo se importa en `packages/core/state/sqlite/sqlite-connection.adapter.ts`; contratos y dominio no dependen de él.
4+
5+
## Base de datos
6+
7+
La base usa `PRAGMA user_version` y migraciones forward-only reproducibles. La versión actual es `2`. Las migraciones se ejecutan dentro del límite transaccional del adapter y rechazan versiones futuras desconocidas. Un esquema corrupto no se repara silenciosamente.
8+
9+
El esquema separa `projects`, `snapshots`, `services`, `operations`, `servers`, `auth_profiles`, `schemas`, `diagnostics`, `provenance` y `source_files`. Los campos variables se almacenan como JSON únicamente en columnas `*_json`; secretos no forman parte del modelo de conexión.
10+
11+
## Ubicación y conexión
12+
13+
Por defecto, la base global del usuario vive en `~/.tanit/state.sqlite`. `TANIT_STATE_DB` permite sustituir la ruta para tests, CI, Docker y modo portable. Cada conexión activa foreign keys, WAL y `busy_timeout` de 5 segundos.
14+
15+
La creación de la carpeta padre es responsabilidad de la infraestructura. Los consumidores futuros deben cerrar la conexión y mantener las escrituras dentro de transacciones explícitas.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const STATE_DB_ENVIRONMENT_VARIABLE = "TANIT_STATE_DB";
2+
export const STATE_DB_DIRECTORY_NAME = ".tanit";
3+
export const STATE_DB_FILE_NAME = "state.sqlite";
4+
export const STATE_DB_SCHEMA_VERSION = 2;
5+
export const STATE_DB_BUSY_TIMEOUT_MS = 5_000;
6+
7+
export const STATE_DB_TABLES = [
8+
"projects",
9+
"snapshots",
10+
"services",
11+
"operations",
12+
"servers",
13+
"auth_profiles",
14+
"schemas",
15+
"diagnostics",
16+
"provenance",
17+
"source_files",
18+
] as const;
19+
20+
export type StateDbTable = (typeof STATE_DB_TABLES)[number];

packages/contracts/interfaces/runtime.d.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,39 @@ declare module "node:fs" {
201201
}
202202
}
203203

204+
// --- bun:sqlite ---------------------------------------------------------
205+
// The product is typechecked without bun-types, but the SQLite adapter is
206+
// intentionally Bun-only infrastructure.
207+
declare module "bun:sqlite" {
208+
export interface Statement<T = Record<string, unknown>> {
209+
get(...params: unknown[]): T | undefined;
210+
all(...params: unknown[]): T[];
211+
run(...params: unknown[]): { changes: number; lastInsertRowid: number };
212+
}
213+
214+
export class Database {
215+
constructor(filename: string, options?: { create?: boolean; strict?: boolean });
216+
exec(sql: string): void;
217+
query<T = Record<string, unknown>>(sql: string): Statement<T>;
218+
transaction<T extends (...args: never[]) => unknown>(callback: T): T;
219+
close(): void;
220+
}
221+
}
222+
223+
declare module "bun:test" {
224+
export function describe(name: string, callback: () => void): void;
225+
export function it(name: string, callback: () => void): void;
226+
export function expect<T>(value: T): {
227+
toBe(expected: unknown): void;
228+
toEqual(expected: unknown): void;
229+
toContain(expected: unknown): void;
230+
toThrow(expected?: unknown): void;
231+
};
232+
export namespace expect {
233+
function arrayContaining(values: unknown[]): unknown;
234+
}
235+
}
236+
204237
// --- node:child_process --------------------------------------------------
205238
declare module "node:child_process" {
206239
/** Output stream of a child, in what this repo uses of it. */
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
import {
5+
STATE_DB_SCHEMA_VERSION,
6+
STATE_DB_TABLES,
7+
} from "../../../contracts/constants/core/state-store.constant.js";
8+
9+
export interface IStateMigrationDatabase {
10+
exec(sql: string): void;
11+
query(sql: string): { get(): unknown; all?(): unknown[] };
12+
}
13+
14+
export interface IStateMigration {
15+
readonly version: number;
16+
readonly up: (database: IStateMigrationDatabase) => void;
17+
readonly down: (database: IStateMigrationDatabase) => void;
18+
}
19+
20+
export class StateDatabaseMigrationError extends Error {
21+
public constructor(message: string, options?: { cause?: unknown }) {
22+
super(message, options);
23+
this.name = "StateDatabaseMigrationError";
24+
}
25+
}
26+
27+
const SCHEMA_SQL = readFileSync(join(import.meta.dir, "schema.sql"), "utf8");
28+
29+
const ADD_PROVENANCE_INDEX = "CREATE INDEX IF NOT EXISTS provenance_snapshot_idx ON provenance(snapshot_id);";
30+
31+
export const STATE_DATABASE_MIGRATIONS: readonly IStateMigration[] = [
32+
{
33+
version: 1,
34+
up: (database) => database.exec(SCHEMA_SQL),
35+
down: (database) => {
36+
for (const table of [...STATE_DB_TABLES].reverse()) database.exec(`DROP TABLE IF EXISTS ${table};`);
37+
},
38+
},
39+
{
40+
version: 2,
41+
up: (database) => database.exec(ADD_PROVENANCE_INDEX),
42+
down: (database) => database.exec("DROP INDEX IF EXISTS provenance_snapshot_idx;"),
43+
},
44+
];
45+
46+
function readVersion(database: IStateMigrationDatabase): number {
47+
const row = database.query("PRAGMA user_version").get() as { user_version?: number } | undefined;
48+
return row?.user_version ?? 0;
49+
}
50+
51+
function setVersion(database: IStateMigrationDatabase, version: number): void {
52+
database.exec(`PRAGMA user_version = ${version}`);
53+
}
54+
55+
function validateSchema(database: IStateMigrationDatabase): void {
56+
const rows = database.query("SELECT name FROM sqlite_master WHERE type = 'table'").all?.() as
57+
| Array<{ name?: string }>
58+
| undefined;
59+
const tables = new Set(rows?.map((row) => row.name).filter((name): name is string => name !== undefined));
60+
const missing = STATE_DB_TABLES.filter((table) => !tables.has(table));
61+
if (missing.length > 0) {
62+
throw new StateDatabaseMigrationError(`Corrupt state database; missing tables: ${missing.join(", ")}`);
63+
}
64+
}
65+
66+
export function migrateStateDatabase(database: IStateMigrationDatabase): number {
67+
const currentVersion = readVersion(database);
68+
if (currentVersion > STATE_DB_SCHEMA_VERSION) {
69+
throw new StateDatabaseMigrationError(`Unsupported future state database version: ${currentVersion}`);
70+
}
71+
72+
database.exec("BEGIN IMMEDIATE");
73+
try {
74+
for (const migration of STATE_DATABASE_MIGRATIONS) {
75+
if (migration.version <= currentVersion) continue;
76+
migration.up(database);
77+
setVersion(database, migration.version);
78+
}
79+
if (STATE_DB_SCHEMA_VERSION > 0) validateSchema(database);
80+
database.exec("COMMIT");
81+
return STATE_DB_SCHEMA_VERSION;
82+
} catch (error) {
83+
database.exec("ROLLBACK");
84+
if (error instanceof StateDatabaseMigrationError) throw error;
85+
throw new StateDatabaseMigrationError("State database migration failed", { cause: error });
86+
}
87+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
CREATE TABLE IF NOT EXISTS projects (
2+
project_id TEXT PRIMARY KEY,
3+
root_path TEXT NOT NULL,
4+
active_snapshot_id TEXT,
5+
revision INTEGER NOT NULL DEFAULT 0,
6+
created_at TEXT NOT NULL,
7+
updated_at TEXT NOT NULL
8+
);
9+
10+
CREATE TABLE IF NOT EXISTS snapshots (
11+
snapshot_id TEXT PRIMARY KEY,
12+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
13+
status TEXT NOT NULL CHECK (status IN ('building', 'complete', 'failed')),
14+
revision INTEGER NOT NULL,
15+
captured_at TEXT NOT NULL,
16+
digest TEXT,
17+
metadata_json TEXT,
18+
failure TEXT,
19+
UNIQUE (project_id, revision)
20+
);
21+
22+
CREATE TABLE IF NOT EXISTS services (
23+
service_id TEXT NOT NULL,
24+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
25+
name TEXT NOT NULL,
26+
PRIMARY KEY (snapshot_id, service_id)
27+
);
28+
29+
CREATE TABLE IF NOT EXISTS operations (
30+
operation_id TEXT NOT NULL,
31+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
32+
service_id TEXT NOT NULL,
33+
method TEXT NOT NULL,
34+
path TEXT NOT NULL,
35+
server_ref TEXT,
36+
auth_ref TEXT,
37+
schema_id TEXT,
38+
PRIMARY KEY (snapshot_id, operation_id),
39+
FOREIGN KEY (snapshot_id, service_id) REFERENCES services(snapshot_id, service_id) ON DELETE CASCADE
40+
);
41+
42+
CREATE TABLE IF NOT EXISTS servers (
43+
server_ref TEXT NOT NULL,
44+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
45+
url TEXT NOT NULL,
46+
metadata_json TEXT,
47+
PRIMARY KEY (snapshot_id, server_ref)
48+
);
49+
50+
CREATE TABLE IF NOT EXISTS auth_profiles (
51+
auth_ref TEXT NOT NULL,
52+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
53+
scheme TEXT NOT NULL,
54+
metadata_json TEXT,
55+
PRIMARY KEY (snapshot_id, auth_ref)
56+
);
57+
58+
CREATE TABLE IF NOT EXISTS schemas (
59+
schema_id TEXT NOT NULL,
60+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
61+
media_type TEXT,
62+
schema_json TEXT NOT NULL,
63+
PRIMARY KEY (snapshot_id, schema_id)
64+
);
65+
66+
CREATE TABLE IF NOT EXISTS diagnostics (
67+
diagnostic_id INTEGER PRIMARY KEY,
68+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
69+
code TEXT NOT NULL,
70+
message TEXT NOT NULL,
71+
severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'error'))
72+
);
73+
74+
CREATE TABLE IF NOT EXISTS provenance (
75+
provenance_id INTEGER PRIMARY KEY,
76+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
77+
source_type TEXT NOT NULL,
78+
source_ref TEXT NOT NULL,
79+
metadata_json TEXT
80+
);
81+
82+
CREATE TABLE IF NOT EXISTS source_files (
83+
source_file_id INTEGER PRIMARY KEY,
84+
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id) ON DELETE CASCADE,
85+
path TEXT NOT NULL,
86+
content_digest TEXT NOT NULL,
87+
metadata_json TEXT,
88+
UNIQUE (snapshot_id, path)
89+
);
90+
91+
CREATE INDEX IF NOT EXISTS snapshots_project_idx ON snapshots(project_id, revision DESC);
92+
CREATE INDEX IF NOT EXISTS diagnostics_snapshot_idx ON diagnostics(snapshot_id);
93+
CREATE INDEX IF NOT EXISTS source_files_snapshot_idx ON source_files(snapshot_id);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { mkdirSync } from "node:fs";
2+
import { dirname } from "node:path";
3+
import { Database } from "bun:sqlite";
4+
5+
import {
6+
STATE_DB_BUSY_TIMEOUT_MS,
7+
STATE_DB_SCHEMA_VERSION,
8+
} from "../../../contracts/constants/core/state-store.constant.js";
9+
import { migrateStateDatabase } from "./migrations.js";
10+
import { resolveStateDatabasePath } from "./state-db-path.service.js";
11+
12+
export interface IStateDatabaseConnection {
13+
readonly path: string;
14+
readonly version: number;
15+
readonly database: Database;
16+
close(): void;
17+
}
18+
19+
export function openStateDatabase(path = resolveStateDatabasePath()): IStateDatabaseConnection {
20+
mkdirSync(dirname(path), { recursive: true });
21+
const database = new Database(path, { create: true, strict: true });
22+
database.exec(`PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = ${STATE_DB_BUSY_TIMEOUT_MS};`);
23+
const version = migrateStateDatabase(database);
24+
if (version !== STATE_DB_SCHEMA_VERSION) {
25+
database.close();
26+
throw new Error(`Unexpected state database version: ${version}`);
27+
}
28+
return { path, version, database, close: () => database.close() };
29+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { homedir } from "node:os";
2+
import { join } from "node:path";
3+
4+
import {
5+
STATE_DB_DIRECTORY_NAME,
6+
STATE_DB_ENVIRONMENT_VARIABLE,
7+
STATE_DB_FILE_NAME,
8+
} from "../../../contracts/constants/core/state-store.constant.js";
9+
10+
export function resolveStateDatabasePath(environment: Record<string, string | undefined> = process.env): string {
11+
const override = environment[STATE_DB_ENVIRONMENT_VARIABLE];
12+
return override && override.trim().length > 0
13+
? override
14+
: join(homedir(), STATE_DB_DIRECTORY_NAME, STATE_DB_FILE_NAME);
15+
}

scripts/gates/lint-naming.script.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const RULES: readonly INamingRule[] = [
8484
path: "packages/core/state/",
8585
what: "estado persistible y serialización canónica",
8686
suffixes: [".service.ts", ".serializer.ts", ".adapter.ts", ".helper.ts"],
87+
exact: ["migrations.ts"],
8788
},
8889
{
8990
path: "packages/core/",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, it } from "bun:test";
5+
6+
import { STATE_DB_BUSY_TIMEOUT_MS, STATE_DB_SCHEMA_VERSION } from "../../../../packages/contracts/constants/core/state-store.constant.js";
7+
import { openStateDatabase } from "../../../../packages/core/state/sqlite/sqlite-connection.adapter.js";
8+
import { resolveStateDatabasePath } from "../../../../packages/core/state/sqlite/state-db-path.service.js";
9+
10+
describe("state sqlite connection", () => {
11+
it("configures pragmas and migrates a file database", () => {
12+
const directory = mkdtempSync(join(tmpdir(), "tanit-state-"));
13+
const path = join(directory, "state.sqlite");
14+
const connection = openStateDatabase(path);
15+
expect(connection.version).toBe(STATE_DB_SCHEMA_VERSION);
16+
expect(connection.database.query("PRAGMA foreign_keys").get()).toEqual({ foreign_keys: 1 });
17+
expect(connection.database.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: "wal" });
18+
expect(connection.database.query("PRAGMA busy_timeout").get()).toEqual({ timeout: STATE_DB_BUSY_TIMEOUT_MS });
19+
connection.close();
20+
rmSync(directory, { recursive: true, force: true });
21+
});
22+
23+
it("uses TANIT_STATE_DB without exposing credentials in the path contract", () => {
24+
expect(resolveStateDatabasePath({ TANIT_STATE_DB: "/tmp/test-state.sqlite" })).toBe("/tmp/test-state.sqlite");
25+
expect(resolveStateDatabasePath({ TANIT_STATE_DB: " " })).toContain(".tanit");
26+
});
27+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { Database } from "bun:sqlite";
4+
import { STATE_DB_SCHEMA_VERSION, STATE_DB_TABLES } from "../../../../packages/contracts/constants/core/state-store.constant.js";
5+
import { migrateStateDatabase, StateDatabaseMigrationError } from "../../../../packages/core/state/sqlite/migrations.js";
6+
7+
describe("state database migrations", () => {
8+
it("creates the fresh schema and all durable tables", () => {
9+
const database = new Database(":memory:");
10+
expect(migrateStateDatabase(database)).toBe(STATE_DB_SCHEMA_VERSION);
11+
const names = database.query("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() as Array<{ name: string }>;
12+
expect(names.map(({ name }) => name)).toEqual(expect.arrayContaining([...STATE_DB_TABLES]));
13+
expect(database.query("PRAGMA user_version").get()).toEqual({ user_version: STATE_DB_SCHEMA_VERSION });
14+
database.close();
15+
});
16+
17+
it("migrates v1 to v2 and is idempotent", () => {
18+
const database = new Database(":memory:");
19+
migrateStateDatabase(database);
20+
database.exec("PRAGMA user_version = 1");
21+
const first = migrateStateDatabase(database);
22+
const second = migrateStateDatabase(database);
23+
expect(first).toBe(2);
24+
expect(second).toBe(2);
25+
expect(database.query("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'provenance_snapshot_idx'").get()).toEqual({ name: "provenance_snapshot_idx" });
26+
database.close();
27+
});
28+
29+
it("rejects an unknown future version", () => {
30+
const database = new Database(":memory:");
31+
database.exec(`PRAGMA user_version = ${STATE_DB_SCHEMA_VERSION + 1}`);
32+
expect(() => migrateStateDatabase(database)).toThrow(StateDatabaseMigrationError);
33+
database.close();
34+
});
35+
36+
it("fails cleanly for a corrupt database schema", () => {
37+
const database = new Database(":memory:");
38+
database.exec("PRAGMA user_version = 1; CREATE TABLE projects (broken TEXT)");
39+
expect(() => migrateStateDatabase(database)).toThrow(StateDatabaseMigrationError);
40+
database.close();
41+
});
42+
43+
it("rolls back a failed migration without changing the version", () => {
44+
const database = new Database(":memory:");
45+
database.exec("PRAGMA user_version = 1");
46+
expect(() => migrateStateDatabase(database)).toThrow(StateDatabaseMigrationError);
47+
expect(database.query("PRAGMA user_version").get()).toEqual({ user_version: 1 });
48+
database.close();
49+
});
50+
});

0 commit comments

Comments
 (0)