mockpit 0.0.0-stage → 1.0.0
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/LICENSE +22 -0
- package/README.md +232 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1830 -0
- package/bin/serveUrl.d.ts +1 -0
- package/bin/serveUrl.js +7 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +230 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +53 -0
- package/dist/server/app.js +3112 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +16 -0
- package/dist/server/cdn.d.ts +6 -0
- package/dist/server/cdn.js +33 -0
- package/dist/server/codeRunner.d.ts +5 -0
- package/dist/server/codeRunner.js +89 -0
- package/dist/server/codeWorker.d.ts +1 -0
- package/dist/server/codeWorker.js +196 -0
- package/dist/server/designGuide.d.ts +16 -0
- package/dist/server/designGuide.js +311 -0
- package/dist/server/events.d.ts +53 -0
- package/dist/server/events.js +14 -0
- package/dist/server/feedbackBatch.d.ts +43 -0
- package/dist/server/feedbackBatch.js +98 -0
- package/dist/server/icons.d.ts +50 -0
- package/dist/server/icons.js +225 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/kits.d.ts +36 -0
- package/dist/server/kits.js +279 -0
- package/dist/server/knobs.d.ts +15 -0
- package/dist/server/knobs.js +333 -0
- package/dist/server/mcpHttp.d.ts +53 -0
- package/dist/server/mcpHttp.js +143 -0
- package/dist/server/mcpSpec.d.ts +191 -0
- package/dist/server/mcpSpec.js +402 -0
- package/dist/server/parts.d.ts +31 -0
- package/dist/server/parts.js +338 -0
- package/dist/server/postMarkdown.d.ts +10 -0
- package/dist/server/postMarkdown.js +271 -0
- package/dist/server/postSurfaces.d.ts +40 -0
- package/dist/server/postSurfaces.js +407 -0
- package/dist/server/public.d.ts +5 -0
- package/dist/server/public.js +5 -0
- package/dist/server/push.d.ts +45 -0
- package/dist/server/push.js +234 -0
- package/dist/server/richRender.d.ts +15 -0
- package/dist/server/richRender.js +392 -0
- package/dist/server/run.d.ts +82 -0
- package/dist/server/run.js +290 -0
- package/dist/server/runApi.d.ts +6 -0
- package/dist/server/runApi.js +82 -0
- package/dist/server/slots.d.ts +14 -0
- package/dist/server/slots.js +71 -0
- package/dist/server/sqlStore.d.ts +119 -0
- package/dist/server/sqlStore.js +954 -0
- package/dist/server/sqliteStorage.d.ts +2 -0
- package/dist/server/sqliteStorage.js +90 -0
- package/dist/server/surfacePage.d.ts +48 -0
- package/dist/server/surfacePage.js +1099 -0
- package/dist/server/themes.d.ts +48 -0
- package/dist/server/themes.js +305 -0
- package/dist/server/types.d.ts +599 -0
- package/dist/server/types.js +299 -0
- package/dist/workers/public.d.ts +1 -0
- package/dist/workers/public.js +3 -0
- package/extensions/mockpit.js +703 -0
- package/guide/AGENT_SETUP.md +23 -0
- package/guide/topics/asks.md +87 -0
- package/guide/topics/feedback.md +127 -0
- package/guide/topics/html.md +175 -0
- package/guide/topics/http.md +76 -0
- package/guide/topics/knobs.md +72 -0
- package/guide/topics/scripts.md +129 -0
- package/guide/topics/surfaces.md +100 -0
- package/package.json +165 -4
- package/skills/launch-video/SKILL.md +159 -0
- package/skills/mockpit/SKILL.md +40 -0
- package/viewer/dist/index.html +1922 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import { collectAssetIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
|
|
2
|
+
function parseJson(raw, fallback) {
|
|
3
|
+
if (typeof raw !== "string" || !raw)
|
|
4
|
+
return fallback;
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const POST_COLUMNS = `
|
|
13
|
+
id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, mockId TEXT NOT NULL, state TEXT,
|
|
14
|
+
variant TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', title TEXT NOT NULL,
|
|
15
|
+
surfaces TEXT NOT NULL, knobs TEXT, slots TEXT NOT NULL DEFAULT '[]',
|
|
16
|
+
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, version INTEGER NOT NULL,
|
|
17
|
+
curFrom INTEGER, curPrompt TEXT, curAuthor TEXT`;
|
|
18
|
+
// Store implementation on SQLite — a Durable Object's `ctx.storage.sql` in the
|
|
19
|
+
// Worker, or node:sqlite via an adapter on Node (see server/sqliteStorage.ts).
|
|
20
|
+
// One workspace = one database, so plain SQL with no tenant columns.
|
|
21
|
+
export class SqlStore {
|
|
22
|
+
sql;
|
|
23
|
+
// Cached set of asset ids referenced by any live surface (current or a
|
|
24
|
+
// historical version of any post). Built lazily and maintained incrementally
|
|
25
|
+
// — post writes add to it, removes invalidate it — so isAssetReferenced (hit
|
|
26
|
+
// on every /a/:id miss) and putAsset's eviction scan don't re-parse every
|
|
27
|
+
// post's surfaces on each call. Stays correct because history is append-only:
|
|
28
|
+
// an asset id once referenced stays referenced until its post is deleted.
|
|
29
|
+
assetRefCache;
|
|
30
|
+
constructor(sql) {
|
|
31
|
+
this.sql = sql;
|
|
32
|
+
this.atomic(() => this.migrate());
|
|
33
|
+
}
|
|
34
|
+
// On a Durable Object the synchronous writes of one event already commit as a
|
|
35
|
+
// unit, so its SqlStorage has no transactionSync; node:sqlite provides one.
|
|
36
|
+
atomic(fn) {
|
|
37
|
+
return this.sql.transactionSync ? this.sql.transactionSync(fn) : fn();
|
|
38
|
+
}
|
|
39
|
+
tables() {
|
|
40
|
+
return new Set(this.sql
|
|
41
|
+
.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
42
|
+
.toArray()
|
|
43
|
+
.map((t) => t.name));
|
|
44
|
+
}
|
|
45
|
+
columns(table) {
|
|
46
|
+
return new Set(this.sql
|
|
47
|
+
.exec(`SELECT name FROM pragma_table_info('${table}')`)
|
|
48
|
+
.toArray()
|
|
49
|
+
.map((c) => c.name));
|
|
50
|
+
}
|
|
51
|
+
// SQLite has no ADD COLUMN IF NOT EXISTS, so probe and patch.
|
|
52
|
+
addMissing(table, columns) {
|
|
53
|
+
const have = this.columns(table);
|
|
54
|
+
for (const [name, decl] of Object.entries(columns)) {
|
|
55
|
+
if (!have.has(name))
|
|
56
|
+
this.sql.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${decl}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
migrate() {
|
|
60
|
+
const before = this.tables();
|
|
61
|
+
// Deployed Durable Objects can never be reset, so a workspace written by a
|
|
62
|
+
// build from before mocks existed is lifted in place (see migrateLegacy).
|
|
63
|
+
const legacy = before.has("posts") && !before.has("mocks");
|
|
64
|
+
this.sql.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
66
|
+
id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
|
|
67
|
+
createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
|
|
68
|
+
agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT
|
|
69
|
+
);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS mocks (
|
|
71
|
+
id TEXT PRIMARY KEY, project TEXT NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL,
|
|
72
|
+
kind TEXT NOT NULL DEFAULT 'component', states TEXT NOT NULL DEFAULT '[]',
|
|
73
|
+
asks TEXT NOT NULL DEFAULT '[]', knobs TEXT NOT NULL DEFAULT '{}', draft TEXT,
|
|
74
|
+
sessionId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS posts (${POST_COLUMNS});
|
|
77
|
+
CREATE TABLE IF NOT EXISTS post_versions (
|
|
78
|
+
postId TEXT NOT NULL, version INTEGER NOT NULL, title TEXT NOT NULL,
|
|
79
|
+
surfaces TEXT NOT NULL, at TEXT NOT NULL,
|
|
80
|
+
fromVersion INTEGER, prompt TEXT, author TEXT,
|
|
81
|
+
PRIMARY KEY (postId, version)
|
|
82
|
+
);
|
|
83
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
84
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL,
|
|
85
|
+
sessionId TEXT NOT NULL, mockId TEXT, postId TEXT,
|
|
86
|
+
author TEXT NOT NULL, text TEXT NOT NULL, createdAt TEXT NOT NULL,
|
|
87
|
+
kind TEXT NOT NULL DEFAULT 'comment', anchor TEXT, anchors TEXT NOT NULL DEFAULT '[]',
|
|
88
|
+
postVersion INTEGER, viewport INTEGER, payload TEXT
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS assets (
|
|
91
|
+
id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, kind TEXT NOT NULL,
|
|
92
|
+
contentType TEXT NOT NULL, byteLength INTEGER NOT NULL, filename TEXT,
|
|
93
|
+
data BLOB NOT NULL, createdAt TEXT NOT NULL, lastAccessedAt TEXT NOT NULL
|
|
94
|
+
);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
96
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL
|
|
97
|
+
);
|
|
98
|
+
`);
|
|
99
|
+
this.addMissing("sessions", {
|
|
100
|
+
agentSeq: "INTEGER NOT NULL DEFAULT 0",
|
|
101
|
+
project: "TEXT",
|
|
102
|
+
key: "TEXT",
|
|
103
|
+
});
|
|
104
|
+
if (legacy)
|
|
105
|
+
this.migrateLegacy();
|
|
106
|
+
this.createIndexes();
|
|
107
|
+
}
|
|
108
|
+
createIndexes() {
|
|
109
|
+
this.sql.exec(`
|
|
110
|
+
CREATE UNIQUE INDEX IF NOT EXISTS mockpit_mocks_slug_idx ON mocks (project, slug);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS mockpit_mocks_updated_at_idx ON mocks (updatedAt DESC);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS mockpit_posts_mock_idx ON posts (mockId, state, variant);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS mockpit_posts_session_created_at_idx ON posts (sessionId, createdAt);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS mockpit_comments_session_seq_idx ON comments (sessionId, seq);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS mockpit_comments_mock_seq_idx ON comments (mockId, seq);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS mockpit_comments_post_seq_idx ON comments (postId, seq);
|
|
117
|
+
CREATE INDEX IF NOT EXISTS mockpit_comments_id_idx ON comments (id);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS mockpit_assets_session_idx ON assets (sessionId);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS mockpit_sessions_key_idx ON sessions (key);
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
// --- in-place migration of a pre-mock workspace ---
|
|
123
|
+
migrateLegacy() {
|
|
124
|
+
this.addMissing("comments", {
|
|
125
|
+
anchor: "TEXT",
|
|
126
|
+
kind: "TEXT NOT NULL DEFAULT 'comment'",
|
|
127
|
+
anchors: "TEXT NOT NULL DEFAULT '[]'",
|
|
128
|
+
draft: "INTEGER NOT NULL DEFAULT 0",
|
|
129
|
+
postVersion: "INTEGER",
|
|
130
|
+
viewport: "INTEGER",
|
|
131
|
+
mockId: "TEXT",
|
|
132
|
+
payload: "TEXT",
|
|
133
|
+
});
|
|
134
|
+
this.migrateToSurfaces();
|
|
135
|
+
this.migrateToPosts();
|
|
136
|
+
this.migrateSurfaceIds();
|
|
137
|
+
this.migrateToItems();
|
|
138
|
+
this.migrateToVersions();
|
|
139
|
+
this.migrateToMocks();
|
|
140
|
+
this.sql.exec(`
|
|
141
|
+
DROP TABLE IF EXISTS trace_steps;
|
|
142
|
+
DROP INDEX IF EXISTS mockpit_comments_post_draft_idx;
|
|
143
|
+
DROP INDEX IF EXISTS mockpit_posts_updated_at_idx;
|
|
144
|
+
DROP INDEX IF EXISTS sideshow_posts_session_created_at_idx;
|
|
145
|
+
DROP INDEX IF EXISTS sideshow_posts_updated_at_idx;
|
|
146
|
+
DROP INDEX IF EXISTS sideshow_comments_session_seq_idx;
|
|
147
|
+
DROP INDEX IF EXISTS sideshow_comments_post_seq_idx;
|
|
148
|
+
DROP INDEX IF EXISTS sideshow_comments_id_idx;
|
|
149
|
+
DROP INDEX IF EXISTS sideshow_assets_session_idx;
|
|
150
|
+
DROP INDEX IF EXISTS sideshow_posts_variant_idx;
|
|
151
|
+
DROP INDEX IF EXISTS sideshow_comments_post_draft_idx;
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
// Pre-0.5.0 workspaces stored a `snippets` table and `comments.snippetId`.
|
|
155
|
+
migrateToSurfaces() {
|
|
156
|
+
const commentCols = this.columns("comments");
|
|
157
|
+
if (commentCols.has("snippetId") && !commentCols.has("surfaceId")) {
|
|
158
|
+
this.sql.exec("ALTER TABLE comments RENAME COLUMN snippetId TO surfaceId");
|
|
159
|
+
}
|
|
160
|
+
if (commentCols.has("snippetTitle") && !commentCols.has("surfaceTitle")) {
|
|
161
|
+
this.sql.exec("ALTER TABLE comments RENAME COLUMN snippetTitle TO surfaceTitle");
|
|
162
|
+
}
|
|
163
|
+
if (!this.tables().has("snippets"))
|
|
164
|
+
return;
|
|
165
|
+
for (const r of this.sql.exec("SELECT * FROM snippets").toArray()) {
|
|
166
|
+
const legacyHistory = parseJson(r.history, []);
|
|
167
|
+
const history = legacyHistory.map((h) => ({
|
|
168
|
+
version: h.version,
|
|
169
|
+
title: h.title,
|
|
170
|
+
surfaces: [htmlSurface(h.html)],
|
|
171
|
+
at: h.at,
|
|
172
|
+
}));
|
|
173
|
+
this.sql.exec("INSERT OR IGNORE INTO posts (id, sessionId, title, surfaces, createdAt, updatedAt, version, history) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", r.id, r.sessionId, r.title, JSON.stringify([htmlSurface(r.html)]), r.createdAt, r.updatedAt, r.version, JSON.stringify(history));
|
|
174
|
+
}
|
|
175
|
+
this.sql.exec("DROP TABLE snippets");
|
|
176
|
+
}
|
|
177
|
+
// 0.5.x workspaces stored a `surfaces` table with a `parts` column and
|
|
178
|
+
// `comments.surfaceId/surfaceTitle`.
|
|
179
|
+
migrateToPosts() {
|
|
180
|
+
const commentCols = this.columns("comments");
|
|
181
|
+
if (commentCols.has("surfaceId") && !commentCols.has("postId")) {
|
|
182
|
+
this.sql.exec("ALTER TABLE comments RENAME COLUMN surfaceId TO postId");
|
|
183
|
+
}
|
|
184
|
+
if (commentCols.has("surfaceTitle") && !commentCols.has("postTitle")) {
|
|
185
|
+
this.sql.exec("ALTER TABLE comments RENAME COLUMN surfaceTitle TO postTitle");
|
|
186
|
+
}
|
|
187
|
+
if (!this.tables().has("surfaces"))
|
|
188
|
+
return;
|
|
189
|
+
for (const r of this.sql.exec("SELECT * FROM surfaces").toArray()) {
|
|
190
|
+
const history = parseJson(r.history, []).map(({ parts, ...rest }) => ({ ...rest, surfaces: parts ?? [] }));
|
|
191
|
+
this.sql.exec("INSERT OR IGNORE INTO posts (id, sessionId, title, surfaces, createdAt, updatedAt, version, history) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", r.id, r.sessionId, r.title, r.parts, r.createdAt, r.updatedAt, r.version, JSON.stringify(history));
|
|
192
|
+
}
|
|
193
|
+
this.sql.exec("DROP TABLE surfaces");
|
|
194
|
+
}
|
|
195
|
+
// Assign stable ids to surfaces written before surface ids existed.
|
|
196
|
+
migrateSurfaceIds() {
|
|
197
|
+
if (this.settingSync("surfaceIdsMigrated") === "1")
|
|
198
|
+
return;
|
|
199
|
+
for (const r of this.sql.exec("SELECT id, surfaces, history FROM posts").toArray()) {
|
|
200
|
+
const surfaces = normalizeSurfaceIds(parseJson(r.surfaces, []));
|
|
201
|
+
const history = parseJson(r.history, []).map((h) => ({
|
|
202
|
+
...h,
|
|
203
|
+
surfaces: normalizeSurfaceIds(h.surfaces ?? []),
|
|
204
|
+
}));
|
|
205
|
+
this.sql.exec("UPDATE posts SET surfaces = ?, history = ? WHERE id = ?", JSON.stringify(surfaces), JSON.stringify(history), r.id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Before items, posts had no project/slug/variant; derive them per post.
|
|
209
|
+
migrateToItems() {
|
|
210
|
+
this.addMissing("posts", {
|
|
211
|
+
project: "TEXT NOT NULL DEFAULT 'workspace'",
|
|
212
|
+
slug: "TEXT NOT NULL DEFAULT ''",
|
|
213
|
+
kind: "TEXT NOT NULL DEFAULT 'component'",
|
|
214
|
+
variant: "TEXT NOT NULL DEFAULT 'default'",
|
|
215
|
+
status: "TEXT NOT NULL DEFAULT 'open'",
|
|
216
|
+
slots: "TEXT NOT NULL DEFAULT '[]'",
|
|
217
|
+
curFrom: "INTEGER",
|
|
218
|
+
curPrompt: "TEXT",
|
|
219
|
+
curAuthor: "TEXT",
|
|
220
|
+
});
|
|
221
|
+
if (this.settingSync("itemsMigrated") === "1")
|
|
222
|
+
return;
|
|
223
|
+
const sessions = new Map();
|
|
224
|
+
for (const r of this.sql.exec("SELECT id, project, cwd FROM sessions").toArray()) {
|
|
225
|
+
sessions.set(r.id, {
|
|
226
|
+
project: r.project ?? null,
|
|
227
|
+
cwd: r.cwd ?? null,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const taken = new Map();
|
|
231
|
+
for (const r of this.sql
|
|
232
|
+
.exec("SELECT id, sessionId, title, slug FROM posts ORDER BY createdAt ASC")
|
|
233
|
+
.toArray()) {
|
|
234
|
+
if ((r.slug ?? "") !== "")
|
|
235
|
+
continue;
|
|
236
|
+
const session = sessions.get(r.sessionId);
|
|
237
|
+
const project = session?.project || projectFromCwd(session?.cwd ?? null) || DEFAULT_PROJECT;
|
|
238
|
+
let used = taken.get(project);
|
|
239
|
+
if (!used)
|
|
240
|
+
taken.set(project, (used = new Set()));
|
|
241
|
+
const base = slugify(r.title);
|
|
242
|
+
const suffix = r.id
|
|
243
|
+
.slice(0, 4)
|
|
244
|
+
.toLowerCase()
|
|
245
|
+
.replace(/[^a-z0-9]/g, "0");
|
|
246
|
+
let slug = `${base}-${suffix}`;
|
|
247
|
+
for (let n = 2; used.has(slug); n++)
|
|
248
|
+
slug = `${base}-${suffix}-${n}`;
|
|
249
|
+
used.add(slug);
|
|
250
|
+
this.sql.exec("UPDATE posts SET project = ?, slug = ? WHERE id = ?", project, slug, r.id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// History used to live in a `history` JSON blob on the post row.
|
|
254
|
+
migrateToVersions() {
|
|
255
|
+
if (this.settingSync("versionsMigrated") === "1")
|
|
256
|
+
return;
|
|
257
|
+
for (const r of this.sql.exec("SELECT id, history FROM posts").toArray()) {
|
|
258
|
+
for (const h of parseJson(r.history, [])) {
|
|
259
|
+
this.insertVersion(r.id, {
|
|
260
|
+
...h,
|
|
261
|
+
surfaces: normalizeSurfaceIds(h.surfaces ?? []),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Every (project, item slug) becomes one single-state mock; its posts become
|
|
267
|
+
// the mock's variants with state null. Unsent operator drafts become the
|
|
268
|
+
// mock's draft so they are still there to send; decisions become plain
|
|
269
|
+
// comments (they were already delivered).
|
|
270
|
+
migrateToMocks() {
|
|
271
|
+
const rows = this.sql.exec("SELECT * FROM posts ORDER BY createdAt ASC, rowid ASC").toArray();
|
|
272
|
+
const groups = new Map();
|
|
273
|
+
for (const r of rows) {
|
|
274
|
+
const key = `${r.project}\u0000${r.slug}`;
|
|
275
|
+
const list = groups.get(key);
|
|
276
|
+
if (list)
|
|
277
|
+
list.push(r);
|
|
278
|
+
else
|
|
279
|
+
groups.set(key, [r]);
|
|
280
|
+
}
|
|
281
|
+
const mockOf = new Map();
|
|
282
|
+
const variantOf = new Map();
|
|
283
|
+
for (const group of groups.values()) {
|
|
284
|
+
const first = group[0];
|
|
285
|
+
const latest = group.reduce((a, b) => b.updatedAt > a.updatedAt ? b : a);
|
|
286
|
+
const id = newId();
|
|
287
|
+
this.sql.exec("INSERT INTO mocks (id, project, slug, title, kind, states, asks, knobs, draft, sessionId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, '[]', '[]', '{}', NULL, ?, ?, ?)", id, first.project || DEFAULT_PROJECT, first.slug || slugify(first.title), first.title, group.some((r) => r.kind === "page") ? "page" : "component", latest.sessionId, first.createdAt, latest.updatedAt);
|
|
288
|
+
const used = new Set();
|
|
289
|
+
for (const r of group) {
|
|
290
|
+
const base = r.variant || DEFAULT_VARIANT;
|
|
291
|
+
let variant = base;
|
|
292
|
+
for (let n = 2; used.has(variant); n++)
|
|
293
|
+
variant = `${base}-${n}`;
|
|
294
|
+
used.add(variant);
|
|
295
|
+
mockOf.set(r.id, id);
|
|
296
|
+
variantOf.set(r.id, variant);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
this.sql.exec(`CREATE TABLE posts_next (${POST_COLUMNS})`);
|
|
300
|
+
for (const r of rows) {
|
|
301
|
+
const id = r.id;
|
|
302
|
+
this.sql.exec("INSERT INTO posts_next (id, sessionId, mockId, state, variant, status, title, surfaces, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)", id, r.sessionId, mockOf.get(id), variantOf.get(id), r.status || "open", r.title, r.surfaces, r.slots || "[]", r.createdAt, r.updatedAt, r.version, r.curFrom ?? null, r.curPrompt ?? null, r.curAuthor ?? null);
|
|
303
|
+
}
|
|
304
|
+
this.sql.exec("DROP TABLE posts");
|
|
305
|
+
this.sql.exec("ALTER TABLE posts_next RENAME TO posts");
|
|
306
|
+
this.sql.exec("UPDATE comments SET mockId = (SELECT mockId FROM posts WHERE posts.id = comments.postId) WHERE mockId IS NULL");
|
|
307
|
+
this.sql.exec("UPDATE comments SET text = (CASE kind WHEN 'accept' THEN 'Accepted' WHEN 'revise' THEN 'Revise' ELSE 'Dropped' END) || (CASE WHEN text = '' THEN '' ELSE ': ' || text END), kind = 'comment' WHERE kind IN ('accept', 'revise', 'drop')");
|
|
308
|
+
const drafts = new Map();
|
|
309
|
+
for (const c of this.sql
|
|
310
|
+
.exec("SELECT * FROM comments WHERE draft = 1 AND mockId IS NOT NULL ORDER BY seq ASC")
|
|
311
|
+
.toArray()) {
|
|
312
|
+
const mockId = c.mockId;
|
|
313
|
+
let draft = drafts.get(mockId);
|
|
314
|
+
if (!draft) {
|
|
315
|
+
draft = {
|
|
316
|
+
version: 1,
|
|
317
|
+
answers: {},
|
|
318
|
+
mix: {},
|
|
319
|
+
tuned: {},
|
|
320
|
+
comments: [],
|
|
321
|
+
updatedAt: c.createdAt,
|
|
322
|
+
};
|
|
323
|
+
drafts.set(mockId, draft);
|
|
324
|
+
}
|
|
325
|
+
draft.version = Math.max(draft.version, c.postVersion ?? 1);
|
|
326
|
+
draft.comments.push({ part: null, state: null, text: c.text });
|
|
327
|
+
draft.updatedAt = c.createdAt;
|
|
328
|
+
}
|
|
329
|
+
for (const [mockId, draft] of drafts) {
|
|
330
|
+
this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", JSON.stringify(draft), mockId);
|
|
331
|
+
}
|
|
332
|
+
this.sql.exec("DELETE FROM comments WHERE draft = 1");
|
|
333
|
+
}
|
|
334
|
+
settingSync(key) {
|
|
335
|
+
const rows = this.sql.exec("SELECT value FROM settings WHERE key = ?", key).toArray();
|
|
336
|
+
return rows.length ? rows[0].value : null;
|
|
337
|
+
}
|
|
338
|
+
// --- rows ---
|
|
339
|
+
insertVersion(postId, v) {
|
|
340
|
+
this.sql.exec("INSERT OR REPLACE INTO post_versions (postId, version, title, surfaces, at, fromVersion, prompt, author) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", postId, v.version, v.title, JSON.stringify(v.surfaces ?? []), v.at, v.from ?? null, v.prompt ?? null, v.author ?? null);
|
|
341
|
+
}
|
|
342
|
+
rowToVersion(r) {
|
|
343
|
+
return {
|
|
344
|
+
version: r.version,
|
|
345
|
+
title: r.title,
|
|
346
|
+
surfaces: parseJson(r.surfaces, []),
|
|
347
|
+
at: r.at,
|
|
348
|
+
// Absent stays absent: the store contract compares entries whole.
|
|
349
|
+
...(r.fromVersion == null ? {} : { from: r.fromVersion }),
|
|
350
|
+
...(r.prompt == null ? {} : { prompt: r.prompt }),
|
|
351
|
+
...(r.author == null ? {} : { author: r.author }),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
historiesFor(ids) {
|
|
355
|
+
const out = new Map();
|
|
356
|
+
if (ids.length === 0)
|
|
357
|
+
return out;
|
|
358
|
+
const rows = this.sql
|
|
359
|
+
.exec(`SELECT * FROM post_versions WHERE postId IN (${ids.map(() => "?").join(",")}) ORDER BY version ASC`, ...ids)
|
|
360
|
+
.toArray();
|
|
361
|
+
for (const r of rows) {
|
|
362
|
+
const id = r.postId;
|
|
363
|
+
let list = out.get(id);
|
|
364
|
+
if (!list)
|
|
365
|
+
out.set(id, (list = []));
|
|
366
|
+
list.push(this.rowToVersion(r));
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
rowsToPosts(rows) {
|
|
371
|
+
const histories = this.historiesFor(rows.map((r) => r.id));
|
|
372
|
+
return rows.map((r) => this.rowToPost(r, histories.get(r.id) ?? []));
|
|
373
|
+
}
|
|
374
|
+
rowToSession(r) {
|
|
375
|
+
return {
|
|
376
|
+
id: r.id,
|
|
377
|
+
agent: r.agent,
|
|
378
|
+
title: r.title ?? null,
|
|
379
|
+
cwd: r.cwd ?? null,
|
|
380
|
+
createdAt: r.createdAt,
|
|
381
|
+
lastActiveAt: r.lastActiveAt,
|
|
382
|
+
agentSeq: r.agentSeq ?? 0,
|
|
383
|
+
project: r.project ?? null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
rowToMock(r) {
|
|
387
|
+
return {
|
|
388
|
+
id: r.id,
|
|
389
|
+
project: r.project,
|
|
390
|
+
slug: r.slug,
|
|
391
|
+
title: r.title,
|
|
392
|
+
kind: (r.kind === "page" ? "page" : "component"),
|
|
393
|
+
states: parseJson(r.states, []),
|
|
394
|
+
asks: parseJson(r.asks, []),
|
|
395
|
+
knobs: parseJson(r.knobs, {}),
|
|
396
|
+
draft: parseJson(r.draft, null),
|
|
397
|
+
sessionId: r.sessionId ?? null,
|
|
398
|
+
createdAt: r.createdAt,
|
|
399
|
+
updatedAt: r.updatedAt,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
rowToPost(r, history) {
|
|
403
|
+
const knobs = parseJson(r.knobs, null);
|
|
404
|
+
return {
|
|
405
|
+
id: r.id,
|
|
406
|
+
sessionId: r.sessionId,
|
|
407
|
+
mock: r.mockId,
|
|
408
|
+
state: r.state ?? null,
|
|
409
|
+
variant: r.variant,
|
|
410
|
+
status: (r.status || "open"),
|
|
411
|
+
title: r.title,
|
|
412
|
+
surfaces: parseJson(r.surfaces, []),
|
|
413
|
+
createdAt: r.createdAt,
|
|
414
|
+
updatedAt: r.updatedAt,
|
|
415
|
+
version: r.version,
|
|
416
|
+
history,
|
|
417
|
+
...(knobs ? { knobs } : {}),
|
|
418
|
+
slots: parseJson(r.slots, []),
|
|
419
|
+
...(r.curFrom == null ? {} : { from: r.curFrom }),
|
|
420
|
+
...(r.curPrompt == null ? {} : { prompt: r.curPrompt }),
|
|
421
|
+
...(r.curAuthor == null ? {} : { author: r.curAuthor }),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// The BLOB comes back as an ArrayBuffer (real DO) or a Uint8Array
|
|
425
|
+
// (node:sqlite); `new Uint8Array(raw)` copies from either into a fresh array.
|
|
426
|
+
rowToAsset(r) {
|
|
427
|
+
const raw = r.data;
|
|
428
|
+
return {
|
|
429
|
+
id: r.id,
|
|
430
|
+
sessionId: r.sessionId,
|
|
431
|
+
kind: r.kind === "image" ? "image" : "file",
|
|
432
|
+
contentType: r.contentType,
|
|
433
|
+
byteLength: r.byteLength,
|
|
434
|
+
filename: r.filename ?? null,
|
|
435
|
+
data: new Uint8Array(raw),
|
|
436
|
+
createdAt: r.createdAt,
|
|
437
|
+
lastAccessedAt: r.lastAccessedAt,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
rowToComment(r) {
|
|
441
|
+
const anchor = parseJson(r.anchor, null);
|
|
442
|
+
const payload = parseJson(r.payload, null);
|
|
443
|
+
return {
|
|
444
|
+
id: r.id,
|
|
445
|
+
seq: r.seq,
|
|
446
|
+
sessionId: r.sessionId,
|
|
447
|
+
mockId: r.mockId ?? null,
|
|
448
|
+
postId: r.postId ?? null,
|
|
449
|
+
author: r.author,
|
|
450
|
+
text: r.text,
|
|
451
|
+
createdAt: r.createdAt,
|
|
452
|
+
...(anchor ? { anchor } : {}),
|
|
453
|
+
kind: (r.kind || "comment"),
|
|
454
|
+
anchors: parseJson(r.anchors, []),
|
|
455
|
+
postVersion: r.postVersion == null ? null : r.postVersion,
|
|
456
|
+
viewport: r.viewport == null ? null : r.viewport,
|
|
457
|
+
...(payload ? { payload } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// --- sessions ---
|
|
461
|
+
async listSessions() {
|
|
462
|
+
return this.sql
|
|
463
|
+
.exec("SELECT * FROM sessions ORDER BY lastActiveAt DESC")
|
|
464
|
+
.toArray()
|
|
465
|
+
.map((r) => this.rowToSession(r));
|
|
466
|
+
}
|
|
467
|
+
async getSession(id) {
|
|
468
|
+
const rows = this.sql.exec("SELECT * FROM sessions WHERE id = ?", id).toArray();
|
|
469
|
+
return rows.length > 0 ? this.rowToSession(rows[0]) : null;
|
|
470
|
+
}
|
|
471
|
+
async getSessionByKey(key) {
|
|
472
|
+
const rows = this.sql
|
|
473
|
+
.exec("SELECT * FROM sessions WHERE key = ? ORDER BY lastActiveAt DESC LIMIT 1", key)
|
|
474
|
+
.toArray();
|
|
475
|
+
return rows.length > 0 ? this.rowToSession(rows[0]) : null;
|
|
476
|
+
}
|
|
477
|
+
async createSession(input) {
|
|
478
|
+
const now = new Date().toISOString();
|
|
479
|
+
const session = {
|
|
480
|
+
id: newId(),
|
|
481
|
+
agent: reservedAgent(stripNul(input.agent).trim() || "agent"),
|
|
482
|
+
title: stripNul(input.title)?.trim() || null,
|
|
483
|
+
cwd: stripNul(input.cwd ?? null),
|
|
484
|
+
createdAt: now,
|
|
485
|
+
lastActiveAt: now,
|
|
486
|
+
agentSeq: 0,
|
|
487
|
+
project: stripNul(input.project)?.trim() || projectFromCwd(stripNul(input.cwd ?? null)) || null,
|
|
488
|
+
};
|
|
489
|
+
this.sql.exec("INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq, project, key) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)", session.id, session.agent, session.title, session.cwd, session.createdAt, session.lastActiveAt, session.project, stripNul(input.key)?.trim() || null);
|
|
490
|
+
return session;
|
|
491
|
+
}
|
|
492
|
+
async renameSession(id, title) {
|
|
493
|
+
const session = await this.getSession(id);
|
|
494
|
+
if (!session)
|
|
495
|
+
return null;
|
|
496
|
+
session.title = stripNul(title).trim() || null;
|
|
497
|
+
this.sql.exec("UPDATE sessions SET title = ? WHERE id = ?", session.title, id);
|
|
498
|
+
return session;
|
|
499
|
+
}
|
|
500
|
+
async removeSession(id) {
|
|
501
|
+
if (!(await this.getSession(id)))
|
|
502
|
+
return false;
|
|
503
|
+
this.atomic(() => {
|
|
504
|
+
this.sql.exec("DELETE FROM comments WHERE sessionId = ?", id);
|
|
505
|
+
this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE sessionId = ?)", id);
|
|
506
|
+
this.sql.exec("DELETE FROM posts WHERE sessionId = ?", id);
|
|
507
|
+
// A mock left with no variants has nothing to show.
|
|
508
|
+
this.sql.exec("DELETE FROM comments WHERE mockId IN (SELECT id FROM mocks WHERE id NOT IN (SELECT mockId FROM posts))");
|
|
509
|
+
this.sql.exec("DELETE FROM mocks WHERE id NOT IN (SELECT mockId FROM posts)");
|
|
510
|
+
this.sql.exec("UPDATE mocks SET sessionId = NULL WHERE sessionId = ?", id);
|
|
511
|
+
// Posts are gone, so the referenced set reflects survivors only: drop this
|
|
512
|
+
// session's own assets except any a surviving surface still points at
|
|
513
|
+
// (assets are content-addressed and may be shared across sessions).
|
|
514
|
+
this.invalidateAssetRefs();
|
|
515
|
+
const referenced = this.referencedAssetIds();
|
|
516
|
+
for (const r of this.sql.exec("SELECT id FROM assets WHERE sessionId = ?", id).toArray()) {
|
|
517
|
+
const aid = r.id;
|
|
518
|
+
if (!referenced.has(aid))
|
|
519
|
+
this.sql.exec("DELETE FROM assets WHERE id = ?", aid);
|
|
520
|
+
}
|
|
521
|
+
this.sql.exec("DELETE FROM sessions WHERE id = ?", id);
|
|
522
|
+
});
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
touch(sessionId) {
|
|
526
|
+
this.sql.exec("UPDATE sessions SET lastActiveAt = ? WHERE id = ?", new Date().toISOString(), sessionId);
|
|
527
|
+
}
|
|
528
|
+
async markAgentSeen(sessionId, seq) {
|
|
529
|
+
this.sql.exec("UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq < ?", seq, sessionId, seq);
|
|
530
|
+
}
|
|
531
|
+
// --- settings ---
|
|
532
|
+
async getSetting(key) {
|
|
533
|
+
return this.settingSync(key);
|
|
534
|
+
}
|
|
535
|
+
async setSetting(key, value) {
|
|
536
|
+
if (key.startsWith("design:"))
|
|
537
|
+
this.invalidateAssetRefs();
|
|
538
|
+
this.sql.exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", stripNul(key), stripNul(value));
|
|
539
|
+
}
|
|
540
|
+
// --- projects / mocks ---
|
|
541
|
+
async listProjects() {
|
|
542
|
+
return summarizeProjects(await this.listMocks(), await this.listSessions(), await this.listPosts());
|
|
543
|
+
}
|
|
544
|
+
async listMocks(project) {
|
|
545
|
+
const rows = project === undefined
|
|
546
|
+
? this.sql.exec("SELECT * FROM mocks ORDER BY updatedAt DESC, rowid DESC").toArray()
|
|
547
|
+
: this.sql
|
|
548
|
+
.exec("SELECT * FROM mocks WHERE project = ? ORDER BY updatedAt DESC, rowid DESC", project)
|
|
549
|
+
.toArray();
|
|
550
|
+
return rows.map((r) => this.rowToMock(r));
|
|
551
|
+
}
|
|
552
|
+
async getMock(id) {
|
|
553
|
+
const rows = this.sql.exec("SELECT * FROM mocks WHERE id = ?", id).toArray();
|
|
554
|
+
return rows.length > 0 ? this.rowToMock(rows[0]) : null;
|
|
555
|
+
}
|
|
556
|
+
async findMock(project, slug) {
|
|
557
|
+
const rows = this.sql
|
|
558
|
+
.exec("SELECT * FROM mocks WHERE project = ? AND slug = ?", project, slug)
|
|
559
|
+
.toArray();
|
|
560
|
+
return rows.length > 0 ? this.rowToMock(rows[0]) : null;
|
|
561
|
+
}
|
|
562
|
+
async createMock(input) {
|
|
563
|
+
const now = new Date().toISOString();
|
|
564
|
+
const mock = {
|
|
565
|
+
id: newId(),
|
|
566
|
+
project: stripNul(input.project).trim() || DEFAULT_PROJECT,
|
|
567
|
+
slug: slugify(stripNul(input.slug)),
|
|
568
|
+
title: stripNul(input.title)?.trim() || input.slug,
|
|
569
|
+
kind: input.kind === "page" ? "page" : "component",
|
|
570
|
+
states: (input.states ?? []).map((s) => stripNul(s)),
|
|
571
|
+
asks: [],
|
|
572
|
+
knobs: input.knobs ?? {},
|
|
573
|
+
draft: null,
|
|
574
|
+
sessionId: input.sessionId ?? null,
|
|
575
|
+
createdAt: now,
|
|
576
|
+
updatedAt: now,
|
|
577
|
+
};
|
|
578
|
+
this.sql.exec("INSERT INTO mocks (id, project, slug, title, kind, states, asks, knobs, draft, sessionId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, '[]', ?, NULL, ?, ?, ?)", mock.id, mock.project, mock.slug, mock.title, mock.kind, JSON.stringify(mock.states), JSON.stringify(mock.knobs), mock.sessionId, mock.createdAt, mock.updatedAt);
|
|
579
|
+
return mock;
|
|
580
|
+
}
|
|
581
|
+
async updateMock(id, patch) {
|
|
582
|
+
const mock = await this.getMock(id);
|
|
583
|
+
if (!mock)
|
|
584
|
+
return null;
|
|
585
|
+
const next = {
|
|
586
|
+
...mock,
|
|
587
|
+
...(patch.title !== undefined && { title: stripNul(patch.title).trim() || mock.title }),
|
|
588
|
+
...(patch.kind !== undefined && { kind: patch.kind }),
|
|
589
|
+
...(patch.states !== undefined && { states: patch.states.map((s) => stripNul(s)) }),
|
|
590
|
+
...(patch.asks !== undefined && { asks: patch.asks }),
|
|
591
|
+
...(patch.knobs !== undefined && { knobs: patch.knobs }),
|
|
592
|
+
...(patch.sessionId !== undefined && { sessionId: patch.sessionId }),
|
|
593
|
+
updatedAt: new Date().toISOString(),
|
|
594
|
+
};
|
|
595
|
+
this.sql.exec("UPDATE mocks SET title = ?, kind = ?, states = ?, asks = ?, knobs = ?, sessionId = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, JSON.stringify(next.states), JSON.stringify(next.asks), JSON.stringify(next.knobs), next.sessionId, next.updatedAt, id);
|
|
596
|
+
return next;
|
|
597
|
+
}
|
|
598
|
+
async removeMock(id) {
|
|
599
|
+
if (!(await this.getMock(id)))
|
|
600
|
+
return false;
|
|
601
|
+
this.atomic(() => {
|
|
602
|
+
this.sql.exec("DELETE FROM comments WHERE mockId = ?", id);
|
|
603
|
+
this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE mockId = ?)", id);
|
|
604
|
+
this.sql.exec("DELETE FROM posts WHERE mockId = ?", id);
|
|
605
|
+
this.sql.exec("DELETE FROM mocks WHERE id = ?", id);
|
|
606
|
+
});
|
|
607
|
+
this.invalidateAssetRefs();
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
async putDraft(mockId, draft) {
|
|
611
|
+
const mock = await this.getMock(mockId);
|
|
612
|
+
if (!mock)
|
|
613
|
+
return null;
|
|
614
|
+
this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, mockId);
|
|
615
|
+
return { ...mock, draft };
|
|
616
|
+
}
|
|
617
|
+
touchMock(mockId, at) {
|
|
618
|
+
this.sql.exec("UPDATE mocks SET updatedAt = ? WHERE id = ?", at, mockId);
|
|
619
|
+
}
|
|
620
|
+
// --- posts ---
|
|
621
|
+
async listPosts(query = {}) {
|
|
622
|
+
const clauses = [];
|
|
623
|
+
const params = [];
|
|
624
|
+
if (query.mockId !== undefined) {
|
|
625
|
+
clauses.push("mockId = ?");
|
|
626
|
+
params.push(query.mockId);
|
|
627
|
+
}
|
|
628
|
+
if (query.sessionId !== undefined) {
|
|
629
|
+
clauses.push("sessionId = ?");
|
|
630
|
+
params.push(query.sessionId);
|
|
631
|
+
}
|
|
632
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
633
|
+
const rows = this.sql
|
|
634
|
+
.exec(`SELECT * FROM posts ${where} ORDER BY createdAt ASC, rowid ASC`, ...params)
|
|
635
|
+
.toArray();
|
|
636
|
+
return this.rowsToPosts(rows);
|
|
637
|
+
}
|
|
638
|
+
async countPostsBySession() {
|
|
639
|
+
const counts = new Map();
|
|
640
|
+
for (const row of this.sql
|
|
641
|
+
.exec("SELECT sessionId, COUNT(*) AS n FROM posts GROUP BY sessionId")
|
|
642
|
+
.toArray()) {
|
|
643
|
+
counts.set(row.sessionId, row.n);
|
|
644
|
+
}
|
|
645
|
+
return counts;
|
|
646
|
+
}
|
|
647
|
+
async getPost(id) {
|
|
648
|
+
const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray();
|
|
649
|
+
return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
|
|
650
|
+
}
|
|
651
|
+
async findPost(mockId, state, variant) {
|
|
652
|
+
const rows = this.sql
|
|
653
|
+
.exec("SELECT * FROM posts WHERE mockId = ? AND state IS ? AND variant = ? ORDER BY createdAt ASC LIMIT 1", mockId, state, variant)
|
|
654
|
+
.toArray();
|
|
655
|
+
return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
|
|
656
|
+
}
|
|
657
|
+
async createPost(input) {
|
|
658
|
+
if (!(await this.getSession(input.sessionId)))
|
|
659
|
+
return null;
|
|
660
|
+
if (!(await this.getMock(input.mock)))
|
|
661
|
+
return null;
|
|
662
|
+
const now = new Date().toISOString();
|
|
663
|
+
const post = {
|
|
664
|
+
id: newId(),
|
|
665
|
+
sessionId: input.sessionId,
|
|
666
|
+
mock: input.mock,
|
|
667
|
+
state: input.state === null ? null : stripNul(input.state),
|
|
668
|
+
variant: stripNul(input.variant)?.trim() || DEFAULT_VARIANT,
|
|
669
|
+
status: "open",
|
|
670
|
+
title: stripNul(input.title)?.trim() || "Untitled",
|
|
671
|
+
surfaces: normalizeSurfaceIds(input.surfaces),
|
|
672
|
+
createdAt: now,
|
|
673
|
+
updatedAt: now,
|
|
674
|
+
version: 1,
|
|
675
|
+
history: [],
|
|
676
|
+
...(input.knobs ? { knobs: input.knobs } : {}),
|
|
677
|
+
slots: input.slots ?? [],
|
|
678
|
+
...(input.from === undefined ? {} : { from: input.from }),
|
|
679
|
+
...(input.prompt === undefined ? {} : { prompt: stripNul(input.prompt) }),
|
|
680
|
+
...(input.author === undefined ? {} : { author: stripNul(input.author) }),
|
|
681
|
+
};
|
|
682
|
+
this.sql.exec("INSERT INTO posts (id, sessionId, mockId, state, variant, status, title, surfaces, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", post.id, post.sessionId, post.mock, post.state, post.variant, post.title, JSON.stringify(post.surfaces), post.knobs ? JSON.stringify(post.knobs) : null, JSON.stringify(post.slots), post.createdAt, post.updatedAt, post.from ?? null, post.prompt ?? null, post.author ?? null);
|
|
683
|
+
this.touch(input.sessionId);
|
|
684
|
+
this.touchMock(post.mock, now);
|
|
685
|
+
this.addAssetRefs(post.surfaces);
|
|
686
|
+
return post;
|
|
687
|
+
}
|
|
688
|
+
async updatePost(id, patch) {
|
|
689
|
+
// Compare-and-set on the version: two concurrent updates serialize without
|
|
690
|
+
// a read-then-write gap. The loser sees 0 rows changed and retries.
|
|
691
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
692
|
+
const post = await this.getPost(id);
|
|
693
|
+
if (!post)
|
|
694
|
+
return null;
|
|
695
|
+
const archived = {
|
|
696
|
+
version: post.version,
|
|
697
|
+
title: post.title,
|
|
698
|
+
surfaces: post.surfaces,
|
|
699
|
+
at: post.updatedAt,
|
|
700
|
+
...(post.from === undefined ? {} : { from: post.from }),
|
|
701
|
+
...(post.prompt === undefined ? {} : { prompt: post.prompt }),
|
|
702
|
+
...(post.author === undefined ? {} : { author: post.author }),
|
|
703
|
+
};
|
|
704
|
+
const history = [...post.history, archived];
|
|
705
|
+
if (history.length > HISTORY_LIMIT)
|
|
706
|
+
history.shift();
|
|
707
|
+
const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
|
|
708
|
+
const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
|
|
709
|
+
const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
|
|
710
|
+
const slots = patch.slots ?? post.slots;
|
|
711
|
+
const state = patch.state === undefined ? post.state : patch.state;
|
|
712
|
+
const version = post.version + 1;
|
|
713
|
+
const updatedAt = new Date().toISOString();
|
|
714
|
+
// A revision may branch from any earlier version; default to the one it replaces.
|
|
715
|
+
const from = patch.from ?? post.version;
|
|
716
|
+
const prompt = stripNul(patch.prompt ?? "");
|
|
717
|
+
const author = patch.author === undefined ? null : stripNul(patch.author);
|
|
718
|
+
this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, knobs = ?, slots = ?, state = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ? WHERE id = ? AND version = ?", title, JSON.stringify(surfaces), knobs ? JSON.stringify(knobs) : null, JSON.stringify(slots), state, updatedAt, version, from, prompt, author, id, post.version);
|
|
719
|
+
const affected = this.sql.exec("SELECT changes() AS n").one().n;
|
|
720
|
+
if (affected > 0) {
|
|
721
|
+
this.insertVersion(id, archived);
|
|
722
|
+
this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version <= ?", id, version - 1 - HISTORY_LIMIT);
|
|
723
|
+
this.touch(post.sessionId);
|
|
724
|
+
this.touchMock(post.mock, updatedAt);
|
|
725
|
+
if (patch.surfaces !== undefined)
|
|
726
|
+
this.addAssetRefs(surfaces);
|
|
727
|
+
const { knobs: _knobs, author: _author, ...rest } = post;
|
|
728
|
+
return {
|
|
729
|
+
...rest,
|
|
730
|
+
title,
|
|
731
|
+
surfaces,
|
|
732
|
+
...(knobs ? { knobs } : {}),
|
|
733
|
+
slots,
|
|
734
|
+
state,
|
|
735
|
+
version,
|
|
736
|
+
updatedAt,
|
|
737
|
+
history,
|
|
738
|
+
from,
|
|
739
|
+
prompt,
|
|
740
|
+
...(author === null ? {} : { author }),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
async removePost(id) {
|
|
747
|
+
if (!(await this.getPost(id)))
|
|
748
|
+
return false;
|
|
749
|
+
this.atomic(() => {
|
|
750
|
+
this.sql.exec("DELETE FROM comments WHERE postId = ?", id);
|
|
751
|
+
this.sql.exec("DELETE FROM post_versions WHERE postId = ?", id);
|
|
752
|
+
this.sql.exec("DELETE FROM posts WHERE id = ?", id);
|
|
753
|
+
});
|
|
754
|
+
this.invalidateAssetRefs();
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
async setPostStatus(id, status) {
|
|
758
|
+
const post = await this.getPost(id);
|
|
759
|
+
if (!post)
|
|
760
|
+
return null;
|
|
761
|
+
this.sql.exec("UPDATE posts SET status = ? WHERE id = ?", status, id);
|
|
762
|
+
return { ...post, status };
|
|
763
|
+
}
|
|
764
|
+
// --- comments ---
|
|
765
|
+
async listComments(query) {
|
|
766
|
+
const clauses = [];
|
|
767
|
+
const params = [];
|
|
768
|
+
if (query.sessionId !== undefined) {
|
|
769
|
+
clauses.push("sessionId = ?");
|
|
770
|
+
params.push(query.sessionId);
|
|
771
|
+
}
|
|
772
|
+
if (query.mockId !== undefined) {
|
|
773
|
+
clauses.push("mockId = ?");
|
|
774
|
+
params.push(query.mockId);
|
|
775
|
+
}
|
|
776
|
+
if (query.postId !== undefined) {
|
|
777
|
+
clauses.push("postId = ?");
|
|
778
|
+
params.push(query.postId);
|
|
779
|
+
}
|
|
780
|
+
if (query.afterSeq !== undefined) {
|
|
781
|
+
clauses.push("seq > ?");
|
|
782
|
+
params.push(query.afterSeq);
|
|
783
|
+
}
|
|
784
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
785
|
+
return this.sql
|
|
786
|
+
.exec(`SELECT * FROM comments ${where} ORDER BY seq ASC`, ...params)
|
|
787
|
+
.toArray()
|
|
788
|
+
.map((r) => this.rowToComment(r));
|
|
789
|
+
}
|
|
790
|
+
insertComment(input) {
|
|
791
|
+
const createdAt = new Date().toISOString();
|
|
792
|
+
const comment = {
|
|
793
|
+
id: newId(),
|
|
794
|
+
sessionId: input.sessionId,
|
|
795
|
+
mockId: input.mockId ?? null,
|
|
796
|
+
postId: input.postId ?? null,
|
|
797
|
+
author: stripNul(input.author).trim() || "user",
|
|
798
|
+
text: stripNul(input.text),
|
|
799
|
+
createdAt,
|
|
800
|
+
...(input.anchor ? { anchor: input.anchor } : {}),
|
|
801
|
+
kind: input.kind ?? "comment",
|
|
802
|
+
anchors: input.anchors ?? [],
|
|
803
|
+
postVersion: input.postVersion ?? null,
|
|
804
|
+
viewport: input.viewport ?? null,
|
|
805
|
+
...(input.payload ? { payload: input.payload } : {}),
|
|
806
|
+
};
|
|
807
|
+
this.sql.exec("INSERT INTO comments (id, sessionId, mockId, postId, author, text, createdAt, kind, anchor, anchors, postVersion, viewport, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", comment.id, comment.sessionId, comment.mockId, comment.postId, comment.author, comment.text, comment.createdAt, comment.kind, comment.anchor ? JSON.stringify(comment.anchor) : null, JSON.stringify(comment.anchors), comment.postVersion, comment.viewport, comment.payload ? JSON.stringify(comment.payload) : null);
|
|
808
|
+
const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq;
|
|
809
|
+
this.touch(input.sessionId);
|
|
810
|
+
return { ...comment, seq };
|
|
811
|
+
}
|
|
812
|
+
async createComment(input) {
|
|
813
|
+
if (!(await this.getSession(input.sessionId)))
|
|
814
|
+
return null;
|
|
815
|
+
const post = input.postId ? await this.getPost(input.postId) : null;
|
|
816
|
+
return this.insertComment({
|
|
817
|
+
...input,
|
|
818
|
+
postId: post?.id ?? null,
|
|
819
|
+
mockId: input.mockId ?? post?.mock ?? null,
|
|
820
|
+
postVersion: input.postVersion ?? post?.version ?? null,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
async removeComment(id) {
|
|
824
|
+
const rows = this.sql.exec("SELECT * FROM comments WHERE id = ?", id).toArray();
|
|
825
|
+
if (rows.length === 0)
|
|
826
|
+
return null;
|
|
827
|
+
const comment = this.rowToComment(rows[0]);
|
|
828
|
+
this.sql.exec("DELETE FROM comments WHERE id = ?", id);
|
|
829
|
+
this.touch(comment.sessionId);
|
|
830
|
+
return comment;
|
|
831
|
+
}
|
|
832
|
+
async commitReply(input) {
|
|
833
|
+
if (!(await this.getSession(input.sessionId)))
|
|
834
|
+
return null;
|
|
835
|
+
if (!(await this.getMock(input.mockId)))
|
|
836
|
+
return null;
|
|
837
|
+
return this.atomic(() => {
|
|
838
|
+
const comment = this.insertComment({
|
|
839
|
+
sessionId: input.sessionId,
|
|
840
|
+
mockId: input.mockId,
|
|
841
|
+
author: "user",
|
|
842
|
+
text: input.text,
|
|
843
|
+
kind: "reply",
|
|
844
|
+
postVersion: input.payload.version,
|
|
845
|
+
payload: input.payload,
|
|
846
|
+
});
|
|
847
|
+
this.sql.exec("UPDATE mocks SET asks = ?, draft = NULL WHERE id = ?", JSON.stringify(input.asks), input.mockId);
|
|
848
|
+
for (const id of input.accept) {
|
|
849
|
+
this.sql.exec("UPDATE posts SET status = 'accepted' WHERE id = ? AND mockId = ?", id, input.mockId);
|
|
850
|
+
}
|
|
851
|
+
for (const id of input.archive) {
|
|
852
|
+
this.sql.exec("UPDATE posts SET status = 'archived' WHERE id = ? AND mockId = ?", id, input.mockId);
|
|
853
|
+
}
|
|
854
|
+
return comment;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
// --- assets ---
|
|
858
|
+
referencedAssetIds() {
|
|
859
|
+
if (this.assetRefCache)
|
|
860
|
+
return this.assetRefCache;
|
|
861
|
+
const out = new Set();
|
|
862
|
+
for (const r of this.sql.exec("SELECT surfaces FROM posts").toArray()) {
|
|
863
|
+
collectAssetIds(parseJson(r.surfaces, []), out);
|
|
864
|
+
}
|
|
865
|
+
for (const r of this.sql.exec("SELECT surfaces FROM post_versions").toArray()) {
|
|
866
|
+
collectAssetIds(parseJson(r.surfaces, []), out);
|
|
867
|
+
}
|
|
868
|
+
// An installed icon set is referenced by its project's design, not by a
|
|
869
|
+
// surface; evicting it would blank that project's icons.
|
|
870
|
+
for (const r of this.sql
|
|
871
|
+
.exec("SELECT value FROM settings WHERE key LIKE 'design:%'")
|
|
872
|
+
.toArray()) {
|
|
873
|
+
const sets = parseJson(r.value, null)?.iconSets;
|
|
874
|
+
for (const ref of Array.isArray(sets) ? sets : []) {
|
|
875
|
+
if (typeof ref?.assetId === "string")
|
|
876
|
+
out.add(ref.assetId);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
this.assetRefCache = out;
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
addAssetRefs(surfaces) {
|
|
883
|
+
if (this.assetRefCache)
|
|
884
|
+
collectAssetIds(surfaces, this.assetRefCache);
|
|
885
|
+
}
|
|
886
|
+
invalidateAssetRefs() {
|
|
887
|
+
this.assetRefCache = undefined;
|
|
888
|
+
}
|
|
889
|
+
async putAsset(input) {
|
|
890
|
+
if (!(await this.getSession(input.sessionId)))
|
|
891
|
+
return null;
|
|
892
|
+
// Content-addressed: identical bytes dedupe to the existing blob (idempotent
|
|
893
|
+
// upload), keeping its original session and createdAt; we just warm it.
|
|
894
|
+
const id = await hashAssetId(input.data);
|
|
895
|
+
if (await this.getAsset(id)) {
|
|
896
|
+
await this.touchAsset(id);
|
|
897
|
+
this.touch(input.sessionId);
|
|
898
|
+
return (await this.getAsset(id));
|
|
899
|
+
}
|
|
900
|
+
const referenced = this.referencedAssetIds();
|
|
901
|
+
const candidates = this.sql
|
|
902
|
+
.exec("SELECT id, byteLength, lastAccessedAt FROM assets")
|
|
903
|
+
.toArray()
|
|
904
|
+
.map((r) => ({
|
|
905
|
+
id: r.id,
|
|
906
|
+
byteLength: r.byteLength,
|
|
907
|
+
lastAccessedAt: r.lastAccessedAt,
|
|
908
|
+
referenced: referenced.has(r.id),
|
|
909
|
+
}));
|
|
910
|
+
for (const evict of selectEvictions(candidates, input.data.byteLength, MAX_WORKSPACE_ASSET_BYTES)) {
|
|
911
|
+
this.sql.exec("DELETE FROM assets WHERE id = ?", evict);
|
|
912
|
+
}
|
|
913
|
+
const now = new Date().toISOString();
|
|
914
|
+
const asset = {
|
|
915
|
+
id,
|
|
916
|
+
sessionId: input.sessionId,
|
|
917
|
+
kind: input.kind,
|
|
918
|
+
contentType: stripNul(input.contentType),
|
|
919
|
+
byteLength: input.data.byteLength,
|
|
920
|
+
filename: stripNul(input.filename ?? null),
|
|
921
|
+
data: input.data,
|
|
922
|
+
createdAt: now,
|
|
923
|
+
lastAccessedAt: now,
|
|
924
|
+
};
|
|
925
|
+
// Bind the blob as an ArrayBuffer (the SqlStorageValue type); the node
|
|
926
|
+
// adapter turns it back into a Uint8Array for node:sqlite.
|
|
927
|
+
const buf = asset.data.buffer.slice(asset.data.byteOffset, asset.data.byteOffset + asset.data.byteLength);
|
|
928
|
+
this.sql.exec("INSERT INTO assets (id, sessionId, kind, contentType, byteLength, filename, data, createdAt, lastAccessedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", asset.id, asset.sessionId, asset.kind, asset.contentType, asset.byteLength, asset.filename, buf, asset.createdAt, asset.lastAccessedAt);
|
|
929
|
+
this.touch(input.sessionId);
|
|
930
|
+
return asset;
|
|
931
|
+
}
|
|
932
|
+
async getAsset(id) {
|
|
933
|
+
const rows = this.sql.exec("SELECT * FROM assets WHERE id = ?", id).toArray();
|
|
934
|
+
return rows.length > 0 ? this.rowToAsset(rows[0]) : null;
|
|
935
|
+
}
|
|
936
|
+
async touchAsset(id) {
|
|
937
|
+
this.sql.exec("UPDATE assets SET lastAccessedAt = ? WHERE id = ?", new Date().toISOString(), id);
|
|
938
|
+
}
|
|
939
|
+
async listAssets(sessionId) {
|
|
940
|
+
return this.sql
|
|
941
|
+
.exec("SELECT * FROM assets WHERE sessionId = ?", sessionId)
|
|
942
|
+
.toArray()
|
|
943
|
+
.map((r) => this.rowToAsset(r));
|
|
944
|
+
}
|
|
945
|
+
async removeAsset(id) {
|
|
946
|
+
if (!(await this.getAsset(id)))
|
|
947
|
+
return false;
|
|
948
|
+
this.sql.exec("DELETE FROM assets WHERE id = ?", id);
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
async isAssetReferenced(id) {
|
|
952
|
+
return this.referencedAssetIds().has(id);
|
|
953
|
+
}
|
|
954
|
+
}
|