mockpit 2.0.0 → 3.1.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 (90) hide show
  1. package/README.md +59 -223
  2. package/bin/demoData.js +2 -2
  3. package/bin/initDesign.js +17 -22
  4. package/bin/mockpit.js +626 -216
  5. package/bin/poster.js +434 -0
  6. package/dist/mcp/server.js +127 -29
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +627 -385
  10. package/dist/server/apiViews.js +198 -81
  11. package/dist/server/app.d.ts +7 -2
  12. package/dist/server/app.js +3514 -984
  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 +640 -0
  18. package/dist/server/codeWorker.js +2 -2
  19. package/dist/server/contrast.d.ts +4 -0
  20. package/dist/server/contrast.js +157 -0
  21. package/dist/server/decisionList.d.ts +48 -0
  22. package/dist/server/decisionList.js +340 -0
  23. package/dist/server/decisions.d.ts +4 -4
  24. package/dist/server/decisions.js +9 -9
  25. package/dist/server/designGuide.d.ts +14 -7
  26. package/dist/server/designGuide.js +314 -88
  27. package/dist/server/embeds.d.ts +3 -1
  28. package/dist/server/embeds.js +31 -6
  29. package/dist/server/eventLog.d.ts +2 -2
  30. package/dist/server/eventLog.js +11 -11
  31. package/dist/server/events.d.ts +15 -6
  32. package/dist/server/feedbackBatch.d.ts +39 -13
  33. package/dist/server/feedbackBatch.js +129 -47
  34. package/dist/server/fills.d.ts +4 -0
  35. package/dist/server/fills.js +55 -1
  36. package/dist/server/guidance.d.ts +1 -1
  37. package/dist/server/guidance.js +2 -2
  38. package/dist/server/handoff.d.ts +4 -1
  39. package/dist/server/handoff.js +8 -2
  40. package/dist/server/icons.js +1 -1
  41. package/dist/server/index.js +39 -10
  42. package/dist/server/jump.d.ts +21 -0
  43. package/dist/server/jump.js +175 -0
  44. package/dist/server/kits.d.ts +2 -2
  45. package/dist/server/kits.js +54 -1
  46. package/dist/server/knobs.d.ts +3 -3
  47. package/dist/server/knobs.js +6 -6
  48. package/dist/server/mcpHttp.d.ts +7 -1
  49. package/dist/server/mcpHttp.js +29 -12
  50. package/dist/server/mcpSpec.d.ts +27 -27
  51. package/dist/server/mcpSpec.js +196 -56
  52. package/dist/server/parts.js +9 -1
  53. package/dist/server/run.d.ts +1 -1
  54. package/dist/server/run.js +47 -17
  55. package/dist/server/runApi.d.ts +3 -3
  56. package/dist/server/runApi.js +34 -21
  57. package/dist/server/sqlStore.d.ts +148 -103
  58. package/dist/server/sqlStore.js +1052 -181
  59. package/dist/server/standings.d.ts +25 -0
  60. package/dist/server/standings.js +113 -0
  61. package/dist/server/surfacePage.d.ts +8 -9
  62. package/dist/server/surfacePage.js +423 -100
  63. package/dist/server/system.d.ts +234 -0
  64. package/dist/server/system.js +1508 -0
  65. package/dist/server/systemChecks.d.ts +15 -0
  66. package/dist/server/systemChecks.js +224 -0
  67. package/dist/server/systemSpecimen.d.ts +2 -0
  68. package/dist/server/systemSpecimen.js +49 -0
  69. package/dist/server/themes.d.ts +9 -7
  70. package/dist/server/themes.js +43 -90
  71. package/dist/server/types.d.ts +266 -93
  72. package/dist/server/types.js +76 -79
  73. package/extensions/mockpit.js +89 -72
  74. package/guide/AGENT_SETUP.md +15 -1
  75. package/guide/packs/design-craft.md +2 -3
  76. package/guide/packs/foundations.md +35 -0
  77. package/guide/topics/asks.md +108 -63
  78. package/guide/topics/feedback.md +129 -120
  79. package/guide/topics/html.md +18 -25
  80. package/guide/topics/http.md +43 -41
  81. package/guide/topics/knobs.md +7 -10
  82. package/guide/topics/scripts.md +22 -33
  83. package/guide/topics/surfaces.md +39 -48
  84. package/guide/topics/system.md +157 -0
  85. package/opencode/mockpit.js +120 -0
  86. package/opencode/package.json +8 -0
  87. package/package.json +15 -14
  88. package/skills/launch-video/SKILL.md +11 -11
  89. package/skills/mockpit/SKILL.md +33 -26
  90. package/viewer/dist/index.html +10 -9
@@ -1,8 +1,9 @@
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
+ import { CURRENT_CONTRACT, normalizeSystem, SYSTEM_LIMITS, systemFromDesignSettings, } from "./system.js";
3
4
  import { RULES_SETTING } from "./guidance.js";
4
5
  // The settings keyed `<prefix>:<project>`, which a project rename carries.
5
- const PROJECT_SETTINGS = ["design", AREA_ORDER_SETTING, RULES_SETTING];
6
+ const PROJECT_SETTINGS = [AREA_ORDER_SETTING, RULES_SETTING, DECISION_EDITS_SETTING];
6
7
  // An optional free-text label: blank means none.
7
8
  const label = (v) => stripNul(v ?? "").trim() || null;
8
9
  function parseJson(raw, fallback) {
@@ -28,7 +29,7 @@ function contractReply(payload) {
28
29
  out.verdict = { ...decision, kind: kind === "accept" ? "use" : "revise" };
29
30
  }
30
31
  if (resolved !== undefined)
31
- out.closed = resolved;
32
+ out.archived = resolved;
32
33
  return out;
33
34
  }
34
35
  // `surfaces` with every `<mockpit-slot>` tag spelled `<mockpit-embed>`, or null
@@ -44,12 +45,52 @@ function slotTagsToEmbed(surfaces) {
44
45
  });
45
46
  return changed ? out : null;
46
47
  }
48
+ // What the post cache may hold by default, in stored surface characters: a
49
+ // Durable Object has 128 MB in all.
50
+ const POST_CACHE_BYTES = 32 * 1024 * 1024;
51
+ const surfaceHeads = (surfaces) => JSON.stringify(surfaces.map((s) => {
52
+ if (s.kind !== "html")
53
+ return { kind: s.kind };
54
+ return {
55
+ kind: s.kind,
56
+ ...(s.poster ? { poster: s.poster } : {}),
57
+ ...(s.poster && s.posterMode ? { posterMode: s.posterMode } : {}),
58
+ ...(s.poster && s.posterMode && s.posterSystem !== undefined
59
+ ? { posterSystem: s.posterSystem }
60
+ : {}),
61
+ ...(/<mockpit-embed|data-fill/i.test(s.html) ? { marked: true } : {}),
62
+ };
63
+ }));
64
+ // surfaceHeads in SQL, for the rows written before heads were: it runs without
65
+ // bringing a workspace's bodies into memory, which a Durable Object can't hold.
66
+ const SURFACE_HEADS_SQL = `CASE WHEN json_valid(surfaces) THEN (
67
+ SELECT json_group_array(json(h)) FROM (
68
+ SELECT '{"kind":' || json_quote(json_extract(value, '$.kind'))
69
+ || CASE WHEN json_extract(value, '$.kind') = 'html' AND json_extract(value, '$.poster') <> ''
70
+ THEN ',"poster":' || json_quote(json_extract(value, '$.poster')) ELSE '' END
71
+ || CASE WHEN json_extract(value, '$.kind') = 'html'
72
+ AND (json_extract(value, '$.html') LIKE '%<mockpit-embed%'
73
+ OR json_extract(value, '$.html') LIKE '%data-fill%')
74
+ THEN ',"marked":true' ELSE '' END
75
+ || '}' AS h
76
+ FROM json_each(surfaces) ORDER BY key
77
+ )
78
+ ) ELSE '[]' END`;
79
+ // Everything a head row needs, spelled out because `SELECT *` reads the bodies.
80
+ 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";
81
+ // Holding every column a head reads, so listing a project's variants never touches
82
+ // a row with a body: those columns sit after `surfaces`, and reaching them through
83
+ // the table walks the body's overflow pages. Any column added to the heads belongs
84
+ // here too (it is built from them), under a new index name so the old one is replaced.
85
+ const POST_HEADS_INDEX = `designId, ${POST_HEAD_COLUMNS.split(", ")
86
+ .filter((c) => c !== "designId")
87
+ .join(", ")}`;
47
88
  const POST_COLUMNS = `
48
- id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, mockId TEXT NOT NULL, state TEXT,
89
+ id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, designId TEXT NOT NULL, state TEXT,
49
90
  variant TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', title TEXT NOT NULL,
50
91
  surfaces TEXT NOT NULL, knobs TEXT, slots TEXT NOT NULL DEFAULT '[]',
51
92
  createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, version INTEGER NOT NULL,
52
- curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, note TEXT`;
93
+ curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, description TEXT`;
53
94
  // Store implementation on SQLite — a Durable Object's `ctx.storage.sql` in the
54
95
  // Worker, or node:sqlite via an adapter on Node (see server/sqliteStorage.ts).
55
96
  // One workspace = one database, so plain SQL with no tenant columns.
@@ -62,8 +103,47 @@ export class SqlStore {
62
103
  // post's surfaces on each call. Stays correct because history is append-only:
63
104
  // an asset id once referenced stays referenced until its post is deleted.
64
105
  assetRefCache;
65
- constructor(sql) {
66
- this.sql = sql;
106
+ // Parsed posts per design, least recently used first. A component is read again
107
+ // for every embed that shows it, and parsing its bodies each time is what a
108
+ // page full of embeds would wait on. A statement that writes posts or versions
109
+ // drops all of it unless `writingPosts` says which posts it touches; the byte
110
+ // budget keeps a Durable Object's memory bounded.
111
+ postCache = new Map();
112
+ postCacheBytes = 0;
113
+ postCacheLimit;
114
+ // Past the cache's drop, for a write that patches the cache itself.
115
+ raw;
116
+ holdPostDrop = false;
117
+ constructor(sql, opts = {}) {
118
+ this.raw = sql;
119
+ this.postCacheLimit = opts.postCacheBytes ?? POST_CACHE_BYTES;
120
+ const drop = () => {
121
+ if (this.holdPostDrop)
122
+ return;
123
+ this.postCache.clear();
124
+ this.postCacheBytes = 0;
125
+ };
126
+ this.sql = {
127
+ exec: (query, ...bindings) => {
128
+ if (!/^\s*SELECT\b/i.test(query) && /\bpost(s|_versions)\b/i.test(query))
129
+ drop();
130
+ return sql.exec(query, ...bindings);
131
+ },
132
+ ...(sql.transactionSync
133
+ ? {
134
+ transactionSync: (fn) => {
135
+ try {
136
+ return sql.transactionSync(fn);
137
+ }
138
+ catch (err) {
139
+ // Reads inside a rolled-back transaction may have cached its writes.
140
+ drop();
141
+ throw err;
142
+ }
143
+ },
144
+ }
145
+ : {}),
146
+ };
67
147
  this.atomic(() => this.migrate());
68
148
  }
69
149
  // On a Durable Object the synchronous writes of one event already commit as a
@@ -91,20 +171,153 @@ export class SqlStore {
91
171
  this.sql.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${decl}`);
92
172
  }
93
173
  }
