mockpit 1.3.0 → 3.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.
Files changed (77) hide show
  1. package/README.md +66 -202
  2. package/bin/demoData.js +5 -5
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +687 -192
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +110 -19
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +563 -41
  10. package/dist/server/apiViews.js +223 -74
  11. package/dist/server/app.d.ts +10 -2
  12. package/dist/server/app.js +3284 -782
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +638 -0
  18. package/dist/server/codeWorker.js +2 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +8 -8
  22. package/dist/server/decisions.js +18 -17
  23. package/dist/server/designGuide.d.ts +10 -6
  24. package/dist/server/designGuide.js +173 -78
  25. package/dist/server/embeds.d.ts +4 -1
  26. package/dist/server/embeds.js +57 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +38 -16
  31. package/dist/server/feedbackBatch.js +121 -50
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/guidance.d.ts +23 -0
  35. package/dist/server/guidance.js +71 -0
  36. package/dist/server/handoff.d.ts +4 -1
  37. package/dist/server/handoff.js +8 -2
  38. package/dist/server/icons.js +1 -1
  39. package/dist/server/index.js +49 -11
  40. package/dist/server/jump.d.ts +21 -0
  41. package/dist/server/jump.js +175 -0
  42. package/dist/server/knobs.d.ts +3 -3
  43. package/dist/server/knobs.js +6 -6
  44. package/dist/server/mcpHttp.d.ts +8 -1
  45. package/dist/server/mcpHttp.js +44 -7
  46. package/dist/server/mcpSpec.d.ts +2 -1
  47. package/dist/server/mcpSpec.js +172 -50
  48. package/dist/server/parts.js +9 -1
  49. package/dist/server/postSurfaces.js +3 -0
  50. package/dist/server/run.d.ts +1 -1
  51. package/dist/server/run.js +31 -17
  52. package/dist/server/runApi.d.ts +3 -3
  53. package/dist/server/runApi.js +34 -24
  54. package/dist/server/sqlStore.d.ts +117 -94
  55. package/dist/server/sqlStore.js +1089 -141
  56. package/dist/server/standings.d.ts +25 -0
  57. package/dist/server/standings.js +113 -0
  58. package/dist/server/surfacePage.d.ts +6 -9
  59. package/dist/server/surfacePage.js +361 -86
  60. package/dist/server/types.d.ts +252 -69
  61. package/dist/server/types.js +87 -69
  62. package/extensions/mockpit.js +245 -65
  63. package/guide/AGENT_SETUP.md +13 -1
  64. package/guide/packs/design-craft.md +34 -0
  65. package/guide/topics/asks.md +117 -69
  66. package/guide/topics/feedback.md +156 -119
  67. package/guide/topics/html.md +19 -32
  68. package/guide/topics/http.md +43 -40
  69. package/guide/topics/knobs.md +7 -10
  70. package/guide/topics/scripts.md +24 -35
  71. package/guide/topics/surfaces.md +50 -41
  72. package/opencode/mockpit.js +120 -0
  73. package/opencode/package.json +8 -0
  74. package/package.json +4 -6
  75. package/skills/launch-video/SKILL.md +12 -12
  76. package/skills/mockpit/SKILL.md +35 -23
  77. package/viewer/dist/index.html +9 -8
@@ -1,4 +1,15 @@
1
- import { collectAssetIds, collectAttachedIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, sameScreen, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
1
+ import { collectAssetIds, collectAttachedIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, isDesignKind, newId, normalizeSurfaceIds, isLive, projectFromCwd, AREA_ORDER_SETTING, DECISION_EDITS_SETTING, reservedAgent, sameScreen, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
2
+ import { renameSlugRefs } from "./embeds.js";
3
+ import { RULES_SETTING } from "./guidance.js";
4
+ // The settings keyed `<prefix>:<project>`, which a project rename carries.
5
+ const PROJECT_SETTINGS = [
6
+ "design",
7
+ AREA_ORDER_SETTING,
8
+ RULES_SETTING,
9
+ DECISION_EDITS_SETTING,
10
+ ];
11
+ // An optional free-text label: blank means none.
12
+ const label = (v) => stripNul(v ?? "").trim() || null;
2
13
  function parseJson(raw, fallback) {
3
14
  if (typeof raw !== "string" || !raw)
4
15
  return fallback;
@@ -9,12 +20,78 @@ function parseJson(raw, fallback) {
9
20
  return fallback;
10
21
  }
11
22
  }
23
+ // A stored reply in the current names, or null when it already uses them.
24
+ function contractReply(payload) {
25
+ if (!("mix" in payload) && !("decision" in payload) && !("resolved" in payload))
26
+ return null;
27
+ const { mix, decision, resolved, ...rest } = payload;
28
+ const out = { ...rest };
29
+ if (mix !== undefined)
30
+ out.borrowed = mix;
31
+ const kind = decision?.kind;
32
+ if (kind === "accept" || kind === "revise") {
33
+ out.verdict = { ...decision, kind: kind === "accept" ? "use" : "revise" };
34
+ }
35
+ if (resolved !== undefined)
36
+ out.archived = resolved;
37
+ return out;
38
+ }
39
+ // `surfaces` with every `<mockpit-slot>` tag spelled `<mockpit-embed>`, or null
40
+ // when none has one. Only html is markup; a code or markdown surface may quote
41
+ // the tag as text.
42
+ function slotTagsToEmbed(surfaces) {
43
+ let changed = false;
44
+ const out = surfaces.map((s) => {
45
+ if (s.kind !== "html" || !/<\/?mockpit-slot\b/i.test(s.html))
46
+ return s;
47
+ changed = true;
48
+ return { ...s, html: s.html.replace(/<(\/?)mockpit-slot\b/gi, "<$1mockpit-embed") };
49
+ });
50
+ return changed ? out : null;
51
+ }
52
+ // What the post cache may hold by default, in stored surface characters: a
53
+ // Durable Object has 128 MB in all.
54
+ const POST_CACHE_BYTES = 32 * 1024 * 1024;
55
+ const surfaceHeads = (surfaces) => JSON.stringify(surfaces.map((s) => {
56
+ if (s.kind !== "html")
57
+ return { kind: s.kind };
58
+ return {
59
+ kind: s.kind,
60
+ ...(s.poster ? { poster: s.poster } : {}),
61
+ ...(s.poster && s.posterMode ? { posterMode: s.posterMode } : {}),
62
+ ...(/<mockpit-embed|data-fill/i.test(s.html) ? { marked: true } : {}),
63
+ };
64
+ }));
65
+ // surfaceHeads in SQL, for the rows written before heads were: it runs without
66
+ // bringing a workspace's bodies into memory, which a Durable Object can't hold.
67
+ const SURFACE_HEADS_SQL = `CASE WHEN json_valid(surfaces) THEN (
68
+ SELECT json_group_array(json(h)) FROM (
69
+ SELECT '{"kind":' || json_quote(json_extract(value, '$.kind'))
70
+ || CASE WHEN json_extract(value, '$.kind') = 'html' AND json_extract(value, '$.poster') <> ''
71
+ THEN ',"poster":' || json_quote(json_extract(value, '$.poster')) ELSE '' END
72
+ || CASE WHEN json_extract(value, '$.kind') = 'html'
73
+ AND (json_extract(value, '$.html') LIKE '%<mockpit-embed%'
74
+ OR json_extract(value, '$.html') LIKE '%data-fill%')
75
+ THEN ',"marked":true' ELSE '' END
76
+ || '}' AS h
77
+ FROM json_each(surfaces) ORDER BY key
78
+ )
79
+ ) ELSE '[]' END`;
80
+ // Everything a head row needs, spelled out because `SELECT *` reads the bodies.
81
+ const POST_HEAD_COLUMNS = "id, sessionId, designId, state, variant, status, title, head, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor, description, forkedFrom, copiedFrom, starred, acceptedVersion, hiddenVersions, curSession, amendedAt, rewrites, archiveReason, stayedVersion, archivedAt";
82
+ // Holding every column a head reads, so listing a project's variants never touches
83
+ // a row with a body: those columns sit after `surfaces`, and reaching them through
84
+ // the table walks the body's overflow pages. Any column added to the heads belongs
85
+ // here too (it is built from them), under a new index name so the old one is replaced.
86
+ const POST_HEADS_INDEX = `designId, ${POST_HEAD_COLUMNS.split(", ")
87
+ .filter((c) => c !== "designId")
88
+ .join(", ")}`;
12
89
  const POST_COLUMNS = `
13
- id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, mockId TEXT NOT NULL, state TEXT,
90
+ id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, designId TEXT NOT NULL, state TEXT,
14
91
  variant TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', title TEXT NOT NULL,
15
92
  surfaces TEXT NOT NULL, knobs TEXT, slots TEXT NOT NULL DEFAULT '[]',
16
93
  createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, version INTEGER NOT NULL,
17
- curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, note TEXT`;
94
+ curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, description TEXT`;
18
95
  // Store implementation on SQLite — a Durable Object's `ctx.storage.sql` in the
19
96
  // Worker, or node:sqlite via an adapter on Node (see server/sqliteStorage.ts).
20
97
  // One workspace = one database, so plain SQL with no tenant columns.
@@ -27,8 +104,47 @@ export class SqlStore {
27
104
  // post's surfaces on each call. Stays correct because history is append-only:
28
105
  // an asset id once referenced stays referenced until its post is deleted.
29
106
  assetRefCache;
30
- constructor(sql) {
31
- this.sql = sql;
107
+ // Parsed posts per design, least recently used first. A component is read again
108
+ // for every embed that shows it, and parsing its bodies each time is what a
109
+ // page full of embeds would wait on. A statement that writes posts or versions
110
+ // drops all of it unless `writingPosts` says which posts it touches; the byte
111
+ // budget keeps a Durable Object's memory bounded.
112
+ postCache = new Map();
113
+ postCacheBytes = 0;
114
+ postCacheLimit;
115
+ // Past the cache's drop, for a write that patches the cache itself.
116
+ raw;
117
+ holdPostDrop = false;
118
+ constructor(sql, opts = {}) {
119
+ this.raw = sql;
120
+ this.postCacheLimit = opts.postCacheBytes ?? POST_CACHE_BYTES;
121
+ const drop = () => {
122
+ if (this.holdPostDrop)
123
+ return;
124
+ this.postCache.clear();
125
+ this.postCacheBytes = 0;
126
+ };
127
+ this.sql = {
128
+ exec: (query, ...bindings) => {
129
+ if (!/^\s*SELECT\b/i.test(query) && /\bpost(s|_versions)\b/i.test(query))
130
+ drop();
131
+ return sql.exec(query, ...bindings);
132
+ },
133
+ ...(sql.transactionSync
134
+ ? {
135
+ transactionSync: (fn) => {
136
+ try {
137
+ return sql.transactionSync(fn);
138
+ }
139
+ catch (err) {
140
+ // Reads inside a rolled-back transaction may have cached its writes.
141
+ drop();
142
+ throw err;
143
+ }
144
+ },
145
+ }
146
+ : {}),
147
+ };
32
148
  this.atomic(() => this.migrate());
33
149
  }
34
150
  // On a Durable Object the synchronous writes of one event already commit as a
@@ -56,20 +172,153 @@ export class SqlStore {
56
172
  this.sql.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${decl}`);
57
173
  }
58
174
  }
