ima2-gen 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/db.js ADDED
@@ -0,0 +1,92 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync, existsSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import { homedir } from "os";
5
+
6
+ const DEFAULT_DB_DIR = join(homedir(), ".ima2");
7
+ const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, "sessions.db");
8
+
9
+ let db = null;
10
+
11
+ export function getDbPath() {
12
+ return process.env.IMA2_DB_PATH || DEFAULT_DB_PATH;
13
+ }
14
+
15
+ export function getDb() {
16
+ if (db) return db;
17
+ const dbPath = getDbPath();
18
+ const dir = dirname(dbPath);
19
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20
+
21
+ db = new Database(dbPath);
22
+ db.pragma("journal_mode = WAL");
23
+ db.pragma("foreign_keys = ON");
24
+ migrate(db);
25
+ return db;
26
+ }
27
+
28
+ function migrate(database) {
29
+ database.exec(`
30
+ CREATE TABLE IF NOT EXISTS _meta (
31
+ key TEXT PRIMARY KEY,
32
+ value TEXT NOT NULL
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS sessions (
36
+ id TEXT PRIMARY KEY,
37
+ title TEXT NOT NULL DEFAULT 'Untitled',
38
+ created_at INTEGER NOT NULL,
39
+ updated_at INTEGER NOT NULL,
40
+ graph_version INTEGER NOT NULL DEFAULT 0
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS nodes (
44
+ session_id TEXT NOT NULL,
45
+ id TEXT NOT NULL,
46
+ x REAL NOT NULL DEFAULT 0,
47
+ y REAL NOT NULL DEFAULT 0,
48
+ data TEXT NOT NULL DEFAULT '{}',
49
+ PRIMARY KEY (session_id, id),
50
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS edges (
54
+ session_id TEXT NOT NULL,
55
+ id TEXT NOT NULL,
56
+ source TEXT NOT NULL,
57
+ target TEXT NOT NULL,
58
+ data TEXT NOT NULL DEFAULT '{}',
59
+ PRIMARY KEY (session_id, id),
60
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_nodes_session ON nodes(session_id);
64
+ CREATE INDEX IF NOT EXISTS idx_edges_session ON edges(session_id);
65
+ `);
66
+
67
+ const sessionColumns = database
68
+ .prepare("PRAGMA table_info(sessions)")
69
+ .all()
70
+ .map((row) => row.name);
71
+ if (!sessionColumns.includes("graph_version")) {
72
+ database.exec(
73
+ "ALTER TABLE sessions ADD COLUMN graph_version INTEGER NOT NULL DEFAULT 0",
74
+ );
75
+ }
76
+
77
+ const row = database.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get();
78
+ if (!row) {
79
+ database.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', '2')").run();
80
+ } else if (row.value !== "2") {
81
+ database
82
+ .prepare("UPDATE _meta SET value = '2' WHERE key = 'schema_version'")
83
+ .run();
84
+ }
85
+ }
86
+
87
+ export function closeDb() {
88
+ if (db) {
89
+ db.close();
90
+ db = null;
91
+ }
92
+ }
@@ -0,0 +1,57 @@
1
+ // In-memory inflight job registry.
2
+ // Tracks generation requests that are currently running on the server so clients
3
+ // can reconcile optimistic UI state after a reload or across tabs.
4
+ //
5
+ // This is intentionally process-local: if the server restarts, inflight jobs
6
+ // are lost (which is correct — the fetch they came from is already gone).
7
+
8
+ const jobs = new Map(); // requestId -> { requestId, kind, prompt, meta, startedAt, phase, phaseAt }
9
+
10
+ // Phases: "queued" → "streaming" (upstream connection open, waiting for image)
11
+ // → "decoding" (b64 received, writing to disk)
12
+ // finishJob removes the entry entirely.
13
+ export function startJob({ requestId, kind, prompt, meta = {} }) {
14
+ if (!requestId) return;
15
+ jobs.set(requestId, {
16
+ requestId,
17
+ kind,
18
+ prompt: typeof prompt === "string" ? prompt.slice(0, 500) : "",
19
+ meta,
20
+ startedAt: Date.now(),
21
+ phase: "queued",
22
+ phaseAt: Date.now(),
23
+ });
24
+ }
25
+
26
+ export function setJobPhase(requestId, phase) {
27
+ if (!requestId) return;
28
+ const j = jobs.get(requestId);
29
+ if (!j) return;
30
+ j.phase = phase;
31
+ j.phaseAt = Date.now();
32
+ }
33
+
34
+ export function finishJob(requestId, _options = {}) {
35
+ if (!requestId) return;
36
+ jobs.delete(requestId);
37
+ }
38
+
39
+ export function listJobs(filters = {}) {
40
+ // Stale reaping: > 10 min is almost certainly a crashed fetch.
41
+ const now = Date.now();
42
+ for (const [id, j] of jobs) {
43
+ if (now - j.startedAt > 10 * 60 * 1000) jobs.delete(id);
44
+ }
45
+ const { kind, sessionId } = filters;
46
+ return Array.from(jobs.values())
47
+ .filter((j) => {
48
+ if (kind && j.kind !== kind) return false;
49
+ if (sessionId && j.meta?.sessionId !== sessionId) return false;
50
+ return true;
51
+ })
52
+ .sort((a, b) => a.startedAt - b.startedAt);
53
+ }
54
+
55
+ export function _resetForTests() {
56
+ jobs.clear();
57
+ }
@@ -0,0 +1,66 @@
1
+ import { writeFile, readFile, access } from "fs/promises";
2
+ import { join, resolve, sep } from "path";
3
+ import { randomBytes } from "crypto";
4
+
5
+ const DIR = "generated";
6
+
7
+ export function newNodeId() {
8
+ return "n_" + randomBytes(5).toString("hex");
9
+ }
10
+
11
+ export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png" }) {
12
+ const filename = `${nodeId}.${ext}`;
13
+ await writeFile(join(rootDir, DIR, filename), Buffer.from(b64, "base64"));
14
+ await writeFile(join(rootDir, DIR, filename + ".json"), JSON.stringify(meta, null, 2));
15
+ return { filename };
16
+ }
17
+
18
+ export async function loadNodeB64(rootDir, filename) {
19
+ const p = resolveGeneratedPath(rootDir, filename);
20
+ try { await access(p); } catch {
21
+ const err = new Error(`Node file not found: ${filename}`);
22
+ err.code = "NODE_NOT_FOUND";
23
+ err.status = 404;
24
+ throw err;
25
+ }
26
+ const buf = await readFile(p);
27
+ return buf.toString("base64");
28
+ }
29
+
30
+ export async function loadNodeMeta(rootDir, nodeId, ext = "png") {
31
+ try {
32
+ return JSON.parse(await readFile(join(rootDir, DIR, `${nodeId}.${ext}.json`), "utf-8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export async function loadAssetB64(rootDir, externalSrc) {
39
+ const p = resolveGeneratedPath(rootDir, externalSrc);
40
+ try { await access(p); } catch {
41
+ const err = new Error(`Asset file not found: ${externalSrc}`);
42
+ err.code = "NODE_NOT_FOUND";
43
+ err.status = 404;
44
+ throw err;
45
+ }
46
+ const buf = await readFile(p);
47
+ return buf.toString("base64");
48
+ }
49
+
50
+ function resolveGeneratedPath(rootDir, relPath) {
51
+ if (typeof relPath !== "string" || relPath.length === 0) {
52
+ const err = new Error("Asset path is required");
53
+ err.code = "NODE_SOURCE_INVALID";
54
+ err.status = 400;
55
+ throw err;
56
+ }
57
+ const baseDir = resolve(rootDir, DIR);
58
+ const target = resolve(baseDir, relPath);
59
+ if (target !== baseDir && !target.startsWith(baseDir + sep)) {
60
+ const err = new Error(`Asset path escapes generated/: ${relPath}`);
61
+ err.code = "NODE_SOURCE_INVALID";
62
+ err.status = 400;
63
+ throw err;
64
+ }
65
+ return target;
66
+ }
@@ -0,0 +1,182 @@
1
+ import { ulid } from "ulid";
2
+ import { getDb } from "./db.js";
3
+
4
+ function now() {
5
+ return Date.now();
6
+ }
7
+
8
+ export function createSession({ title = "Untitled" } = {}) {
9
+ const db = getDb();
10
+ const id = "s_" + ulid();
11
+ const t = now();
12
+ db.prepare(
13
+ "INSERT INTO sessions (id, title, created_at, updated_at, graph_version) VALUES (?, ?, ?, ?, 0)",
14
+ ).run(id, title, t, t);
15
+ return { id, title, createdAt: t, updatedAt: t, graphVersion: 0 };
16
+ }
17
+
18
+ export function listSessions() {
19
+ const db = getDb();
20
+ const rows = db
21
+ .prepare(
22
+ "SELECT id, title, created_at AS createdAt, updated_at AS updatedAt, graph_version AS graphVersion FROM sessions ORDER BY updated_at DESC",
23
+ )
24
+ .all();
25
+ return rows.map((r) => ({
26
+ ...r,
27
+ nodeCount: db
28
+ .prepare("SELECT COUNT(*) AS c FROM nodes WHERE session_id = ?")
29
+ .get(r.id).c,
30
+ }));
31
+ }
32
+
33
+ export function getSession(id) {
34
+ const db = getDb();
35
+ const session = db
36
+ .prepare(
37
+ "SELECT id, title, created_at AS createdAt, updated_at AS updatedAt, graph_version AS graphVersion FROM sessions WHERE id = ?",
38
+ )
39
+ .get(id);
40
+ if (!session) return null;
41
+ const nodes = db
42
+ .prepare("SELECT id, x, y, data FROM nodes WHERE session_id = ?")
43
+ .all(id)
44
+ .map((n) => ({ id: n.id, x: n.x, y: n.y, data: safeParse(n.data) }));
45
+ const edges = db
46
+ .prepare("SELECT id, source, target, data FROM edges WHERE session_id = ?")
47
+ .all(id)
48
+ .map((e) => ({
49
+ id: e.id,
50
+ source: e.source,
51
+ target: e.target,
52
+ data: safeParse(e.data),
53
+ }));
54
+ return { ...session, nodes, edges };
55
+ }
56
+
57
+ export function renameSession(id, title) {
58
+ const db = getDb();
59
+ const res = db
60
+ .prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?")
61
+ .run(title, now(), id);
62
+ return res.changes > 0;
63
+ }
64
+
65
+ export function deleteSession(id) {
66
+ const db = getDb();
67
+ const res = db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
68
+ return res.changes > 0;
69
+ }
70
+
71
+ const MAX_STR = 10_000;
72
+
73
+ function cleanStr(v) {
74
+ if (typeof v !== "string") return "";
75
+ return v.length > MAX_STR ? v.slice(0, MAX_STR) : v;
76
+ }
77
+
78
+ function cleanData(v) {
79
+ try {
80
+ const json = JSON.stringify(v ?? {});
81
+ return json.length > MAX_STR * 10 ? "{}" : json;
82
+ } catch {
83
+ return "{}";
84
+ }
85
+ }
86
+
87
+ export function saveGraph(sessionId, { nodes = [], edges = [], expectedVersion = null }) {
88
+ const db = getDb();
89
+ const sessionExists = db
90
+ .prepare("SELECT 1 FROM sessions WHERE id = ?")
91
+ .get(sessionId);
92
+ if (!sessionExists) {
93
+ const err = new Error(`Session not found: ${sessionId}`);
94
+ err.code = "SESSION_NOT_FOUND";
95
+ err.status = 404;
96
+ throw err;
97
+ }
98
+
99
+ const versionRow = db
100
+ .prepare("SELECT graph_version AS graphVersion FROM sessions WHERE id = ?")
101
+ .get(sessionId);
102
+ const currentVersion = versionRow?.graphVersion ?? 0;
103
+ if (
104
+ typeof expectedVersion === "number" &&
105
+ Number.isFinite(expectedVersion) &&
106
+ expectedVersion !== currentVersion
107
+ ) {
108
+ const err = new Error(
109
+ `Graph version conflict for session ${sessionId}: expected ${expectedVersion}, got ${currentVersion}`,
110
+ );
111
+ err.code = "GRAPH_VERSION_CONFLICT";
112
+ err.status = 409;
113
+ err.currentVersion = currentVersion;
114
+ throw err;
115
+ }
116
+
117
+ // Validate edges reference existing nodes (drop dangling).
118
+ const nodeIds = new Set(nodes.map((n) => n?.id).filter(Boolean).map(String));
119
+ const cleanEdges = edges.filter(
120
+ (e) => e?.id && e?.source && e?.target && nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)),
121
+ );
122
+
123
+ const tx = db.transaction(() => {
124
+ db.prepare("DELETE FROM nodes WHERE session_id = ?").run(sessionId);
125
+ db.prepare("DELETE FROM edges WHERE session_id = ?").run(sessionId);
126
+
127
+ const insNode = db.prepare(
128
+ "INSERT INTO nodes (session_id, id, x, y, data) VALUES (?, ?, ?, ?, ?)",
129
+ );
130
+ for (const n of nodes) {
131
+ if (!n?.id) continue;
132
+ const x = Number(n.x ?? n.position?.x ?? 0);
133
+ const y = Number(n.y ?? n.position?.y ?? 0);
134
+ insNode.run(
135
+ sessionId,
136
+ cleanStr(String(n.id)),
137
+ Number.isFinite(x) ? x : 0,
138
+ Number.isFinite(y) ? y : 0,
139
+ cleanData(n.data),
140
+ );
141
+ }
142
+
143
+ const insEdge = db.prepare(
144
+ "INSERT INTO edges (session_id, id, source, target, data) VALUES (?, ?, ?, ?, ?)",
145
+ );
146
+ for (const e of cleanEdges) {
147
+ insEdge.run(
148
+ sessionId,
149
+ cleanStr(String(e.id)),
150
+ cleanStr(String(e.source)),
151
+ cleanStr(String(e.target)),
152
+ cleanData(e.data),
153
+ );
154
+ }
155
+
156
+ db.prepare("UPDATE sessions SET updated_at = ?, graph_version = graph_version + 1 WHERE id = ?").run(
157
+ now(),
158
+ sessionId,
159
+ );
160
+
161
+ return db
162
+ .prepare("SELECT graph_version AS graphVersion FROM sessions WHERE id = ?")
163
+ .get(sessionId).graphVersion;
164
+ });
165
+
166
+ const nextVersion = tx();
167
+ return { ok: true, graphVersion: nextVersion };
168
+ }
169
+
170
+ function safeParse(json) {
171
+ try {
172
+ return JSON.parse(json);
173
+ } catch {
174
+ return {};
175
+ }
176
+ }
177
+
178
+ export function ensureDefaultSession() {
179
+ const sessions = listSessions();
180
+ if (sessions.length > 0) return sessions[0];
181
+ return createSession({ title: "My first graph" });
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "GPT Image 2 generator with OAuth & API key support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,9 +9,14 @@
9
9
  "scripts": {
10
10
  "start": "node bin/ima2.js serve",
11
11
  "dev": "node --watch server.js",
12
+ "ui:install": "cd ui && npm install",
13
+ "ui:dev": "cd ui && npm run dev",
14
+ "ui:build": "cd ui && npm run build",
15
+ "build": "npm run ui:build",
16
+ "test": "node scripts/run-tests.mjs",
12
17
  "setup": "node bin/ima2.js setup",
13
- "prepublishOnly": "npm run lint:pkg",
14
- "lint:pkg": "node -e \"const p=require('./package.json'); if(!p.name||!p.version||!p.bin) throw new Error('missing fields')\"",
18
+ "prepublishOnly": "npm run build && npm run lint:pkg",
19
+ "lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
15
20
  "release:patch": "npm version patch && npm publish && git push origin main --tags",
16
21
  "release:minor": "npm version minor && npm publish && git push origin main --tags",
17
22
  "release:major": "npm version major && npm publish && git push origin main --tags"
@@ -30,19 +35,22 @@
30
35
  },
31
36
  "files": [
32
37
  "bin/",
33
- "public/",
38
+ "lib/",
39
+ "ui/dist/",
34
40
  "assets/",
35
41
  "server.js",
36
42
  ".env.example",
37
43
  "README.md"
38
44
  ],
39
45
  "engines": {
40
- "node": ">=18"
46
+ "node": ">=20"
41
47
  },
42
48
  "dependencies": {
49
+ "better-sqlite3": "^12.9.0",
43
50
  "dotenv": "^17.4.2",
44
51
  "express": "^5.1.0",
45
52
  "openai": "^5.8.2",
46
- "openai-oauth": "^1.0.2"
53
+ "openai-oauth": "^1.0.2",
54
+ "ulid": "^3.0.2"
47
55
  }
48
56
  }