174
+ // What was called a mock is a design, in the store as everywhere else. First of all
175
+ // the migrations: every one below it speaks the new names, and a workspace with no
176
+ // designs table would otherwise read as older than mocks.
177
+ // The words stored inside JSON and values, once every older migration has written
178
+ // its own: a kind, what an answer's comment is called, and an archive in a reply.
179
+ migrateToDesignWords() {
180
+ const kinds = { page: "screen", sketch: "idea" };
181
+ for (const [from, to] of Object.entries(kinds)) {
182
+ this.sql.exec("UPDATE designs SET kind = ? WHERE kind = ?", to, from);
183
+ }
184
+ const renameKeys = (o, names) => {
185
+ let changed = false;
186
+ for (const [from, to] of Object.entries(names)) {
187
+ if (from in o) {
188
+ o[to] = o[from];
189
+ delete o[from];
190
+ changed = true;
191
+ }
192
+ }
193
+ return changed;
194
+ };
195
+ for (const r of this.sql.exec("SELECT id, asks, draft FROM designs").toArray()) {
196
+ const asks = parseJson(r.asks, []);
197
+ let asksChanged = false;
198
+ for (const ask of asks) {
199
+ if (renameKeys(ask, { note: "comment", noteImages: "commentImages" }))
200
+ asksChanged = true;
201
+ }
202
+ if (asksChanged) {
203
+ this.sql.exec("UPDATE designs SET asks = ? WHERE id = ?", JSON.stringify(asks), r.id);
204
+ }
205
+ const draft = parseJson(r.draft, null);
206
+ if (draft && renameKeys(draft, { notes: "askComments", noteImages: "askCommentImages" })) {
207
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify(draft), r.id);
208
+ }
209
+ }
210
+ for (const r of this.sql
211
+ .exec("SELECT seq, payload FROM comments WHERE kind = 'reply' AND payload IS NOT NULL")
212
+ .toArray()) {
213
+ const payload = parseJson(r.payload, null);
214
+ if (!payload)
215
+ continue;
216
+ let changed = renameKeys(payload, {
217
+ notes: "askComments",
218
+ noteImages: "askCommentImages",
219
+ closed: "archived",
220
+ });
221
+ const edited = payload.edited;
222
+ if (edited) {
223
+ if (typeof edited.kind === "string" && edited.kind in kinds) {
224
+ edited.kind = kinds[edited.kind];
225
+ changed = true;
226
+ }
227
+ if ("grouped" in edited) {
228
+ delete edited.grouped;
229
+ changed = true;
230
+ // All it said was about grouping, which no longer exists: it would read as a blank line.
231
+ if (!Object.keys(edited).length) {
232
+ this.sql.exec("DELETE FROM comments WHERE seq = ?", r.seq);
233
+ continue;
234
+ }
235
+ }
236
+ }
237
+ if (changed) {
238
+ this.sql.exec("UPDATE comments SET payload = ? WHERE seq = ?", JSON.stringify(payload), r.seq);
239
+ }
240
+ }
241
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('designWords', '1')");
242
+ }
243
+ // A value, so migrateToDesignWords' renames never reached it, and that one has
244
+ // already run on every workspace that needs this.
245
+ migrateAskScopeWord() {
246
+ for (const r of this.sql.exec("SELECT id, asks FROM designs").toArray()) {
247
+ const asks = parseJson(r.asks, []);
248
+ const old = asks.filter((a) => a.scope === "mock");
249
+ if (!old.length)
250
+ continue;
251
+ for (const a of old)
252
+ a.scope = "design";
253
+ this.sql.exec("UPDATE designs SET asks = ? WHERE id = ?", JSON.stringify(asks), r.id);
254
+ }
255
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('askScopeWord', '1')");
256
+ }
257
+ renameMocksToDesigns() {
258
+ if (!this.tables().has("mocks") || this.tables().has("designs"))
259
+ return;
260
+ for (const name of [
261
+ "mockpit_mocks_slug_idx",
262
+ "mockpit_mocks_updated_at_idx",
263
+ "mockpit_posts_mock_idx",
264
+ "mockpit_comments_mock_seq_idx",
265
+ "mockpit_posts_copies_idx",
266
+ ]) {
267
+ this.sql.exec(`DROP INDEX IF EXISTS ${name}`);
268
+ }
269
+ this.sql.exec("ALTER TABLE mocks RENAME TO designs");
270
+ for (const table of ["posts", "comments"]) {
271
+ if (this.columns(table).has("mockId")) {
272
+ this.sql.exec(`ALTER TABLE ${table} RENAME COLUMN mockId TO designId`);
273
+ }
274
+ }
275
+ // Steps and copy references are stored as JSON, keyed by the old word.
276
+ const renamed = (raw) => typeof raw === "string" ? raw.replaceAll('"mock":', '"design":') : raw;
277
+ for (const r of this.sql.exec("SELECT project, name, steps FROM flows").toArray()) {
278
+ this.sql.exec("UPDATE flows SET steps = ? WHERE project = ? AND name = ?", renamed(r.steps), r.project, r.name);
279
+ }
280
+ // Only the key: a user's text can hold the same letters, escaped inside its string.
281
+ for (const r of this.sql
282
+ .exec("SELECT seq, payload FROM comments WHERE payload LIKE '%\"mockId\":%'")
283
+ .toArray()) {
284
+ this.sql.exec("UPDATE comments SET payload = ? WHERE seq = ?", r.payload.replace(/(?<!\\)"mockId":/g, '"designId":'), r.seq);
285
+ }
286
+ // The same release gave the design's archive fields and a variant's description
287
+ // their names; the JSON that speaks these words waits for migrateToDesignWords.
288
+ const designCols = this.columns("designs");
289
+ if (designCols.has("closedAt")) {
290
+ this.sql.exec("ALTER TABLE designs RENAME COLUMN closedAt TO archivedAt");
291
+ }
292
+ if (designCols.has("closeReason")) {
293
+ this.sql.exec("ALTER TABLE designs RENAME COLUMN closeReason TO archiveReason");
294
+ }
295
+ if (this.columns("posts").has("note")) {
296
+ this.sql.exec("ALTER TABLE posts RENAME COLUMN note TO description");
297
+ }
298
+ if (this.columns("posts").has("copiedFrom")) {
299
+ for (const r of this.sql
300
+ .exec("SELECT id, copiedFrom FROM posts WHERE copiedFrom IS NOT NULL")
301
+ .toArray()) {
302
+ this.sql.exec("UPDATE posts SET copiedFrom = ? WHERE id = ?", renamed(r.copiedFrom), r.id);
303
+ }
304
+ }
305
+ }
94
306
  migrate() {
307
+ this.renameMocksToDesigns();
95
308
  const before = this.tables();
96
309
  // 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");
310
+ // build from before designs existed is lifted in place (see migrateLegacy).
311
+ const legacy = before.has("posts") && !before.has("designs");
99
312
  this.sql.exec(`
100
313
  CREATE TABLE IF NOT EXISTS sessions (
101
314
  id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
102
315
  createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
103
316
  agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT, agentActiveAt TEXT
104
317
  );
105
- CREATE TABLE IF NOT EXISTS mocks (
318
+ CREATE TABLE IF NOT EXISTS designs (
106
319
  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 '[]',
320
+ kind TEXT NOT NULL DEFAULT 'idea', states TEXT NOT NULL DEFAULT '[]',
108
321
  asks TEXT NOT NULL DEFAULT '[]', knobs TEXT NOT NULL DEFAULT '{}', draft TEXT,
109
322
  sessionId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
110
323
  widths TEXT NOT NULL DEFAULT '[]'
@@ -118,7 +331,7 @@ export class SqlStore {
118
331
  );
119
332
  CREATE TABLE IF NOT EXISTS comments (
120
333
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL,
121
- sessionId TEXT NOT NULL, mockId TEXT, postId TEXT,
334
+ sessionId TEXT NOT NULL, designId TEXT, postId TEXT,
122
335
  author TEXT NOT NULL, text TEXT NOT NULL, createdAt TEXT NOT NULL,
123
336
  kind TEXT NOT NULL DEFAULT 'comment', anchor TEXT, anchors TEXT NOT NULL DEFAULT '[]',
124
337
  postVersion INTEGER, viewport INTEGER, payload TEXT
@@ -136,6 +349,14 @@ export class SqlStore {
136
349
  createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
137
350
  PRIMARY KEY (project, name)
138
351
  );
352
+ CREATE TABLE IF NOT EXISTS systems (
353
+ project TEXT PRIMARY KEY, rev INTEGER NOT NULL, json TEXT NOT NULL
354
+ );
355
+ CREATE TABLE IF NOT EXISTS system_revisions (
356
+ project TEXT NOT NULL, rev INTEGER NOT NULL, json TEXT NOT NULL, at TEXT NOT NULL,
357
+ sessionId TEXT, summary TEXT NOT NULL DEFAULT '',
358
+ PRIMARY KEY (project, rev)
359
+ );
139
360
  `);
140
361
  this.addMissing("sessions", {
141
362
  agentSeq: "INTEGER NOT NULL DEFAULT 0",
@@ -144,40 +365,104 @@ export class SqlStore {
144
365
  agentActiveAt: "TEXT",
145
366
  });
146
367
  // 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");
368
+ const designCols = this.columns("designs");
369
+ if (designCols.has("resolvedAt") && !designCols.has("archivedAt")) {
370
+ this.sql.exec("ALTER TABLE designs RENAME COLUMN resolvedAt TO archivedAt");
150
371
  }
151
- this.addMissing("mocks", {
372
+ this.addMissing("designs", {
152
373
  widths: "TEXT NOT NULL DEFAULT '[]'",
153
- closedAt: "TEXT",
374
+ archivedAt: "TEXT",
375
+ archiveReason: "TEXT",
154
376
  area: "TEXT",
155
377
  source: "TEXT",
156
- situations: "TEXT NOT NULL DEFAULT '{}'",
378
+ shortlist: "INTEGER NOT NULL DEFAULT 0",
379
+ notes: "TEXT",
380
+ stateOutlines: "TEXT NOT NULL DEFAULT '{}'",
381
+ answerAdditions: "TEXT NOT NULL DEFAULT '[]'",
382
+ standings: "TEXT NOT NULL DEFAULT '{}'",
383
+ sets: "TEXT NOT NULL DEFAULT '[]'",
384
+ });
385
+ this.migrateSituations();
386
+ // Before the legacy lift, whose history rows are written with every column.
387
+ this.addMissing("post_versions", {
388
+ sessionId: "TEXT",
389
+ amendedAt: "TEXT",
390
+ contract: "INTEGER",
391
+ system: "INTEGER",
157
392
  });
158
393
  if (legacy)
159
394
  this.migrateLegacy();
160
395
  // After the legacy lift, which rebuilds posts from POST_COLUMNS alone.
161
396
  const pinned = this.columns("posts").has("acceptedVersion");
397
+ const dated = this.columns("posts").has("archivedAt");
162
398
  this.addMissing("posts", {
163
- note: "TEXT",
399
+ description: "TEXT",
164
400
  forkedFrom: "TEXT",
401
+ copiedFrom: "TEXT",
402
+ // Inert: kept answers (`Design.standings`) took over from the star.
165
403
  starred: "INTEGER NOT NULL DEFAULT 0",
166
404
  acceptedVersion: "INTEGER",
167
405
  hiddenVersions: "TEXT",
406
+ curSession: "TEXT",
407
+ amendedAt: "TEXT",
408
+ rewrites: "INTEGER NOT NULL DEFAULT 0",
409
+ archiveReason: "TEXT",
410
+ stayedVersion: "INTEGER",
411
+ archivedAt: "TEXT",
412
+ curContract: "INTEGER",
413
+ curSystem: "INTEGER",
168
414
  });
169
415
  // Before the pin, accepted meant "its latest": freeze that as the version in use.
170
416
  if (!pinned) {
171
417
  this.sql.exec("UPDATE posts SET acceptedVersion = version WHERE status = 'accepted'");
172
418
  }
419
+ for (const table of ["posts", "post_versions"]) {
420
+ if (this.columns(table).has("head"))
421
+ continue;
422
+ this.addMissing(table, { head: "TEXT" });
423
+ this.sql.exec(`UPDATE ${table} SET head = ${SURFACE_HEADS_SQL}`);
424
+ }
173
425
  if (this.settingSync("sketchKind") !== "1")
174
426
  this.migrateToSketches();
427
+ this.migrateSystems();
175
428
  if (this.settingSync("contractTerms") !== "1")
176
429
  this.migrateToContractTerms();
430
+ if (this.settingSync("designWords") !== "1")
431
+ this.migrateToDesignWords();
432
+ if (this.settingSync("askScopeWord") !== "1")
433
+ this.migrateAskScopeWord();
434
+ // The day it went was never kept, and the design's last change is the nearest thing.
435
+ // Frozen now, or the next reason or draft on the design would move it to that day.
436
+ if (!dated) {
437
+ this.sql.exec("UPDATE posts SET archivedAt = (SELECT updatedAt FROM designs WHERE designs.id = posts.designId) WHERE status = 'archived'");
438
+ }
177
439
  this.createIndexes();
178
440
  }
441
+ // A situation was already a state of its own, filed in `states` under the name
442
+ // feedback gave it ("Writing · Couldn't save"), with its asks, comments and posts
443
+ // there too, so those keep that name and only the map that set it apart goes. The
444
+ // column itself stays on a workspace that has it, empty: nothing reads it any more,
445
+ // and new workspaces never get one.
446
+ migrateSituations() {
447
+ if (!this.columns("designs").has("situations"))
448
+ return;
449
+ for (const r of this.sql
450
+ .exec("SELECT id, states, situations FROM designs WHERE situations != '{}'")
451
+ .toArray()) {
452
+ const states = parseJson(r.states, []);
453
+ const map = parseJson(r.situations, {});
454
+ for (const [state, names] of Object.entries(map)) {
455
+ for (const name of names) {
456
+ const key = `${state} · ${name}`;
457
+ if (!states.includes(key))
458
+ states.push(key);
459
+ }
460
+ }
461
+ this.sql.exec("UPDATE designs SET states = ?, situations = '{}' WHERE id = ?", JSON.stringify(states), r.id);
462
+ }
463
+ }
179
464
  // The agent contract renamed what replies and drafts store (mix → borrowed,
180
- // decision → verdict with accept → use, resolved → closed) and retired drop
465
+ // decision → verdict with accept → use, resolved → archived) and retired drop
181
466
  // and the `<mockpit-slot>` embed alias. Stored data is rewritten once, here,
182
467
  // so nothing past the store reads an old name: a dropped variant is archived
183
468
  // (both were hidden from the choice and restorable), a drop verdict loses its
@@ -195,13 +480,13 @@ export class SqlStore {
195
480
  }
196
481
  }
197
482
  for (const r of this.sql
198
- .exec("SELECT id, draft FROM mocks WHERE draft IS NOT NULL")
483
+ .exec("SELECT id, draft FROM designs WHERE draft IS NOT NULL")
199
484
  .toArray()) {
200
485
  const draft = parseJson(r.draft, null);
201
486
  if (!draft || !("mix" in draft))
202
487
  continue;
203
488
  const { mix, ...rest } = draft;
204
- this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", JSON.stringify({ ...rest, borrowed: mix ?? {} }), r.id);
489
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify({ ...rest, borrowed: mix ?? {} }), r.id);
205
490
  }
206
491
  for (const r of this.sql
207
492
  .exec("SELECT id, surfaces FROM posts WHERE surfaces LIKE '%mockpit-slot%'")
@@ -223,19 +508,22 @@ export class SqlStore {
223
508
  }
224
509
  createIndexes() {
225
510
  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);
511
+ CREATE UNIQUE INDEX IF NOT EXISTS mockpit_designs_slug_idx ON designs (project, slug);
512
+ CREATE INDEX IF NOT EXISTS mockpit_designs_updated_at_idx ON designs (updatedAt DESC);
513
+ CREATE INDEX IF NOT EXISTS mockpit_posts_design_idx ON posts (designId, state, variant);
229
514
  CREATE INDEX IF NOT EXISTS mockpit_posts_session_created_at_idx ON posts (sessionId, createdAt);
515
+ CREATE INDEX IF NOT EXISTS mockpit_posts_copies_idx ON posts (designId, state, variant, status, copiedFrom) WHERE copiedFrom IS NOT NULL;
516
+ CREATE INDEX IF NOT EXISTS mockpit_posts_heads_idx ON posts (${POST_HEADS_INDEX});
517
+ CREATE INDEX IF NOT EXISTS mockpit_post_versions_heads_idx ON post_versions (postId, version, head);
230
518
  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);
519
+ CREATE INDEX IF NOT EXISTS mockpit_comments_design_seq_idx ON comments (designId, seq);
232
520
  CREATE INDEX IF NOT EXISTS mockpit_comments_post_seq_idx ON comments (postId, seq);
233
521
  CREATE INDEX IF NOT EXISTS mockpit_comments_id_idx ON comments (id);
234
522
  CREATE INDEX IF NOT EXISTS mockpit_assets_session_idx ON assets (sessionId);
235
523
  CREATE INDEX IF NOT EXISTS mockpit_sessions_key_idx ON sessions (key);
236
524
  `);
237
525
  }
238
- // --- in-place migration of a pre-mock workspace ---
526
+ // --- in-place migration of a pre-design workspace ---
239
527
  migrateLegacy() {
240
528
  this.addMissing("comments", {
241
529
  anchor: "TEXT",
@@ -244,7 +532,7 @@ export class SqlStore {
244
532
  draft: "INTEGER NOT NULL DEFAULT 0",
245
533
  postVersion: "INTEGER",
246
534
  viewport: "INTEGER",
247
- mockId: "TEXT",
535
+ designId: "TEXT",
248
536
  payload: "TEXT",
249
537
  });
250
538
  this.migrateToSurfaces();
@@ -252,7 +540,7 @@ export class SqlStore {
252
540
  this.migrateSurfaceIds();
253
541
  this.migrateToItems();
254
542
  this.migrateToVersions();
255
- this.migrateToMocks();
543
+ this.migrateToDesigns();
256
544
  this.sql.exec(`
257
545
  DROP TABLE IF EXISTS trace_steps;
258
546
  DROP INDEX IF EXISTS mockpit_comments_post_draft_idx;
@@ -379,11 +667,11 @@ export class SqlStore {
379
667
  }
380
668
  }