175
+ // What was called a mock is a design, in the store as everywhere else. First of all
176
+ // the migrations: every one below it speaks the new names, and a workspace with no
177
+ // designs table would otherwise read as older than mocks.
178
+ // The words stored inside JSON and values, once every older migration has written
179
+ // its own: a kind, what an answer's comment is called, and an archive in a reply.
180
+ migrateToDesignWords() {
181
+ const kinds = { page: "screen", sketch: "idea" };
182
+ for (const [from, to] of Object.entries(kinds)) {
183
+ this.sql.exec("UPDATE designs SET kind = ? WHERE kind = ?", to, from);
184
+ }
185
+ const renameKeys = (o, names) => {
186
+ let changed = false;
187
+ for (const [from, to] of Object.entries(names)) {
188
+ if (from in o) {
189
+ o[to] = o[from];
190
+ delete o[from];
191
+ changed = true;
192
+ }
193
+ }
194
+ return changed;
195
+ };
196
+ for (const r of this.sql.exec("SELECT id, asks, draft FROM designs").toArray()) {
197
+ const asks = parseJson(r.asks, []);
198
+ let asksChanged = false;
199
+ for (const ask of asks) {
200
+ if (renameKeys(ask, { note: "comment", noteImages: "commentImages" }))
201
+ asksChanged = true;
202
+ }
203
+ if (asksChanged) {
204
+ this.sql.exec("UPDATE designs SET asks = ? WHERE id = ?", JSON.stringify(asks), r.id);
205
+ }
206
+ const draft = parseJson(r.draft, null);
207
+ if (draft && renameKeys(draft, { notes: "askComments", noteImages: "askCommentImages" })) {
208
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify(draft), r.id);
209
+ }
210
+ }
211
+ for (const r of this.sql
212
+ .exec("SELECT seq, payload FROM comments WHERE kind = 'reply' AND payload IS NOT NULL")
213
+ .toArray()) {
214
+ const payload = parseJson(r.payload, null);
215
+ if (!payload)
216
+ continue;
217
+ let changed = renameKeys(payload, {
218
+ notes: "askComments",
219
+ noteImages: "askCommentImages",
220
+ closed: "archived",
221
+ });
222
+ const edited = payload.edited;
223
+ if (edited) {
224
+ if (typeof edited.kind === "string" && edited.kind in kinds) {
225
+ edited.kind = kinds[edited.kind];
226
+ changed = true;
227
+ }
228
+ if ("grouped" in edited) {
229
+ delete edited.grouped;
230
+ changed = true;
231
+ // All it said was about grouping, which no longer exists: it would read as a blank line.
232
+ if (!Object.keys(edited).length) {
233
+ this.sql.exec("DELETE FROM comments WHERE seq = ?", r.seq);
234
+ continue;
235
+ }
236
+ }
237
+ }
238
+ if (changed) {
239
+ this.sql.exec("UPDATE comments SET payload = ? WHERE seq = ?", JSON.stringify(payload), r.seq);
240
+ }
241
+ }
242
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('designWords', '1')");
243
+ }
244
+ // A value, so migrateToDesignWords' renames never reached it, and that one has
245
+ // already run on every workspace that needs this.
246
+ migrateAskScopeWord() {
247
+ for (const r of this.sql.exec("SELECT id, asks FROM designs").toArray()) {
248
+ const asks = parseJson(r.asks, []);
249
+ const old = asks.filter((a) => a.scope === "mock");
250
+ if (!old.length)
251
+ continue;
252
+ for (const a of old)
253
+ a.scope = "design";
254
+ this.sql.exec("UPDATE designs SET asks = ? WHERE id = ?", JSON.stringify(asks), r.id);
255
+ }
256
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('askScopeWord', '1')");
257
+ }
258
+ renameMocksToDesigns() {
259
+ if (!this.tables().has("mocks") || this.tables().has("designs"))
260
+ return;
261
+ for (const name of [
262
+ "mockpit_mocks_slug_idx",
263
+ "mockpit_mocks_updated_at_idx",
264
+ "mockpit_posts_mock_idx",
265
+ "mockpit_comments_mock_seq_idx",
266
+ "mockpit_posts_copies_idx",
267
+ ]) {
268
+ this.sql.exec(`DROP INDEX IF EXISTS ${name}`);
269
+ }
270
+ this.sql.exec("ALTER TABLE mocks RENAME TO designs");
271
+ for (const table of ["posts", "comments"]) {
272
+ if (this.columns(table).has("mockId")) {
273
+ this.sql.exec(`ALTER TABLE ${table} RENAME COLUMN mockId TO designId`);
274
+ }
275
+ }
276
+ // Steps and copy references are stored as JSON, keyed by the old word.
277
+ const renamed = (raw) => typeof raw === "string" ? raw.replaceAll('"mock":', '"design":') : raw;
278
+ for (const r of this.sql.exec("SELECT project, name, steps FROM flows").toArray()) {
279
+ this.sql.exec("UPDATE flows SET steps = ? WHERE project = ? AND name = ?", renamed(r.steps), r.project, r.name);
280
+ }
281
+ // Only the key: a user's text can hold the same letters, escaped inside its string.
282
+ for (const r of this.sql
283
+ .exec("SELECT seq, payload FROM comments WHERE payload LIKE '%\"mockId\":%'")
284
+ .toArray()) {
285
+ this.sql.exec("UPDATE comments SET payload = ? WHERE seq = ?", r.payload.replace(/(?<!\\)"mockId":/g, '"designId":'), r.seq);
286
+ }
287
+ // The same release gave the design's archive fields and a variant's description
288
+ // their names; the JSON that speaks these words waits for migrateToDesignWords.
289
+ const designCols = this.columns("designs");
290
+ if (designCols.has("closedAt")) {
291
+ this.sql.exec("ALTER TABLE designs RENAME COLUMN closedAt TO archivedAt");
292
+ }
293
+ if (designCols.has("closeReason")) {
294
+ this.sql.exec("ALTER TABLE designs RENAME COLUMN closeReason TO archiveReason");
295
+ }
296
+ if (this.columns("posts").has("note")) {
297
+ this.sql.exec("ALTER TABLE posts RENAME COLUMN note TO description");
298
+ }
299
+ if (this.columns("posts").has("copiedFrom")) {
300
+ for (const r of this.sql
301
+ .exec("SELECT id, copiedFrom FROM posts WHERE copiedFrom IS NOT NULL")
302
+ .toArray()) {
303
+ this.sql.exec("UPDATE posts SET copiedFrom = ? WHERE id = ?", renamed(r.copiedFrom), r.id);
304
+ }
305
+ }
306
+ }
59
307
  migrate() {
308
+ this.renameMocksToDesigns();
60
309
  const before = this.tables();
61
310
  // 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");
311
+ // build from before designs existed is lifted in place (see migrateLegacy).
312
+ const legacy = before.has("posts") && !before.has("designs");
64
313
  this.sql.exec(`
65
314
  CREATE TABLE IF NOT EXISTS sessions (
66
315
  id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
67
316
  createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
68
317
  agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT, agentActiveAt TEXT
69
318
  );
70
- CREATE TABLE IF NOT EXISTS mocks (
319
+ CREATE TABLE IF NOT EXISTS designs (
71
320
  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 '[]',
321
+ kind TEXT NOT NULL DEFAULT 'idea', states TEXT NOT NULL DEFAULT '[]',
73
322
  asks TEXT NOT NULL DEFAULT '[]', knobs TEXT NOT NULL DEFAULT '{}', draft TEXT,
74
323
  sessionId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
75
324
  widths TEXT NOT NULL DEFAULT '[]'
@@ -83,7 +332,7 @@ export class SqlStore {
83
332
  );
84
333
  CREATE TABLE IF NOT EXISTS comments (
85
334
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL,
86
- sessionId TEXT NOT NULL, mockId TEXT, postId TEXT,
335
+ sessionId TEXT NOT NULL, designId TEXT, postId TEXT,
87
336
  author TEXT NOT NULL, text TEXT NOT NULL, createdAt TEXT NOT NULL,
88
337
  kind TEXT NOT NULL DEFAULT 'comment', anchor TEXT, anchors TEXT NOT NULL DEFAULT '[]',
89
338
  postVersion INTEGER, viewport INTEGER, payload TEXT
@@ -108,39 +357,158 @@ export class SqlStore {
108
357
  key: "TEXT",
109
358
  agentActiveAt: "TEXT",
110
359
  });
111
- this.addMissing("mocks", { widths: "TEXT NOT NULL DEFAULT '[]'", resolvedAt: "TEXT" });
360
+ // The close column's first name.
361
+ const designCols = this.columns("designs");
362
+ if (designCols.has("resolvedAt") && !designCols.has("archivedAt")) {
363
+ this.sql.exec("ALTER TABLE designs RENAME COLUMN resolvedAt TO archivedAt");
364
+ }
365
+ this.addMissing("designs", {
366
+ widths: "TEXT NOT NULL DEFAULT '[]'",
367
+ archivedAt: "TEXT",
368
+ archiveReason: "TEXT",
369
+ area: "TEXT",
370
+ source: "TEXT",
371
+ shortlist: "INTEGER NOT NULL DEFAULT 0",
372
+ notes: "TEXT",
373
+ stateOutlines: "TEXT NOT NULL DEFAULT '{}'",
374
+ answerAdditions: "TEXT NOT NULL DEFAULT '[]'",
375
+ standings: "TEXT NOT NULL DEFAULT '{}'",
376
+ sets: "TEXT NOT NULL DEFAULT '[]'",
377
+ });
378
+ this.migrateSituations();
379
+ // Before the legacy lift, whose history rows are written with every column.
380
+ this.addMissing("post_versions", { sessionId: "TEXT", amendedAt: "TEXT" });
112
381
  if (legacy)
113
382
  this.migrateLegacy();
114
383
  // After the legacy lift, which rebuilds posts from POST_COLUMNS alone.
115
384
  const pinned = this.columns("posts").has("acceptedVersion");
385
+ const dated = this.columns("posts").has("archivedAt");
116
386
  this.addMissing("posts", {
117
- note: "TEXT",
387
+ description: "TEXT",
118
388
  forkedFrom: "TEXT",
389
+ copiedFrom: "TEXT",
390
+ // Inert: kept answers (`Design.standings`) took over from the star.
119
391
  starred: "INTEGER NOT NULL DEFAULT 0",
120
392
  acceptedVersion: "INTEGER",
121
393
  hiddenVersions: "TEXT",
394
+ curSession: "TEXT",
395
+ amendedAt: "TEXT",
396
+ rewrites: "INTEGER NOT NULL DEFAULT 0",
397
+ archiveReason: "TEXT",
398
+ stayedVersion: "INTEGER",
399
+ archivedAt: "TEXT",
122
400
  });
123
401
  // Before the pin, accepted meant "its latest": freeze that as the version in use.
124
402
  if (!pinned) {
125
403
  this.sql.exec("UPDATE posts SET acceptedVersion = version WHERE status = 'accepted'");
126
404
  }
405
+ for (const table of ["posts", "post_versions"]) {
406
+ if (this.columns(table).has("head"))
407
+ continue;
408
+ this.addMissing(table, { head: "TEXT" });
409
+ this.sql.exec(`UPDATE ${table} SET head = ${SURFACE_HEADS_SQL}`);
410
+ }
411
+ if (this.settingSync("sketchKind") !== "1")
412
+ this.migrateToSketches();
413
+ if (this.settingSync("contractTerms") !== "1")
414
+ this.migrateToContractTerms();
415
+ if (this.settingSync("designWords") !== "1")
416
+ this.migrateToDesignWords();
417
+ if (this.settingSync("askScopeWord") !== "1")
418
+ this.migrateAskScopeWord();
419
+ // The day it went was never kept, and the design's last change is the nearest thing.
420
+ // Frozen now, or the next reason or draft on the design would move it to that day.
421
+ if (!dated) {
422
+ this.sql.exec("UPDATE posts SET archivedAt = (SELECT updatedAt FROM designs WHERE designs.id = posts.designId) WHERE status = 'archived'");
423
+ }
127
424
  this.createIndexes();
128
425
  }
426
+ // A situation was already a state of its own, filed in `states` under the name
427
+ // feedback gave it ("Writing · Couldn't save"), with its asks, comments and posts
428
+ // there too, so those keep that name and only the map that set it apart goes. The
429
+ // column itself stays on a workspace that has it, empty: nothing reads it any more,
430
+ // and new workspaces never get one.
431
+ migrateSituations() {
432
+ if (!this.columns("designs").has("situations"))
433
+ return;
434
+ for (const r of this.sql
435
+ .exec("SELECT id, states, situations FROM designs WHERE situations != '{}'")
436
+ .toArray()) {
437
+ const states = parseJson(r.states, []);
438
+ const map = parseJson(r.situations, {});
439
+ for (const [state, names] of Object.entries(map)) {
440
+ for (const name of names) {
441
+ const key = `${state} · ${name}`;
442
+ if (!states.includes(key))
443
+ states.push(key);
444
+ }
445
+ }
446
+ this.sql.exec("UPDATE designs SET states = ?, situations = '{}' WHERE id = ?", JSON.stringify(states), r.id);
447
+ }
448
+ }
449
+ // The agent contract renamed what replies and drafts store (mix → borrowed,
450
+ // decision → verdict with accept → use, resolved → archived) and retired drop
451
+ // and the `<mockpit-slot>` embed alias. Stored data is rewritten once, here,
452
+ // so nothing past the store reads an old name: a dropped variant is archived
453
+ // (both were hidden from the choice and restorable), a drop verdict loses its
454
+ // verdict (the variant's archived status says the rest), and every stored
455
+ // version spells its embeds `<mockpit-embed>` so it still renders them.
456
+ migrateToContractTerms() {
457
+ this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL WHERE status = 'dropped'");
458
+ for (const r of this.sql
459
+ .exec("SELECT seq, payload FROM comments WHERE kind = 'reply' AND payload IS NOT NULL")
460
+ .toArray()) {
461
+ const payload = parseJson(r.payload, null);
462
+ const next = payload && contractReply(payload);
463
+ if (next) {
464
+ this.sql.exec("UPDATE comments SET payload = ? WHERE seq = ?", JSON.stringify(next), r.seq);
465
+ }
466
+ }
467
+ for (const r of this.sql
468
+ .exec("SELECT id, draft FROM designs WHERE draft IS NOT NULL")
469
+ .toArray()) {
470
+ const draft = parseJson(r.draft, null);
471
+ if (!draft || !("mix" in draft))
472
+ continue;
473
+ const { mix, ...rest } = draft;
474
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify({ ...rest, borrowed: mix ?? {} }), r.id);
475
+ }
476
+ for (const r of this.sql
477
+ .exec("SELECT id, surfaces FROM posts WHERE surfaces LIKE '%mockpit-slot%'")
478
+ .toArray()) {
479
+ const surfaces = slotTagsToEmbed(parseJson(r.surfaces, []));
480
+ if (surfaces) {
481
+ this.sql.exec("UPDATE posts SET surfaces = ? WHERE id = ?", JSON.stringify(surfaces), r.id);
482
+ }
483
+ }
484
+ for (const r of this.sql
485
+ .exec("SELECT postId, version, surfaces FROM post_versions WHERE surfaces LIKE '%mockpit-slot%'")
486
+ .toArray()) {
487
+ const surfaces = slotTagsToEmbed(parseJson(r.surfaces, []));
488
+ if (surfaces) {
489
+ this.sql.exec("UPDATE post_versions SET surfaces = ? WHERE postId = ? AND version = ?", JSON.stringify(surfaces), r.postId, r.version);
490
+ }
491
+ }
492
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('contractTerms', '1')");
493
+ }
129
494
  createIndexes() {
130
495
  this.sql.exec(`
131
- CREATE UNIQUE INDEX IF NOT EXISTS mockpit_mocks_slug_idx ON mocks (project, slug);
132
- CREATE INDEX IF NOT EXISTS mockpit_mocks_updated_at_idx ON mocks (updatedAt DESC);
133
- CREATE INDEX IF NOT EXISTS mockpit_posts_mock_idx ON posts (mockId, state, variant);
496
+ CREATE UNIQUE INDEX IF NOT EXISTS mockpit_designs_slug_idx ON designs (project, slug);
497
+ CREATE INDEX IF NOT EXISTS mockpit_designs_updated_at_idx ON designs (updatedAt DESC);
498
+ CREATE INDEX IF NOT EXISTS mockpit_posts_design_idx ON posts (designId, state, variant);
134
499
  CREATE INDEX IF NOT EXISTS mockpit_posts_session_created_at_idx ON posts (sessionId, createdAt);
500
+ CREATE INDEX IF NOT EXISTS mockpit_posts_copies_idx ON posts (designId, state, variant, status, copiedFrom) WHERE copiedFrom IS NOT NULL;
501
+ CREATE INDEX IF NOT EXISTS mockpit_posts_heads_idx ON posts (${POST_HEADS_INDEX});
502
+ CREATE INDEX IF NOT EXISTS mockpit_post_versions_heads_idx ON post_versions (postId, version, head);
135
503
  CREATE INDEX IF NOT EXISTS mockpit_comments_session_seq_idx ON comments (sessionId, seq);
136
- CREATE INDEX IF NOT EXISTS mockpit_comments_mock_seq_idx ON comments (mockId, seq);
504
+ CREATE INDEX IF NOT EXISTS mockpit_comments_design_seq_idx ON comments (designId, seq);
137
505
  CREATE INDEX IF NOT EXISTS mockpit_comments_post_seq_idx ON comments (postId, seq);
138
506
  CREATE INDEX IF NOT EXISTS mockpit_comments_id_idx ON comments (id);
139
507
  CREATE INDEX IF NOT EXISTS mockpit_assets_session_idx ON assets (sessionId);
140
508
  CREATE INDEX IF NOT EXISTS mockpit_sessions_key_idx ON sessions (key);
141
509
  `);
142
510
  }
143
- // --- in-place migration of a pre-mock workspace ---
511
+ // --- in-place migration of a pre-design workspace ---
144
512
  migrateLegacy() {
145
513
  this.addMissing("comments", {
146
514
  anchor: "TEXT",
@@ -149,7 +517,7 @@ export class SqlStore {
149
517
  draft: "INTEGER NOT NULL DEFAULT 0",
150
518
  postVersion: "INTEGER",
151
519
  viewport: "INTEGER",
152
- mockId: "TEXT",
520
+ designId: "TEXT",
153
521
  payload: "TEXT",
154
522
  });
155
523
  this.migrateToSurfaces();
@@ -157,7 +525,7 @@ export class SqlStore {
157
525
  this.migrateSurfaceIds();
158
526
  this.migrateToItems();
159
527
  this.migrateToVersions();
160
- this.migrateToMocks();
528
+ this.migrateToDesigns();
161
529
  this.sql.exec(`
162
530
  DROP TABLE IF EXISTS trace_steps;
163
531
  DROP INDEX IF EXISTS mockpit_comments_post_draft_idx;
@@ -284,11 +652,11 @@ export class SqlStore {
284
652
  }
285
653
  }
286
654
  }
