feat: AssetRepository SQLite + cycle need-review persistant (v0.10.0)

Table assets sur node:sqlite (Node 24, zéro dep native) : CRUD,
listByStatus, incrementExec, setRiskTier anti-escalade, expireProvisional
(cron PROVISOIRE→BLOQUÉ). 6 tests. Bump Node 24 (sqlite stable), Dockerfile
24.13 + copie tsconfig.build.json. 0 vuln.

Palier de risque : reversible (persistance d'état, aucune mutation d'infra).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:24:35 +02:00
parent 1cce8c9db6
commit 56e948c976
6 changed files with 238 additions and 15 deletions
+156
View File
@@ -0,0 +1,156 @@
import { DatabaseSync } from "node:sqlite";
import type { RiskTier } from "../audit/log.js";
import {
type Asset,
type AssetStatus,
type AssetType,
assertNoEscalation,
} from "./assets.js";
/**
* Persistance de la table d'assets (cycle "need review", Phase 2).
*
* Backend SQLite via `node:sqlite` (intégré à Node 24, zéro dépendance native).
* La table fait foi de l'état du cycle de vie ; le commit/doc d'un asset vivent
* dans Git (champs commit_link / doc_link). Voir docs/risk-tiers.md.
*/
interface Row {
id: string;
type: string;
version: string;
risk_tier: string;
status: string;
created_at: number;
expires_at: number | null;
exec_count: number;
commit_link: string | null;
doc_link: string | null;
}
function rowToAsset(r: Row): Asset {
return {
id: r.id,
type: r.type as AssetType,
version: r.version,
riskTier: r.risk_tier as RiskTier,
status: r.status as AssetStatus,
createdAt: r.created_at,
expiresAt: r.expires_at,
execCount: r.exec_count,
commitLink: r.commit_link,
docLink: r.doc_link,
};
}
export class AssetRepository {
private readonly db: DatabaseSync;
constructor(dbPath: string) {
this.db = new DatabaseSync(dbPath);
this.db.exec("PRAGMA journal_mode = WAL;");
this.migrate();
}
private migrate(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
version TEXT NOT NULL,
risk_tier TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
exec_count INTEGER NOT NULL DEFAULT 0,
commit_link TEXT,
doc_link TEXT
);
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
`);
}
create(asset: Asset): void {
this.db
.prepare(
`INSERT INTO assets
(id, type, version, risk_tier, status, created_at, expires_at, exec_count, commit_link, doc_link)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
asset.id,
asset.type,
asset.version,
asset.riskTier,
asset.status,
asset.createdAt,
asset.expiresAt,
asset.execCount,
asset.commitLink,
asset.docLink,
);
}
get(id: string): Asset | null {
const row = this.db.prepare("SELECT * FROM assets WHERE id = ?").get(id) as
| Row
| undefined;
return row ? rowToAsset(row) : null;
}
list(): Asset[] {
const rows = this.db
.prepare("SELECT * FROM assets ORDER BY created_at DESC")
.all() as unknown as Row[];
return rows.map(rowToAsset);
}
listByStatus(status: AssetStatus): Asset[] {
const rows = this.db
.prepare("SELECT * FROM assets WHERE status = ? ORDER BY created_at DESC")
.all(status) as unknown as Row[];
return rows.map(rowToAsset);
}
updateStatus(id: string, status: AssetStatus): void {
this.db.prepare("UPDATE assets SET status = ? WHERE id = ?").run(status, id);
}
/** Change le palier de risque en refusant toute escalade (privileged→reversible). */
setRiskTier(id: string, next: RiskTier): void {
const current = this.get(id);
if (!current) throw new Error(`asset inconnu: ${id}`);
assertNoEscalation(current.riskTier, next);
this.db.prepare("UPDATE assets SET risk_tier = ? WHERE id = ?").run(next, id);
}
incrementExec(id: string): number {
this.db
.prepare("UPDATE assets SET exec_count = exec_count + 1 WHERE id = ?")
.run(id);
return this.get(id)?.execCount ?? 0;
}
/**
* Cron : passe en BLOQUÉ les assets PROVISOIRE dont le sursis a expiré.
* Retourne les ids basculés. (Les privilégiés sont déjà BLOQUÉS, sans sursis.)
*/
expireProvisional(now = Date.now()): string[] {
const due = this.db
.prepare(
"SELECT id FROM assets WHERE status = 'provisoire' AND expires_at IS NOT NULL AND expires_at <= ?",
)
.all(now) as unknown as { id: string }[];
if (due.length === 0) return [];
this.db
.prepare(
"UPDATE assets SET status = 'bloqué' WHERE status = 'provisoire' AND expires_at IS NOT NULL AND expires_at <= ?",
)
.run(now);
return due.map((r) => r.id);
}
close(): void {
this.db.close();
}
}