91 lines
5.6 KiB
TypeScript
91 lines
5.6 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
import type { InventoryType, ReferenceRepository } from '../../domain/model';
|
|
import type { BuildProfile, ReferenceManifest, ReferenceSnapshot } from './types';
|
|
import { MANIFEST_FILE, DATABASE_DIRECTORY } from './compiler';
|
|
|
|
const MAX_IN_BATCH = 500;
|
|
type TypeRow = { type_id: number; name_json: string; category_id: number; volume: number | null };
|
|
|
|
function assertArtifactPath(root: string, database: string): string {
|
|
if (!database || isAbsolute(database) || database.includes('\\') || database.split('/').includes('..')) throw new Error('active reference database path is not a safe relative artifact path');
|
|
const artifactRoot = resolve(root, DATABASE_DIRECTORY);
|
|
const candidate = resolve(root, database);
|
|
const remainder = relative(artifactRoot, candidate);
|
|
if (!remainder || remainder.includes('/') || remainder.startsWith('..') || isAbsolute(remainder) || extname(candidate) !== '.sqlite' || !/^[a-f0-9]{64}\.sqlite$/.test(basename(candidate))) throw new Error('active reference database is outside the artifact directory');
|
|
return candidate;
|
|
}
|
|
|
|
/** Runtime reader: a read-only handle pinned to one immutable manifest artifact. */
|
|
export class SqliteReferenceRepository implements ReferenceRepository, ReferenceSnapshot {
|
|
private readonly db: Database;
|
|
private closed = false;
|
|
readonly pinnedVersion: string;
|
|
readonly databasePath: string;
|
|
|
|
constructor(directory: string, expectedProfile?: BuildProfile) {
|
|
const root = resolve(directory);
|
|
const manifestPath = join(root, MANIFEST_FILE);
|
|
if (!existsSync(manifestPath)) throw new Error(`reference manifest not found: ${manifestPath}`);
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ReferenceManifest;
|
|
if (manifest.format !== 1 || !manifest.active) throw new Error(`reference manifest has no active artifact: ${manifestPath}`);
|
|
if (expectedProfile && manifest.active.profile !== expectedProfile) throw new Error(`reference artifact must use the ${expectedProfile} profile`);
|
|
this.pinnedVersion = manifest.active.version;
|
|
this.databasePath = assertArtifactPath(root, manifest.active.database);
|
|
if (basename(this.databasePath) !== `${this.pinnedVersion}.sqlite`) throw new Error('active reference database filename does not match its manifest version');
|
|
if (!existsSync(this.databasePath)) throw new Error(`active reference database not found: ${this.databasePath}`);
|
|
this.db = new Database(this.databasePath, { readonly: true, strict: true });
|
|
assertReaderSchema(this.db);
|
|
}
|
|
|
|
/** Open another independent pinned handle. The caller owns and closes it. */
|
|
pin(): SqliteReferenceSnapshot { this.assertOpen(); return new SqliteReferenceSnapshot(this.databasePath, this.pinnedVersion); }
|
|
snapshot(): SqliteReferenceSnapshot { return this.pin(); }
|
|
|
|
async types(ids: number[]): Promise<InventoryType[]> { this.assertOpen(); return queryTypes(this.db, ids); }
|
|
async version(): Promise<string> { this.assertOpen(); return this.pinnedVersion; }
|
|
close(): void { if (!this.closed) { this.closed = true; this.db.close(); } }
|
|
[Symbol.dispose](): void { this.close(); }
|
|
private assertOpen(): void { if (this.closed) throw new Error('reference repository is closed'); }
|
|
}
|
|
|
|
export class SqliteReferenceSnapshot implements ReferenceSnapshot {
|
|
private readonly db: Database;
|
|
private closed = false;
|
|
constructor(readonly databasePath: string, readonly pinnedVersion: string) {
|
|
this.db = new Database(databasePath, { readonly: true, strict: true });
|
|
assertReaderSchema(this.db);
|
|
}
|
|
async types(ids: number[]): Promise<InventoryType[]> { this.assertOpen(); return queryTypes(this.db, ids); }
|
|
async version(): Promise<string> { this.assertOpen(); return this.pinnedVersion; }
|
|
close(): void { if (!this.closed) { this.closed = true; this.db.close(); } }
|
|
[Symbol.dispose](): void { this.close(); }
|
|
private assertOpen(): void { if (this.closed) throw new Error('reference snapshot is closed'); }
|
|
}
|
|
|
|
/** Cheap metadata check only; full integrity_check belongs to the compiler. */
|
|
function assertReaderSchema(db: Database): void {
|
|
const row = db.query<{ present: number }, []>("SELECT COUNT(*) AS present FROM sqlite_master WHERE type='table' AND name IN ('types','categories')").get();
|
|
if (!row || Number(row.present) !== 2) { db.close(); throw new Error('active reference database has an unexpected schema'); }
|
|
}
|
|
|
|
async function queryTypes(db: Database, requested: number[]): Promise<InventoryType[]> {
|
|
const unique = [...new Set(requested.filter((id) => Number.isSafeInteger(id) && id >= 0))];
|
|
if (!unique.length) return [];
|
|
const rows: TypeRow[] = [];
|
|
// SQLite has a finite host-parameter limit. Chunking also bounds preparation
|
|
// cost for callers that pass a complete asset snapshot in one request.
|
|
for (let offset = 0; offset < unique.length; offset += MAX_IN_BATCH) {
|
|
const batch = unique.slice(offset, offset + MAX_IN_BATCH);
|
|
const placeholders = batch.map(() => '?').join(',');
|
|
rows.push(...db.query<TypeRow, number[]>(`SELECT type_id,name_json,category_id,volume FROM types WHERE type_id IN (${placeholders})`).all(...batch));
|
|
}
|
|
const byId = new Map(rows.map((row) => [row.type_id, row]));
|
|
return unique.flatMap((id) => {
|
|
const row = byId.get(id); if (!row) return [];
|
|
const names = JSON.parse(row.name_json) as Record<string, string>;
|
|
return [{ id: row.type_id, name: names.en ?? Object.values(names)[0] ?? String(row.type_id), categoryId: row.category_id, ...(row.volume === null ? {} : { volume: row.volume }) }];
|
|
});
|
|
}
|