287
- // Every (project, item slug) becomes one single-state mock; its posts become
288
- // the mock's variants with state null. Unsent operator drafts become the
289
- // mock's draft so they are still there to send; decisions become plain
655
+ // Every (project, item slug) becomes one single-state design; its posts become
656
+ // the design's variants with state null. Unsent operator drafts become the
657
+ // design's draft so they are still there to send; decisions become plain
290
658
  // comments (they were already delivered).
291
- migrateToMocks() {
659
+ migrateToDesigns() {
292
660
  const rows = this.sql.exec("SELECT * FROM posts ORDER BY createdAt ASC, rowid ASC").toArray();
293
661
  const groups = new Map();
294
662
  for (const r of rows) {
@@ -299,13 +667,13 @@ export class SqlStore {
299
667
  else
300
668
  groups.set(key, [r]);
301
669
  }
302
- const mockOf = new Map();
670
+ const designOf = new Map();
303
671
  const variantOf = new Map();
304
672
  for (const group of groups.values()) {
305
673
  const first = group[0];
306
674
  const latest = group.reduce((a, b) => b.updatedAt > a.updatedAt ? b : a);
307
675
  const id = newId();
308
- 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);
676
+ this.sql.exec("INSERT INTO designs (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);
309
677
  const used = new Set();
310
678
  for (const r of group) {
311
679
  const base = r.variant || DEFAULT_VARIANT;
@@ -313,52 +681,79 @@ export class SqlStore {
313
681
  for (let n = 2; used.has(variant); n++)
314
682
  variant = `${base}-${n}`;
315
683
  used.add(variant);
316
- mockOf.set(r.id, id);
684
+ designOf.set(r.id, id);
317
685
  variantOf.set(r.id, variant);
318
686
  }
319
687
  }
320
688
  this.sql.exec(`CREATE TABLE posts_next (${POST_COLUMNS})`);
321
689
  for (const r of rows) {
322
690
  const id = r.id;
323
- 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);
691
+ this.sql.exec("INSERT INTO posts_next (id, sessionId, designId, state, variant, status, title, surfaces, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)", id, r.sessionId, designOf.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);
324
692
  }
325
693
  this.sql.exec("DROP TABLE posts");
326
694
  this.sql.exec("ALTER TABLE posts_next RENAME TO posts");
327
- this.sql.exec("UPDATE comments SET mockId = (SELECT mockId FROM posts WHERE posts.id = comments.postId) WHERE mockId IS NULL");
695
+ this.sql.exec("UPDATE comments SET designId = (SELECT designId FROM posts WHERE posts.id = comments.postId) WHERE designId IS NULL");
328
696
  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')");
329
697
  const drafts = new Map();
330
698
  for (const c of this.sql
331
- .exec("SELECT * FROM comments WHERE draft = 1 AND mockId IS NOT NULL ORDER BY seq ASC")
699
+ .exec("SELECT * FROM comments WHERE draft = 1 AND designId IS NOT NULL ORDER BY seq ASC")
332
700
  .toArray()) {
333
- const mockId = c.mockId;
334
- let draft = drafts.get(mockId);
701
+ const designId = c.designId;
702
+ let draft = drafts.get(designId);
335
703
  if (!draft) {
336
704
  draft = {
337
705
  version: 1,
338
706
  answers: {},
339
- mix: {},
707
+ borrowed: {},
340
708
  tuned: {},
341
709
  comments: [],
342
710
  updatedAt: c.createdAt,
343
711
  };
344
- drafts.set(mockId, draft);
712
+ drafts.set(designId, draft);
345
713
  }
346
714
  draft.version = Math.max(draft.version, c.postVersion ?? 1);
347
715
  draft.comments.push({ part: null, state: null, text: c.text });
348
716
  draft.updatedAt = c.createdAt;
349
717
  }
350
- for (const [mockId, draft] of drafts) {
351
- this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", JSON.stringify(draft), mockId);
718
+ for (const [designId, draft] of drafts) {
719
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify(draft), designId);
352
720
  }
353
721
  this.sql.exec("DELETE FROM comments WHERE draft = 1");
354
722
  }
723
+ // A design no one declared used to be a component. Keep that only where it acts
724
+ // as one, embedded somewhere or a step of a flow; the rest become sketches.
725
+ migrateToSketches() {
726
+ const projectOf = new Map();
727
+ for (const r of this.sql.exec("SELECT id, project FROM designs").toArray()) {
728
+ projectOf.set(r.id, r.project);
729
+ }
730
+ const used = new Set();
731
+ for (const r of this.sql.exec("SELECT designId, slots FROM posts").toArray()) {
732
+ const project = projectOf.get(r.designId);
733
+ for (const e of parseJson(r.slots, []))
734
+ used.add(`${project}\u0000${e.slug}`);
735
+ }
736
+ for (const r of this.sql.exec("SELECT project, steps FROM flows").toArray()) {
737
+ for (const x of parseJson(r.steps, [])) {
738
+ used.add(`${r.project}\u0000${x.design}`);
739
+ }
740
+ }
741
+ for (const r of this.sql
742
+ .exec("SELECT id, project, slug FROM designs WHERE kind = 'component'")
743
+ .toArray()) {
744
+ if (used.has(`${r.project}\u0000${r.slug}`))
745
+ continue;
746
+ this.sql.exec("UPDATE designs SET kind = 'sketch' WHERE id = ?", r.id);
747
+ }
748
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('sketchKind', '1')");
749
+ }
355
750
  settingSync(key) {
356
751
  const rows = this.sql.exec("SELECT value FROM settings WHERE key = ?", key).toArray();
357
752
  return rows.length ? rows[0].value : null;
358
753
  }
359
754
  // --- rows ---
360
755
  insertVersion(postId, v) {
361
- 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);
756
+ this.sql.exec("INSERT OR REPLACE INTO post_versions (postId, version, title, surfaces, head, at, fromVersion, prompt, author, sessionId, amendedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", postId, v.version, v.title, JSON.stringify(v.surfaces ?? []), surfaceHeads(v.surfaces ?? []), v.at, v.from ?? null, v.prompt ?? null, v.author ?? null, v.publishedBy ?? null, v.amendedAt ?? null);
362
757
  }
363
758
  rowToVersion(r) {
364
759
  return {
@@ -370,9 +765,11 @@ export class SqlStore {
370
765
  ...(r.fromVersion == null ? {} : { from: r.fromVersion }),
371
766
  ...(r.prompt == null ? {} : { prompt: r.prompt }),
372
767
  ...(r.author == null ? {} : { author: r.author }),
768
+ ...(r.sessionId == null ? {} : { publishedBy: r.sessionId }),
769
+ ...(r.amendedAt == null ? {} : { amendedAt: r.amendedAt }),
373
770
  };
374
771
  }
375
- historiesFor(ids) {
772
+ historiesFor(ids, weigh) {
376
773
  const out = new Map();
377
774
  if (ids.length === 0)
378
775
  return out;
@@ -384,12 +781,13 @@ export class SqlStore {
384
781
  let list = out.get(id);
385
782
  if (!list)
386
783
  out.set(id, (list = []));
784
+ weigh?.(r.surfaces.length);
387
785
  list.push(this.rowToVersion(r));
388
786
  }
389
787
  return out;
390
788
  }
391
- rowsToPosts(rows) {
392
- const histories = this.historiesFor(rows.map((r) => r.id));
789
+ rowsToPosts(rows, weigh) {
790
+ const histories = this.historiesFor(rows.map((r) => r.id), weigh);
393
791
  return rows.map((r) => this.rowToPost(r, histories.get(r.id) ?? []));
394
792
  }
395
793
  rowToSession(r) {
@@ -406,20 +804,29 @@ export class SqlStore {
406
804
  keyed: r.key != null,
407
805
  };
408
806
  }
409
- rowToMock(r) {
807
+ rowToDesign(r) {
410
808
  return {
411
809
  id: r.id,
412
810
  project: r.project,
413
811
  slug: r.slug,
414
812
  title: r.title,
415
- kind: (r.kind === "page" ? "page" : "component"),
813
+ kind: isDesignKind(r.kind) ? r.kind : "idea",
814
+ area: r.area ?? null,
815
+ source: r.source ?? null,
416
816
  states: parseJson(r.states, []),
417
817
  asks: parseJson(r.asks, []),
418
818
  knobs: parseJson(r.knobs, {}),
419
819
  widths: parseJson(r.widths, []),
420
820
  draft: parseJson(r.draft, null),
421
821
  sessionId: r.sessionId ?? null,
422
- resolvedAt: r.resolvedAt ?? null,
822
+ shortlist: !!r.shortlist,
823
+ archivedAt: r.archivedAt ?? null,
824
+ archiveReason: r.archiveReason ?? null,
825
+ notes: parseJson(r.notes, null),
826
+ stateOutlines: parseJson(r.stateOutlines, {}),
827
+ answerAdditions: parseJson(r.answerAdditions, []),
828
+ standings: parseJson(r.standings, {}),
829
+ sets: parseJson(r.sets, []),
423
830
  createdAt: r.createdAt,
424
831
  updatedAt: r.updatedAt,
425
832
  };
@@ -430,7 +837,7 @@ export class SqlStore {
430
837
  return {
431
838
  id: r.id,
432
839
  sessionId: r.sessionId,
433
- mock: r.mockId,
840
+ design: r.designId,
434
841
  state: r.state ?? null,
435
842
  variant: r.variant,
436
843
  status: (r.status || "open"),
@@ -441,18 +848,24 @@ export class SqlStore {
441
848
  version: r.version,
442
849
  history,
443
850
  ...(knobs ? { knobs } : {}),
444
- ...(r.note == null ? {} : { note: r.note }),
851
+ ...(r.description == null ? {} : { description: r.description }),
445
852
  // The column keeps its first name, from when embeds were called slots.
446
853
  embeds: parseJson(r.slots, []),
447
854
  ...(r.forkedFrom == null ? {} : { forkedFrom: parseJson(r.forkedFrom, null) }),
448
- ...(r.starred ? { starred: true } : {}),
855
+ ...(r.copiedFrom == null ? {} : { copiedFrom: parseJson(r.copiedFrom, null) }),
449
856
  ...(r.status === "accepted" && r.acceptedVersion != null
450
857
  ? { acceptedVersion: r.acceptedVersion }
451
858
  : {}),
452
859
  ...(hidden.length ? { hiddenVersions: hidden } : {}),
860
+ ...(r.archiveReason == null ? {} : { archiveReason: r.archiveReason }),
861
+ ...(r.stayedVersion == null ? {} : { stayedVersion: r.stayedVersion }),
862
+ ...(r.archivedAt == null ? {} : { archivedAt: r.archivedAt }),
453
863
  ...(r.curFrom == null ? {} : { from: r.curFrom }),
454
864
  ...(r.curPrompt == null ? {} : { prompt: r.curPrompt }),
455
865
  ...(r.curAuthor == null ? {} : { author: r.curAuthor }),
866
+ ...(r.curSession == null ? {} : { publishedBy: r.curSession }),
867
+ ...(r.amendedAt == null ? {} : { amendedAt: r.amendedAt }),
868
+ ...(r.rewrites ? { rewrites: r.rewrites } : {}),
456
869
  };
457
870
  }
458
871
  // The BLOB comes back as an ArrayBuffer (real DO) or a Uint8Array
@@ -478,7 +891,7 @@ export class SqlStore {
478
891
  id: r.id,
479
892
  seq: r.seq,
480
893
  sessionId: r.sessionId,
481
- mockId: r.mockId ?? null,
894
+ designId: r.designId ?? null,
482
895
  postId: r.postId ?? null,
483
896
  author: r.author,
484
897
  text: r.text,
@@ -541,11 +954,18 @@ export class SqlStore {
541
954
  this.atomic(() => {
542
955
  this.sql.exec("DELETE FROM comments WHERE sessionId = ?", id);
543
956
  this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE sessionId = ?)", id);
957
+ const drawn = JSON.stringify(this.sql
958
+ .exec("SELECT DISTINCT designId FROM posts WHERE sessionId = ?", id)
959
+ .toArray()
960
+ .map((r) => r.designId));
544
961
  this.sql.exec("DELETE FROM posts WHERE sessionId = ?", id);
545
- // A mock left with no variants has nothing to show.
546
- this.sql.exec("DELETE FROM comments WHERE mockId IN (SELECT id FROM mocks WHERE id NOT IN (SELECT mockId FROM posts))");
547
- this.sql.exec("DELETE FROM mocks WHERE id NOT IN (SELECT mockId FROM posts)");
548
- this.sql.exec("UPDATE mocks SET sessionId = NULL WHERE sessionId = ?", id);
962
+ // A design left with no variants has nothing to show, unless it was declared ahead
963
+ // of being drawn, which only goes with the session that planned it. One this
964
+ // session drew is not a plan, whoever wrote to it last: it would come back as one.
965
+ const emptied = "id NOT IN (SELECT designId FROM posts) AND (kind = 'idea' OR sessionId = ? OR id IN (SELECT value FROM json_each(?)))";
966
+ this.sql.exec(`DELETE FROM comments WHERE designId IN (SELECT id FROM designs WHERE ${emptied})`, id, drawn);
967
+ this.sql.exec(`DELETE FROM designs WHERE ${emptied}`, id, drawn);
968
+ this.sql.exec("UPDATE designs SET sessionId = NULL WHERE sessionId = ?", id);
549
969
  // Posts are gone, so the referenced set reflects survivors only: drop this
550
970
  // session's own assets except any a surviving surface still points at
551
971
  // (assets are content-addressed and may be shared across sessions).
@@ -583,81 +1003,130 @@ export class SqlStore {
583
1003
  this.invalidateAssetRefs();
584
1004
  this.sql.exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", stripNul(key), stripNul(value));
585
1005
  }
586
- // --- projects / mocks ---
1006
+ // --- projects / designs ---
587
1007
  async listProjects() {
588
- return summarizeProjects(await this.listMocks(), await this.listSessions(), await this.listPosts());
1008
+ return summarizeProjects(await this.listDesigns(), await this.listSessions());
1009
+ }
1010
+ // Spelled out because `SELECT *` on posts reads the bodies, the very cost search avoids.
1011
+ async listJumpRows() {
1012
+ return {
1013
+ designs: this.sql
1014
+ .exec("SELECT id, project, slug, title, kind, area, states, asks, widths, archivedAt, updatedAt FROM designs")
1015
+ .toArray()
1016
+ .map((r) => ({
1017
+ id: r.id,
1018
+ project: r.project,
1019
+ slug: r.slug,
1020
+ title: r.title,
1021
+ kind: r.kind,
1022
+ area: r.area ?? null,
1023
+ states: parseJson(r.states, []),
1024
+ asks: parseJson(r.asks, []),
1025
+ widths: parseJson(r.widths, []),
1026
+ archivedAt: r.archivedAt ?? null,
1027
+ updatedAt: r.updatedAt,
1028
+ })),
1029
+ variants: this.sql
1030
+ .exec("SELECT designId, state, variant, description, status FROM posts")
1031
+ .toArray()
1032
+ .map((r) => ({
1033
+ designId: r.designId,
1034
+ state: r.state ?? null,
1035
+ variant: r.variant,
1036
+ description: r.description ?? null,
1037
+ status: r.status,
1038
+ })),
1039
+ };
589
1040
  }
590
- async listMocks(project) {
1041
+ async listDesigns(project) {
591
1042
  const rows = project === undefined
592
- ? this.sql.exec("SELECT * FROM mocks ORDER BY updatedAt DESC, rowid DESC").toArray()
1043
+ ? this.sql.exec("SELECT * FROM designs ORDER BY updatedAt DESC, rowid DESC").toArray()
593
1044
  : this.sql
594
- .exec("SELECT * FROM mocks WHERE project = ? ORDER BY updatedAt DESC, rowid DESC", project)
1045
+ .exec("SELECT * FROM designs WHERE project = ? ORDER BY updatedAt DESC, rowid DESC", project)
595
1046
  .toArray();
596
- return rows.map((r) => this.rowToMock(r));
1047
+ return rows.map((r) => this.rowToDesign(r));
597
1048
  }
598
- async getMock(id) {
599
- const rows = this.sql.exec("SELECT * FROM mocks WHERE id = ?", id).toArray();
600
- return rows.length > 0 ? this.rowToMock(rows[0]) : null;
1049
+ async getDesign(id) {
1050
+ const rows = this.sql.exec("SELECT * FROM designs WHERE id = ?", id).toArray();
1051
+ return rows.length > 0 ? this.rowToDesign(rows[0]) : null;
601
1052
  }
602
- async findMock(project, slug) {
1053
+ async findDesign(project, slug) {
603
1054
  const rows = this.sql
604
- .exec("SELECT * FROM mocks WHERE project = ? AND slug = ?", project, slug)
1055
+ .exec("SELECT * FROM designs WHERE project = ? AND slug = ?", project, slug)
605
1056
  .toArray();
606
- return rows.length > 0 ? this.rowToMock(rows[0]) : null;
1057
+ return rows.length > 0 ? this.rowToDesign(rows[0]) : null;
607
1058
  }
608
- async createMock(input) {
1059
+ async createDesign(input) {
609
1060
  const now = new Date().toISOString();
610
- const mock = {
1061
+ const design = {
611
1062
  id: newId(),
612
1063
  project: stripNul(input.project).trim() || DEFAULT_PROJECT,
613
1064
  slug: slugify(stripNul(input.slug)),
614
1065
  title: stripNul(input.title)?.trim() || input.slug,
615
- kind: input.kind === "page" ? "page" : "component",
1066
+ kind: isDesignKind(input.kind) ? input.kind : "idea",
1067
+ area: label(input.area),
1068
+ source: label(input.source),
616
1069
  states: (input.states ?? []).map((s) => stripNul(s)),
617
1070
  asks: [],
618
1071
  knobs: input.knobs ?? {},
619
1072
  widths: input.widths ?? [],
620
1073
  draft: null,
621
1074
  sessionId: input.sessionId ?? null,
622
- resolvedAt: null,
1075
+ shortlist: !!input.shortlist,
1076
+ archivedAt: null,
1077
+ archiveReason: null,
1078
+ notes: null,
1079
+ stateOutlines: {},
1080
+ answerAdditions: [],
1081
+ standings: {},
1082
+ sets: [],
623
1083
  createdAt: now,
624
1084
  updatedAt: now,
625
1085
  };
626
- this.sql.exec("INSERT INTO mocks (id, project, slug, title, kind, states, asks, knobs, widths, draft, sessionId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, '[]', ?, ?, NULL, ?, ?, ?)", mock.id, mock.project, mock.slug, mock.title, mock.kind, JSON.stringify(mock.states), JSON.stringify(mock.knobs), JSON.stringify(mock.widths), mock.sessionId, mock.createdAt, mock.updatedAt);
627
- return mock;
1086
+ this.sql.exec("INSERT INTO designs (id, project, slug, title, kind, area, source, states, asks, knobs, widths, draft, sessionId, shortlist, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, NULL, ?, ?, ?, ?)", design.id, design.project, design.slug, design.title, design.kind, design.area, design.source, JSON.stringify(design.states), JSON.stringify(design.knobs), JSON.stringify(design.widths), design.sessionId, design.shortlist ? 1 : 0, design.createdAt, design.updatedAt);
1087
+ return design;
628
1088
  }
629
- async updateMock(id, patch) {
630
- const mock = await this.getMock(id);
631
- if (!mock)
1089
+ async updateDesign(id, patch) {
1090
+ const design = await this.getDesign(id);
1091
+ if (!design)
632
1092
  return null;
633
1093
  const next = {
634
- ...mock,
635
- ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || mock.title }),
1094
+ ...design,
1095
+ ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || design.title }),
636
1096
  ...(patch.kind !== undefined && { kind: patch.kind }),
1097
+ ...(patch.area !== undefined && { area: label(patch.area) }),
1098
+ ...(patch.source !== undefined && { source: label(patch.source) }),
637
1099
  ...(patch.states !== undefined && { states: patch.states.map((s) => stripNul(s)) }),
638
1100
  ...(patch.asks !== undefined && { asks: patch.asks }),
639
1101
  ...(patch.knobs !== undefined && { knobs: patch.knobs }),
640
1102
  ...(patch.widths !== undefined && { widths: patch.widths }),
641
1103
  ...(patch.sessionId !== undefined && { sessionId: patch.sessionId }),
642
- ...(patch.resolvedAt !== undefined && { resolvedAt: patch.resolvedAt }),
1104
+ ...(patch.archivedAt !== undefined && { archivedAt: patch.archivedAt }),
1105
+ ...(patch.archivedAt === null && { archiveReason: null }),
1106
+ ...(patch.archiveReason !== undefined && { archiveReason: patch.archiveReason }),
1107
+ ...(patch.notes !== undefined && { notes: patch.notes }),
1108
+ ...(patch.stateOutlines !== undefined && { stateOutlines: patch.stateOutlines }),
1109
+ ...(patch.answerAdditions !== undefined && { answerAdditions: patch.answerAdditions }),
1110
+ ...(patch.standings !== undefined && { standings: patch.standings }),
1111
+ ...(patch.sets !== undefined && { sets: patch.sets }),
643
1112
  updatedAt: new Date().toISOString(),
644
1113
  };
645
- this.sql.exec("UPDATE mocks SET title = ?, kind = ?, states = ?, asks = ?, knobs = ?, widths = ?, sessionId = ?, resolvedAt = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, JSON.stringify(next.states), JSON.stringify(next.asks), JSON.stringify(next.knobs), JSON.stringify(next.widths), next.sessionId, next.resolvedAt, next.updatedAt, id);
1114
+ this.sql.exec("UPDATE designs SET title = ?, kind = ?, area = ?, source = ?, states = ?, asks = ?, knobs = ?, widths = ?, sessionId = ?, archivedAt = ?, archiveReason = ?, notes = ?, stateOutlines = ?, answerAdditions = ?, standings = ?, sets = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, next.area, next.source, JSON.stringify(next.states), JSON.stringify(next.asks), JSON.stringify(next.knobs), JSON.stringify(next.widths), next.sessionId, next.archivedAt, next.archiveReason, next.notes ? JSON.stringify(next.notes) : null, JSON.stringify(next.stateOutlines), JSON.stringify(next.answerAdditions), JSON.stringify(next.standings), JSON.stringify(next.sets), next.updatedAt, id);
646
1115
  if (patch.asks !== undefined)
647
1116
  this.invalidateAssetRefs();
648
1117
  return next;
649
1118
  }
650
- async removeMock(id) {
651
- const mock = await this.getMock(id);
652
- if (!mock)
1119
+ async removeDesign(id) {
1120
+ const design = await this.getDesign(id);
1121
+ if (!design)
653
1122
  return false;
654
1123
  this.atomic(() => {
655
- this.sql.exec("DELETE FROM comments WHERE mockId = ?", id);
656
- this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE mockId = ?)", id);
657
- this.sql.exec("DELETE FROM posts WHERE mockId = ?", id);
658
- this.sql.exec("DELETE FROM mocks WHERE id = ?", id);
659
- for (const flow of this.flowsOf(mock.project)) {
660
- const steps = flow.steps.filter((x) => x.mock !== mock.slug);
1124
+ this.sql.exec("DELETE FROM comments WHERE designId = ?", id);
1125
+ this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE designId = ?)", id);
1126
+ this.sql.exec("DELETE FROM posts WHERE designId = ?", id);
1127
+ this.sql.exec("DELETE FROM designs WHERE id = ?", id);
1128
+ for (const flow of this.flowsOf(design.project)) {
1129
+ const steps = flow.steps.filter((x) => x.design !== design.slug);
661
1130
  if (steps.length !== flow.steps.length)
662
1131
  this.writeFlow(flow, steps);
663
1132
  }
@@ -665,6 +1134,124 @@ export class SqlStore {
665
1134
  this.invalidateAssetRefs();
666
1135
  return true;
667
1136
  }
1137
+ async nameFirstState(id, state) {
1138
+ const name = stripNul(state);
1139
+ const design = await this.getDesign(id);
1140
+ if (!design || design.states.length > 0)
1141
+ return null;
1142
+ const updatedAt = new Date().toISOString();
1143
+ this.atomic(() => {
1144
+ this.sql.exec("UPDATE posts SET state = ? WHERE designId = ? AND state IS NULL", name, id);
1145
+ this.sql.exec("UPDATE designs SET states = ?, updatedAt = ? WHERE id = ?", JSON.stringify([name]), updatedAt, id);
1146
+ for (const r of this.sql
1147
+ .exec("SELECT id, copiedFrom FROM posts WHERE copiedFrom IS NOT NULL AND designId IN (SELECT id FROM designs WHERE project = ?)", design.project)
1148
+ .toArray()) {
1149
+ const from = parseJson(r.copiedFrom, null);
1150
+ if (from?.design !== id || from.state !== null)
1151
+ continue;
1152
+ this.sql.exec("UPDATE posts SET copiedFrom = ? WHERE id = ?", JSON.stringify({ ...from, state: name }), r.id);
1153
+ }
1154
+ for (const flow of this.flowsOf(design.project)) {
1155
+ if (!flow.steps.some((x) => x.design === design.slug && x.state === null))
1156
+ continue;
1157
+ this.writeFlow(flow, flow.steps.map((x) => x.design === design.slug && x.state === null ? { ...x, state: name } : x));
1158
+ }
1159
+ if (design.draft) {
1160
+ const comments = design.draft.comments.map((c) => c.state === null ? { ...c, state: name } : c);
1161
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify({ ...design.draft, comments }), id);
1162
+ }
1163
+ });
1164
+ // Copies elsewhere changed too, so nothing cached can be patched one post at a time.
1165
+ this.postCache.clear();
1166
+ this.postCacheBytes = 0;
1167
+ return this.getDesign(id);
1168
+ }
1169
+ async renameDesign(id, slug) {
1170
+ const to = slugify(stripNul(slug));
1171
+ return this.atomic(() => {
1172
+ const rows = this.sql.exec("SELECT * FROM designs WHERE id = ?", id).toArray();
1173
+ if (rows.length === 0)
1174
+ return null;
1175
+ const design = this.rowToDesign(rows[0]);
1176
+ const from = design.slug;
1177
+ if (to === from)
1178
+ return design;
1179
+ const taken = this.sql
1180
+ .exec("SELECT id FROM designs WHERE project = ? AND slug = ?", design.project, to)
1181
+ .toArray();
1182
+ if (taken.length > 0)
1183
+ return null;
1184
+ const updatedAt = new Date().toISOString();
1185
+ this.sql.exec("UPDATE designs SET slug = ?, updatedAt = ? WHERE id = ?", to, updatedAt, id);
1186
+ // History is rewritten too: embeds resolve by slug at render time, so an
1187
+ // old version left naming the old slug would show its components missing.
1188
+ const rewrite = (raw) => {
1189
+ let changed = false;
1190
+ const next = parseJson(raw, []).map((x) => {
1191
+ if (x.kind !== "html")
1192
+ return x;
1193
+ const html = renameSlugRefs(x.html, from, to);
1194
+ if (html === x.html)
1195
+ return x;
1196
+ changed = true;
1197
+ return { ...x, html };
1198
+ });
1199
+ return changed ? JSON.stringify(next) : null;
1200
+ };
1201
+ const posts = this.sql
1202
+ .exec("SELECT id, surfaces, slots FROM posts WHERE designId IN (SELECT id FROM designs WHERE project = ?)", design.project)
1203
+ .toArray();
1204
+ for (const r of posts) {
1205
+ const postId = r.id;
1206
+ const surfaces = rewrite(r.surfaces);
1207
+ if (surfaces)
1208
+ this.sql.exec("UPDATE posts SET surfaces = ? WHERE id = ?", surfaces, postId);
1209
+ const embeds = parseJson(r.slots, []);
1210
+ if (embeds.some((e) => e.slug === from)) {
1211
+ this.sql.exec("UPDATE posts SET slots = ? WHERE id = ?", JSON.stringify(embeds.map((e) => (e.slug === from ? { ...e, slug: to } : e))), postId);
1212
+ }
1213
+ for (const v of this.sql
1214
+ .exec("SELECT version, surfaces FROM post_versions WHERE postId = ?", postId)
1215
+ .toArray()) {
1216
+ const old = rewrite(v.surfaces);
1217
+ if (!old)
1218
+ continue;
1219
+ this.sql.exec("UPDATE post_versions SET surfaces = ? WHERE postId = ? AND version = ?", old, postId, v.version);
1220
+ }
1221
+ }
1222
+ for (const flow of this.flowsOf(design.project)) {
1223
+ if (!flow.steps.some((x) => x.design === from))
1224
+ continue;
1225
+ this.writeFlow(flow, flow.steps.map((x) => (x.design === from ? { ...x, design: to } : x)));
1226
+ }
1227
+ return { ...design, slug: to, updatedAt };
1228
+ });
1229
+ }
1230
+ async renameProject(from, to) {
1231
+ const target = stripNul(to).trim();
1232
+ return this.atomic(() => {
1233
+ const occupied = this.sql
1234
+ .exec("SELECT COUNT(*) AS n FROM designs WHERE project = ?", target)
1235
+ .one().n;
1236
+ if (occupied > 0)
1237
+ return false;
1238
+ // A project with no designs has nothing a leftover flow could walk.
1239
+ this.sql.exec("DELETE FROM flows WHERE project = ?", target);
1240
+ this.sql.exec("UPDATE designs SET project = ? WHERE project = ?", target, from);
1241
+ this.sql.exec("UPDATE sessions SET project = ? WHERE project = ?", target, from);
1242
+ this.sql.exec("UPDATE flows SET project = ? WHERE project = ?", target, from);
1243
+ for (const prefix of PROJECT_SETTINGS) {
1244
+ const value = this.settingSync(`${prefix}:${from}`);
1245
+ if (value === null)
1246
+ continue;
1247
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", `${prefix}:${target}`, value);
1248
+ this.sql.exec("DELETE FROM settings WHERE key = ?", `${prefix}:${from}`);
1249
+ if (prefix === "design")
1250
+ this.invalidateAssetRefs();
1251
+ }
1252
+ return true;
1253
+ });
1254
+ }
668
1255
  // --- flows ---
669
1256
  flowsOf(project) {
670
1257
  return this.sql
@@ -692,7 +1279,7 @@ export class SqlStore {
692
1279
  return this.flowsOf(project);
693
1280
  }
694
1281
  async placeFlowStep(project, name, screen, step) {
695
- const clean = { mock: stripNul(screen.mock), state: stripNul(screen.state) };
1282
+ const clean = { design: stripNul(screen.design), state: stripNul(screen.state) };
696
1283
  const label = stripNul(name);
697
1284
  return this.atomic(() => {
698
1285
  const now = new Date().toISOString();
@@ -711,24 +1298,27 @@ export class SqlStore {
711
1298
  return this.writeFlow(flow, steps);
712
1299
  });
713
1300
  }
714
- async putDraft(mockId, draft) {
715
- const mock = await this.getMock(mockId);
716
- if (!mock)
1301
+ async putDraft(designId, draft) {
1302
+ const design = await this.getDesign(designId);
1303
+ if (!design)
717
1304
  return null;
718
- this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, mockId);
1305
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, designId);
719
1306
  this.invalidateAssetRefs();
720
- return { ...mock, draft };
1307
+ return { ...design, draft };
721
1308
  }
722
- touchMock(mockId, at) {
723
- this.sql.exec("UPDATE mocks SET updatedAt = ? WHERE id = ?", at, mockId);
1309
+ touchDesign(designId, at) {
1310
+ this.sql.exec("UPDATE designs SET updatedAt = ? WHERE id = ?", at, designId);
724
1311
  }
725
1312
  // --- posts ---
726
1313
  async listPosts(query = {}) {
1314
+ if (query.designId !== undefined && query.sessionId === undefined) {
1315
+ return this.postsOfDesign(query.designId).slice();
1316
+ }
727
1317
  const clauses = [];
728
1318
  const params = [];
729
- if (query.mockId !== undefined) {
730
- clauses.push("mockId = ?");
731
- params.push(query.mockId);
1319
+ if (query.designId !== undefined) {
1320
+ clauses.push("designId = ?");
1321
+ params.push(query.designId);
732
1322
  }
733
1323
  if (query.sessionId !== undefined) {
734
1324
  clauses.push("sessionId = ?");
@@ -740,6 +1330,129 @@ export class SqlStore {
740
1330
  .toArray();
741
1331
  return this.rowsToPosts(rows);
742
1332
  }
1333
+ async listPostHeads(project) {
1334
+ const scope = project === undefined ? "" : "WHERE designId IN (SELECT id FROM designs WHERE project = ?)";
1335
+ const args = project === undefined ? [] : [project];
1336
+ const history = new Map();
1337
+ for (const r of this.sql
1338
+ .exec(`SELECT postId, version, head FROM post_versions WHERE postId IN (SELECT id FROM posts ${scope}) ORDER BY version ASC`, ...args)
1339
+ .toArray()) {
1340
+ const id = r.postId;
1341
+ let list = history.get(id);
1342
+ if (!list)
1343
+ history.set(id, (list = []));
1344
+ list.push({ version: r.version, surfaces: parseJson(r.head, []) });
1345
+ }
1346
+ return this.sql
1347
+ .exec(`SELECT ${POST_HEAD_COLUMNS} FROM posts ${scope} ORDER BY createdAt ASC, rowid ASC`, ...args)
1348
+ .toArray()
1349
+ .map((r) => ({
1350
+ ...this.rowToPost(r, []),
1351
+ surfaces: parseJson(r.head, []),
1352
+ history: history.get(r.id) ?? [],
1353
+ }));
1354
+ }
1355
+ // Every open design screen asks, and a project's posts can be hundreds of MB:
1356
+ // the index holds each column read here, so no row with a body is touched.
1357
+ async listCopies(project) {
1358
+ return this.sql
1359
+ .exec("SELECT designId, state, variant, status, copiedFrom FROM posts WHERE copiedFrom IS NOT NULL AND designId IN (SELECT id FROM designs WHERE project = ?)", project)
1360
+ .toArray()
1361
+ .filter((r) => isLive(r.status))
1362
+ .map((r) => ({
1363
+ design: r.designId,
1364
+ state: r.state,
1365
+ variant: r.variant,
1366
+ copiedFrom: parseJson(r.copiedFrom, null),
1367
+ }));
1368
+ }
1369
+ postsOfDesign(designId) {
1370
+ const hit = this.postCache.get(designId);
1371
+ if (hit) {
1372
+ this.postCache.delete(designId);
1373
+ this.postCache.set(designId, hit);
1374
+ return hit.posts;
1375
+ }
1376
+ const rows = this.sql
1377
+ .exec("SELECT * FROM posts WHERE designId = ? ORDER BY createdAt ASC, rowid ASC", designId)
1378
+ .toArray();
1379
+ let bytes = 0;
1380
+ for (const r of rows)
1381
+ bytes += r.surfaces.length;
1382
+ const posts = this.rowsToPosts(rows, (n) => (bytes += n));
1383
+ if (bytes > this.postCacheLimit)
1384
+ return posts;
1385
+ this.postCache.set(designId, { posts, bytes });
1386
+ this.postCacheBytes += bytes;
1387
+ this.trimPostCache();
1388
+ return posts;
1389
+ }
1390
+ trimPostCache() {
1391
+ for (const [id, entry] of this.postCache) {
1392
+ if (this.postCacheBytes <= this.postCacheLimit)
1393
+ break;
1394
+ this.postCache.delete(id);
1395
+ this.postCacheBytes -= entry.bytes;
1396
+ }
1397
+ }
1398
+ // A synchronous write to these posts of one design, re-reading just them after:
1399
+ // a publish into a big lab would otherwise leave the viewer's next read parsing
1400
+ // every design in it.
1401
+ writingPosts(designId, ids, write) {
1402
+ this.holdPostDrop = true;
1403
+ let out;
1404
+ try {
1405
+ out = write();
1406
+ }
1407
+ catch (err) {
1408
+ const entry = this.postCache.get(designId);
1409
+ if (entry) {
1410
+ this.postCache.delete(designId);
1411
+ this.postCacheBytes -= entry.bytes;
1412
+ }
1413
+ throw err;
1414
+ }
1415
+ finally {
1416
+ this.holdPostDrop = false;
1417
+ }
1418
+ const entry = this.postCache.get(designId);
1419
+ if (entry) {
1420
+ const weigh = (p) => p
1421
+ ? [p.surfaces, ...p.history.map((h) => h.surfaces)].reduce((n, x) => n + JSON.stringify(x).length, 0)
1422
+ : 0;
1423
+ for (const id of ids) {
1424
+ const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray();
1425
+ const fresh = rows.length ? this.rowsToPosts(rows)[0] : null;
1426
+ const stale = entry.posts.find((p) => p.id === id);
1427
+ const change = weigh(fresh) - weigh(stale);
1428
+ entry.bytes += change;
1429
+ this.postCacheBytes += change;
1430
+ if (!fresh)
1431
+ entry.posts = entry.posts.filter((p) => p.id !== id);
1432
+ else if (stale)
1433
+ entry.posts = entry.posts.map((p) => (p.id === id ? fresh : p));
1434
+ else
1435
+ entry.posts = [...entry.posts, fresh];
1436
+ }
1437
+ this.trimPostCache();
1438
+ }
1439
+ return out;
1440
+ }
1441
+ cachedPost(id) {
1442
+ for (const { posts } of this.postCache.values()) {
1443
+ const hit = posts.find((p) => p.id === id);
1444
+ if (hit)
1445
+ return hit;
1446
+ }
1447
+ return null;
1448
+ }
1449
+ // For a write that leaves the bodies alone, so the size stands. Archiving
1450
+ // through a lab would otherwise re-read every design in it after each ×.
1451
+ recache(post) {
1452
+ const entry = this.postCache.get(post.design);
1453
+ if (entry)
1454
+ entry.posts = entry.posts.map((p) => (p.id === post.id ? post : p));
1455
+ }
743
1456
  async countPostsBySession() {
744
1457
  const counts = new Map();
745
1458
  for (const row of this.sql
@@ -753,22 +1466,25 @@ export class SqlStore {
753
1466
  const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray();
754
1467
  return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
755
1468
  }
756
- async findPost(mockId, state, variant) {
1469
+ async findPost(designId, state, variant) {
1470
+ const cached = this.postCache.get(designId);
1471
+ if (cached)
1472
+ return cached.posts.find((p) => p.state === state && p.variant === variant) ?? null;
757
1473
  const rows = this.sql
758
- .exec("SELECT * FROM posts WHERE mockId = ? AND state IS ? AND variant = ? ORDER BY createdAt ASC LIMIT 1", mockId, state, variant)
1474
+ .exec("SELECT * FROM posts WHERE designId = ? AND state IS ? AND variant = ? ORDER BY createdAt ASC LIMIT 1", designId, state, variant)
759
1475
  .toArray();
760
1476
  return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
761
1477
  }
762
1478
  async createPost(input) {
763
1479
  if (!(await this.getSession(input.sessionId)))
764
1480
  return null;
765
- if (!(await this.getMock(input.mock)))
1481
+ if (!(await this.getDesign(input.design)))
766
1482
  return null;
767
1483
  const now = new Date().toISOString();
768
1484
  const post = {
769
1485
  id: newId(),
770
1486
  sessionId: input.sessionId,
771
- mock: input.mock,
1487
+ design: input.design,
772
1488
  state: input.state === null ? null : stripNul(input.state),
773
1489
  variant: stripNul(input.variant)?.trim() || DEFAULT_VARIANT,
774
1490
  status: "open",
@@ -779,16 +1495,20 @@ export class SqlStore {
779
1495
  version: 1,
780
1496
  history: [],
781
1497
  ...(input.knobs ? { knobs: input.knobs } : {}),
782
- ...(stripNul(input.note ?? "").trim() ? { note: stripNul(input.note ?? "").trim() } : {}),
1498
+ ...(stripNul(input.description ?? "").trim()
1499
+ ? { description: stripNul(input.description ?? "").trim() }
1500
+ : {}),
783
1501
  embeds: input.embeds ?? [],
784
1502
  ...(input.forkedFrom ? { forkedFrom: input.forkedFrom } : {}),
1503
+ ...(input.copiedFrom ? { copiedFrom: input.copiedFrom } : {}),
785
1504
  ...(input.from === undefined ? {} : { from: input.from }),
786
1505
  ...(input.prompt === undefined ? {} : { prompt: stripNul(input.prompt) }),
787
1506
  ...(input.author === undefined ? {} : { author: stripNul(input.author) }),
1507
+ publishedBy: input.publishedBy ?? input.sessionId,
788
1508
  };
789
- this.sql.exec("INSERT INTO posts (id, sessionId, mockId, state, variant, status, title, surfaces, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor, note, forkedFrom) 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.embeds), post.createdAt, post.updatedAt, post.from ?? null, post.prompt ?? null, post.author ?? null, post.note ?? null, post.forkedFrom ? JSON.stringify(post.forkedFrom) : null);
1509
+ this.writingPosts(post.design, [post.id], () => this.sql.exec("INSERT INTO posts (id, sessionId, designId, state, variant, status, title, surfaces, head, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor, description, forkedFrom, copiedFrom, curSession) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)", post.id, post.sessionId, post.design, post.state, post.variant, post.title, JSON.stringify(post.surfaces), surfaceHeads(post.surfaces), post.knobs ? JSON.stringify(post.knobs) : null, JSON.stringify(post.embeds), post.createdAt, post.updatedAt, post.from ?? null, post.prompt ?? null, post.author ?? null, post.description ?? null, post.forkedFrom ? JSON.stringify(post.forkedFrom) : null, post.copiedFrom ? JSON.stringify(post.copiedFrom) : null, post.publishedBy ?? null));
790
1510
  this.touch(input.sessionId);
791
- this.touchMock(post.mock, now);
1511
+ this.touchDesign(post.design, now);
792
1512
  this.addAssetRefs(post.surfaces);
793
1513
  return post;
794
1514
  }
@@ -807,6 +1527,8 @@ export class SqlStore {
807
1527
  ...(post.from === undefined ? {} : { from: post.from }),
808
1528
  ...(post.prompt === undefined ? {} : { prompt: post.prompt }),
809
1529
  ...(post.author === undefined ? {} : { author: post.author }),
1530
+ ...(post.publishedBy === undefined ? {} : { publishedBy: post.publishedBy }),
1531
+ ...(post.amendedAt === undefined ? {} : { amendedAt: post.amendedAt }),
810
1532
  };
811
1533
  // The version in use is kept past the limit: export and embeds resolve to it.
812
1534
  const pin = post.acceptedVersion ?? -1;
@@ -817,7 +1539,9 @@ export class SqlStore {
817
1539
  const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
818
1540
  const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
819
1541
  const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
820
- const note = patch.note === undefined ? post.note : stripNul(patch.note).trim() || undefined;
1542
+ const description = patch.description === undefined
1543
+ ? post.description
1544
+ : stripNul(patch.description).trim() || undefined;
821
1545
  const embeds = patch.embeds ?? post.embeds;
822
1546
  const state = patch.state === undefined ? post.state : patch.state;
823
1547
  const version = post.version + 1;
@@ -826,22 +1550,27 @@ export class SqlStore {
826
1550
  const from = patch.from ?? post.version;
827
1551
  const prompt = stripNul(patch.prompt ?? "");
828
1552
  const author = patch.author === undefined ? null : stripNul(patch.author);
829
- this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, knobs = ?, note = ?, slots = ?, state = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ? WHERE id = ? AND version = ?", title, JSON.stringify(surfaces), knobs ? JSON.stringify(knobs) : null, note ?? null, JSON.stringify(embeds), state, updatedAt, version, from, prompt, author, id, post.version);
830
- const affected = this.sql.exec("SELECT changes() AS n").one().n;
831
- if (affected > 0) {
1553
+ const publishedBy = patch.publishedBy ?? null;
1554
+ const wrote = this.writingPosts(post.design, [id], () => {
1555
+ this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, head = ?, knobs = ?, description = ?, slots = ?, state = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ?, curSession = ?, amendedAt = NULL WHERE id = ? AND version = ?", title, JSON.stringify(surfaces), surfaceHeads(surfaces), knobs ? JSON.stringify(knobs) : null, description ?? null, JSON.stringify(embeds), state, updatedAt, version, from, prompt, author, publishedBy, id, post.version);
1556
+ if (this.sql.exec("SELECT changes() AS n").one().n === 0)
1557
+ return false;
832
1558
  this.insertVersion(id, archived);
833
1559
  this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version <= ? AND version != ?", id, version - 1 - HISTORY_LIMIT, pin);
1560
+ return true;
1561
+ });
1562
+ if (wrote) {
834
1563
  this.touch(post.sessionId);
835
- this.touchMock(post.mock, updatedAt);
1564
+ this.touchDesign(post.design, updatedAt);
836
1565
  if (patch.surfaces !== undefined)
837
1566
  this.addAssetRefs(surfaces);
838
- const { knobs: _knobs, note: _note, author: _author, ...rest } = post;
1567
+ const { knobs: _knobs, description: _description, author: _author, publishedBy: _by, amendedAt: _amended, ...rest } = post;
839
1568
  return {
840
1569
  ...rest,
841
1570
  title,
842
1571
  surfaces,
843
1572
  ...(knobs ? { knobs } : {}),
844
- ...(note ? { note } : {}),
1573
+ ...(description ? { description } : {}),
845
1574
  embeds,
846
1575
  state,
847
1576
  version,
@@ -850,11 +1579,116 @@ export class SqlStore {
850
1579
  from,
851
1580
  prompt,
852
1581
  ...(author === null ? {} : { author }),
1582
+ ...(publishedBy === null ? {} : { publishedBy }),
853
1583
  };
854
1584
  }
855
1585
  }
856
1586
  return null;
857
1587
  }
1588
+ async amendPost(id, version, patch) {
1589
+ const post = await this.getPost(id);
1590
+ if (!post || post.version !== version)
1591
+ return null;
1592
+ const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
1593
+ const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
1594
+ const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
1595
+ const description = patch.description === undefined
1596
+ ? post.description
1597
+ : stripNul(patch.description).trim() || undefined;
1598
+ const embeds = patch.embeds ?? post.embeds;
1599
+ const from = patch.from ?? post.from;
1600
+ const prompt = patch.prompt === undefined ? post.prompt : stripNul(patch.prompt);
1601
+ const author = patch.author === undefined ? post.author : stripNul(patch.author);
1602
+ const amendedAt = new Date().toISOString();
1603
+ const rewrites = (post.rewrites ?? 0) + 1;
1604
+ const wrote = this.writingPosts(post.design, [id], () => {
1605
+ this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, head = ?, knobs = ?, description = ?, slots = ?, curFrom = ?, curPrompt = ?, curAuthor = ?, amendedAt = ?, rewrites = ? WHERE id = ? AND version = ?", title, JSON.stringify(surfaces), surfaceHeads(surfaces), knobs ? JSON.stringify(knobs) : null, description ?? null, JSON.stringify(embeds), from ?? null, prompt ?? null, author ?? null, amendedAt, rewrites, id, version);
1606
+ return this.sql.exec("SELECT changes() AS n").one().n > 0;
1607
+ });
1608
+ if (!wrote)
1609
+ return null;
1610
+ this.touch(post.sessionId);
1611
+ this.touchDesign(post.design, amendedAt);
1612
+ // The surfaces it replaced may have held the only reference to an asset.
1613
+ this.invalidateAssetRefs();
1614
+ const { knobs: _knobs, description: _description, from: _from, prompt: _prompt, author: _author, ...rest } = post;
1615
+ return {
1616
+ ...rest,
1617
+ title,
1618
+ surfaces,
1619
+ ...(knobs ? { knobs } : {}),
1620
+ ...(description ? { description } : {}),
1621
+ embeds,
1622
+ ...(from === undefined ? {} : { from }),
1623
+ ...(prompt === undefined ? {} : { prompt }),
1624
+ ...(author === undefined ? {} : { author }),
1625
+ amendedAt,
1626
+ rewrites,
1627
+ };
1628
+ }
1629
+ async setPosterOnce(id, version, poster, mode) {
1630
+ const post = await this.getPost(id);
1631
+ if (!post || post.version !== version)
1632
+ return false;
1633
+ const at = post.surfaces.findIndex((s) => s.kind === "html");
1634
+ const page = post.surfaces[at];
1635
+ if (!page || page.kind !== "html" || page.poster)
1636
+ return false;
1637
+ const surfaces = post.surfaces.map((s, i) => i === at ? { ...page, poster, ...(mode ? { posterMode: mode } : {}) } : s);
1638
+ const wrote = this.writingPosts(post.design, [id], () => {
1639
+ this.sql.exec("UPDATE posts SET surfaces = ?, head = ? WHERE id = ? AND version = ?", JSON.stringify(surfaces), surfaceHeads(surfaces), id, version);
1640
+ return this.sql.exec("SELECT changes() AS n").one().n > 0;
1641
+ });
1642
+ if (wrote)
1643
+ this.invalidateAssetRefs();
1644
+ return wrote;
1645
+ }
1646
+ async withdrawVersion(id, version, embeds) {
1647
+ const post = await this.getPost(id);
1648
+ if (!post || post.version !== version)
1649
+ return null;
1650
+ const previous = post.history.at(-1);
1651
+ const now = new Date().toISOString();
1652
+ if (!previous) {
1653
+ this.writingPosts(post.design, [id], () => this.atomic(() => {
1654
+ // Only the agent can have written on a version the user never touched;
1655
+ // its messages stay in the design's thread.
1656
+ this.sql.exec("UPDATE comments SET postId = NULL WHERE postId = ?", id);
1657
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ?", id);
1658
+ this.sql.exec("DELETE FROM posts WHERE id = ?", id);
1659
+ }));
1660
+ this.touchDesign(post.design, now);
1661
+ this.invalidateAssetRefs();
1662
+ return { post: null };
1663
+ }
1664
+ const rewrites = (post.rewrites ?? 0) + 1;
1665
+ const hidden = (post.hiddenVersions ?? []).filter((v) => v !== version);
1666
+ this.writingPosts(post.design, [id], () => this.atomic(() => {
1667
+ this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, head = ?, slots = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ?, curSession = ?, amendedAt = ?, rewrites = ?, hiddenVersions = ? WHERE id = ? AND version = ?", previous.title, JSON.stringify(previous.surfaces), surfaceHeads(previous.surfaces), JSON.stringify(embeds), previous.at, previous.version, previous.from ?? null, previous.prompt ?? null, previous.author ?? null, previous.publishedBy ?? null, previous.amendedAt ?? null, rewrites, hidden.length ? JSON.stringify(hidden) : null, id, version);
1668
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version = ?", id, previous.version);
1669
+ }));
1670
+ this.touchDesign(post.design, now);
1671
+ this.invalidateAssetRefs();
1672
+ const { from: _from, prompt: _prompt, author: _author, publishedBy: _by, amendedAt: _amended, hiddenVersions: _hidden, ...rest } = post;
1673
+ return {
1674
+ post: {
1675
+ ...rest,
1676
+ title: previous.title,
1677
+ surfaces: previous.surfaces,
1678
+ embeds,
1679
+ updatedAt: previous.at,
1680
+ version: previous.version,
1681
+ history: post.history.slice(0, -1),
1682
+ ...(previous.from === undefined ? {} : { from: previous.from }),
1683
+ ...(previous.prompt === undefined ? {} : { prompt: previous.prompt }),
1684
+ ...(previous.author === undefined ? {} : { author: previous.author }),
1685
+ ...(previous.publishedBy === undefined ? {} : { publishedBy: previous.publishedBy }),
1686
+ ...(previous.amendedAt === undefined ? {} : { amendedAt: previous.amendedAt }),
1687
+ ...(hidden.length ? { hiddenVersions: hidden } : {}),
1688
+ rewrites,
1689
+ },
1690
+ };
1691
+ }
858
1692
  async removePost(id) {
859
1693
  if (!(await this.getPost(id)))
860
1694
  return false;
@@ -867,24 +1701,61 @@ export class SqlStore {
867
1701
  return true;
868
1702
  }
869
1703
  async setPostStatus(id, status, acceptedVersion) {
870
- const post = await this.getPost(id);
1704
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
871
1705
  if (!post)
872
1706
  return null;
873
1707
  const pin = status === "accepted" ? (acceptedVersion ?? post.version) : null;
874
- this.sql.exec("UPDATE posts SET status = ?, acceptedVersion = ? WHERE id = ?", status, pin, id);
875
- const { acceptedVersion: _, ...rest } = post;
876
- return pin === null ? { ...rest, status } : { ...rest, status, acceptedVersion: pin };
1708
+ // A reason says why it left, and a stay was about the version once in use.
1709
+ const reason = status === "archived" ? (post.archiveReason ?? null) : null;
1710
+ // Archiving what's already archived keeps the day it went.
1711
+ const archivedAt = status === "archived"
1712
+ ? ((post.status === "archived" ? post.archivedAt : undefined) ?? new Date().toISOString())
1713
+ : null;
1714
+ this.raw.exec("UPDATE posts SET status = ?, acceptedVersion = ?, archiveReason = ?, stayedVersion = NULL, archivedAt = ? WHERE id = ?", status, pin, reason, archivedAt, id);
1715
+ const { acceptedVersion: _, archiveReason: _reason, stayedVersion: _stayed, archivedAt: _archivedAt, ...rest } = post;
1716
+ const next = {
1717
+ ...rest,
1718
+ status,
1719
+ ...(pin === null ? {} : { acceptedVersion: pin }),
1720
+ ...(reason === null ? {} : { archiveReason: reason }),
1721
+ ...(archivedAt === null ? {} : { archivedAt }),
1722
+ };
1723
+ this.recache(next);
1724
+ return pin !== null && next.hiddenVersions?.includes(pin)
1725
+ ? this.setVersionHidden(id, pin, false)
1726
+ : next;
877
1727
  }
878
- async setPostStarred(id, starred) {
879
- const post = await this.getPost(id);
1728
+ async setPostArchived(id, archivedAt, reason) {
1729
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1730
+ if (!post)
1731
+ return null;
1732
+ this.raw.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL, archiveReason = ?, stayedVersion = NULL, archivedAt = ? WHERE id = ?", reason, archivedAt, id);
1733
+ const { acceptedVersion: _, archiveReason: _reason, stayedVersion: _stayed, ...rest } = post;
1734
+ const next = {
1735
+ ...rest,
1736
+ status: "archived",
1737
+ archivedAt,
1738
+ ...(reason === null ? {} : { archiveReason: reason }),
1739
+ };
1740
+ this.recache(next);
1741
+ return next;
1742
+ }
1743
+ async setForkedFrom(id, from) {
1744
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
880
1745
  if (!post)
881
1746
  return null;
882
- this.sql.exec("UPDATE posts SET starred = ? WHERE id = ?", starred ? 1 : 0, id);
883
- const { starred: _, ...rest } = post;
884
- return starred ? { ...rest, starred: true } : rest;
1747
+ this.raw.exec("UPDATE posts SET forkedFrom = ? WHERE id = ?", from ? JSON.stringify(from) : null, id);
1748
+ const { forkedFrom: _, ...rest } = post;
1749
+ const next = from ? { ...rest, forkedFrom: from } : rest;
1750
+ this.recache(next);
1751
+ return next;
1752
+ }
1753
+ async bumpRewrites(designId) {
1754
+ const ids = (await this.listPosts({ designId })).map((p) => p.id);
1755
+ this.writingPosts(designId, ids, () => this.sql.exec("UPDATE posts SET rewrites = rewrites + 1 WHERE designId = ?", designId));
885
1756
  }
886
1757
  async setVersionHidden(id, version, hidden) {
887
- const post = await this.getPost(id);
1758
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
888
1759
  if (!post)
889
1760
  return null;
890
1761
  const set = new Set(post.hiddenVersions ?? []);
@@ -893,9 +1764,46 @@ export class SqlStore {
893
1764
  else
894
1765
  set.delete(version);
895
1766
  const list = [...set].sort((a, b) => a - b);
896
- this.sql.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", list.length ? JSON.stringify(list) : null, id);
1767
+ this.raw.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", list.length ? JSON.stringify(list) : null, id);
897
1768
  const { hiddenVersions: _, ...rest } = post;
898
- return list.length ? { ...rest, hiddenVersions: list } : rest;
1769
+ const next = list.length ? { ...rest, hiddenVersions: list } : rest;
1770
+ this.recache(next);
1771
+ return next;
1772
+ }
1773
+ async deleteOldVersions(id, versions) {
1774
+ const post = await this.getPost(id);
1775
+ if (!post)
1776
+ return null;
1777
+ const gone = new Set(versions.filter((v) => v !== post.version));
1778
+ const hidden = (post.hiddenVersions ?? []).filter((v) => !gone.has(v));
1779
+ this.writingPosts(post.design, [id], () => this.atomic(() => {
1780
+ for (const v of gone) {
1781
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version = ?", id, v);
1782
+ }
1783
+ this.sql.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", hidden.length ? JSON.stringify(hidden) : null, id);
1784
+ }));
1785
+ this.touchDesign(post.design, new Date().toISOString());
1786
+ this.invalidateAssetRefs();
1787
+ return this.getPost(id);
1788
+ }
1789
+ async setPostReason(id, reason) {
1790
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1791
+ if (!post)
1792
+ return null;
1793
+ this.raw.exec("UPDATE posts SET archiveReason = ? WHERE id = ?", reason, id);
1794
+ const { archiveReason: _, ...rest } = post;
1795
+ const next = reason ? { ...rest, archiveReason: reason } : rest;
1796
+ this.recache(next);
1797
+ return next;
1798
+ }
1799
+ async setPostStayed(id, version) {
1800
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1801
+ if (!post)
1802
+ return null;
1803
+ this.raw.exec("UPDATE posts SET stayedVersion = ? WHERE id = ?", version, id);
1804
+ const next = { ...post, stayedVersion: version };
1805
+ this.recache(next);
1806
+ return next;
899
1807
  }
900
1808
  // --- comments ---
901
1809
  async listComments(query) {
@@ -905,9 +1813,9 @@ export class SqlStore {
905
1813
  clauses.push("sessionId = ?");
906
1814
  params.push(query.sessionId);
907
1815
  }
908
- if (query.mockId !== undefined) {
909
- clauses.push("mockId = ?");
910
- params.push(query.mockId);
1816
+ if (query.designId !== undefined) {
1817
+ clauses.push("designId = ?");
1818
+ params.push(query.designId);
911
1819
  }
912
1820
  if (query.postId !== undefined) {
913
1821
  clauses.push("postId = ?");
@@ -928,7 +1836,7 @@ export class SqlStore {
928
1836
  const comment = {
929
1837
  id: newId(),
930
1838
  sessionId: input.sessionId,
931
- mockId: input.mockId ?? null,
1839
+ designId: input.designId ?? null,
932
1840
  postId: input.postId ?? null,
933
1841
  author: stripNul(input.author).trim() || "user",
934
1842
  text: stripNul(input.text),
@@ -940,7 +1848,7 @@ export class SqlStore {
940
1848
  viewport: input.viewport ?? null,
941
1849
  ...(input.payload ? { payload: input.payload } : {}),
942
1850
  };
943
- 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);
1851
+ this.sql.exec("INSERT INTO comments (id, sessionId, designId, postId, author, text, createdAt, kind, anchor, anchors, postVersion, viewport, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", comment.id, comment.sessionId, comment.designId, 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);
944
1852
  const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq;
945
1853
  this.touch(input.sessionId);
946
1854
  return { ...comment, seq };
@@ -952,7 +1860,7 @@ export class SqlStore {
952
1860
  return this.insertComment({
953
1861
  ...input,
954
1862
  postId: post?.id ?? null,
955
- mockId: input.mockId ?? post?.mock ?? null,
1863
+ designId: input.designId ?? post?.design ?? null,
956
1864
  postVersion: input.postVersion ?? post?.version ?? null,
957
1865
  });
958
1866
  }
@@ -967,15 +1875,41 @@ export class SqlStore {
967
1875
  this.touch(comment.sessionId);
968
1876
  return comment;
969
1877
  }
1878
+ async listDecisionSources(project) {
1879
+ const variants = this.sql
1880
+ .exec("SELECT designId, state, variant, status, version, acceptedVersion FROM posts WHERE designId IN (SELECT id FROM designs WHERE project = ?) ORDER BY createdAt ASC, rowid ASC", project)
1881
+ .toArray()
1882
+ .map((r) => ({
1883
+ designId: r.designId,
1884
+ state: r.state ?? null,
1885
+ variant: r.variant,
1886
+ status: r.status,
1887
+ version: r.version,
1888
+ acceptedVersion: r.acceptedVersion ?? null,
1889
+ }));
1890
+ // A close or a move from Home is a reply too, and decides nothing.
1891
+ const decidedAt = {};
1892
+ for (const r of this.sql
1893
+ .exec(`SELECT designId, MAX(createdAt) AS at FROM comments
1894
+ WHERE kind = 'reply' AND designId IN (SELECT id FROM designs WHERE project = ?)
1895
+ AND json_extract(payload, '$.archived') IS NULL
1896
+ AND json_extract(payload, '$.edited') IS NULL
1897
+ GROUP BY designId`, project)
1898
+ .toArray()) {
1899
+ decidedAt[r.designId] = r.at;
1900
+ }
1901
+ return { variants, decidedAt };
1902
+ }
970
1903
  async commitReply(input) {
971
1904
  if (!(await this.getSession(input.sessionId)))
972
1905
  return null;
973
- if (!(await this.getMock(input.mockId)))
1906
+ if (!(await this.getDesign(input.designId)))
974
1907
  return null;
975
- return this.atomic(() => {
1908
+ const moved = [...input.accept.map((x) => x.id), ...input.archive, ...(input.release ?? [])];
1909
+ return this.writingPosts(input.designId, moved, () => this.atomic(() => {
976
1910
  const comment = this.insertComment({
977
1911
  sessionId: input.sessionId,
978
- mockId: input.mockId,
1912
+ designId: input.designId,
979
1913
  author: "user",
980
1914
  text: input.text,
981
1915
  kind: "reply",
@@ -983,23 +1917,37 @@ export class SqlStore {
983
1917
  payload: input.payload,
984
1918
  });
985
1919
  this.sql.exec(input.keepDraft
986
- ? "UPDATE mocks SET asks = ? WHERE id = ?"
987
- : "UPDATE mocks SET asks = ?, draft = NULL WHERE id = ?", JSON.stringify(input.asks), input.mockId);
988
- if (input.resolvedAt !== undefined) {
989
- this.sql.exec("UPDATE mocks SET resolvedAt = ?, updatedAt = ? WHERE id = ?", input.resolvedAt, input.resolvedAt, input.mockId);
1920
+ ? "UPDATE designs SET asks = ? WHERE id = ?"
1921
+ : "UPDATE designs SET asks = ?, draft = NULL WHERE id = ?", JSON.stringify(input.asks), input.designId);
1922
+ if (input.standings) {
1923
+ this.sql.exec("UPDATE designs SET standings = ? WHERE id = ?", JSON.stringify(input.standings), input.designId);
1924
+ }
1925
+ if (input.owner) {
1926
+ this.sql.exec("UPDATE designs SET sessionId = ? WHERE id = ?", input.owner, input.designId);
990
1927
  }
991
1928
  for (const { id, version } of input.accept) {
992
- this.sql.exec("UPDATE posts SET status = 'accepted', acceptedVersion = ? WHERE id = ? AND mockId = ?", version, id, input.mockId);
1929
+ // Picking an older version is passing on the newer ones. Otherwise they'd read
1930
+ // as unruled-on, and the viewer would move straight back to them.
1931
+ this.sql.exec("UPDATE posts SET status = 'accepted', acceptedVersion = ?, archiveReason = NULL, stayedVersion = CASE WHEN ? < version THEN version ELSE NULL END, archivedAt = NULL WHERE id = ? AND designId = ?", version, version, id, input.designId);
1932
+ // An answer's whole page stays hidden until it is chosen; now it's a version.
1933
+ const hiddenRow = this.sql
1934
+ .exec("SELECT hiddenVersions FROM posts WHERE id = ?", id)
1935
+ .toArray()[0];
1936
+ const hidden = parseJson(hiddenRow?.hiddenVersions, []);
1937
+ if (hidden.includes(version)) {
1938
+ const left = hidden.filter((v) => v !== version);
1939
+ this.sql.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", left.length ? JSON.stringify(left) : null, id);
1940
+ }
993
1941
  }
994
- for (const id of input.archive) {
995
- this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL WHERE id = ? AND mockId = ?", id, input.mockId);
1942
+ for (const id of input.release ?? []) {
1943
+ this.sql.exec("UPDATE posts SET status = 'open', acceptedVersion = NULL, stayedVersion = NULL WHERE id = ? AND designId = ? AND status = 'accepted'", id, input.designId);
996
1944
  }
997
- for (const id of input.drop ?? []) {
998
- this.sql.exec("UPDATE posts SET status = 'dropped', acceptedVersion = NULL WHERE id = ? AND mockId = ?", id, input.mockId);
1945
+ for (const id of input.archive) {
1946
+ this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL, archivedAt = ? WHERE id = ? AND designId = ?", new Date().toISOString(), id, input.designId);
999
1947
  }
1000
1948
  this.invalidateAssetRefs();
1001
1949
  return comment;
1002
- });
1950
+ }));
1003
1951
  }
1004
1952
  // --- assets ---
1005
1953
  referencedAssetIds() {
@@ -1013,7 +1961,7 @@ export class SqlStore {
1013
1961
  collectAssetIds(parseJson(r.surfaces, []), out);
1014
1962
  }
1015
1963
  // The user's attachments: in a draft, in a sent reply, on an answered ask.
1016
- for (const r of this.sql.exec("SELECT asks, draft FROM mocks").toArray()) {
1964
+ for (const r of this.sql.exec("SELECT asks, draft FROM designs").toArray()) {
1017
1965
  collectAttachedIds(parseJson(r.draft, null), out);
1018
1966
  for (const ask of parseJson(r.asks, []))
1019
1967
  collectAttachedIds(ask, out);