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); });