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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user