Zion Boggan
repos/Zion Pm/lib/db.ts
zionboggan.com ↗
141 lines · typescript
History for this file →
1
import Database from 'better-sqlite3';
2
import { ulid } from 'ulid';
3
import fs from 'fs';
4
import path from 'path';
5
 
6
const DATA_DIR = process.env.ZPM_DATA_DIR || path.join(process.cwd(), 'data');
7
fs.mkdirSync(DATA_DIR, { recursive: true });
8
const DB_PATH = path.join(DATA_DIR, 'zionpm.db');
9
 
10
export const db = new Database(DB_PATH);
11
db.pragma('journal_mode = WAL');
12
 
13
db.exec(`
14
CREATE TABLE IF NOT EXISTS channels(
15
  id            TEXT PRIMARY KEY,
16
  name          TEXT NOT NULL UNIQUE,
17
  default_model TEXT NOT NULL,
18
  created_at    INTEGER NOT NULL
19
);
20
CREATE TABLE IF NOT EXISTS messages(
21
  id            TEXT PRIMARY KEY,
22
  channel_id    TEXT NOT NULL REFERENCES channels(id),
23
  role          TEXT NOT NULL,
24
  author_model  TEXT,
25
  body          TEXT NOT NULL,
26
  parent_id     TEXT REFERENCES messages(id),
27
  created_at    INTEGER NOT NULL
28
);
29
CREATE TABLE IF NOT EXISTS tasks(
30
  id             TEXT PRIMARY KEY,
31
  title          TEXT NOT NULL,
32
  status         TEXT NOT NULL DEFAULT 'todo',
33
  assigned_model TEXT,
34
  channel_id     TEXT REFERENCES channels(id),
35
  created_at     INTEGER NOT NULL,
36
  updated_at     INTEGER NOT NULL
37
);
38
CREATE TABLE IF NOT EXISTS outbox(
39
  id           TEXT PRIMARY KEY,
40
  action_type  TEXT NOT NULL,
41
  payload_json TEXT NOT NULL,
42
  status       TEXT NOT NULL DEFAULT 'draft',
43
  created_at   INTEGER NOT NULL,
44
  approved_at  INTEGER
45
);
46
CREATE TABLE IF NOT EXISTS cap_state(
47
  model        TEXT PRIMARY KEY,
48
  available    INTEGER NOT NULL,
49
  reason       TEXT,
50
  last_checked INTEGER NOT NULL
51
);
52
`);
53
 
54
export const now = () => Date.now();
55
export { ulid };
56
 
57
function seed() {
58
  const chanCount = (db.prepare('SELECT COUNT(*) c FROM channels').get() as { c: number }).c;
59
  if (chanCount === 0) {
60
    const ins = db.prepare(
61
      'INSERT INTO channels(id,name,default_model,created_at) VALUES(?,?,?,?)'
62
    );
63
    const seedCh: [string, string][] = [
64
      ['pm', 'glm'],
65
      ['claude', 'claude'],
66
      ['codex', 'codex'],
67
      ['ollama', 'ollama'],
68
    ];
69
    const t = now();
70
    for (const [name, model] of seedCh) ins.run(ulid(), name, model, t);
71
  }
72
  const capIns = db.prepare(
73
    `INSERT INTO cap_state(model,available,reason,last_checked)
74
     VALUES(?,?,?,?) ON CONFLICT(model) DO NOTHING`
75
  );
76
  const t = now();
77
  for (const m of ['claude', 'codex', 'ollama']) capIns.run(m, 1, 'seeded', t);
78
}
79
seed();
80
 
81
export interface Channel { id: string; name: string; default_model: string; created_at: number; }
82
export interface Message { id: string; channel_id: string; role: string; author_model: string | null; body: string; parent_id: string | null; created_at: number; }
83
export interface Task { id: string; title: string; status: string; assigned_model: string | null; channel_id: string | null; created_at: number; updated_at: number; }
84
export interface Outbox { id: string; action_type: string; payload_json: string; status: string; created_at: number; approved_at: number | null; }
85
export interface CapState { model: string; available: number; reason: string | null; last_checked: number; }
86
 
