mockpit 2.0.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 (73) hide show
  1. package/README.md +60 -212
  2. package/bin/demoData.js +2 -2
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +407 -187
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +80 -24
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +289 -52
  10. package/dist/server/apiViews.js +189 -81
  11. package/dist/server/app.d.ts +7 -2
  12. package/dist/server/app.js +2845 -832
  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 +1 -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 +4 -4
  22. package/dist/server/decisions.js +9 -9
  23. package/dist/server/designGuide.d.ts +5 -5
  24. package/dist/server/designGuide.js +128 -67
  25. package/dist/server/embeds.d.ts +3 -1
  26. package/dist/server/embeds.js +31 -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 +35 -13
  31. package/dist/server/feedbackBatch.js +111 -47
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/handoff.d.ts +4 -1
  35. package/dist/server/handoff.js +8 -2
  36. package/dist/server/icons.js +1 -1
  37. package/dist/server/index.js +39 -10
  38. package/dist/server/jump.d.ts +21 -0
  39. package/dist/server/jump.js +175 -0
  40. package/dist/server/knobs.d.ts +3 -3
  41. package/dist/server/knobs.js +6 -6
  42. package/dist/server/mcpHttp.d.ts +5 -1
  43. package/dist/server/mcpHttp.js +16 -11
  44. package/dist/server/mcpSpec.js +118 -46
  45. package/dist/server/parts.js +9 -1
  46. package/dist/server/run.d.ts +1 -1
  47. package/dist/server/run.js +25 -16
  48. package/dist/server/runApi.d.ts +3 -3
  49. package/dist/server/runApi.js +25 -19
  50. package/dist/server/sqlStore.d.ts +112 -96
  51. package/dist/server/sqlStore.js +906 -172
  52. package/dist/server/standings.d.ts +25 -0
  53. package/dist/server/standings.js +113 -0
  54. package/dist/server/surfacePage.d.ts +6 -9
  55. package/dist/server/surfacePage.js +361 -86
  56. package/dist/server/types.d.ts +226 -72
  57. package/dist/server/types.js +73 -78
  58. package/extensions/mockpit.js +89 -72
  59. package/guide/AGENT_SETUP.md +13 -1
  60. package/guide/packs/design-craft.md +2 -3
  61. package/guide/topics/asks.md +106 -62
  62. package/guide/topics/feedback.md +126 -119
  63. package/guide/topics/html.md +17 -24
  64. package/guide/topics/http.md +41 -41
  65. package/guide/topics/knobs.md +7 -10
  66. package/guide/topics/scripts.md +22 -33
  67. package/guide/topics/surfaces.md +39 -48
  68. package/opencode/mockpit.js +120 -0
  69. package/opencode/package.json +8 -0
  70. package/package.json +4 -3
  71. package/skills/launch-video/SKILL.md +11 -11
  72. package/skills/mockpit/SKILL.md +26 -22
  73. package/viewer/dist/index.html +9 -9
@@ -1,8 +1,13 @@
1
- import { collectAssetIds, collectAttachedIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, isMockKind, newId, normalizeSurfaceIds, projectFromCwd, AREA_ORDER_SETTING, 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
2
  import { renameSlugRefs } from "./embeds.js";
3
3
  import { RULES_SETTING } from "./guidance.js";
4
4
  // The settings keyed `<prefix>:<project>`, which a project rename carries.
5
- const PROJECT_SETTINGS = ["design", AREA_ORDER_SETTING, RULES_SETTING];
5
+ const PROJECT_SETTINGS = [
6
+ "design",
7
+ AREA_ORDER_SETTING,
8
+ RULES_SETTING,
9
+ DECISION_EDITS_SETTING,
10
+ ];
6
11
  // An optional free-text label: blank means none.
7
12
  const label = (v) => stripNul(v ?? "").trim() || null;
8
13
  function parseJson(raw, fallback) {
@@ -28,7 +33,7 @@ function contractReply(payload) {
28
33
  out.verdict = { ...decision, kind: kind === "accept" ? "use" : "revise" };
29
34
  }
30
35
  if (resolved !== undefined)
31
- out.closed = resolved;
36
+ out.archived = resolved;
32
37
  return out;
33
38
  }
34
39
  // `surfaces` with every `<mockpit-slot>` tag spelled `<mockpit-embed>`, or null
@@ -44,12 +49,49 @@ function slotTagsToEmbed(surfaces) {
44
49
  });
45
50
  return changed ? out : null;
46
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(", ")}`;
47
89
  const POST_COLUMNS = `
48
- 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,
49
91
  variant TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', title TEXT NOT NULL,
50
92
  surfaces TEXT NOT NULL, knobs TEXT, slots TEXT NOT NULL DEFAULT '[]',
51
93
  createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, version INTEGER NOT NULL,
52
- curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, note TEXT`;
94
+ curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, description TEXT`;
53
95
  // Store implementation on SQLite — a Durable Object's `ctx.storage.sql` in the
54
96
  // Worker, or node:sqlite via an adapter on Node (see server/sqliteStorage.ts).
55
97
  // One workspace = one database, so plain SQL with no tenant columns.
@@ -62,8 +104,47 @@ export class SqlStore {
62
104
  // post's surfaces on each call. Stays correct because history is append-only:
63
105
  // an asset id once referenced stays referenced until its post is deleted.
64
106
  assetRefCache;
65
- constructor(sql) {
66
- 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
+ };
67
148
  this.atomic(() => this.migrate());
68
149
  }
69
150
  // On a Durable Object the synchronous writes of one event already commit as a