381
669
  }
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
670
+ // Every (project, item slug) becomes one single-state design; its posts become
671
+ // the design's variants with state null. Unsent operator drafts become the
672
+ // design's draft so they are still there to send; decisions become plain
385
673
  // comments (they were already delivered).
386
- migrateToMocks() {
674
+ migrateToDesigns() {
387
675
  const rows = this.sql.exec("SELECT * FROM posts ORDER BY createdAt ASC, rowid ASC").toArray();
388
676
  const groups = new Map();
389
677
  for (const r of rows) {
@@ -394,13 +682,13 @@ export class SqlStore {
394
682
  else
395
683
  groups.set(key, [r]);
396
684
  }
397
- const mockOf = new Map();
685
+ const designOf = new Map();
398
686
  const variantOf = new Map();
399
687
  for (const group of groups.values()) {
400
688
  const first = group[0];
401
689
  const latest = group.reduce((a, b) => b.updatedAt > a.updatedAt ? b : a);
402
690
  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);
691
+ 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
692
  const used = new Set();
405
693
  for (const r of group) {
406
694
  const base = r.variant || DEFAULT_VARIANT;
@@ -408,25 +696,25 @@ export class SqlStore {
408
696
  for (let n = 2; used.has(variant); n++)
409
697
  variant = `${base}-${n}`;
410
698
  used.add(variant);
411
- mockOf.set(r.id, id);
699
+ designOf.set(r.id, id);
412
700
  variantOf.set(r.id, variant);
413
701
  }
414
702
  }
415
703
  this.sql.exec(`CREATE TABLE posts_next (${POST_COLUMNS})`);
416
704
  for (const r of rows) {
417
705
  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);
706
+ 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
707
  }
420
708
  this.sql.exec("DROP TABLE posts");
421
709
  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");
710
+ this.sql.exec("UPDATE comments SET designId = (SELECT designId FROM posts WHERE posts.id = comments.postId) WHERE designId IS NULL");
423
711
  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
712
  const drafts = new Map();
425
713
  for (const c of this.sql
426
- .exec("SELECT * FROM comments WHERE draft = 1 AND mockId IS NOT NULL ORDER BY seq ASC")
714
+ .exec("SELECT * FROM comments WHERE draft = 1 AND designId IS NOT NULL ORDER BY seq ASC")
427
715
  .toArray()) {
428
- const mockId = c.mockId;
429
- let draft = drafts.get(mockId);
716
+ const designId = c.designId;
717
+ let draft = drafts.get(designId);
430
718
  if (!draft) {
431
719
  draft = {
432
720
  version: 1,
@@ -436,41 +724,41 @@ export class SqlStore {
436
724
  comments: [],
437
725
  updatedAt: c.createdAt,
438
726
  };
439
- drafts.set(mockId, draft);
727
+ drafts.set(designId, draft);
440
728
  }
441
729
  draft.version = Math.max(draft.version, c.postVersion ?? 1);
442
730
  draft.comments.push({ part: null, state: null, text: c.text });
443
731
  draft.updatedAt = c.createdAt;
444
732
  }
445
- for (const [mockId, draft] of drafts) {
446
- this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", JSON.stringify(draft), mockId);
733
+ for (const [designId, draft] of drafts) {
734
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify(draft), designId);
447
735
  }
448
736
  this.sql.exec("DELETE FROM comments WHERE draft = 1");
449
737
  }
450
- // A mock no one declared used to be a component. Keep that only where it acts
738
+ // A design no one declared used to be a component. Keep that only where it acts
451
739
  // as one, embedded somewhere or a step of a flow; the rest become sketches.
452
740
  migrateToSketches() {
453
741
  const projectOf = new Map();
454
- for (const r of this.sql.exec("SELECT id, project FROM mocks").toArray()) {
742
+ for (const r of this.sql.exec("SELECT id, project FROM designs").toArray()) {
455
743
  projectOf.set(r.id, r.project);
456
744
  }
457
745
  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);
746
+ for (const r of this.sql.exec("SELECT designId, slots FROM posts").toArray()) {
747
+ const project = projectOf.get(r.designId);
460
748
  for (const e of parseJson(r.slots, []))
461
749
  used.add(`${project}\u0000${e.slug}`);
462
750
  }
463
751
  for (const r of this.sql.exec("SELECT project, steps FROM flows").toArray()) {
464
752
  for (const x of parseJson(r.steps, [])) {
465
- used.add(`${r.project}\u0000${x.mock}`);
753
+ used.add(`${r.project}\u0000${x.design}`);
466
754
  }
467
755
  }
468
756
  for (const r of this.sql
469
- .exec("SELECT id, project, slug FROM mocks WHERE kind = 'component'")
757
+ .exec("SELECT id, project, slug FROM designs WHERE kind = 'component'")
470
758
  .toArray()) {
471
759
  if (used.has(`${r.project}\u0000${r.slug}`))
472
760
  continue;
473
- this.sql.exec("UPDATE mocks SET kind = 'sketch' WHERE id = ?", r.id);
761
+ this.sql.exec("UPDATE designs SET kind = 'sketch' WHERE id = ?", r.id);
474
762
  }
475
763
  this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('sketchKind', '1')");
476
764
  }
@@ -480,7 +768,7 @@ export class SqlStore {
480
768
  }
481
769
  // --- rows ---
482
770
  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);
771
+ this.sql.exec("INSERT OR REPLACE INTO post_versions (postId, version, title, surfaces, head, at, fromVersion, prompt, author, sessionId, amendedAt, contract, system) 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, v.contract ?? null, v.system ?? null);
484
772
  }
485
773
  rowToVersion(r) {
486
774
  return {
@@ -492,9 +780,13 @@ export class SqlStore {
492
780
  ...(r.fromVersion == null ? {} : { from: r.fromVersion }),
493
781
  ...(r.prompt == null ? {} : { prompt: r.prompt }),
494
782
  ...(r.author == null ? {} : { author: r.author }),
783
+ ...(r.sessionId == null ? {} : { publishedBy: r.sessionId }),
784
+ ...(r.amendedAt == null ? {} : { amendedAt: r.amendedAt }),
785
+ ...(r.contract == null ? {} : { contract: r.contract }),
786
+ ...(r.system == null ? {} : { system: r.system }),
495
787
  };
496
788
  }
497
- historiesFor(ids) {
789
+ historiesFor(ids, weigh) {
498
790
  const out = new Map();
499
791
  if (ids.length === 0)
500
792
  return out;
@@ -506,12 +798,13 @@ export class SqlStore {
506
798
  let list = out.get(id);
507
799
  if (!list)
508
800
  out.set(id, (list = []));
801
+ weigh?.(r.surfaces.length);
509
802
  list.push(this.rowToVersion(r));
510
803
  }
511
804
  return out;
512
805
  }
513
- rowsToPosts(rows) {
514
- const histories = this.historiesFor(rows.map((r) => r.id));
806
+ rowsToPosts(rows, weigh) {
807
+ const histories = this.historiesFor(rows.map((r) => r.id), weigh);
515
808
  return rows.map((r) => this.rowToPost(r, histories.get(r.id) ?? []));
516
809
  }
517
810
  rowToSession(r) {
@@ -528,23 +821,29 @@ export class SqlStore {
528
821
  keyed: r.key != null,
529
822
  };
530
823
  }
531
- rowToMock(r) {
824
+ rowToDesign(r) {
532
825
  return {
533
826
  id: r.id,
534
827
  project: r.project,
535
828
  slug: r.slug,
536
829
  title: r.title,
537
- kind: isMockKind(r.kind) ? r.kind : "sketch",
830
+ kind: isDesignKind(r.kind) ? r.kind : "idea",
538
831
  area: r.area ?? null,
539
832
  source: r.source ?? null,
540
833
  states: parseJson(r.states, []),
541
- situations: parseJson(r.situations, {}),
542
834
  asks: parseJson(r.asks, []),
543
835
  knobs: parseJson(r.knobs, {}),
544
836
  widths: parseJson(r.widths, []),
545
837
  draft: parseJson(r.draft, null),
546
838
  sessionId: r.sessionId ?? null,
547
- closedAt: r.closedAt ?? null,
839
+ shortlist: !!r.shortlist,
840
+ archivedAt: r.archivedAt ?? null,
841
+ archiveReason: r.archiveReason ?? null,
842
+ notes: parseJson(r.notes, null),
843
+ stateOutlines: parseJson(r.stateOutlines, {}),
844
+ answerAdditions: parseJson(r.answerAdditions, []),
845
+ standings: parseJson(r.standings, {}),
846
+ sets: parseJson(r.sets, []),
548
847
  createdAt: r.createdAt,
549
848
  updatedAt: r.updatedAt,
550
849
  };
@@ -555,7 +854,7 @@ export class SqlStore {
555
854
  return {
556
855
  id: r.id,
557
856
  sessionId: r.sessionId,
558
- mock: r.mockId,
857
+ design: r.designId,
559
858
  state: r.state ?? null,
560
859
  variant: r.variant,
561
860
  status: (r.status || "open"),
@@ -566,18 +865,26 @@ export class SqlStore {
566
865
  version: r.version,
567
866
  history,
568
867
  ...(knobs ? { knobs } : {}),
569
- ...(r.note == null ? {} : { note: r.note }),
868
+ ...(r.description == null ? {} : { description: r.description }),
570
869
  // The column keeps its first name, from when embeds were called slots.
571
870
  embeds: parseJson(r.slots, []),
572
871
  ...(r.forkedFrom == null ? {} : { forkedFrom: parseJson(r.forkedFrom, null) }),
573
- ...(r.starred ? { starred: true } : {}),
872
+ ...(r.copiedFrom == null ? {} : { copiedFrom: parseJson(r.copiedFrom, null) }),
574
873
  ...(r.status === "accepted" && r.acceptedVersion != null
575
874
  ? { acceptedVersion: r.acceptedVersion }
576
875
  : {}),
577
876
  ...(hidden.length ? { hiddenVersions: hidden } : {}),
877
+ ...(r.archiveReason == null ? {} : { archiveReason: r.archiveReason }),
878
+ ...(r.stayedVersion == null ? {} : { stayedVersion: r.stayedVersion }),
879
+ ...(r.archivedAt == null ? {} : { archivedAt: r.archivedAt }),
578
880
  ...(r.curFrom == null ? {} : { from: r.curFrom }),
579
881
  ...(r.curPrompt == null ? {} : { prompt: r.curPrompt }),
580
882
  ...(r.curAuthor == null ? {} : { author: r.curAuthor }),
883
+ ...(r.curSession == null ? {} : { publishedBy: r.curSession }),
884
+ ...(r.amendedAt == null ? {} : { amendedAt: r.amendedAt }),
885
+ ...(r.curContract == null ? {} : { contract: r.curContract }),
886
+ ...(r.curSystem == null ? {} : { system: r.curSystem }),
887
+ ...(r.rewrites ? { rewrites: r.rewrites } : {}),
581
888
  };
582
889
  }
583
890
  // The BLOB comes back as an ArrayBuffer (real DO) or a Uint8Array
@@ -603,7 +910,7 @@ export class SqlStore {
603
910
  id: r.id,
604
911
  seq: r.seq,
605
912
  sessionId: r.sessionId,
606
- mockId: r.mockId ?? null,
913
+ designId: r.designId ?? null,
607
914
  postId: r.postId ?? null,
608
915
  author: r.author,
609
916
  text: r.text,
@@ -666,11 +973,18 @@ export class SqlStore {
666
973
  this.atomic(() => {
667
974
  this.sql.exec("DELETE FROM comments WHERE sessionId = ?", id);
668
975
  this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE sessionId = ?)", id);
976
+ const drawn = JSON.stringify(this.sql
977
+ .exec("SELECT DISTINCT designId FROM posts WHERE sessionId = ?", id)
978
+ .toArray()
979
+ .map((r) => r.designId));
669
980
  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);
981
+ // A design left with no variants has nothing to show, unless it was declared ahead
982
+ // of being drawn, which only goes with the session that planned it. One this
983
+ // session drew is not a plan, whoever wrote to it last: it would come back as one.
984
+ const emptied = "id NOT IN (SELECT designId FROM posts) AND (kind = 'idea' OR sessionId = ? OR id IN (SELECT value FROM json_each(?)))";
985
+ this.sql.exec(`DELETE FROM comments WHERE designId IN (SELECT id FROM designs WHERE ${emptied})`, id, drawn);
986
+ this.sql.exec(`DELETE FROM designs WHERE ${emptied}`, id, drawn);
987
+ this.sql.exec("UPDATE designs SET sessionId = NULL WHERE sessionId = ?", id);
674
988
  // Posts are gone, so the referenced set reflects survivors only: drop this
675
989
  // session's own assets except any a surviving surface still points at
676
990
  // (assets are content-addressed and may be shared across sessions).
@@ -704,91 +1018,132 @@ export class SqlStore {
704
1018
  return this.settingSync(key);
705
1019
  }
706
1020
  async setSetting(key, value) {
707
- if (key.startsWith("design:"))
708
- this.invalidateAssetRefs();
709
1021
  this.sql.exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", stripNul(key), stripNul(value));
710
1022
  }
711
- // --- projects / mocks ---
1023
+ // --- projects / designs ---
712
1024
  async listProjects() {
713
- return summarizeProjects(await this.listMocks(), await this.listSessions(), await this.listPosts());
1025
+ return summarizeProjects(await this.listDesigns(), await this.listSessions());
1026
+ }
1027
+ // Spelled out because `SELECT *` on posts reads the bodies, the very cost search avoids.
1028
+ async listJumpRows() {
1029
+ return {
1030
+ designs: this.sql
1031
+ .exec("SELECT id, project, slug, title, kind, area, states, asks, widths, archivedAt, updatedAt FROM designs")
1032
+ .toArray()
1033
+ .map((r) => ({
1034
+ id: r.id,
1035
+ project: r.project,
1036
+ slug: r.slug,
1037
+ title: r.title,
1038
+ kind: r.kind,
1039
+ area: r.area ?? null,
1040
+ states: parseJson(r.states, []),
1041
+ asks: parseJson(r.asks, []),
1042
+ widths: parseJson(r.widths, []),
1043
+ archivedAt: r.archivedAt ?? null,
1044
+ updatedAt: r.updatedAt,
1045
+ })),
1046
+ variants: this.sql
1047
+ .exec("SELECT designId, state, variant, description, status FROM posts")
1048
+ .toArray()
1049
+ .map((r) => ({
1050
+ designId: r.designId,
1051
+ state: r.state ?? null,
1052
+ variant: r.variant,
1053
+ description: r.description ?? null,
1054
+ status: r.status,
1055
+ })),
1056
+ };
714
1057
  }
715
- async listMocks(project) {
1058
+ async listDesigns(project) {
716
1059
  const rows = project === undefined
717
- ? this.sql.exec("SELECT * FROM mocks ORDER BY updatedAt DESC, rowid DESC").toArray()
1060
+ ? this.sql.exec("SELECT * FROM designs ORDER BY updatedAt DESC, rowid DESC").toArray()
718
1061
  : this.sql
719
- .exec("SELECT * FROM mocks WHERE project = ? ORDER BY updatedAt DESC, rowid DESC", project)
1062
+ .exec("SELECT * FROM designs WHERE project = ? ORDER BY updatedAt DESC, rowid DESC", project)
720
1063
  .toArray();
721
- return rows.map((r) => this.rowToMock(r));
1064
+ return rows.map((r) => this.rowToDesign(r));
722
1065
  }
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;
1066
+ async getDesign(id) {
1067
+ const rows = this.sql.exec("SELECT * FROM designs WHERE id = ?", id).toArray();
1068
+ return rows.length > 0 ? this.rowToDesign(rows[0]) : null;
726
1069
  }
727
- async findMock(project, slug) {
1070
+ async findDesign(project, slug) {
728
1071
  const rows = this.sql
729
- .exec("SELECT * FROM mocks WHERE project = ? AND slug = ?", project, slug)
1072
+ .exec("SELECT * FROM designs WHERE project = ? AND slug = ?", project, slug)
730
1073
  .toArray();
731
- return rows.length > 0 ? this.rowToMock(rows[0]) : null;
1074
+ return rows.length > 0 ? this.rowToDesign(rows[0]) : null;
732
1075
  }
733
- async createMock(input) {
1076
+ async createDesign(input) {
734
1077
  const now = new Date().toISOString();
735
- const mock = {
1078
+ const design = {
736
1079
  id: newId(),
737
1080
  project: stripNul(input.project).trim() || DEFAULT_PROJECT,
738
1081
  slug: slugify(stripNul(input.slug)),
739
1082
  title: stripNul(input.title)?.trim() || input.slug,
740
- kind: isMockKind(input.kind) ? input.kind : "sketch",
1083
+ kind: isDesignKind(input.kind) ? input.kind : "idea",
741
1084
  area: label(input.area),
742
1085
  source: label(input.source),
743
1086
  states: (input.states ?? []).map((s) => stripNul(s)),
744
- situations: {},
745
1087
  asks: [],
746
1088
  knobs: input.knobs ?? {},
747
1089
  widths: input.widths ?? [],
748
1090
  draft: null,
749
1091
  sessionId: input.sessionId ?? null,
750
- closedAt: null,
1092
+ shortlist: !!input.shortlist,
1093
+ archivedAt: null,
1094
+ archiveReason: null,
1095
+ notes: null,
1096
+ stateOutlines: {},
1097
+ answerAdditions: [],
1098
+ standings: {},
1099
+ sets: [],
751
1100
  createdAt: now,
752
1101
  updatedAt: now,
753
1102
  };
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;
1103
+ 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);
1104
+ return design;
756
1105
  }
757
- async updateMock(id, patch) {
758
- const mock = await this.getMock(id);
759
- if (!mock)
1106
+ async updateDesign(id, patch) {
1107
+ const design = await this.getDesign(id);
1108
+ if (!design)
760
1109
  return null;
761
1110
  const next = {
762
- ...mock,
763
- ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || mock.title }),
1111
+ ...design,
1112
+ ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || design.title }),
764
1113
  ...(patch.kind !== undefined && { kind: patch.kind }),
765
1114
  ...(patch.area !== undefined && { area: label(patch.area) }),
766
1115
  ...(patch.source !== undefined && { source: label(patch.source) }),
767
1116
  ...(patch.states !== undefined && { states: patch.states.map((s) => stripNul(s)) }),
768
- ...(patch.situations !== undefined && { situations: patch.situations }),
769
1117
  ...(patch.asks !== undefined && { asks: patch.asks }),
770
1118
  ...(patch.knobs !== undefined && { knobs: patch.knobs }),
771
1119
  ...(patch.widths !== undefined && { widths: patch.widths }),
772
1120
  ...(patch.sessionId !== undefined && { sessionId: patch.sessionId }),
773
- ...(patch.closedAt !== undefined && { closedAt: patch.closedAt }),
1121
+ ...(patch.archivedAt !== undefined && { archivedAt: patch.archivedAt }),
1122
+ ...(patch.archivedAt === null && { archiveReason: null }),
1123
+ ...(patch.archiveReason !== undefined && { archiveReason: patch.archiveReason }),
1124
+ ...(patch.notes !== undefined && { notes: patch.notes }),
1125
+ ...(patch.stateOutlines !== undefined && { stateOutlines: patch.stateOutlines }),
1126
+ ...(patch.answerAdditions !== undefined && { answerAdditions: patch.answerAdditions }),
1127
+ ...(patch.standings !== undefined && { standings: patch.standings }),
1128
+ ...(patch.sets !== undefined && { sets: patch.sets }),
774
1129
  updatedAt: new Date().toISOString(),
775
1130
  };
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);
1131
+ 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
1132
  if (patch.asks !== undefined)
778
1133
  this.invalidateAssetRefs();
779
1134
  return next;
780
1135
  }
781
- async removeMock(id) {
782
- const mock = await this.getMock(id);
783
- if (!mock)
1136
+ async removeDesign(id) {
1137
+ const design = await this.getDesign(id);
1138
+ if (!design)
784
1139
  return false;
785
1140
  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);
1141
+ this.sql.exec("DELETE FROM comments WHERE designId = ?", id);
1142
+ this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE designId = ?)", id);
1143
+ this.sql.exec("DELETE FROM posts WHERE designId = ?", id);
1144
+ this.sql.exec("DELETE FROM designs WHERE id = ?", id);
1145
+ for (const flow of this.flowsOf(design.project)) {
1146
+ const steps = flow.steps.filter((x) => x.design !== design.slug);
792
1147
  if (steps.length !== flow.steps.length)
793
1148
  this.writeFlow(flow, steps);
794
1149
  }
@@ -796,23 +1151,55 @@ export class SqlStore {
796
1151
  this.invalidateAssetRefs();
797
1152
  return true;
798
1153
  }
799
- async renameMock(id, slug) {
1154
+ async nameFirstState(id, state) {
1155
+ const name = stripNul(state);
1156
+ const design = await this.getDesign(id);
1157
+ if (!design || design.states.length > 0)
1158
+ return null;
1159
+ const updatedAt = new Date().toISOString();
1160
+ this.atomic(() => {
1161
+ this.sql.exec("UPDATE posts SET state = ? WHERE designId = ? AND state IS NULL", name, id);
1162
+ this.sql.exec("UPDATE designs SET states = ?, updatedAt = ? WHERE id = ?", JSON.stringify([name]), updatedAt, id);
1163
+ for (const r of this.sql
1164
+ .exec("SELECT id, copiedFrom FROM posts WHERE copiedFrom IS NOT NULL AND designId IN (SELECT id FROM designs WHERE project = ?)", design.project)
1165
+ .toArray()) {
1166
+ const from = parseJson(r.copiedFrom, null);
1167
+ if (from?.design !== id || from.state !== null)
1168
+ continue;
1169
+ this.sql.exec("UPDATE posts SET copiedFrom = ? WHERE id = ?", JSON.stringify({ ...from, state: name }), r.id);
1170
+ }
1171
+ for (const flow of this.flowsOf(design.project)) {
1172
+ if (!flow.steps.some((x) => x.design === design.slug && x.state === null))
1173
+ continue;
1174
+ this.writeFlow(flow, flow.steps.map((x) => x.design === design.slug && x.state === null ? { ...x, state: name } : x));
1175
+ }
1176
+ if (design.draft) {
1177
+ const comments = design.draft.comments.map((c) => c.state === null ? { ...c, state: name } : c);
1178
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", JSON.stringify({ ...design.draft, comments }), id);
1179
+ }
1180
+ });
1181
+ // Copies elsewhere changed too, so nothing cached can be patched one post at a time.
1182
+ this.postCache.clear();
1183
+ this.postCacheBytes = 0;
1184
+ return this.getDesign(id);
1185
+ }
1186
+ async renameDesign(id, slug) {
800
1187
  const to = slugify(stripNul(slug));
801
1188
  return this.atomic(() => {
802
- const rows = this.sql.exec("SELECT * FROM mocks WHERE id = ?", id).toArray();
1189
+ const rows = this.sql.exec("SELECT * FROM designs WHERE id = ?", id).toArray();
803
1190
  if (rows.length === 0)
804
1191
  return null;
805
- const mock = this.rowToMock(rows[0]);
806
- const from = mock.slug;
1192
+ const design = this.rowToDesign(rows[0]);
1193
+ const from = design.slug;
807
1194
  if (to === from)
808
- return mock;
1195
+ return design;
809
1196
  const taken = this.sql
810
- .exec("SELECT id FROM mocks WHERE project = ? AND slug = ?", mock.project, to)
1197
+ .exec("SELECT id FROM designs WHERE project = ? AND slug = ?", design.project, to)
811
1198
  .toArray();
812
1199
  if (taken.length > 0)
813
1200
  return null;
814
1201
  const updatedAt = new Date().toISOString();
815
- this.sql.exec("UPDATE mocks SET slug = ?, updatedAt = ? WHERE id = ?", to, updatedAt, id);
1202
+ this.sql.exec("UPDATE designs SET slug = ?, updatedAt = ? WHERE id = ?", to, updatedAt, id);
816
1203
  // History is rewritten too: embeds resolve by slug at render time, so an
817
1204
  // old version left naming the old slug would show its components missing.
818
1205
  const rewrite = (raw) => {
@@ -829,7 +1216,7 @@ export class SqlStore {
829
1216
  return changed ? JSON.stringify(next) : null;
830
1217
  };
831
1218
  const posts = this.sql
832
- .exec("SELECT id, surfaces, slots FROM posts WHERE mockId IN (SELECT id FROM mocks WHERE project = ?)", mock.project)
1219
+ .exec("SELECT id, surfaces, slots FROM posts WHERE designId IN (SELECT id FROM designs WHERE project = ?)", design.project)
833
1220
  .toArray();
834
1221
  for (const r of posts) {
835
1222
  const postId = r.id;
@@ -849,25 +1236,25 @@ export class SqlStore {
849
1236
  this.sql.exec("UPDATE post_versions SET surfaces = ? WHERE postId = ? AND version = ?", old, postId, v.version);
850
1237
  }
851
1238
  }
852
- for (const flow of this.flowsOf(mock.project)) {
853
- if (!flow.steps.some((x) => x.mock === from))
1239
+ for (const flow of this.flowsOf(design.project)) {
1240
+ if (!flow.steps.some((x) => x.design === from))
854
1241
  continue;
855
- this.writeFlow(flow, flow.steps.map((x) => (x.mock === from ? { ...x, mock: to } : x)));
1242
+ this.writeFlow(flow, flow.steps.map((x) => (x.design === from ? { ...x, design: to } : x)));
856
1243
  }
857
- return { ...mock, slug: to, updatedAt };
1244
+ return { ...design, slug: to, updatedAt };
858
1245
  });
859
1246
  }
860
1247
  async renameProject(from, to) {
861
1248
  const target = stripNul(to).trim();
862
1249
  return this.atomic(() => {
863
1250
  const occupied = this.sql
864
- .exec("SELECT COUNT(*) AS n FROM mocks WHERE project = ?", target)
1251
+ .exec("SELECT COUNT(*) AS n FROM designs WHERE project = ?", target)
865
1252
  .one().n;
866
1253
  if (occupied > 0)
867
1254
  return false;
868
- // A project with no mocks has nothing a leftover flow could walk.
1255
+ // A project with no designs has nothing a leftover flow could walk.
869
1256
  this.sql.exec("DELETE FROM flows WHERE project = ?", target);
870
- this.sql.exec("UPDATE mocks SET project = ? WHERE project = ?", target, from);
1257
+ this.sql.exec("UPDATE designs SET project = ? WHERE project = ?", target, from);
871
1258
  this.sql.exec("UPDATE sessions SET project = ? WHERE project = ?", target, from);
872
1259
  this.sql.exec("UPDATE flows SET project = ? WHERE project = ?", target, from);
873
1260
  for (const prefix of PROJECT_SETTINGS) {
@@ -876,12 +1263,98 @@ export class SqlStore {
876
1263
  continue;
877
1264
  this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", `${prefix}:${target}`, value);
878
1265
  this.sql.exec("DELETE FROM settings WHERE key = ?", `${prefix}:${from}`);
879
- if (prefix === "design")
880
- this.invalidateAssetRefs();
1266
+ }
1267
+ if (this.sql.exec("SELECT 1 FROM systems WHERE project = ?", from).toArray().length) {
1268
+ this.sql.exec("DELETE FROM systems WHERE project = ?", target);
1269
+ this.sql.exec("DELETE FROM system_revisions WHERE project = ?", target);
1270
+ for (const table of ["systems", "system_revisions"]) {
1271
+ for (const r of this.sql
1272
+ .exec(`SELECT rowid AS id, json FROM ${table} WHERE project = ?`, from)
1273
+ .toArray()) {
1274
+ const moved = { ...parseJson(r.json, {}), project: target };
1275
+ this.sql.exec(`UPDATE ${table} SET project = ?, json = ? WHERE rowid = ?`, target, JSON.stringify(moved), r.id);
1276
+ }
1277
+ }
881
1278
  }
882
1279
  return true;
883
1280
  });
884
1281
  }
1282
+ // --- systems ---
1283
+ async getSystem(project) {
1284
+ const rows = this.sql.exec("SELECT json FROM systems WHERE project = ?", project).toArray();
1285
+ return rows.length ? normalizeSystem(project, parseJson(rows[0].json, null)) : null;
1286
+ }
1287
+ async putSystem(system, meta) {
1288
+ this.atomic(() => this.writeSystem(system, meta));
1289
+ this.invalidateAssetRefs();
1290
+ return system;
1291
+ }
1292
+ async commitSystemDecision(input) {
1293
+ const tell = input.tell;
1294
+ const comment = this.atomic(() => {
1295
+ this.writeSystem(input.system, { sessionId: null, summary: input.summary });
1296
+ return tell
1297
+ ? this.insertComment({
1298
+ sessionId: tell.sessionId,
1299
+ author: "user",
1300
+ text: "",
1301
+ kind: "reply",
1302
+ payload: tell.payload,
1303
+ })
1304
+ : null;
1305
+ });
1306
+ this.invalidateAssetRefs();
1307
+ return comment;
1308
+ }
1309
+ // Unwrapped, so a reply's transaction can carry it.
1310
+ writeSystem(system, meta) {
1311
+ const json = JSON.stringify(system);
1312
+ const at = new Date().toISOString();
1313
+ {
1314
+ this.sql.exec("INSERT INTO systems (project, rev, json) VALUES (?, ?, ?) ON CONFLICT(project) DO UPDATE SET rev = excluded.rev, json = excluded.json", system.project, system.rev, json);
1315
+ this.sql.exec("INSERT OR REPLACE INTO system_revisions (project, rev, json, at, sessionId, summary) VALUES (?, ?, ?, ?, ?, ?)", system.project, system.rev, json, at, meta.sessionId, stripNul(meta.summary).slice(0, SYSTEM_LIMITS.summary));
1316
+ this.sql.exec("DELETE FROM system_revisions WHERE project = ? AND rev <= ?", system.project, system.rev - SYSTEM_LIMITS.revisions);
1317
+ }
1318
+ }
1319
+ async listSystemRevisions(project) {
1320
+ return this.sql
1321
+ .exec("SELECT rev, at, sessionId, summary FROM system_revisions WHERE project = ? ORDER BY rev DESC", project)
1322
+ .toArray()
1323
+ .map((r) => ({
1324
+ rev: r.rev,
1325
+ at: r.at,
1326
+ sessionId: r.sessionId ?? null,
1327
+ summary: r.summary,
1328
+ }));
1329
+ }
1330
+ async getSystemRevision(project, rev) {
1331
+ const rows = this.sql
1332
+ .exec("SELECT json FROM system_revisions WHERE project = ? AND rev = ?", project, rev)
1333
+ .toArray();
1334
+ return rows.length ? normalizeSystem(project, parseJson(rows[0].json, null)) : null;
1335
+ }
1336
+ // Each project's old design settings become its system, with the repo's
1337
+ // tokens derived, and the setting goes.
1338
+ migrateSystems() {
1339
+ const rows = this.sql
1340
+ .exec("SELECT key, value FROM settings WHERE key LIKE 'design:%'")
1341
+ .toArray();
1342
+ if (rows.length === 0)
1343
+ return;
1344
+ const at = new Date().toISOString();
1345
+ for (const r of rows) {
1346
+ const project = r.key.slice("design:".length);
1347
+ const have = this.sql.exec("SELECT 1 FROM systems WHERE project = ?", project).toArray();
1348
+ if (have.length === 0) {
1349
+ const system = { ...systemFromDesignSettings(project, parseJson(r.value, null)), rev: 1 };
1350
+ const json = JSON.stringify(system);
1351
+ this.sql.exec("INSERT INTO systems (project, rev, json) VALUES (?, 1, ?)", project, json);
1352
+ this.sql.exec("INSERT INTO system_revisions (project, rev, json, at, sessionId, summary) VALUES (?, 1, ?, ?, NULL, ?)", project, json, at, "from what the project stored before");
1353
+ }
1354
+ this.sql.exec("DELETE FROM settings WHERE key = ?", r.key);
1355
+ }
1356
+ this.invalidateAssetRefs();
1357
+ }
885
1358
  // --- flows ---
886
1359
  flowsOf(project) {
887
1360
  return this.sql
@@ -909,7 +1382,7 @@ export class SqlStore {
909
1382
  return this.flowsOf(project);
910
1383
  }
911
1384
  async placeFlowStep(project, name, screen, step) {
912
- const clean = { mock: stripNul(screen.mock), state: stripNul(screen.state) };
1385
+ const clean = { design: stripNul(screen.design), state: stripNul(screen.state) };
913
1386
  const label = stripNul(name);
914
1387
  return this.atomic(() => {
915
1388
  const now = new Date().toISOString();
@@ -928,24 +1401,27 @@ export class SqlStore {
928
1401
  return this.writeFlow(flow, steps);
929
1402
  });
930
1403
  }
931
- async putDraft(mockId, draft) {
932
- const mock = await this.getMock(mockId);
933
- if (!mock)
1404
+ async putDraft(designId, draft) {
1405
+ const design = await this.getDesign(designId);
1406
+ if (!design)
934
1407
  return null;
935
- this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, mockId);
1408
+ this.sql.exec("UPDATE designs SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, designId);
936
1409
  this.invalidateAssetRefs();
937
- return { ...mock, draft };
1410
+ return { ...design, draft };
938
1411
  }
939
- touchMock(mockId, at) {
940
- this.sql.exec("UPDATE mocks SET updatedAt = ? WHERE id = ?", at, mockId);
1412
+ touchDesign(designId, at) {
1413
+ this.sql.exec("UPDATE designs SET updatedAt = ? WHERE id = ?", at, designId);
941
1414
  }
942
1415
  // --- posts ---
943
1416
  async listPosts(query = {}) {
1417
+ if (query.designId !== undefined && query.sessionId === undefined) {
1418
+ return this.postsOfDesign(query.designId).slice();
1419
+ }
944
1420
  const clauses = [];
945
1421
  const params = [];
946
- if (query.mockId !== undefined) {
947
- clauses.push("mockId = ?");
948
- params.push(query.mockId);
1422
+ if (query.designId !== undefined) {
1423
+ clauses.push("designId = ?");
1424
+ params.push(query.designId);
949
1425
  }
950
1426
  if (query.sessionId !== undefined) {
951
1427
  clauses.push("sessionId = ?");
@@ -957,6 +1433,129 @@ export class SqlStore {
957
1433
  .toArray();
958
1434
  return this.rowsToPosts(rows);
959
1435
  }
1436
+ async listPostHeads(project) {
1437
+ const scope = project === undefined ? "" : "WHERE designId IN (SELECT id FROM designs WHERE project = ?)";
1438
+ const args = project === undefined ? [] : [project];
1439
+ const history = new Map();
1440
+ for (const r of this.sql
1441
+ .exec(`SELECT postId, version, head FROM post_versions WHERE postId IN (SELECT id FROM posts ${scope}) ORDER BY version ASC`, ...args)
1442
+ .toArray()) {
1443
+ const id = r.postId;
1444
+ let list = history.get(id);
1445
+ if (!list)
1446
+ history.set(id, (list = []));
1447
+ list.push({ version: r.version, surfaces: parseJson(r.head, []) });
1448
+ }
1449
+ return this.sql
1450
+ .exec(`SELECT ${POST_HEAD_COLUMNS} FROM posts ${scope} ORDER BY createdAt ASC, rowid ASC`, ...args)
1451
+ .toArray()
1452
+ .map((r) => ({
1453
+ ...this.rowToPost(r, []),
1454
+ surfaces: parseJson(r.head, []),
1455
+ history: history.get(r.id) ?? [],
1456
+ }));
1457
+ }
1458
+ // Every open design screen asks, and a project's posts can be hundreds of MB:
1459
+ // the index holds each column read here, so no row with a body is touched.
1460
+ async listCopies(project) {
1461
+ return this.sql
1462
+ .exec("SELECT designId, state, variant, status, copiedFrom FROM posts WHERE copiedFrom IS NOT NULL AND designId IN (SELECT id FROM designs WHERE project = ?)", project)
1463
+ .toArray()
1464
+ .filter((r) => isLive(r.status))
1465
+ .map((r) => ({
1466
+ design: r.designId,
1467
+ state: r.state,
1468
+ variant: r.variant,
1469
+ copiedFrom: parseJson(r.copiedFrom, null),
1470
+ }));
1471
+ }
1472
+ postsOfDesign(designId) {
1473
+ const hit = this.postCache.get(designId);
1474
+ if (hit) {
1475
+ this.postCache.delete(designId);
1476
+ this.postCache.set(designId, hit);
1477
+ return hit.posts;
1478
+ }
1479
+ const rows = this.sql
1480
+ .exec("SELECT * FROM posts WHERE designId = ? ORDER BY createdAt ASC, rowid ASC", designId)
1481
+ .toArray();
1482
+ let bytes = 0;
1483
+ for (const r of rows)
1484
+ bytes += r.surfaces.length;
1485
+ const posts = this.rowsToPosts(rows, (n) => (bytes += n));
1486
+ if (bytes > this.postCacheLimit)
1487
+ return posts;
1488
+ this.postCache.set(designId, { posts, bytes });
1489
+ this.postCacheBytes += bytes;
1490
+ this.trimPostCache();
1491
+ return posts;
1492
+ }
1493
+ trimPostCache() {
1494
+ for (const [id, entry] of this.postCache) {
1495
+ if (this.postCacheBytes <= this.postCacheLimit)
1496
+ break;
1497
+ this.postCache.delete(id);
1498
+ this.postCacheBytes -= entry.bytes;
1499
+ }
1500
+ }
1501
+ // A synchronous write to these posts of one design, re-reading just them after:
1502
+ // a publish into a big lab would otherwise leave the viewer's next read parsing
1503
+ // every design in it.
1504
+ writingPosts(designId, ids, write) {
1505
+ this.holdPostDrop = true;
1506
+ let out;
1507
+ try {
1508
+ out = write();
1509
+ }
1510
+ catch (err) {
1511
+ const entry = this.postCache.get(designId);
1512
+ if (entry) {
1513
+ this.postCache.delete(designId);
1514
+ this.postCacheBytes -= entry.bytes;
1515
+ }
1516
+ throw err;
1517
+ }
1518
+ finally {
1519
+ this.holdPostDrop = false;
1520
+ }
1521
+ const entry = this.postCache.get(designId);
1522
+ if (entry) {
1523
+ const weigh = (p) => p
1524
+ ? [p.surfaces, ...p.history.map((h) => h.surfaces)].reduce((n, x) => n + JSON.stringify(x).length, 0)
1525
+ : 0;
1526
+ for (const id of ids) {
1527
+ const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray();
1528
+ const fresh = rows.length ? this.rowsToPosts(rows)[0] : null;
1529
+ const stale = entry.posts.find((p) => p.id === id);
1530
+ const change = weigh(fresh) - weigh(stale);
1531
+ entry.bytes += change;
1532
+ this.postCacheBytes += change;
1533
+ if (!fresh)
1534
+ entry.posts = entry.posts.filter((p) => p.id !== id);
1535
+ else if (stale)
1536
+ entry.posts = entry.posts.map((p) => (p.id === id ? fresh : p));
1537
+ else
1538
+ entry.posts = [...entry.posts, fresh];
1539
+ }
1540
+ this.trimPostCache();
1541
+ }
1542
+ return out;
1543
+ }
1544
+ cachedPost(id) {
1545
+ for (const { posts } of this.postCache.values()) {
1546
+ const hit = posts.find((p) => p.id === id);
1547
+ if (hit)
1548
+ return hit;
1549
+ }
1550
+ return null;
1551
+ }
1552
+ // For a write that leaves the bodies alone, so the size stands. Archiving
1553
+ // through a lab would otherwise re-read every design in it after each ×.
1554
+ recache(post) {
1555
+ const entry = this.postCache.get(post.design);
1556
+ if (entry)
1557
+ entry.posts = entry.posts.map((p) => (p.id === post.id ? post : p));
1558
+ }
960
1559
  async countPostsBySession() {
961
1560
  const counts = new Map();
962
1561
  for (const row of this.sql
@@ -970,22 +1569,25 @@ export class SqlStore {
970
1569
  const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray();
971
1570
  return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
972
1571
  }
973
- async findPost(mockId, state, variant) {
1572
+ async findPost(designId, state, variant) {
1573
+ const cached = this.postCache.get(designId);
1574
+ if (cached)
1575
+ return cached.posts.find((p) => p.state === state && p.variant === variant) ?? null;
974
1576
  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)
1577
+ .exec("SELECT * FROM posts WHERE designId = ? AND state IS ? AND variant = ? ORDER BY createdAt ASC LIMIT 1", designId, state, variant)
976
1578
  .toArray();
977
1579
  return rows.length > 0 ? this.rowsToPosts(rows)[0] : null;
978
1580
  }
979
1581
  async createPost(input) {
980
1582
  if (!(await this.getSession(input.sessionId)))
981
1583
  return null;
982
- if (!(await this.getMock(input.mock)))
1584
+ if (!(await this.getDesign(input.design)))
983
1585
  return null;
984
1586
  const now = new Date().toISOString();
985
1587
  const post = {
986
1588
  id: newId(),
987
1589
  sessionId: input.sessionId,
988
- mock: input.mock,
1590
+ design: input.design,
989
1591
  state: input.state === null ? null : stripNul(input.state),
990
1592
  variant: stripNul(input.variant)?.trim() || DEFAULT_VARIANT,
991
1593
  status: "open",
@@ -996,16 +1598,22 @@ export class SqlStore {
996
1598
  version: 1,
997
1599
  history: [],
998
1600
  ...(input.knobs ? { knobs: input.knobs } : {}),
999
- ...(stripNul(input.note ?? "").trim() ? { note: stripNul(input.note ?? "").trim() } : {}),
1601
+ ...(stripNul(input.description ?? "").trim()
1602
+ ? { description: stripNul(input.description ?? "").trim() }
1603
+ : {}),
1000
1604
  embeds: input.embeds ?? [],
1001
1605
  ...(input.forkedFrom ? { forkedFrom: input.forkedFrom } : {}),
1606
+ ...(input.copiedFrom ? { copiedFrom: input.copiedFrom } : {}),
1002
1607
  ...(input.from === undefined ? {} : { from: input.from }),
1003
1608
  ...(input.prompt === undefined ? {} : { prompt: stripNul(input.prompt) }),
1004
1609
  ...(input.author === undefined ? {} : { author: stripNul(input.author) }),
1610
+ publishedBy: input.publishedBy ?? input.sessionId,
1611
+ contract: input.contract ?? CURRENT_CONTRACT,
1612
+ ...(input.system === undefined ? {} : { system: input.system }),
1005
1613
  };
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);
1614
+ 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, curContract, curSystem) 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, post.contract ?? null, post.system ?? null));
1007
1615
  this.touch(input.sessionId);
1008
- this.touchMock(post.mock, now);
1616
+ this.touchDesign(post.design, now);
1009
1617
  this.addAssetRefs(post.surfaces);
1010
1618
  return post;
1011
1619
  }
@@ -1024,6 +1632,10 @@ export class SqlStore {
1024
1632
  ...(post.from === undefined ? {} : { from: post.from }),
1025
1633
  ...(post.prompt === undefined ? {} : { prompt: post.prompt }),
1026
1634
  ...(post.author === undefined ? {} : { author: post.author }),
1635
+ ...(post.publishedBy === undefined ? {} : { publishedBy: post.publishedBy }),
1636
+ ...(post.amendedAt === undefined ? {} : { amendedAt: post.amendedAt }),
1637
+ ...(post.contract === undefined ? {} : { contract: post.contract }),
1638
+ ...(post.system === undefined ? {} : { system: post.system }),
1027
1639
  };
1028
1640
  // The version in use is kept past the limit: export and embeds resolve to it.
1029
1641
  const pin = post.acceptedVersion ?? -1;
@@ -1034,7 +1646,9 @@ export class SqlStore {
1034
1646
  const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
1035
1647
  const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
1036
1648
  const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
1037
- const note = patch.note === undefined ? post.note : stripNul(patch.note).trim() || undefined;
1649
+ const description = patch.description === undefined
1650
+ ? post.description
1651
+ : stripNul(patch.description).trim() || undefined;
1038
1652
  const embeds = patch.embeds ?? post.embeds;
1039
1653
  const state = patch.state === undefined ? post.state : patch.state;
1040
1654
  const version = post.version + 1;
@@ -1043,22 +1657,31 @@ export class SqlStore {
1043
1657
  const from = patch.from ?? post.version;
1044
1658
  const prompt = stripNul(patch.prompt ?? "");
1045
1659
  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) {
1660
+ const publishedBy = patch.publishedBy ?? null;
1661
+ const contract = patch.contract ?? CURRENT_CONTRACT;
1662
+ const system = patch.system ?? null;
1663
+ const wrote = this.writingPosts(post.design, [id], () => {
1664
+ this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, head = ?, knobs = ?, description = ?, slots = ?, state = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ?, curSession = ?, amendedAt = NULL, curContract = ?, curSystem = ? 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, contract, system, id, post.version);
1665
+ if (this.sql.exec("SELECT changes() AS n").one().n === 0)
1666
+ return false;
1049
1667
  this.insertVersion(id, archived);
1050
1668
  this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version <= ? AND version != ?", id, version - 1 - HISTORY_LIMIT, pin);
1669
+ return true;
1670
+ });
1671
+ if (wrote) {
1051
1672
  this.touch(post.sessionId);
1052
- this.touchMock(post.mock, updatedAt);
1673
+ this.touchDesign(post.design, updatedAt);
1053
1674
  if (patch.surfaces !== undefined)
1054
1675
  this.addAssetRefs(surfaces);
1055
- const { knobs: _knobs, note: _note, author: _author, ...rest } = post;
1676
+ const { knobs: _knobs, description: _description, author: _author, publishedBy: _by, amendedAt: _amended, contract: _contract, system: _system, ...rest } = post;
1056
1677
  return {
1057
1678
  ...rest,
1679
+ contract,
1680
+ ...(system === null ? {} : { system }),
1058
1681
  title,
1059
1682
  surfaces,
1060
1683
  ...(knobs ? { knobs } : {}),
1061
- ...(note ? { note } : {}),
1684
+ ...(description ? { description } : {}),
1062
1685
  embeds,
1063
1686
  state,
1064
1687
  version,
@@ -1067,11 +1690,136 @@ export class SqlStore {
1067
1690
  from,
1068
1691
  prompt,
1069
1692
  ...(author === null ? {} : { author }),
1693
+ ...(publishedBy === null ? {} : { publishedBy }),
1070
1694
  };
1071
1695
  }
1072
1696
  }
1073
1697
  return null;
1074
1698
  }
1699
+ async amendPost(id, version, patch) {
1700
+ const post = await this.getPost(id);
1701
+ if (!post || post.version !== version)
1702
+ return null;
1703
+ const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
1704
+ const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
1705
+ const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
1706
+ const description = patch.description === undefined
1707
+ ? post.description
1708
+ : stripNul(patch.description).trim() || undefined;
1709
+ const embeds = patch.embeds ?? post.embeds;
1710
+ const from = patch.from ?? post.from;
1711
+ const prompt = patch.prompt === undefined ? post.prompt : stripNul(patch.prompt);
1712
+ const author = patch.author === undefined ? post.author : stripNul(patch.author);
1713
+ const amendedAt = new Date().toISOString();
1714
+ const rewrites = (post.rewrites ?? 0) + 1;
1715
+ const contract = patch.contract ?? CURRENT_CONTRACT;
1716
+ const system = patch.system ?? post.system;
1717
+ const wrote = this.writingPosts(post.design, [id], () => {
1718
+ this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, head = ?, knobs = ?, description = ?, slots = ?, curFrom = ?, curPrompt = ?, curAuthor = ?, amendedAt = ?, rewrites = ?, curContract = ?, curSystem = ? 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, contract, system ?? null, id, version);
1719
+ return this.sql.exec("SELECT changes() AS n").one().n > 0;
1720
+ });
1721
+ if (!wrote)
1722
+ return null;
1723
+ this.touch(post.sessionId);
1724
+ this.touchDesign(post.design, amendedAt);
1725
+ // The surfaces it replaced may have held the only reference to an asset.
1726
+ this.invalidateAssetRefs();
1727
+ const { knobs: _knobs, description: _description, from: _from, prompt: _prompt, author: _author, ...rest } = post;
1728
+ return {
1729
+ ...rest,
1730
+ title,
1731
+ surfaces,
1732
+ ...(knobs ? { knobs } : {}),
1733
+ ...(description ? { description } : {}),
1734
+ embeds,
1735
+ ...(from === undefined ? {} : { from }),
1736
+ ...(prompt === undefined ? {} : { prompt }),
1737
+ ...(author === undefined ? {} : { author }),
1738
+ amendedAt,
1739
+ rewrites,
1740
+ contract,
1741
+ ...(system === undefined ? {} : { system }),
1742
+ };
1743
+ }
1744
+ async setPosterOnce(id, version, poster, mode, system) {
1745
+ const post = await this.getPost(id);
1746
+ if (!post || post.version !== version)
1747
+ return false;
1748
+ const at = post.surfaces.findIndex((s) => s.kind === "html");
1749
+ const page = post.surfaces[at];
1750
+ if (!page || page.kind !== "html")
1751
+ return false;
1752
+ // A still mockpit took under another system revision no longer shows, so a new
1753
+ // one may take its place; an agent's own, or a current one, stays.
1754
+ const stale = page.posterMode !== undefined &&
1755
+ system !== undefined &&
1756
+ (page.posterSystem === undefined ? system > 1 : page.posterSystem !== system);
1757
+ if (page.poster && !stale)
1758
+ return false;
1759
+ const surfaces = post.surfaces.map((s, i) => i === at
1760
+ ? {
1761
+ ...page,
1762
+ poster,
1763
+ ...(mode ? { posterMode: mode } : {}),
1764
+ ...(mode && system !== undefined ? { posterSystem: system } : {}),
1765
+ }
1766
+ : s);
1767
+ const wrote = this.writingPosts(post.design, [id], () => {
1768
+ this.sql.exec("UPDATE posts SET surfaces = ?, head = ? WHERE id = ? AND version = ?", JSON.stringify(surfaces), surfaceHeads(surfaces), id, version);
1769
+ return this.sql.exec("SELECT changes() AS n").one().n > 0;
1770
+ });
1771
+ if (wrote)
1772
+ this.invalidateAssetRefs();
1773
+ return wrote;
1774
+ }
1775
+ async withdrawVersion(id, version, embeds) {
1776
+ const post = await this.getPost(id);
1777
+ if (!post || post.version !== version)
1778
+ return null;
1779
+ const previous = post.history.at(-1);
1780
+ const now = new Date().toISOString();
1781
+ if (!previous) {
1782
+ this.writingPosts(post.design, [id], () => this.atomic(() => {
1783
+ // Only the agent can have written on a version the user never touched;
1784
+ // its messages stay in the design's thread.
1785
+ this.sql.exec("UPDATE comments SET postId = NULL WHERE postId = ?", id);
1786
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ?", id);
1787
+ this.sql.exec("DELETE FROM posts WHERE id = ?", id);
1788
+ }));
1789
+ this.touchDesign(post.design, now);
1790
+ this.invalidateAssetRefs();
1791
+ return { post: null };
1792
+ }
1793
+ const rewrites = (post.rewrites ?? 0) + 1;
1794
+ const hidden = (post.hiddenVersions ?? []).filter((v) => v !== version);
1795
+ this.writingPosts(post.design, [id], () => this.atomic(() => {
1796
+ this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, head = ?, slots = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ?, curSession = ?, amendedAt = ?, rewrites = ?, hiddenVersions = ?, curContract = ?, curSystem = ? 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, previous.contract ?? null, previous.system ?? null, id, version);
1797
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version = ?", id, previous.version);
1798
+ }));
1799
+ this.touchDesign(post.design, now);
1800
+ this.invalidateAssetRefs();
1801
+ const { from: _from, prompt: _prompt, author: _author, publishedBy: _by, amendedAt: _amended, hiddenVersions: _hidden, contract: _contract, system: _system, ...rest } = post;
1802
+ return {
1803
+ post: {
1804
+ ...rest,
1805
+ title: previous.title,
1806
+ surfaces: previous.surfaces,
1807
+ embeds,
1808
+ updatedAt: previous.at,
1809
+ version: previous.version,
1810
+ history: post.history.slice(0, -1),
1811
+ ...(previous.from === undefined ? {} : { from: previous.from }),
1812
+ ...(previous.prompt === undefined ? {} : { prompt: previous.prompt }),
1813
+ ...(previous.author === undefined ? {} : { author: previous.author }),
1814
+ ...(previous.publishedBy === undefined ? {} : { publishedBy: previous.publishedBy }),
1815
+ ...(previous.amendedAt === undefined ? {} : { amendedAt: previous.amendedAt }),
1816
+ ...(hidden.length ? { hiddenVersions: hidden } : {}),
1817
+ ...(previous.contract === undefined ? {} : { contract: previous.contract }),
1818
+ ...(previous.system === undefined ? {} : { system: previous.system }),
1819
+ rewrites,
1820
+ },
1821
+ };
1822
+ }
1075
1823
  async removePost(id) {
1076
1824
  if (!(await this.getPost(id)))
1077
1825
  return false;
@@ -1084,24 +1832,61 @@ export class SqlStore {
1084
1832
  return true;
1085
1833
  }
1086
1834
  async setPostStatus(id, status, acceptedVersion) {
1087
- const post = await this.getPost(id);
1835
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1088
1836
  if (!post)
1089
1837
  return null;
1090
1838
  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 };
1839
+ // A reason says why it left, and a stay was about the version once in use.
1840
+ const reason = status === "archived" ? (post.archiveReason ?? null) : null;
1841
+ // Archiving what's already archived keeps the day it went.
1842
+ const archivedAt = status === "archived"
1843
+ ? ((post.status === "archived" ? post.archivedAt : undefined) ?? new Date().toISOString())
1844
+ : null;
1845
+ this.raw.exec("UPDATE posts SET status = ?, acceptedVersion = ?, archiveReason = ?, stayedVersion = NULL, archivedAt = ? WHERE id = ?", status, pin, reason, archivedAt, id);
1846
+ const { acceptedVersion: _, archiveReason: _reason, stayedVersion: _stayed, archivedAt: _archivedAt, ...rest } = post;
1847
+ const next = {
1848
+ ...rest,
1849
+ status,
1850
+ ...(pin === null ? {} : { acceptedVersion: pin }),
1851
+ ...(reason === null ? {} : { archiveReason: reason }),
1852
+ ...(archivedAt === null ? {} : { archivedAt }),
1853
+ };
1854
+ this.recache(next);
1855
+ return pin !== null && next.hiddenVersions?.includes(pin)
1856
+ ? this.setVersionHidden(id, pin, false)
1857
+ : next;
1094
1858
  }
1095
- async setPostStarred(id, starred) {
1096
- const post = await this.getPost(id);
1859
+ async setPostArchived(id, archivedAt, reason) {
1860
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1097
1861
  if (!post)
1098
1862
  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;
1863
+ this.raw.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL, archiveReason = ?, stayedVersion = NULL, archivedAt = ? WHERE id = ?", reason, archivedAt, id);
1864
+ const { acceptedVersion: _, archiveReason: _reason, stayedVersion: _stayed, ...rest } = post;
1865
+ const next = {
1866
+ ...rest,
1867
+ status: "archived",
1868
+ archivedAt,
1869
+ ...(reason === null ? {} : { archiveReason: reason }),
1870
+ };
1871
+ this.recache(next);
1872
+ return next;
1873
+ }
1874
+ async setForkedFrom(id, from) {
1875
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1876
+ if (!post)
1877
+ return null;
1878
+ this.raw.exec("UPDATE posts SET forkedFrom = ? WHERE id = ?", from ? JSON.stringify(from) : null, id);
1879
+ const { forkedFrom: _, ...rest } = post;
1880
+ const next = from ? { ...rest, forkedFrom: from } : rest;
1881
+ this.recache(next);
1882
+ return next;
1883
+ }
1884
+ async bumpRewrites(designId) {
1885
+ const ids = (await this.listPosts({ designId })).map((p) => p.id);
1886
+ this.writingPosts(designId, ids, () => this.sql.exec("UPDATE posts SET rewrites = rewrites + 1 WHERE designId = ?", designId));
1102
1887
  }
1103
1888
  async setVersionHidden(id, version, hidden) {
1104
- const post = await this.getPost(id);
1889
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1105
1890
  if (!post)
1106
1891
  return null;
1107
1892
  const set = new Set(post.hiddenVersions ?? []);
@@ -1110,21 +1895,60 @@ export class SqlStore {
1110
1895
  else
1111
1896
  set.delete(version);
1112
1897
  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);
1898
+ this.raw.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", list.length ? JSON.stringify(list) : null, id);
1114
1899
  const { hiddenVersions: _, ...rest } = post;
1115
- return list.length ? { ...rest, hiddenVersions: list } : rest;
1900
+ const next = list.length ? { ...rest, hiddenVersions: list } : rest;
1901
+ this.recache(next);
1902
+ return next;
1903
+ }
1904
+ async deleteOldVersions(id, versions) {
1905
+ const post = await this.getPost(id);
1906
+ if (!post)
1907
+ return null;
1908
+ const gone = new Set(versions.filter((v) => v !== post.version));
1909
+ const hidden = (post.hiddenVersions ?? []).filter((v) => !gone.has(v));
1910
+ this.writingPosts(post.design, [id], () => this.atomic(() => {
1911
+ for (const v of gone) {
1912
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version = ?", id, v);
1913
+ }
1914
+ this.sql.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", hidden.length ? JSON.stringify(hidden) : null, id);
1915
+ }));
1916
+ this.touchDesign(post.design, new Date().toISOString());
1917
+ this.invalidateAssetRefs();
1918
+ return this.getPost(id);
1919
+ }
1920
+ async setPostReason(id, reason) {
1921
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1922
+ if (!post)
1923
+ return null;
1924
+ this.raw.exec("UPDATE posts SET archiveReason = ? WHERE id = ?", reason, id);
1925
+ const { archiveReason: _, ...rest } = post;
1926
+ const next = reason ? { ...rest, archiveReason: reason } : rest;
1927
+ this.recache(next);
1928
+ return next;
1929
+ }
1930
+ async setPostStayed(id, version) {
1931
+ const post = this.cachedPost(id) ?? (await this.getPost(id));
1932
+ if (!post)
1933
+ return null;
1934
+ this.raw.exec("UPDATE posts SET stayedVersion = ? WHERE id = ?", version, id);
1935
+ const next = { ...post, stayedVersion: version };
1936
+ this.recache(next);
1937
+ return next;
1116
1938
  }
1117
1939
  // --- comments ---
1118
1940
  async listComments(query) {
1119
1941
  const clauses = [];
1120
1942
  const params = [];
1943
+ if (query.noDesign)
1944
+ clauses.push("designId IS NULL");
1121
1945
  if (query.sessionId !== undefined) {
1122
1946
  clauses.push("sessionId = ?");
1123
1947
  params.push(query.sessionId);
1124
1948
  }
1125
- if (query.mockId !== undefined) {
1126
- clauses.push("mockId = ?");
1127
- params.push(query.mockId);
1949
+ if (query.designId !== undefined) {
1950
+ clauses.push("designId = ?");
1951
+ params.push(query.designId);
1128
1952
  }
1129
1953
  if (query.postId !== undefined) {
1130
1954
  clauses.push("postId = ?");
@@ -1145,7 +1969,7 @@ export class SqlStore {
1145
1969
  const comment = {
1146
1970
  id: newId(),
1147
1971
  sessionId: input.sessionId,
1148
- mockId: input.mockId ?? null,
1972
+ designId: input.designId ?? null,
1149
1973
  postId: input.postId ?? null,
1150
1974
  author: stripNul(input.author).trim() || "user",
1151
1975
  text: stripNul(input.text),
@@ -1157,7 +1981,7 @@ export class SqlStore {
1157
1981
  viewport: input.viewport ?? null,
1158
1982
  ...(input.payload ? { payload: input.payload } : {}),
1159
1983
  };
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);
1984
+ 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
1985
  const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq;
1162
1986
  this.touch(input.sessionId);
1163
1987
  return { ...comment, seq };
@@ -1169,7 +1993,7 @@ export class SqlStore {
1169
1993
  return this.insertComment({
1170
1994
  ...input,
1171
1995
  postId: post?.id ?? null,
1172
- mockId: input.mockId ?? post?.mock ?? null,
1996
+ designId: input.designId ?? post?.design ?? null,
1173
1997
  postVersion: input.postVersion ?? post?.version ?? null,
1174
1998
  });
1175
1999
  }
@@ -1184,36 +2008,85 @@ export class SqlStore {
1184
2008
  this.touch(comment.sessionId);
1185
2009
  return comment;
1186
2010
  }
2011
+ async listDecisionSources(project) {
2012
+ const variants = this.sql
2013
+ .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)
2014
+ .toArray()
2015
+ .map((r) => ({
2016
+ designId: r.designId,
2017
+ state: r.state ?? null,
2018
+ variant: r.variant,
2019
+ status: r.status,
2020
+ version: r.version,
2021
+ acceptedVersion: r.acceptedVersion ?? null,
2022
+ }));
2023
+ // A close or a move from Home is a reply too, and decides nothing.
2024
+ const decidedAt = {};
2025
+ for (const r of this.sql
2026
+ .exec(`SELECT designId, MAX(createdAt) AS at FROM comments
2027
+ WHERE kind = 'reply' AND designId IN (SELECT id FROM designs WHERE project = ?)
2028
+ AND json_extract(payload, '$.archived') IS NULL
2029
+ AND json_extract(payload, '$.edited') IS NULL
2030
+ GROUP BY designId`, project)
2031
+ .toArray()) {
2032
+ decidedAt[r.designId] = r.at;
2033
+ }
2034
+ return { variants, decidedAt };
2035
+ }
1187
2036
  async commitReply(input) {
1188
2037
  if (!(await this.getSession(input.sessionId)))
1189
2038
  return null;
1190
- if (!(await this.getMock(input.mockId)))
2039
+ if (!(await this.getDesign(input.designId)))
1191
2040
  return null;
1192
- return this.atomic(() => {
2041
+ const moved = [...input.accept.map((x) => x.id), ...input.archive, ...(input.release ?? [])];
2042
+ return this.writingPosts(input.designId, moved, () => this.atomic(() => {
1193
2043
  const comment = this.insertComment({
1194
2044
  sessionId: input.sessionId,
1195
- mockId: input.mockId,
2045
+ designId: input.designId,
1196
2046
  author: "user",
1197
2047
  text: input.text,
1198
2048
  kind: "reply",
1199
2049
  postVersion: input.payload.version,
1200
2050
  payload: input.payload,
1201
2051
  });
2052
+ if (input.system) {
2053
+ this.writeSystem(input.system.next, {
2054
+ sessionId: input.sessionId,
2055
+ summary: input.system.summary,
2056
+ });
2057
+ }
1202
2058
  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);
2059
+ ? "UPDATE designs SET asks = ? WHERE id = ?"
2060
+ : "UPDATE designs SET asks = ?, draft = NULL WHERE id = ?", JSON.stringify(input.asks), input.designId);
2061
+ if (input.standings) {
2062
+ this.sql.exec("UPDATE designs SET standings = ? WHERE id = ?", JSON.stringify(input.standings), input.designId);
2063
+ }
2064
+ if (input.owner) {
2065
+ this.sql.exec("UPDATE designs SET sessionId = ? WHERE id = ?", input.owner, input.designId);
1207
2066
  }
1208
2067
  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);
2068
+ // Picking an older version is passing on the newer ones. Otherwise they'd read
2069
+ // as unruled-on, and the viewer would move straight back to them.
2070
+ 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);
2071
+ // An answer's whole page stays hidden until it is chosen; now it's a version.
2072
+ const hiddenRow = this.sql
2073
+ .exec("SELECT hiddenVersions FROM posts WHERE id = ?", id)
2074
+ .toArray()[0];
2075
+ const hidden = parseJson(hiddenRow?.hiddenVersions, []);
2076
+ if (hidden.includes(version)) {
2077
+ const left = hidden.filter((v) => v !== version);
2078
+ this.sql.exec("UPDATE posts SET hiddenVersions = ? WHERE id = ?", left.length ? JSON.stringify(left) : null, id);
2079
+ }
2080
+ }
2081
+ for (const id of input.release ?? []) {
2082
+ this.sql.exec("UPDATE posts SET status = 'open', acceptedVersion = NULL, stayedVersion = NULL WHERE id = ? AND designId = ? AND status = 'accepted'", id, input.designId);
1210
2083
  }
1211
2084
  for (const id of input.archive) {
1212
- this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL WHERE id = ? AND mockId = ?", id, input.mockId);
2085
+ this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL, archivedAt = ? WHERE id = ? AND designId = ?", new Date().toISOString(), id, input.designId);
1213
2086
  }
1214
2087
  this.invalidateAssetRefs();
1215
2088
  return comment;
1216
- });
2089
+ }));
1217
2090
  }
1218
2091
  // --- assets ---
1219
2092
  referencedAssetIds() {
@@ -1227,7 +2100,7 @@ export class SqlStore {
1227
2100
  collectAssetIds(parseJson(r.surfaces, []), out);
1228
2101
  }
1229
2102
  // 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()) {
2103
+ for (const r of this.sql.exec("SELECT asks, draft FROM designs").toArray()) {
1231
2104
  collectAttachedIds(parseJson(r.draft, null), out);
1232
2105
  for (const ask of parseJson(r.asks, []))
1233
2106
  collectAttachedIds(ask, out);
@@ -1237,12 +2110,10 @@ export class SqlStore {
1237
2110
  .toArray()) {
1238
2111
  collectAttachedIds(parseJson(r.payload, null), out);
1239
2112
  }
1240
- // An installed icon set is referenced by its project's design, not by a
2113
+ // An installed icon set is referenced by its project's system, not by a
1241
2114
  // surface; evicting it would blank that project's icons.
1242
- for (const r of this.sql
1243
- .exec("SELECT value FROM settings WHERE key LIKE 'design:%'")
1244
- .toArray()) {
1245
- const sets = parseJson(r.value, null)?.iconSets;
2115
+ for (const r of this.sql.exec("SELECT json FROM systems").toArray()) {
2116
+ const sets = parseJson(r.json, null)?.iconSets;
1246
2117
  for (const ref of Array.isArray(sets) ? sets : []) {
1247
2118
  if (typeof ref?.assetId === "string")
1248
2119
  out.add(ref.assetId);