87
export const listChannels = () => db.prepare('SELECT * FROM channels ORDER BY created_at').all() as Channel[];
88
export const getChannel = (id: string) => db.prepare('SELECT * FROM channels WHERE id=?').get(id) as Channel | undefined;
89
export const getChannelByName = (name: string) => db.prepare('SELECT * FROM channels WHERE name=?').get(name) as Channel | undefined;
90
export function createChannel(name: string, defaultModel: string): Channel {
91
  const id = ulid();
92
  db.prepare('INSERT INTO channels(id,name,default_model,created_at) VALUES(?,?,?,?)').run(id, name, defaultModel, now());
93
  return getChannel(id)!;
94
}
95
 
96
export const listMessages = (channelId: string) =>
97
  db.prepare('SELECT * FROM messages WHERE channel_id=? ORDER BY created_at').all(channelId) as Message[];
98
export function addMessage(channelId: string, role: string, body: string, authorModel: string | null = null, parentId: string | null = null): Message {
99
  const id = ulid();
100
  db.prepare('INSERT INTO messages(id,channel_id,role,author_model,body,parent_id,created_at) VALUES(?,?,?,?,?,?,?)')
101
    .run(id, channelId, role, authorModel, body, parentId, now());
102
  return db.prepare('SELECT * FROM messages WHERE id=?').get(id) as Message;
103
}
104
 
105
export const listTasks = () => db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all() as Task[];
106
export function createTask(title: string, opts: { status?: string; assignedModel?: string | null; channelId?: string | null } = {}): Task {
107
  const id = ulid();
108
  const t = now();
109
  db.prepare('INSERT INTO tasks(id,title,status,assigned_model,channel_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)')
110
    .run(id, title, opts.status || 'todo', opts.assignedModel ?? null, opts.channelId ?? null, t, t);
111
  return db.prepare('SELECT * FROM tasks WHERE id=?').get(id) as Task;
112
}
113
export function updateTask(id: string, patch: { status?: string; assignedModel?: string | null; title?: string }): Task | undefined {
114
  const cur = db.prepare('SELECT * FROM tasks WHERE id=?').get(id) as Task | undefined;
115
  if (!cur) return undefined;
116
  db.prepare('UPDATE tasks SET status=?, assigned_model=?, title=?, updated_at=? WHERE id=?')
117
    .run(patch.status ?? cur.status, patch.assignedModel ?? cur.assigned_model, patch.title ?? cur.title, now(), id);
118
  return db.prepare('SELECT * FROM tasks WHERE id=?').get(id) as Task;
119
}
120
 
121
export const listOutbox = () => db.prepare('SELECT * FROM outbox ORDER BY created_at DESC').all() as Outbox[];
122
export function draftOutbox(actionType: string, payload: unknown): Outbox {
123
  const id = ulid();
124
  db.prepare('INSERT INTO outbox(id,action_type,payload_json,status,created_at) VALUES(?,?,?,?,?)')
125
    .run(id, actionType, JSON.stringify(payload), 'draft', now());
126
  return db.prepare('SELECT * FROM outbox WHERE id=?').get(id) as Outbox;
127
}
128
export function setOutboxStatus(id: string, status: 'approved' | 'rejected' | 'sent'): Outbox | undefined {
129
  const approvedAt = status === 'approved' ? now() : null;
130
  db.prepare('UPDATE outbox SET status=?, approved_at=COALESCE(?,approved_at) WHERE id=?').run(status, approvedAt, id);
131
  return db.prepare('SELECT * FROM outbox WHERE id=?').get(id) as Outbox | undefined;
132
}
133
 
134
export const listCaps = () => db.prepare('SELECT * FROM cap_state ORDER BY model').all() as CapState[];
135
export const getCap = (model: string) => db.prepare('SELECT * FROM cap_state WHERE model=?').get(model) as CapState | undefined;
136
export function setCap(model: string, available: number, reason: string) {
137
  db.prepare(`INSERT INTO cap_state(model,available,reason,last_checked) VALUES(?,?,?,?)
138
              ON CONFLICT(model) DO UPDATE SET available=excluded.available, reason=excluded.reason, last_checked=excluded.last_checked`)
139
    .run(model, available, reason, now());
140
}
141
export const touchCap = (model: string) => db.prepare('UPDATE cap_state SET last_checked=? WHERE model=?').run(now(), model);