@@ -91,20 +172,153 @@ export class SqlStore {
91
172
  this.sql.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${decl}`);
92
173
  }
93
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
+ }
94
307
  migrate() {
308
+ this.renameMocksToDesigns();
95
309
  const before = this.tables();
96
310
  // Deployed Durable Objects can never be reset, so a workspace written by a
97
- // build from before mocks existed is lifted in place (see migrateLegacy).
98
- 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");
99
313
  this.sql.exec(`
100
314
  CREATE TABLE IF NOT EXISTS sessions (
101
315
  id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
102
316
  createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
103
317
  agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT, agentActiveAt TEXT
104
318
  );
105
- CREATE TABLE IF NOT EXISTS mocks (
319
+ CREATE TABLE IF NOT EXISTS designs (
106
320
  id TEXT PRIMARY KEY, project TEXT NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL,
107
- kind TEXT NOT NULL DEFAULT 'sketch', states TEXT NOT NULL DEFAULT '[]',
321
+ kind TEXT NOT NULL DEFAULT 'idea', states TEXT NOT NULL DEFAULT '[]',
108
322
  asks TEXT NOT NULL DEFAULT '[]', knobs TEXT NOT NULL DEFAULT '{}', draft TEXT,
109
323
  sessionId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
110
324
  widths TEXT NOT NULL DEFAULT '[]'
@@ -118,7 +332,7 @@ export class SqlStore {
118
332
  );
119
333
  CREATE TABLE IF NOT EXISTS comments (
120
334
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL,
121
- sessionId TEXT NOT NULL, mockId TEXT, postId TEXT,
335
+ sessionId TEXT NOT NULL, designId TEXT, postId TEXT,
122
336
  author TEXT NOT NULL, text TEXT NOT NULL, createdAt TEXT NOT NULL,
123
337
  kind TEXT NOT NULL DEFAULT 'comment', anchor TEXT, anchors TEXT NOT NULL DEFAULT '[]',
124
338
  postVersion INTEGER, viewport INTEGER, payload TEXT
@@ -144,40 +358,96 @@ export class SqlStore {
144
358
  agentActiveAt: "TEXT",
145
359
  });
146
360
  // The close column's first name.
147
- const mockCols = this.columns("mocks");
148
- if (mockCols.has("resolvedAt") && !mockCols.has("closedAt")) {
149
- this.sql.exec("ALTER TABLE mocks RENAME COLUMN resolvedAt TO closedAt");
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");
150
364
  }
151
- this.addMissing("mocks", {
365
+ this.addMissing("designs", {
152
366
  widths: "TEXT NOT NULL DEFAULT '[]'",
153
- closedAt: "TEXT",
367
+ archivedAt: "TEXT",
368
+ archiveReason: "TEXT",
154
369
  area: "TEXT",
155
370
  source: "TEXT",
156
- situations: "TEXT NOT NULL DEFAULT '{}'",
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 '[]'",
157
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" });
158
381
  if (legacy)
159
382
  this.migrateLegacy();
160
383
  // After the legacy lift, which rebuilds posts from POST_COLUMNS alone.
161
384
  const pinned = this.columns("posts").has("acceptedVersion");
385
+ const dated = this.columns("posts").has("archivedAt");
162
386
  this.addMissing("posts", {
163
- note: "TEXT",
387
+ description: "TEXT",
164
388
  forkedFrom: "TEXT",
389
+ copiedFrom: "TEXT",
390
+ // Inert: kept answers (`Design.standings`) took over from the star.
165
391
  starred: "INTEGER NOT NULL DEFAULT 0",
166
392
  acceptedVersion: "INTEGER",
167
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",
168
400
  });
169
401
  // Before the pin, accepted meant "its latest": freeze that as the version in use.
170
402
  if (!pinned) {
171
403
  this.sql.exec("UPDATE posts SET acceptedVersion = version WHERE status = 'accepted'");
172
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
+ }
173
411
  if (this.settingSync("sketchKind") !== "1")
174
412
  this.migrateToSketches();
175
413
  if (this.settingSync("contractTerms") !== "1")
176
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
+ }
177
424
  this.createIndexes();
178
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
+ }
179
449
  // The agent contract renamed what replies and drafts store (mix → borrowed,
180
- // decision → verdict with accept → use, resolved → closed) and retired drop
450
+ // decision → verdict with accept → use, resolved → archived) and retired drop
181
451
  // and the `<mockpit-slot>` embed alias. Stored data is rewritten once, here,
182
452
  // so nothing past the store reads an old name: a dropped variant is archived
183
453
  // (both were hidden from the choice and restorable), a drop verdict loses its
@@ -195,13 +465,13 @@ export class SqlStore {
195
465
  }
196
466
  }
197
467
  for (const r of this.sql
198
- .exec("SELECT id, draft FROM mocks WHERE draft IS NOT NULL")
468
+ .exec("SELECT id, draft FROM designs WHERE draft IS NOT NULL")
199
469
  .toArray()) {
200
470
  const draft = parseJson(r.draft, null);
201
471
  if (!draft || !("mix" in draft))
202
472
  continue;
203
473
  const { mix, ...rest } = draft;
204
- this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", JSON.stringify({ ...rest, borrowed: mix ?? {} }), r.id);
474
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify({ ...rest, borrowed: mix ?? {} }), r.id);
205
475
  }
206
476
  for (const r of this.sql
207
477
  .exec("SELECT id, surfaces FROM posts WHERE surfaces LIKE '%mockpit-slot%'")
@@ -223,19 +493,22 @@ export class SqlStore {
223
493
  }
224
494
  createIndexes() {
225
495
  this.sql.exec(`
226
- CREATE UNIQUE INDEX IF NOT EXISTS mockpit_mocks_slug_idx ON mocks (project, slug);
227
- CREATE INDEX IF NOT EXISTS mockpit_mocks_updated_at_idx ON mocks (updatedAt DESC);
228
- 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);
229
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);
230
503
  CREATE INDEX IF NOT EXISTS mockpit_comments_session_seq_idx ON comments (sessionId, seq);
231
- 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);
232
505
  CREATE INDEX IF NOT EXISTS mockpit_comments_post_seq_idx ON comments (postId, seq);
233
506
  CREATE INDEX IF NOT EXISTS mockpit_comments_id_idx ON comments (id);
234
507
  CREATE INDEX IF NOT EXISTS mockpit_assets_session_idx ON assets (sessionId);
235
508
  CREATE INDEX IF NOT EXISTS mockpit_sessions_key_idx ON sessions (key);
236
509
  `);
237
510
  }
238
- // --- in-place migration of a pre-mock workspace ---
511
+ // --- in-place migration of a pre-design workspace ---
239
512
  migrateLegacy() {
240
513
  this.addMissing("comments", {
241
514
  anchor: "TEXT",
@@ -244,7 +517,7 @@ export class SqlStore {
244
517
  draft: "INTEGER NOT NULL DEFAULT 0",
245
518
  postVersion: "INTEGER",
246
519
  viewport: "INTEGER",
247
- mockId: "TEXT",
520
+ designId: "TEXT",
248
521
  payload: "TEXT",
249
522
  });
250
523
  this.migrateToSurfaces();
@@ -252,7 +525,7 @@ export class SqlStore {
252
525
  this.migrateSurfaceIds();
253
526
  this.migrateToItems();
254
527
  this.migrateToVersions();
255
- this.migrateToMocks();
528
+ this.migrateToDesigns();
256
529
  this.sql.exec(`
257
530
  DROP TABLE IF EXISTS trace_steps;
258
531
  DROP INDEX IF EXISTS mockpit_comments_post_draft_idx;
@@ -379,11 +652,11 @@ export class SqlStore {
379
652
  }
380
653
  }
381
654
  }
382
- // Every (project, item slug) becomes one single-state mock; its posts become
383
- // the mock's variants with state null. Unsent operator drafts become the
384
- // 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
385
658
  // comments (they were already delivered).
386
- migrateToMocks() {
659
+ migrateToDesigns() {
387
660
  const rows = this.sql.exec("SELECT * FROM posts ORDER BY createdAt ASC, rowid ASC").toArray();
388
661
  const groups = new Map();
389
662
  for (const r of rows) {
@@ -394,13 +667,13 @@ export class SqlStore {
394
667
  else
395
668
  groups.set(key, [r]);
396
669
  }
397
- const mockOf = new Map();
670
+ const designOf = new Map();
398
671
  const variantOf = new Map();
399
672
  for (const group of groups.values()) {
400
673
  const first = group[0];
401
674
  const latest = group.reduce((a, b) => b.updatedAt > a.updatedAt ? b : a);
402
675
  const id = newId();
403
- 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);
404
677
  const used = new Set();
405
678
  for (const r of group) {
406
679
  const base = r.variant || DEFAULT_VARIANT;
@@ -408,25 +681,25 @@ export class SqlStore {
408
681
  for (let n = 2; used.has(variant); n++)
409
682
  variant = `${base}-${n}`;
410
683
  used.add(variant);
411
- mockOf.set(r.id, id);
684
+ designOf.set(r.id, id);
412
685
  variantOf.set(r.id, variant);
413
686
  }
414
687
  }
415
688
  this.sql.exec(`CREATE TABLE posts_next (${POST_COLUMNS})`);
416
689
  for (const r of rows) {
417
690
  const id = r.id;
418
- 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);
419
692
  }
420
693
  this.sql.exec("DROP TABLE posts");
421
694
  this.sql.exec("ALTER TABLE posts_next RENAME TO posts");
422
- 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");
423
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')");
424
697
  const drafts = new Map();
425
698
  for (const c of this.sql
426
- .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")
427
700
  .toArray()) {
428
- const mockId = c.mockId;
429
- let draft = drafts.get(mockId);
701
+ const designId = c.designId;
702
+ let draft = drafts.get(designId);
430
703
  if (!draft) {
431
704
  draft = {
432
705
  version: 1,
@@ -436,41 +709,41 @@ export class SqlStore {
436
709
  comments: [],
437
710
  updatedAt: c.createdAt,
438
711
  };
439
- drafts.set(mockId, draft);
712
+ drafts.set(designId, draft);
440
713
  }
441
714
  draft.version = Math.max(draft.version, c.postVersion ?? 1);
442
715
  draft.comments.push({ part: null, state: null, text: c.text });
443
716
  draft.updatedAt = c.createdAt;
444
717
  }
445
- for (const [mockId, draft] of drafts) {
446
- 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);
447
720
  }
448
721
  this.sql.exec("DELETE FROM comments WHERE draft = 1");
449
722
  }
450
- // A mock no one declared used to be a component. Keep that only where it acts
723
+ // A design no one declared used to be a component. Keep that only where it acts
451
724
  // as one, embedded somewhere or a step of a flow; the rest become sketches.
452
725
  migrateToSketches() {
453
726
  const projectOf = new Map();
454
- for (const r of this.sql.exec("SELECT id, project FROM mocks").toArray()) {
727
+ for (const r of this.sql.exec("SELECT id, project FROM designs").toArray()) {
455
728
  projectOf.set(r.id, r.project);
456
729
  }
457
730
  const used = new Set();
458
- for (const r of this.sql.exec("SELECT mockId, slots FROM posts").toArray()) {
459
- const project = projectOf.get(r.mockId);
731
+ for (const r of this.sql.exec("SELECT designId, slots FROM posts").toArray()) {
732
+ const project = projectOf.get(r.designId);
460
733
  for (const e of parseJson(r.slots, []))
461
734
  used.add(`${project}\u0000${e.slug}`);
462
735
  }
463
736
  for (const r of this.sql.exec("SELECT project, steps FROM flows").toArray()) {
464
737
  for (const x of parseJson(r.steps, [])) {
465
- used.add(`${r.project}\u0000${x.mock}`);
738
+ used.add(`${r.project}\u0000${x.design}`);
466
739
  }
467
740
  }
468
741
  for (const r of this.sql
469
- .exec("SELECT id, project, slug FROM mocks WHERE kind = 'component'")
742
+ .exec("SELECT id, project, slug FROM designs WHERE kind = 'component'")
470
743
  .toArray()) {
471
744
  if (used.has(`${r.project}\u0000${r.slug}`))
472
745
  continue;
473
- this.sql.exec("UPDATE mocks SET kind = 'sketch' WHERE id = ?", r.id);
746
+ this.sql.exec("UPDATE designs SET kind = 'sketch' WHERE id = ?", r.id);
474
747
  }
475
748
  this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('sketchKind', '1')");
476
749
  }
@@ -480,7 +753,7 @@ export class SqlStore {
480
753
  }
481
754
  // --- rows ---
482
755
  insertVersion(postId, v) {
483
- 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);
484
757
  }
485
758
  rowToVersion(r) {
486
759
  return {
@@ -492,9 +765,11 @@ export class SqlStore {
492
765
  ...(r.fromVersion == null ? {} : { from: r.fromVersion }),
493
766
  ...(r.prompt == null ? {} : { prompt: r.prompt }),
494
767
  ...(r.author == null ? {} : { author: r.author }),
768
+ ...(r.sessionId == null ? {} : { publishedBy: r.sessionId }),
769
+ ...(r.amendedAt == null ? {} : { amendedAt: r.amendedAt }),
495
770
  };
496
771
  }
497
- historiesFor(ids) {
772
+ historiesFor(ids, weigh) {
498
773
  const out = new Map();
499
774
  if (ids.length === 0)
500
775
  return out;
@@ -506,12 +781,13 @@ export class SqlStore {
506
781
  let list = out.get(id);
507
782
  if (!list)
508
783
  out.set(id, (list = []));
784
+ weigh?.(r.surfaces.length);
509
785
  list.push(this.rowToVersion(r));
510
786
  }
511
787
  return out;
512
788
  }
513
- rowsToPosts(rows) {
514
- const histories = this.historiesFor(rows.map((r) => r.id));
789
+ rowsToPosts(rows, weigh) {
790
+ const histories = this.historiesFor(rows.map((r) => r.id), weigh);
515
791
  return rows.map((r) => this.rowToPost(r, histories.get(r.id) ?? []));
516
792
  }
517
793
  rowToSession(r) {
@@ -528,23 +804,29 @@ export class SqlStore {
528
804
  keyed: r.key != null,
529
805
  };
530
806
  }
531
- rowToMock(r) {
807
+ rowToDesign(r) {
532
808
  return {
533
809
  id: r.id,
534
810
  project: r.project,
535
811
  slug: r.slug,
536
812
  title: r.title,
537
- kind: isMockKind(r.kind) ? r.kind : "sketch",
813
+ kind: isDesignKind(r.kind) ? r.kind : "idea",
538
814
  area: r.area ?? null,
539
815
  source: r.source ?? null,
540
816
  states: parseJson(r.states, []),
541
- situations: parseJson(r.situations, {}),
542
817
  asks: parseJson(r.asks, []),
543
818
  knobs: parseJson(r.knobs, {}),
544
819
  widths: parseJson(r.widths, []),
545
820
  draft: parseJson(r.draft, null),
546
821
  sessionId: r.sessionId ?? null,
547
- closedAt: r.closedAt ?? 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, []),
548
830
  createdAt: r.createdAt,
549
831
  updatedAt: r.updatedAt,
550
832
  };
@@ -555,7 +837,7 @@ export class SqlStore {
555
837
  return {
556
838
  id: r.id,
557
839
  sessionId: r.sessionId,
558
- mock: r.mockId,
840
+ design: r.designId,
559
841
  state: r.state ?? null,
560
842
  variant: r.variant,
561
843
  status: (r.status || "open"),
@@ -566,18 +848,24 @@ export class SqlStore {
566
848
  version: r.version,
567
849
  history,
568
850
  ...(knobs ? { knobs } : {}),
569
- ...(r.note == null ? {} : { note: r.note }),
851
+ ...(r.description == null ? {} : { description: r.description }),
570
852
  // The column keeps its first name, from when embeds were called slots.
571
853
  embeds: parseJson(r.slots, []),
572
854
  ...(r.forkedFrom == null ? {} : { forkedFrom: parseJson(r.forkedFrom, null) }),
573
- ...(r.starred ? { starred: true } : {}),
855
+ ...(r.copiedFrom == null ? {} : { copiedFrom: parseJson(r.copiedFrom, null) }),
574
856
  ...(r.status === "accepted" && r.acceptedVersion != null
575
857
  ? { acceptedVersion: r.acceptedVersion }
576
858
  : {}),
577
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 }),
578
863
  ...(r.curFrom == null ? {} : { from: r.curFrom }),
579
864
  ...(r.curPrompt == null ? {} : { prompt: r.curPrompt }),
580
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 } : {}),
581
869
  };
582
870
  }
583
871
  // The BLOB comes back as an ArrayBuffer (real DO) or a Uint8Array
@@ -603,7 +891,7 @@ export class SqlStore {
603
891
  id: r.id,
604
892
  seq: r.seq,
605
893
  sessionId: r.sessionId,
606
- mockId: r.mockId ?? null,
894
+ designId: r.designId ?? null,
607
895
  postId: r.postId ?? null,
608
896
  author: r.author,
609
897
  text: r.text,
@@ -666,11 +954,18 @@ export class SqlStore {
666
954
  this.atomic(() => {
667
955
  this.sql.exec("DELETE FROM comments WHERE sessionId = ?", id);
668
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));
669
961
  this.sql.exec("DELETE FROM posts WHERE sessionId = ?", id);
670
- // A mock left with no variants has nothing to show.
671
- this.sql.exec("DELETE FROM comments WHERE mockId IN (SELECT id FROM mocks WHERE id NOT IN (SELECT mockId FROM posts))");
672
- this.sql.exec("DELETE FROM mocks WHERE id NOT IN (SELECT mockId FROM posts)");
673
- 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);
674
969
  // Posts are gone, so the referenced set reflects survivors only: drop this
675
970
  // session's own assets except any a surviving surface still points at
676
971
  // (assets are content-addressed and may be shared across sessions).
@@ -708,87 +1003,130 @@ export class SqlStore {
708
1003
  this.invalidateAssetRefs();
709
1004
  this.sql.exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", stripNul(key), stripNul(value));
710
1005
  }
711
- // --- projects / mocks ---
1006
+ // --- projects / designs ---
712
1007
  async listProjects() {
713
- return summarizeProjects(await this.listMocks(), await this.listSessions(), await this.listPosts());
1008
+ return summarizeProjects(await this.listDesigns(), await this.listSessions());
714
1009
  }
715
- async listMocks(project) {
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
+ };
1040
+ }
1041
+ async listDesigns(project) {
716
1042
  const rows = project === undefined
717
- ? 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()
718
1044
  : this.sql
719
- .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)
720
1046
  .toArray();
721
- return rows.map((r) => this.rowToMock(r));
1047
+ return rows.map((r) => this.rowToDesign(r));
722
1048
  }
723
- async getMock(id) {
724
- const rows = this.sql.exec("SELECT * FROM mocks WHERE id = ?", id).toArray();
725
- 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;
726
1052
  }
727
- async findMock(project, slug) {
1053
+ async findDesign(project, slug) {
728
1054
  const rows = this.sql
729
- .exec("SELECT * FROM mocks WHERE project = ? AND slug = ?", project, slug)
1055
+ .exec("SELECT * FROM designs WHERE project = ? AND slug = ?", project, slug)
730
1056
  .toArray();
731
- return rows.length > 0 ? this.rowToMock(rows[0]) : null;
1057
+ return rows.length > 0 ? this.rowToDesign(rows[0]) : null;
732
1058
  }
733
- async createMock(input) {
1059
+ async createDesign(input) {
734
1060
  const now = new Date().toISOString();
735
- const mock = {
1061
+ const design = {
736
1062
  id: newId(),
737
1063
  project: stripNul(input.project).trim() || DEFAULT_PROJECT,
738
1064
  slug: slugify(stripNul(input.slug)),
739
1065
  title: stripNul(input.title)?.trim() || input.slug,
740
- kind: isMockKind(input.kind) ? input.kind : "sketch",
1066
+ kind: isDesignKind(input.kind) ? input.kind : "idea",
741
1067
  area: label(input.area),
742
1068
  source: label(input.source),
743
1069
  states: (input.states ?? []).map((s) => stripNul(s)),
744
- situations: {},
745
1070
  asks: [],
746
1071
  knobs: input.knobs ?? {},
747
1072
  widths: input.widths ?? [],
748
1073
  draft: null,
749
1074
  sessionId: input.sessionId ?? null,
750
- closedAt: null,
1075
+ shortlist: !!input.shortlist,
1076
+ archivedAt: null,
1077
+ archiveReason: null,
1078
+ notes: null,
1079
+ stateOutlines: {},
1080
+ answerAdditions: [],
1081
+ standings: {},
1082
+ sets: [],
751
1083
  createdAt: now,
752
1084
  updatedAt: now,
753
1085
  };
754
- this.sql.exec("INSERT INTO mocks (id, project, slug, title, kind, area, source, states, asks, knobs, widths, draft, sessionId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, NULL, ?, ?, ?)", mock.id, mock.project, mock.slug, mock.title, mock.kind, mock.area, mock.source, JSON.stringify(mock.states), JSON.stringify(mock.knobs), JSON.stringify(mock.widths), mock.sessionId, mock.createdAt, mock.updatedAt);
755
- 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;
756
1088
  }
757
- async updateMock(id, patch) {
758
- const mock = await this.getMock(id);
759
- if (!mock)
1089
+ async updateDesign(id, patch) {
1090
+ const design = await this.getDesign(id);
1091
+ if (!design)
760
1092
  return null;
761
1093
  const next = {
762
- ...mock,
763
- ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || mock.title }),
1094
+ ...design,
1095
+ ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || design.title }),
764
1096
  ...(patch.kind !== undefined && { kind: patch.kind }),
765
1097
  ...(patch.area !== undefined && { area: label(patch.area) }),
766
1098
  ...(patch.source !== undefined && { source: label(patch.source) }),
767
1099
  ...(patch.states !== undefined && { states: patch.states.map((s) => stripNul(s)) }),
768
- ...(patch.situations !== undefined && { situations: patch.situations }),
769
1100
  ...(patch.asks !== undefined && { asks: patch.asks }),
770
1101
  ...(patch.knobs !== undefined && { knobs: patch.knobs }),
771
1102
  ...(patch.widths !== undefined && { widths: patch.widths }),
772
1103
  ...(patch.sessionId !== undefined && { sessionId: patch.sessionId }),
773
- ...(patch.closedAt !== undefined && { closedAt: patch.closedAt }),
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 }),
774
1112
  updatedAt: new Date().toISOString(),
775
1113
  };
776
- this.sql.exec("UPDATE mocks SET title = ?, kind = ?, area = ?, source = ?, states = ?, situations = ?, asks = ?, knobs = ?, widths = ?, sessionId = ?, closedAt = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, next.area, next.source, JSON.stringify(next.states), JSON.stringify(next.situations), JSON.stringify(next.asks), JSON.stringify(next.knobs), JSON.stringify(next.widths), next.sessionId, next.closedAt, 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);
777
1115
  if (patch.asks !== undefined)
778
1116
  this.invalidateAssetRefs();
779
1117
  return next;
780
1118
  }
781
- async removeMock(id) {
782
- const mock = await this.getMock(id);
783
- if (!mock)
1119
+ async removeDesign(id) {
1120
+ const design = await this.getDesign(id);
1121
+ if (!design)
784
1122
  return false;
785
1123
  this.atomic(() => {
786
- this.sql.exec("DELETE FROM comments WHERE mockId = ?", id);
787
- this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE mockId = ?)", id);
788
- this.sql.exec("DELETE FROM posts WHERE mockId = ?", id);
789
- this.sql.exec("DELETE FROM mocks WHERE id = ?", id);
790
- for (const flow of this.flowsOf(mock.project)) {
791
- 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);
792
1130
  if (steps.length !== flow.steps.length)
793
1131
  this.writeFlow(flow, steps);
794
1132
  }
@@ -796,23 +1134,55 @@ export class SqlStore {
796
1134
  this.invalidateAssetRefs();
797
1135
  return true;
798
1136
  }
799
- async renameMock(id, slug) {
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) {
800
1170
  const to = slugify(stripNul(slug));
801
1171
  return this.atomic(() => {
802
- const rows = this.sql.exec("SELECT * FROM mocks WHERE id = ?", id).toArray();
1172
+ const rows = this.sql.exec("SELECT * FROM designs WHERE id = ?", id).toArray();
803
1173
  if (rows.length === 0)
804
1174
  return null;
805
- const mock = this.rowToMock(rows[0]);
806
- const from = mock.slug;
1175
+ const design = this.rowToDesign(rows[0]);
1176
+ const from = design.slug;
807
1177
  if (to === from)
808
- return mock;
1178
+ return design;
809
1179
  const taken = this.sql
810
- .exec("SELECT id FROM mocks WHERE project = ? AND slug = ?", mock.project, to)
1180
+ .exec("SELECT id FROM designs WHERE project = ? AND slug = ?", design.project, to)
811
1181
  .toArray();
812
1182
  if (taken.length > 0)
813
1183
  return null;
814
1184
  const updatedAt = new Date().toISOString();
815
- this.sql.exec("UPDATE mocks SET slug = ?, updatedAt = ? WHERE id = ?", to, updatedAt, id);
1185
+ this.sql.exec("UPDATE designs SET slug = ?, updatedAt = ? WHERE id = ?", to, updatedAt, id);
816
1186
  // History is rewritten too: embeds resolve by slug at render time, so an
817
1187
  // old version left naming the old slug would show its components missing.
818
1188
  const rewrite = (raw) => {
@@ -829,7 +1199,7 @@ export class SqlStore {
829
1199
  return changed ? JSON.stringify(next) : null;
830
1200
  };
831
1201
  const posts = this.sql
832
- .exec("SELECT id, surfaces, slots FROM posts WHERE mockId IN (SELECT id FROM mocks WHERE project = ?)", mock.project)
1202
+ .exec("SELECT id, surfaces, slots FROM posts WHERE designId IN (SELECT id FROM designs WHERE project = ?)", design.project)
833
1203
  .toArray();
834
1204
  for (const r of posts) {
835
1205
  const postId = r.id;
@@ -849,25 +1219,25 @@ export class SqlStore {
849
1219
  this.sql.exec("UPDATE post_versions SET surfaces = ? WHERE postId = ? AND version = ?", old, postId, v.version);
850
1220
  }
851
1221
  }
852
- for (const flow of this.flowsOf(mock.project)) {
853
- if (!flow.steps.some((x) => x.mock === from))
1222
+ for (const flow of this.flowsOf(design.project)) {
1223
+ if (!flow.steps.some((x) => x.design === from))
854
1224
  continue;
855
- this.writeFlow(flow, flow.steps.map((x) => (x.mock === from ? { ...x, mock: to } : x)));
1225
+ this.writeFlow(flow, flow.steps.map((x) => (x.design === from ? { ...x, design: to } : x)));
856
1226
  }
857
- return { ...mock, slug: to, updatedAt };
1227
+ return { ...design, slug: to, updatedAt };
858
1228
  });
859
1229
  }
860
1230
  async renameProject(from, to) {
861
1231
  const target = stripNul(to).trim();
862
1232
  return this.atomic(() => {
863
1233
  const occupied = this.sql
864
- .exec("SELECT COUNT(*) AS n FROM mocks WHERE project = ?", target)
1234
+ .exec("SELECT COUNT(*) AS n FROM designs WHERE project = ?", target)
865
1235
  .one().n;
866
1236
  if (occupied > 0)
867
1237
  return false;
868
- // A project with no mocks has nothing a leftover flow could walk.
1238
+ // A project with no designs has nothing a leftover flow could walk.
869
1239
  this.sql.exec("DELETE FROM flows WHERE project = ?", target);
870
- this.sql.exec("UPDATE mocks SET project = ? WHERE project = ?", target, from);
1240
+ this.sql.exec("UPDATE designs SET project = ? WHERE project = ?", target, from);
871
1241
  this.sql.exec("UPDATE sessions SET project = ? WHERE project = ?", target, from);
872
1242
  this.sql.exec("UPDATE flows SET project = ? WHERE project = ?", target, from);
873
1243
  for (const prefix of PROJECT_SETTINGS) {
@@ -909,7 +1279,7 @@ export class SqlStore {
909
1279
  return this.flowsOf(project);
910
1280
  }
911
1281
  async placeFlowStep(project, name, screen, step) {
912
- const clean = { mock: stripNul(screen.mock), state: stripNul(screen.state) };
1282
+ const clean = { design: stripNul(screen.design), state: stripNul(screen.state) };
913
1283
  const label = stripNul(name);
914
1284
  return this.atomic(() => {
915
1285
  const now = new Date().toISOString();
@@ -928,24 +1298,27 @@ export class SqlStore {
928
1298
  return this.writeFlow(flow, steps);
929
1299
  });
930
1300
  }
931
- async putDraft(mockId, draft) {
932
- const mock = await this.getMock(mockId);
933
- if (!mock)
1301
+ async putDraft(designId, draft) {
1302
+ const design = await this.getDesign(designId);
1303
+ if (!design)
934
1304
  return null;
935
- 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);
936
1306
  this.invalidateAssetRefs();
937
- return { ...mock, draft };
1307
+ return { ...design, draft };
938
1308
  }
939
- touchMock(mockId, at) {
940
- 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);
941
1311
  }
942
1312
  // --- posts ---
943
1313
  async listPosts(query = {}) {
1314
+ if (query.designId !== undefined && query.sessionId === undefined) {
1315
+ return this.postsOfDesign(query.designId).slice();
1316
+ }
944
1317
  const clauses = [];
945
1318
  const params = [];
946
- if (query.mockId !== undefined) {
947
- clauses.push("mockId = ?");
948
- params.push(query.mockId);
1319
+ if (query.designId !== undefined) {
1320
+ clauses.push("designId = ?");
1321
+ params.push(query.designId);
949
1322
  }
950
1323
  if (query.sessionId !== undefined) {
951
1324
  clauses.push("sessionId = ?");
@@ -957,6 +1330,129 @@ export class SqlStore {
957
1330
  .toArray();
958
1331
  return this.rowsToPosts(rows);
959
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
+ }
960
1456
  async countPostsBySession() {
961
1457
  const counts = new Map();
962
1458
  for (const row of this.sql
@@ -970,22 +1466,25 @@ export class SqlStore {
970
1466
  const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray();
971
1467
  return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
972
1468
  }
973
- 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;
974
1473
  const rows = this.sql
975
- .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)
976
1475
  .toArray();
977
1476
  return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
978
1477
  }
979
1478
  async createPost(input) {
980
1479
  if (!(await this.getSession(input.sessionId)))
981
1480
  return null;
982
- if (!(await this.getMock(input.mock)))
1481
+ if (!(await this.getDesign(input.design)))
983
1482
  return null;
984
1483
  const now = new Date().toISOString();
985
1484
  const post = {
986
1485
  id: newId(),
987
1486
  sessionId: input.sessionId,
988
- mock: input.mock,
1487
+ design: input.design,
989
1488
  state: input.state === null ? null : stripNul(input.state),
990
1489
  variant: stripNul(input.variant)?.trim() || DEFAULT_VARIANT,
991
1490
  status: "open",
@@ -996,16 +1495,20 @@ export class SqlStore {
996
1495
  version: 1,
997
1496
  history: [],
998
1497
  ...(input.knobs ? { knobs: input.knobs } : {}),
999
- ...(stripNul(input.note ?? "").trim() ? { note: stripNul(input.note ?? "").trim() } : {}),
1498
+ ...(stripNul(input.description ?? "").trim()
1499
+ ? { description: stripNul(input.description ?? "").trim() }
1500
+ : {}),
1000
1501
  embeds: input.embeds ?? [],
1001
1502
  ...(input.forkedFrom ? { forkedFrom: input.forkedFrom } : {}),
1503
+ ...(input.copiedFrom ? { copiedFrom: input.copiedFrom } : {}),
1002
1504
  ...(input.from === undefined ? {} : { from: input.from }),
1003
1505
  ...(input.prompt === undefined ? {} : { prompt: stripNul(input.prompt) }),
1004
1506
  ...(input.author === undefined ? {} : { author: stripNul(input.author) }),
1507
+ publishedBy: input.publishedBy ?? input.sessionId,
1005
1508
  };
1006
- 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));
1007
1510
  this.touch(input.sessionId);
1008
- this.touchMock(post.mock, now);
1511
+ this.touchDesign(post.design, now);
1009
1512
  this.addAssetRefs(post.surfaces);
1010
1513
  return post;
1011
1514
  }
@@ -1024,6 +1527,8 @@ export class SqlStore {
1024
1527
  ...(post.from === undefined ? {} : { from: post.from }),
1025
1528
  ...(post.prompt === undefined ? {} : { prompt: post.prompt }),
1026
1529
  ...(post.author === undefined ? {} : { author: post.author }),
1530
+ ...(post.publishedBy === undefined ? {} : { publishedBy: post.publishedBy }),
1531
+ ...(post.amendedAt === undefined ? {} : { amendedAt: post.amendedAt }),
1027
1532
  };
1028
1533
  // The version in use is kept past the limit: export and embeds resolve to it.
1029
1534
  const pin = post.acceptedVersion ?? -1;
@@ -1034,7 +1539,9 @@ export class SqlStore {
1034
1539
  const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
1035
1540
  const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
1036
1541
  const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
1037
- 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;
1038
1545
  const embeds = patch.embeds ?? post.embeds;
1039
1546
  const state = patch.state === undefined ? post.state : patch.state;
1040
1547
  const version = post.version + 1;
@@ -1043,22 +1550,27 @@ export class SqlStore {
1043
1550
  const from = patch.from ?? post.version;
1044
1551
  const prompt = stripNul(patch.prompt ?? "");
1045
1552
  const author = patch.author === undefined ? null : stripNul(patch.author);
1046
- 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);
1047
- const affected = this.sql.exec("SELECT changes() AS n").one().n;
1048
- 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;
1049
1558
  this.insertVersion(id, archived);
1050
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) {
1051
1563
  this.touch(post.sessionId);
1052
- this.touchMock(post.mock, updatedAt);
1564
+ this.touchDesign(post.design, updatedAt);
1053
1565
  if (patch.surfaces !== undefined)
1054
1566
  this.addAssetRefs(surfaces);
1055
- const { knobs: _knobs, note: _note, author: _author, ...rest } = post;
1567
+ const { knobs: _knobs, description: _description, author: _author, publishedBy: _by, amendedAt: _amended, ...rest } = post;
1056
1568
  return {
1057
1569
  ...rest,
1058
1570
  title,
1059
1571
  surfaces,
1060
1572
  ...(knobs ? { knobs } : {}),
1061
- ...(note ? { note } : {}),
1573
+ ...(description ? { description } : {}),
1062
1574
  embeds,
1063
1575
  state,
1064
1576
  version,
@@ -1067,11 +1579,116 @@ export class SqlStore {
1067
1579
  from,
1068
1580
  prompt,
1069
1581
  ...(author === null ? {} : { author }),
1582
+ ...(publishedBy === null ? {} : { publishedBy }),
1070
1583
  };
1071
1584
  }
1072
1585
  }
1073
1586
  return null;
1074
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
+ }
1075
1692
  async removePost(id) {
1076
1693
  if (!(await this.getPost(id)))
1077
1694
  return false;
@@ -1084,24 +1701,61 @@ export class SqlStore {
1084
1701
  return true;
1085
1702
  }
1086
1703
  async setPostStatus(id, status, acceptedVersion) {
1087
- const post = await this.getPost(id);
1704
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1088
1705
  if (!post)
1089
1706
  return null;
1090
1707
  const pin = status === "accepted" ? (acceptedVersion ?? post.version) : null;
1091
- this.sql.exec("UPDATE posts SET status = ?, acceptedVersion = ? WHERE id = ?", status, pin, id);
1092
- const { acceptedVersion: _, ...rest } = post;
1093
- 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;
1094
1727
  }
1095
- async setPostStarred(id, starred) {
1096
- const post = await this.getPost(id);
1728
+ async setPostArchived(id, archivedAt, reason) {
1729
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1097
1730
  if (!post)
1098
1731
  return null;
1099
- this.sql.exec("UPDATE posts SET starred = ? WHERE id = ?", starred ? 1 : 0, id);
1100
- const { starred: _, ...rest } = post;
1101
- return starred ? { ...rest, starred: true } : rest;
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));
1745
+ if (!post)
1746
+ return null;
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));
1102
1756
  }
1103
1757
  async setVersionHidden(id, version, hidden) {
1104
- const post = await this.getPost(id);
1758
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1105
1759
  if (!post)
1106
1760
  return null;
1107
1761
  const set = new Set(post.hiddenVersions ?? []);
@@ -1110,9 +1764,46 @@ export class SqlStore {
1110
1764
  else
1111
1765
  set.delete(version);
1112
1766
  const list = [...set].sort((a, b) => a - b);
1113
- 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);
1114
1768
  const { hiddenVersions: _, ...rest } = post;
1115
- 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;
1116
1807
  }
1117
1808
  // --- comments ---
1118
1809
  async listComments(query) {
@@ -1122,9 +1813,9 @@ export class SqlStore {
1122
1813
  clauses.push("sessionId = ?");
1123
1814
  params.push(query.sessionId);
1124
1815
  }
1125
- if (query.mockId !== undefined) {
1126
- clauses.push("mockId = ?");
1127
- params.push(query.mockId);
1816
+ if (query.designId !== undefined) {
1817
+ clauses.push("designId = ?");
1818
+ params.push(query.designId);
1128
1819
  }
1129
1820
  if (query.postId !== undefined) {
1130
1821
  clauses.push("postId = ?");
@@ -1145,7 +1836,7 @@ export class SqlStore {
1145
1836
  const comment = {
1146
1837
  id: newId(),
1147
1838
  sessionId: input.sessionId,
1148
- mockId: input.mockId ?? null,
1839
+ designId: input.designId ?? null,
1149
1840
  postId: input.postId ?? null,
1150
1841
  author: stripNul(input.author).trim() || "user",
1151
1842
  text: stripNul(input.text),
@@ -1157,7 +1848,7 @@ export class SqlStore {
1157
1848
  viewport: input.viewport ?? null,
1158
1849
  ...(input.payload ? { payload: input.payload } : {}),
1159
1850
  };
1160
- 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);
1161
1852
  const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq;
1162
1853
  this.touch(input.sessionId);
1163
1854
  return { ...comment, seq };
@@ -1169,7 +1860,7 @@ export class SqlStore {
1169
1860
  return this.insertComment({
1170
1861
  ...input,
1171
1862
  postId: post?.id ?? null,
1172
- mockId: input.mockId ?? post?.mock ?? null,
1863
+ designId: input.designId ?? post?.design ?? null,
1173
1864
  postVersion: input.postVersion ?? post?.version ?? null,
1174
1865
  });
1175
1866
  }
@@ -1184,15 +1875,41 @@ export class SqlStore {
1184
1875
  this.touch(comment.sessionId);
1185
1876
  return comment;
1186
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
+ }
1187
1903
  async commitReply(input) {
1188
1904
  if (!(await this.getSession(input.sessionId)))
1189
1905
  return null;
1190
- if (!(await this.getMock(input.mockId)))
1906
+ if (!(await this.getDesign(input.designId)))
1191
1907
  return null;
1192
- 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(() => {
1193
1910
  const comment = this.insertComment({
1194
1911
  sessionId: input.sessionId,
1195
- mockId: input.mockId,
1912
+ designId: input.designId,
1196
1913
  author: "user",
1197
1914
  text: input.text,
1198
1915
  kind: "reply",
@@ -1200,20 +1917,37 @@ export class SqlStore {
1200
1917
  payload: input.payload,
1201
1918
  });
1202
1919
  this.sql.exec(input.keepDraft
1203
- ? "UPDATE mocks SET asks = ? WHERE id = ?"
1204
- : "UPDATE mocks SET asks = ?, draft = NULL WHERE id = ?", JSON.stringify(input.asks), input.mockId);
1205
- if (input.closedAt !== undefined) {
1206
- this.sql.exec("UPDATE mocks SET closedAt = ?, updatedAt = ? WHERE id = ?", input.closedAt, input.closedAt, 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);
1207
1927
  }
1208
1928
  for (const { id, version } of input.accept) {
1209
- 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
+ }
1941
+ }
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);
1210
1944
  }
1211
1945
  for (const id of input.archive) {
1212
- this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL WHERE id = ? AND mockId = ?", id, input.mockId);
1946
+ this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL, archivedAt = ? WHERE id = ? AND designId = ?", new Date().toISOString(), id, input.designId);
1213
1947
  }
1214
1948
  this.invalidateAssetRefs();
1215
1949
  return comment;
1216
- });
1950
+ }));
1217
1951
  }
1218
1952
  // --- assets ---
1219
1953
  referencedAssetIds() {
@@ -1227,7 +1961,7 @@ export class SqlStore {
1227
1961
  collectAssetIds(parseJson(r.surfaces, []), out);
1228
1962
  }
1229
1963
  // The user's attachments: in a draft, in a sent reply, on an answered ask.
1230
- 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()) {
1231
1965
  collectAttachedIds(parseJson(r.draft, null), out);
1232
1966
  for (const ask of parseJson(r.asks, []))
1233
1967
  collectAttachedIds(ask, out);