Spaces:
Paused
Paused
File size: 7,144 Bytes
c85705b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs-extra';
// βββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type ProjectStatus = 'starting' | 'ready' | 'running' | 'error' | 'stopped';
export interface ProjectRecord {
projectId: string;
createdAt: number; // Unix timestamp (ms)
status: ProjectStatus;
hostPort: number | null; // Port exposed on the HOST machine (e.g. 3101, 3102)
containerPort: number; // Port inside container (typically 3000, 5173)
containerIp: string;
runtimeType: string; // 'node' | 'python' | 'static'
startCommand: string; // JSON-serialised string[]
lastSeen: number; // Unix timestamp (ms) of last HTTP access
}
// βββ Singleton DB ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const DB_DIR = path.join(__dirname, '../data');
const DB_PATH = path.join(DB_DIR, 'projects.db');
fs.ensureDirSync(DB_DIR);
const db = new Database(DB_PATH);
// Enable WAL mode for better concurrent-read performance
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
// βββ Schema ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
projectId TEXT PRIMARY KEY,
createdAt INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'starting',
hostPort INTEGER UNIQUE, -- exposed on host machine
containerPort INTEGER NOT NULL DEFAULT 3000,
containerIp TEXT NOT NULL DEFAULT '',
runtimeType TEXT NOT NULL DEFAULT 'node',
startCommand TEXT NOT NULL DEFAULT '[]',
lastSeen INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_lastSeen ON projects(lastSeen);
CREATE INDEX IF NOT EXISTS idx_projects_hostPort ON projects(hostPort);
`);
// βββ Prepared Statements βββββββββββββββββββββββββββββββββββββββββββββββββββββ
const stmtInsert = db.prepare(`
INSERT INTO projects (projectId, createdAt, status, hostPort, containerPort, containerIp, runtimeType, startCommand, lastSeen)
VALUES (@projectId, @createdAt, @status, @hostPort, @containerPort, @containerIp, @runtimeType, @startCommand, @lastSeen)
`);
const stmtUpdateStatus = db.prepare(`
UPDATE projects SET status = @status WHERE projectId = @projectId
`);
const stmtUpdateRuntime = db.prepare(`
UPDATE projects
SET status = @status, containerIp = @containerIp, lastSeen = @lastSeen
WHERE projectId = @projectId
`);
const stmtUpdateLastSeen = db.prepare(`
UPDATE projects SET lastSeen = @lastSeen WHERE projectId = @projectId
`);
const stmtGetById = db.prepare(`
SELECT * FROM projects WHERE projectId = ?
`);
const stmtGetAll = db.prepare(`
SELECT * FROM projects ORDER BY createdAt DESC
`);
const stmtGetByStatus = db.prepare(`
SELECT * FROM projects WHERE status = ? ORDER BY createdAt DESC
`);
const stmtGetUsedPorts = db.prepare(`
SELECT hostPort FROM projects WHERE hostPort IS NOT NULL AND status != 'stopped'
`);
const stmtDelete = db.prepare(`
DELETE FROM projects WHERE projectId = ?
`);
const stmtGetInactive = db.prepare(`
SELECT projectId FROM projects WHERE lastSeen < ? AND status != 'stopped'
`);
// βββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export class ProjectDB {
/**
* Insert a new project record when a container is first created.
*/
static create(record: {
projectId: string;
status: ProjectStatus;
hostPort: number;
containerPort: number;
containerIp: string;
runtimeType: string;
startCommand: string[];
}): void {
const now = Date.now();
stmtInsert.run({
projectId: record.projectId,
createdAt: now,
lastSeen: now,
status: record.status,
hostPort: record.hostPort,
containerPort: record.containerPort,
containerIp: record.containerIp,
runtimeType: record.runtimeType,
startCommand: JSON.stringify(record.startCommand),
});
}
/** Update just the status field. */
static setStatus(projectId: string, status: ProjectStatus): void {
stmtUpdateStatus.run({ projectId, status });
}
/** Bulk-update status, ip, and lastSeen at once. */
static updateRuntime(projectId: string, status: ProjectStatus, containerIp: string): void {
stmtUpdateRuntime.run({ projectId, status, containerIp, lastSeen: Date.now() });
}
/** Refresh the lastSeen timestamp (called on every preview access). */
static touch(projectId: string): void {
stmtUpdateLastSeen.run({ projectId, lastSeen: Date.now() });
}
/** Fetch a single project by its ID. Returns null if not found. */
static get(projectId: string): ProjectRecord | null {
return (stmtGetById.get(projectId) as ProjectRecord | undefined) ?? null;
}
/** List all projects. */
static getAll(): ProjectRecord[] {
return stmtGetAll.all() as ProjectRecord[];
}
/** List projects filtered by status. */
static getByStatus(status: ProjectStatus): ProjectRecord[] {
return stmtGetByStatus.all(status) as ProjectRecord[];
}
/** Return all host ports that are currently in use (not stopped). */
static getUsedHostPorts(): Set<number> {
const rows = stmtGetUsedPorts.all() as { hostPort: number }[];
return new Set(rows.map(r => r.hostPort));
}
/** Get projectIds that haven't been accessed since `beforeMs`. */
static getInactiveIds(beforeMs: number): string[] {
const rows = stmtGetInactive.all(beforeMs) as { projectId: string }[];
return rows.map(r => r.projectId);
}
/** Delete a project record entirely. */
static remove(projectId: string): void {
stmtDelete.run(projectId);
}
/** Gracefully close the DB (called on process exit). */
static close(): void {
db.close();
}
}
// βββ Graceful shutdown βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
process.on('exit', () => ProjectDB.close());
process.on('SIGINT', () => { ProjectDB.close(); process.exit(0); });
process.on('SIGTERM', () => { ProjectDB.close(); process.exit(0); });
|