mockpit 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +69 -48
  2. package/bin/demoData.js +3 -3
  3. package/bin/mockpit.js +426 -51
  4. package/dist/mcp/server.js +45 -7
  5. package/dist/server/apiViews.d.ts +407 -21
  6. package/dist/server/apiViews.js +110 -11
  7. package/dist/server/app.d.ts +5 -1
  8. package/dist/server/app.js +1454 -192
  9. package/dist/server/codeWorker.js +2 -1
  10. package/dist/server/decisions.d.ts +23 -0
  11. package/dist/server/decisions.js +100 -0
  12. package/dist/server/designGuide.d.ts +6 -2
  13. package/dist/server/designGuide.js +96 -27
  14. package/dist/server/embeds.d.ts +26 -0
  15. package/dist/server/embeds.js +144 -0
  16. package/dist/server/eventLog.js +2 -1
  17. package/dist/server/feedbackBatch.d.ts +15 -4
  18. package/dist/server/feedbackBatch.js +31 -7
  19. package/dist/server/fills.d.ts +41 -0
  20. package/dist/server/fills.js +328 -0
  21. package/dist/server/guidance.d.ts +23 -0
  22. package/dist/server/guidance.js +71 -0
  23. package/dist/server/index.js +25 -2
  24. package/dist/server/knobs.d.ts +6 -2
  25. package/dist/server/knobs.js +11 -1
  26. package/dist/server/mcpHttp.d.ts +6 -0
  27. package/dist/server/mcpHttp.js +35 -0
  28. package/dist/server/mcpSpec.d.ts +14 -13
  29. package/dist/server/mcpSpec.js +133 -18
  30. package/dist/server/postSurfaces.js +6 -12
  31. package/dist/server/richRender.js +3 -1
  32. package/dist/server/run.js +10 -1
  33. package/dist/server/runApi.d.ts +3 -3
  34. package/dist/server/runApi.js +18 -13
  35. package/dist/server/sqlStore.d.ts +97 -5
  36. package/dist/server/sqlStore.js +367 -27
  37. package/dist/server/surfacePage.d.ts +6 -2
  38. package/dist/server/surfacePage.js +142 -10
  39. package/dist/server/themes.d.ts +2 -0
  40. package/dist/server/themes.js +23 -3
  41. package/dist/server/types.d.ts +107 -16
  42. package/dist/server/types.js +73 -10
  43. package/dist/server/viewerRequest.d.ts +2 -0
  44. package/dist/server/viewerRequest.js +14 -0
  45. package/extensions/mockpit.js +179 -16
  46. package/guide/packs/design-craft.md +35 -0
  47. package/guide/topics/asks.md +82 -31
  48. package/guide/topics/feedback.md +151 -43
  49. package/guide/topics/html.md +8 -10
  50. package/guide/topics/http.md +8 -4
  51. package/guide/topics/knobs.md +4 -3
  52. package/guide/topics/scripts.md +2 -2
  53. package/guide/topics/surfaces.md +45 -11
  54. package/package.json +3 -15
  55. package/skills/launch-video/SKILL.md +2 -2
  56. package/skills/mockpit/SKILL.md +23 -7
  57. package/viewer/dist/index.html +9 -8
  58. package/dist/server/slots.d.ts +0 -14
  59. package/dist/server/slots.js +0 -71
@@ -1,4 +1,10 @@
1
- import { collectAssetIds, collectAttachedIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
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";
2
+ import { renameSlugRefs } from "./embeds.js";
3
+ import { RULES_SETTING } from "./guidance.js";
4
+ // The settings keyed `<prefix>:<project>`, which a project rename carries.
5
+ const PROJECT_SETTINGS = ["design", AREA_ORDER_SETTING, RULES_SETTING];
6
+ // An optional free-text label: blank means none.
7
+ const label = (v) => stripNul(v ?? "").trim() || null;
2
8
  function parseJson(raw, fallback) {
3
9
  if (typeof raw !== "string" || !raw)
4
10
  return fallback;
@@ -9,12 +15,41 @@ function parseJson(raw, fallback) {
9
15
  return fallback;
10
16
  }
11
17
  }
18
+ // A stored reply in the current names, or null when it already uses them.
19
+ function contractReply(payload) {
20
+ if (!("mix" in payload) && !("decision" in payload) && !("resolved" in payload))
21
+ return null;
22
+ const { mix, decision, resolved, ...rest } = payload;
23
+ const out = { ...rest };
24
+ if (mix !== undefined)
25
+ out.borrowed = mix;
26
+ const kind = decision?.kind;
27
+ if (kind === "accept" || kind === "revise") {
28
+ out.verdict = { ...decision, kind: kind === "accept" ? "use" : "revise" };
29
+ }
30
+ if (resolved !== undefined)
31
+ out.closed = resolved;
32
+ return out;
33
+ }
34
+ // `surfaces` with every `<mockpit-slot>` tag spelled `<mockpit-embed>`, or null
35
+ // when none has one. Only html is markup; a code or markdown surface may quote
36
+ // the tag as text.
37
+ function slotTagsToEmbed(surfaces) {
38
+ let changed = false;
39
+ const out = surfaces.map((s) => {
40
+ if (s.kind !== "html" || !/<\/?mockpit-slot\b/i.test(s.html))
41
+ return s;
42
+ changed = true;
43
+ return { ...s, html: s.html.replace(/<(\/?)mockpit-slot\b/gi, "<$1mockpit-embed") };
44
+ });
45
+ return changed ? out : null;
46
+ }
12
47
  const POST_COLUMNS = `
13
48
  id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, mockId TEXT NOT NULL, state TEXT,
14
49
  variant TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', title TEXT NOT NULL,
15
50
  surfaces TEXT NOT NULL, knobs TEXT, slots TEXT NOT NULL DEFAULT '[]',
16
51
  createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, version INTEGER NOT NULL,
17
- curFrom INTEGER, curPrompt TEXT, curAuthor TEXT`;
52
+ curFrom INTEGER, curPrompt TEXT, curAuthor TEXT, note TEXT`;
18
53
  // Store implementation on SQLite — a Durable Object's `ctx.storage.sql` in the
19
54
  // Worker, or node:sqlite via an adapter on Node (see server/sqliteStorage.ts).
20
55
  // One workspace = one database, so plain SQL with no tenant columns.
@@ -69,9 +104,10 @@ export class SqlStore {
69
104
  );
70
105
  CREATE TABLE IF NOT EXISTS mocks (
71
106
  id TEXT PRIMARY KEY, project TEXT NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL,
72
- kind TEXT NOT NULL DEFAULT 'component', states TEXT NOT NULL DEFAULT '[]',
107
+ kind TEXT NOT NULL DEFAULT 'sketch', states TEXT NOT NULL DEFAULT '[]',
73
108
  asks TEXT NOT NULL DEFAULT '[]', knobs TEXT NOT NULL DEFAULT '{}', draft TEXT,
74
- sessionId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
109
+ sessionId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
110
+ widths TEXT NOT NULL DEFAULT '[]'
75
111
  );
76
112
  CREATE TABLE IF NOT EXISTS posts (${POST_COLUMNS});
77
113
  CREATE TABLE IF NOT EXISTS post_versions (
@@ -95,6 +131,11 @@ export class SqlStore {
95
131
  CREATE TABLE IF NOT EXISTS settings (
96
132
  key TEXT PRIMARY KEY, value TEXT NOT NULL
97
133
  );
134
+ CREATE TABLE IF NOT EXISTS flows (
135
+ project TEXT NOT NULL, name TEXT NOT NULL, steps TEXT NOT NULL DEFAULT '[]',
136
+ createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
137
+ PRIMARY KEY (project, name)
138
+ );
98
139
  `);
99
140
  this.addMissing("sessions", {
100
141
  agentSeq: "INTEGER NOT NULL DEFAULT 0",
@@ -102,10 +143,84 @@ export class SqlStore {
102
143
  key: "TEXT",
103
144
  agentActiveAt: "TEXT",
104
145
  });
146
+ // 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");
150
+ }
151
+ this.addMissing("mocks", {
152
+ widths: "TEXT NOT NULL DEFAULT '[]'",
153
+ closedAt: "TEXT",
154
+ area: "TEXT",
155
+ source: "TEXT",
156
+ situations: "TEXT NOT NULL DEFAULT '{}'",
157
+ });
105
158
  if (legacy)
106
159
  this.migrateLegacy();
160
+ // After the legacy lift, which rebuilds posts from POST_COLUMNS alone.
161
+ const pinned = this.columns("posts").has("acceptedVersion");
162
+ this.addMissing("posts", {
163
+ note: "TEXT",
164
+ forkedFrom: "TEXT",
165
+ starred: "INTEGER NOT NULL DEFAULT 0",
166
+ acceptedVersion: "INTEGER",
167
+ hiddenVersions: "TEXT",
168
+ });
169
+ // Before the pin, accepted meant "its latest": freeze that as the version in use.
170
+ if (!pinned) {
171
+ this.sql.exec("UPDATE posts SET acceptedVersion = version WHERE status = 'accepted'");
172
+ }
173
+ if (this.settingSync("sketchKind") !== "1")
174
+ this.migrateToSketches();
175
+ if (this.settingSync("contractTerms") !== "1")
176
+ this.migrateToContractTerms();
107
177
  this.createIndexes();
108
178
  }
179
+ // The agent contract renamed what replies and drafts store (mix → borrowed,
180
+ // decision → verdict with accept → use, resolved → closed) and retired drop
181
+ // and the `<mockpit-slot>` embed alias. Stored data is rewritten once, here,
182
+ // so nothing past the store reads an old name: a dropped variant is archived
183
+ // (both were hidden from the choice and restorable), a drop verdict loses its
184
+ // verdict (the variant's archived status says the rest), and every stored
185
+ // version spells its embeds `<mockpit-embed>` so it still renders them.
186
+ migrateToContractTerms() {
187
+ this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL WHERE status = 'dropped'");
188
+ for (const r of this.sql
189
+ .exec("SELECT seq, payload FROM comments WHERE kind = 'reply' AND payload IS NOT NULL")
190
+ .toArray()) {
191
+ const payload = parseJson(r.payload, null);
192
+ const next = payload && contractReply(payload);
193
+ if (next) {
194
+ this.sql.exec("UPDATE comments SET payload = ? WHERE seq = ?", JSON.stringify(next), r.seq);
195
+ }
196
+ }
197
+ for (const r of this.sql
198
+ .exec("SELECT id, draft FROM mocks WHERE draft IS NOT NULL")
199
+ .toArray()) {
200
+ const draft = parseJson(r.draft, null);
201
+ if (!draft || !("mix" in draft))
202
+ continue;
203
+ const { mix, ...rest } = draft;
204
+ this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", JSON.stringify({ ...rest, borrowed: mix ?? {} }), r.id);
205
+ }
206
+ for (const r of this.sql
207
+ .exec("SELECT id, surfaces FROM posts WHERE surfaces LIKE '%mockpit-slot%'")
208
+ .toArray()) {
209
+ const surfaces = slotTagsToEmbed(parseJson(r.surfaces, []));
210
+ if (surfaces) {
211
+ this.sql.exec("UPDATE posts SET surfaces = ? WHERE id = ?", JSON.stringify(surfaces), r.id);
212
+ }
213
+ }
214
+ for (const r of this.sql
215
+ .exec("SELECT postId, version, surfaces FROM post_versions WHERE surfaces LIKE '%mockpit-slot%'")
216
+ .toArray()) {
217
+ const surfaces = slotTagsToEmbed(parseJson(r.surfaces, []));
218
+ if (surfaces) {
219
+ this.sql.exec("UPDATE post_versions SET surfaces = ? WHERE postId = ? AND version = ?", JSON.stringify(surfaces), r.postId, r.version);
220
+ }
221
+ }
222
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('contractTerms', '1')");
223
+ }
109
224
  createIndexes() {
110
225
  this.sql.exec(`
111
226
  CREATE UNIQUE INDEX IF NOT EXISTS mockpit_mocks_slug_idx ON mocks (project, slug);
@@ -316,7 +431,7 @@ export class SqlStore {
316
431
  draft = {
317
432
  version: 1,
318
433
  answers: {},
319
- mix: {},
434
+ borrowed: {},
320
435
  tuned: {},
321
436
  comments: [],
322
437
  updatedAt: c.createdAt,
@@ -332,6 +447,33 @@ export class SqlStore {
332
447
  }
333
448
  this.sql.exec("DELETE FROM comments WHERE draft = 1");
334
449
  }
450
+ // A mock no one declared used to be a component. Keep that only where it acts
451
+ // as one, embedded somewhere or a step of a flow; the rest become sketches.
452
+ migrateToSketches() {
453
+ const projectOf = new Map();
454
+ for (const r of this.sql.exec("SELECT id, project FROM mocks").toArray()) {
455
+ projectOf.set(r.id, r.project);
456
+ }
457
+ 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);
460
+ for (const e of parseJson(r.slots, []))
461
+ used.add(`${project}\u0000${e.slug}`);
462
+ }
463
+ for (const r of this.sql.exec("SELECT project, steps FROM flows").toArray()) {
464
+ for (const x of parseJson(r.steps, [])) {
465
+ used.add(`${r.project}\u0000${x.mock}`);
466
+ }
467
+ }
468
+ for (const r of this.sql
469
+ .exec("SELECT id, project, slug FROM mocks WHERE kind = 'component'")
470
+ .toArray()) {
471
+ if (used.has(`${r.project}\u0000${r.slug}`))
472
+ continue;
473
+ this.sql.exec("UPDATE mocks SET kind = 'sketch' WHERE id = ?", r.id);
474
+ }
475
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('sketchKind', '1')");
476
+ }
335
477
  settingSync(key) {
336
478
  const rows = this.sql.exec("SELECT value FROM settings WHERE key = ?", key).toArray();
337
479
  return rows.length ? rows[0].value : null;
@@ -392,18 +534,24 @@ export class SqlStore {
392
534
  project: r.project,
393
535
  slug: r.slug,
394
536
  title: r.title,
395
- kind: (r.kind === "page" ? "page" : "component"),
537
+ kind: isMockKind(r.kind) ? r.kind : "sketch",
538
+ area: r.area ?? null,
539
+ source: r.source ?? null,
396
540
  states: parseJson(r.states, []),
541
+ situations: parseJson(r.situations, {}),
397
542
  asks: parseJson(r.asks, []),
398
543
  knobs: parseJson(r.knobs, {}),
544
+ widths: parseJson(r.widths, []),
399
545
  draft: parseJson(r.draft, null),
400
546
  sessionId: r.sessionId ?? null,
547
+ closedAt: r.closedAt ?? null,
401
548
  createdAt: r.createdAt,
402
549
  updatedAt: r.updatedAt,
403
550
  };
404
551
  }
405
552
  rowToPost(r, history) {
406
553
  const knobs = parseJson(r.knobs, null);
554
+ const hidden = parseJson(r.hiddenVersions, []);
407
555
  return {
408
556
  id: r.id,
409
557
  sessionId: r.sessionId,
@@ -418,7 +566,15 @@ export class SqlStore {
418
566
  version: r.version,
419
567
  history,
420
568
  ...(knobs ? { knobs } : {}),
421
- slots: parseJson(r.slots, []),
569
+ ...(r.note == null ? {} : { note: r.note }),
570
+ // The column keeps its first name, from when embeds were called slots.
571
+ embeds: parseJson(r.slots, []),
572
+ ...(r.forkedFrom == null ? {} : { forkedFrom: parseJson(r.forkedFrom, null) }),
573
+ ...(r.starred ? { starred: true } : {}),
574
+ ...(r.status === "accepted" && r.acceptedVersion != null
575
+ ? { acceptedVersion: r.acceptedVersion }
576
+ : {}),
577
+ ...(hidden.length ? { hiddenVersions: hidden } : {}),
422
578
  ...(r.curFrom == null ? {} : { from: r.curFrom }),
423
579
  ...(r.curPrompt == null ? {} : { prompt: r.curPrompt }),
424
580
  ...(r.curAuthor == null ? {} : { author: r.curAuthor }),
@@ -581,16 +737,21 @@ export class SqlStore {
581
737
  project: stripNul(input.project).trim() || DEFAULT_PROJECT,
582
738
  slug: slugify(stripNul(input.slug)),
583
739
  title: stripNul(input.title)?.trim() || input.slug,
584
- kind: input.kind === "page" ? "page" : "component",
740
+ kind: isMockKind(input.kind) ? input.kind : "sketch",
741
+ area: label(input.area),
742
+ source: label(input.source),
585
743
  states: (input.states ?? []).map((s) => stripNul(s)),
744
+ situations: {},
586
745
  asks: [],
587
746
  knobs: input.knobs ?? {},
747
+ widths: input.widths ?? [],
588
748
  draft: null,
589
749
  sessionId: input.sessionId ?? null,
750
+ closedAt: null,
590
751
  createdAt: now,
591
752
  updatedAt: now,
592
753
  };
593
- this.sql.exec("INSERT INTO mocks (id, project, slug, title, kind, states, asks, knobs, draft, sessionId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, '[]', ?, NULL, ?, ?, ?)", mock.id, mock.project, mock.slug, mock.title, mock.kind, JSON.stringify(mock.states), JSON.stringify(mock.knobs), mock.sessionId, mock.createdAt, mock.updatedAt);
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);
594
755
  return mock;
595
756
  }
596
757
  async updateMock(id, patch) {
@@ -601,29 +762,172 @@ export class SqlStore {
601
762
  ...mock,
602
763
  ...(patch.title !== undefined && { title: stripNul(patch.title).trim() || mock.title }),
603
764
  ...(patch.kind !== undefined && { kind: patch.kind }),
765
+ ...(patch.area !== undefined && { area: label(patch.area) }),
766
+ ...(patch.source !== undefined && { source: label(patch.source) }),
604
767
  ...(patch.states !== undefined && { states: patch.states.map((s) => stripNul(s)) }),
768
+ ...(patch.situations !== undefined && { situations: patch.situations }),
605
769
  ...(patch.asks !== undefined && { asks: patch.asks }),
606
770
  ...(patch.knobs !== undefined && { knobs: patch.knobs }),
771
+ ...(patch.widths !== undefined && { widths: patch.widths }),
607
772
  ...(patch.sessionId !== undefined && { sessionId: patch.sessionId }),
773
+ ...(patch.closedAt !== undefined && { closedAt: patch.closedAt }),
608
774
  updatedAt: new Date().toISOString(),
609
775
  };
610
- this.sql.exec("UPDATE mocks SET title = ?, kind = ?, states = ?, asks = ?, knobs = ?, sessionId = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, JSON.stringify(next.states), JSON.stringify(next.asks), JSON.stringify(next.knobs), next.sessionId, next.updatedAt, id);
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);
611
777
  if (patch.asks !== undefined)
612
778
  this.invalidateAssetRefs();
613
779
  return next;
614
780
  }
615
781
  async removeMock(id) {
616
- if (!(await this.getMock(id)))
782
+ const mock = await this.getMock(id);
783
+ if (!mock)
617
784
  return false;
618
785
  this.atomic(() => {
619
786
  this.sql.exec("DELETE FROM comments WHERE mockId = ?", id);
620
787
  this.sql.exec("DELETE FROM post_versions WHERE postId IN (SELECT id FROM posts WHERE mockId = ?)", id);
621
788
  this.sql.exec("DELETE FROM posts WHERE mockId = ?", id);
622
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);
792
+ if (steps.length !== flow.steps.length)
793
+ this.writeFlow(flow, steps);
794
+ }
623
795
  });
624
796
  this.invalidateAssetRefs();
625
797
  return true;
626
798
  }
799
+ async renameMock(id, slug) {
800
+ const to = slugify(stripNul(slug));
801
+ return this.atomic(() => {
802
+ const rows = this.sql.exec("SELECT * FROM mocks WHERE id = ?", id).toArray();
803
+ if (rows.length === 0)
804
+ return null;
805
+ const mock = this.rowToMock(rows[0]);
806
+ const from = mock.slug;
807
+ if (to === from)
808
+ return mock;
809
+ const taken = this.sql
810
+ .exec("SELECT id FROM mocks WHERE project = ? AND slug = ?", mock.project, to)
811
+ .toArray();
812
+ if (taken.length > 0)
813
+ return null;
814
+ const updatedAt = new Date().toISOString();
815
+ this.sql.exec("UPDATE mocks SET slug = ?, updatedAt = ? WHERE id = ?", to, updatedAt, id);
816
+ // History is rewritten too: embeds resolve by slug at render time, so an
817
+ // old version left naming the old slug would show its components missing.
818
+ const rewrite = (raw) => {
819
+ let changed = false;
820
+ const next = parseJson(raw, []).map((x) => {
821
+ if (x.kind !== "html")
822
+ return x;
823
+ const html = renameSlugRefs(x.html, from, to);
824
+ if (html === x.html)
825
+ return x;
826
+ changed = true;
827
+ return { ...x, html };
828
+ });
829
+ return changed ? JSON.stringify(next) : null;
830
+ };
831
+ const posts = this.sql
832
+ .exec("SELECT id, surfaces, slots FROM posts WHERE mockId IN (SELECT id FROM mocks WHERE project = ?)", mock.project)
833
+ .toArray();
834
+ for (const r of posts) {
835
+ const postId = r.id;
836
+ const surfaces = rewrite(r.surfaces);
837
+ if (surfaces)
838
+ this.sql.exec("UPDATE posts SET surfaces = ? WHERE id = ?", surfaces, postId);
839
+ const embeds = parseJson(r.slots, []);
840
+ if (embeds.some((e) => e.slug === from)) {
841
+ this.sql.exec("UPDATE posts SET slots = ? WHERE id = ?", JSON.stringify(embeds.map((e) => (e.slug === from ? { ...e, slug: to } : e))), postId);
842
+ }
843
+ for (const v of this.sql
844
+ .exec("SELECT version, surfaces FROM post_versions WHERE postId = ?", postId)
845
+ .toArray()) {
846
+ const old = rewrite(v.surfaces);
847
+ if (!old)
848
+ continue;
849
+ this.sql.exec("UPDATE post_versions SET surfaces = ? WHERE postId = ? AND version = ?", old, postId, v.version);
850
+ }
851
+ }
852
+ for (const flow of this.flowsOf(mock.project)) {
853
+ if (!flow.steps.some((x) => x.mock === from))
854
+ continue;
855
+ this.writeFlow(flow, flow.steps.map((x) => (x.mock === from ? { ...x, mock: to } : x)));
856
+ }
857
+ return { ...mock, slug: to, updatedAt };
858
+ });
859
+ }
860
+ async renameProject(from, to) {
861
+ const target = stripNul(to).trim();
862
+ return this.atomic(() => {
863
+ const occupied = this.sql
864
+ .exec("SELECT COUNT(*) AS n FROM mocks WHERE project = ?", target)
865
+ .one().n;
866
+ if (occupied > 0)
867
+ return false;
868
+ // A project with no mocks has nothing a leftover flow could walk.
869
+ this.sql.exec("DELETE FROM flows WHERE project = ?", target);
870
+ this.sql.exec("UPDATE mocks SET project = ? WHERE project = ?", target, from);
871
+ this.sql.exec("UPDATE sessions SET project = ? WHERE project = ?", target, from);
872
+ this.sql.exec("UPDATE flows SET project = ? WHERE project = ?", target, from);
873
+ for (const prefix of PROJECT_SETTINGS) {
874
+ const value = this.settingSync(`${prefix}:${from}`);
875
+ if (value === null)
876
+ continue;
877
+ this.sql.exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", `${prefix}:${target}`, value);
878
+ this.sql.exec("DELETE FROM settings WHERE key = ?", `${prefix}:${from}`);
879
+ if (prefix === "design")
880
+ this.invalidateAssetRefs();
881
+ }
882
+ return true;
883
+ });
884
+ }
885
+ // --- flows ---
886
+ flowsOf(project) {
887
+ return this.sql
888
+ .exec("SELECT * FROM flows WHERE project = ? ORDER BY createdAt, rowid", project)
889
+ .toArray()
890
+ .map((r) => ({
891
+ project: r.project,
892
+ name: r.name,
893
+ steps: parseJson(r.steps, []),
894
+ createdAt: r.createdAt,
895
+ updatedAt: r.updatedAt,
896
+ }));
897
+ }
898
+ // An emptied flow is deleted rather than kept as a name with nothing to walk.
899
+ writeFlow(flow, steps) {
900
+ if (steps.length === 0) {
901
+ this.sql.exec("DELETE FROM flows WHERE project = ? AND name = ?", flow.project, flow.name);
902
+ return null;
903
+ }
904
+ const next = { ...flow, steps, updatedAt: new Date().toISOString() };
905
+ this.sql.exec("INSERT INTO flows (project, name, steps, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(project, name) DO UPDATE SET steps = excluded.steps, updatedAt = excluded.updatedAt", next.project, next.name, JSON.stringify(next.steps), next.createdAt, next.updatedAt);
906
+ return next;
907
+ }
908
+ async listFlows(project) {
909
+ return this.flowsOf(project);
910
+ }
911
+ async placeFlowStep(project, name, screen, step) {
912
+ const clean = { mock: stripNul(screen.mock), state: stripNul(screen.state) };
913
+ const label = stripNul(name);
914
+ return this.atomic(() => {
915
+ const now = new Date().toISOString();
916
+ const flow = this.flowsOf(project).find((f) => f.name === label) ?? {
917
+ project,
918
+ name: label,
919
+ steps: [],
920
+ createdAt: now,
921
+ updatedAt: now,
922
+ };
923
+ const steps = flow.steps.filter((x) => !sameScreen(x, clean));
924
+ if (step > 0)
925
+ steps.splice(Math.min(step, steps.length + 1) - 1, 0, clean);
926
+ if (steps.length === 0 && flow.steps.length === 0)
927
+ return null;
928
+ return this.writeFlow(flow, steps);
929
+ });
930
+ }
627
931
  async putDraft(mockId, draft) {
628
932
  const mock = await this.getMock(mockId);
629
933
  if (!mock)
@@ -692,12 +996,14 @@ export class SqlStore {
692
996
  version: 1,
693
997
  history: [],
694
998
  ...(input.knobs ? { knobs: input.knobs } : {}),
695
- slots: input.slots ?? [],
999
+ ...(stripNul(input.note ?? "").trim() ? { note: stripNul(input.note ?? "").trim() } : {}),
1000
+ embeds: input.embeds ?? [],
1001
+ ...(input.forkedFrom ? { forkedFrom: input.forkedFrom } : {}),
696
1002
  ...(input.from === undefined ? {} : { from: input.from }),
697
1003
  ...(input.prompt === undefined ? {} : { prompt: stripNul(input.prompt) }),
698
1004
  ...(input.author === undefined ? {} : { author: stripNul(input.author) }),
699
1005
  };
700
- this.sql.exec("INSERT INTO posts (id, sessionId, mockId, state, variant, status, title, surfaces, knobs, slots, createdAt, updatedAt, version, curFrom, curPrompt, curAuthor) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", post.id, post.sessionId, post.mock, post.state, post.variant, post.title, JSON.stringify(post.surfaces), post.knobs ? JSON.stringify(post.knobs) : null, JSON.stringify(post.slots), post.createdAt, post.updatedAt, post.from ?? null, post.prompt ?? null, post.author ?? null);
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);
701
1007
  this.touch(input.sessionId);
702
1008
  this.touchMock(post.mock, now);
703
1009
  this.addAssetRefs(post.surfaces);
@@ -719,13 +1025,17 @@ export class SqlStore {
719
1025
  ...(post.prompt === undefined ? {} : { prompt: post.prompt }),
720
1026
  ...(post.author === undefined ? {} : { author: post.author }),
721
1027
  };
1028
+ // The version in use is kept past the limit: export and embeds resolve to it.
1029
+ const pin = post.acceptedVersion ?? -1;
722
1030
  const history = [...post.history, archived];
723
- if (history.length > HISTORY_LIMIT)
724
- history.shift();
1031
+ const prunable = history.filter((h) => h.version !== pin);
1032
+ if (prunable.length > HISTORY_LIMIT)
1033
+ history.splice(history.indexOf(prunable[0]), 1);
725
1034
  const title = patch.title !== undefined ? stripNul(patch.title).trim() || post.title : post.title;
726
1035
  const surfaces = patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : post.surfaces;
727
1036
  const knobs = patch.knobs === undefined ? post.knobs : (patch.knobs ?? undefined);
728
- const slots = patch.slots ?? post.slots;
1037
+ const note = patch.note === undefined ? post.note : stripNul(patch.note).trim() || undefined;
1038
+ const embeds = patch.embeds ?? post.embeds;
729
1039
  const state = patch.state === undefined ? post.state : patch.state;
730
1040
  const version = post.version + 1;
731
1041
  const updatedAt = new Date().toISOString();
@@ -733,22 +1043,23 @@ export class SqlStore {
733
1043
  const from = patch.from ?? post.version;
734
1044
  const prompt = stripNul(patch.prompt ?? "");
735
1045
  const author = patch.author === undefined ? null : stripNul(patch.author);
736
- this.sql.exec("UPDATE posts SET title = ?, surfaces = ?, knobs = ?, slots = ?, state = ?, updatedAt = ?, version = ?, curFrom = ?, curPrompt = ?, curAuthor = ? WHERE id = ? AND version = ?", title, JSON.stringify(surfaces), knobs ? JSON.stringify(knobs) : null, JSON.stringify(slots), state, updatedAt, version, from, prompt, author, id, post.version);
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);
737
1047
  const affected = this.sql.exec("SELECT changes() AS n").one().n;
738
1048
  if (affected > 0) {
739
1049
  this.insertVersion(id, archived);
740
- this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version <= ?", id, version - 1 - HISTORY_LIMIT);
1050
+ this.sql.exec("DELETE FROM post_versions WHERE postId = ? AND version <= ? AND version != ?", id, version - 1 - HISTORY_LIMIT, pin);
741
1051
  this.touch(post.sessionId);
742
1052
  this.touchMock(post.mock, updatedAt);
743
1053
  if (patch.surfaces !== undefined)
744
1054
  this.addAssetRefs(surfaces);
745
- const { knobs: _knobs, author: _author, ...rest } = post;
1055
+ const { knobs: _knobs, note: _note, author: _author, ...rest } = post;
746
1056
  return {
747
1057
  ...rest,
748
1058
  title,
749
1059
  surfaces,
750
1060
  ...(knobs ? { knobs } : {}),
751
- slots,
1061
+ ...(note ? { note } : {}),
1062
+ embeds,
752
1063
  state,
753
1064
  version,
754
1065
  updatedAt,
@@ -772,12 +1083,36 @@ export class SqlStore {
772
1083
  this.invalidateAssetRefs();
773
1084
  return true;
774
1085
  }
775
- async setPostStatus(id, status) {
1086
+ async setPostStatus(id, status, acceptedVersion) {
1087
+ const post = await this.getPost(id);
1088
+ if (!post)
1089
+ return null;
1090
+ 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 };
1094
+ }
1095
+ async setPostStarred(id, starred) {
776
1096
  const post = await this.getPost(id);
777
1097
  if (!post)
778
1098
  return null;
779
- this.sql.exec("UPDATE posts SET status = ? WHERE id = ?", status, id);
780
- return { ...post, status };
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;
1102
+ }
1103
+ async setVersionHidden(id, version, hidden) {
1104
+ const post = await this.getPost(id);
1105
+ if (!post)
1106
+ return null;
1107
+ const set = new Set(post.hiddenVersions ?? []);
1108
+ if (hidden)
1109
+ set.add(version);
1110
+ else
1111
+ set.delete(version);
1112
+ 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);
1114
+ const { hiddenVersions: _, ...rest } = post;
1115
+ return list.length ? { ...rest, hiddenVersions: list } : rest;
781
1116
  }
782
1117
  // --- comments ---
783
1118
  async listComments(query) {
@@ -864,12 +1199,17 @@ export class SqlStore {
864
1199
  postVersion: input.payload.version,
865
1200
  payload: input.payload,
866
1201
  });
867
- this.sql.exec("UPDATE mocks SET asks = ?, draft = NULL WHERE id = ?", JSON.stringify(input.asks), input.mockId);
868
- for (const id of input.accept) {
869
- this.sql.exec("UPDATE posts SET status = 'accepted' WHERE id = ? AND mockId = ?", id, input.mockId);
1202
+ 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);
1207
+ }
1208
+ 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);
870
1210
  }
871
1211
  for (const id of input.archive) {
872
- this.sql.exec("UPDATE posts SET status = 'archived' WHERE id = ? AND mockId = ?", id, input.mockId);
1212
+ this.sql.exec("UPDATE posts SET status = 'archived', acceptedVersion = NULL WHERE id = ? AND mockId = ?", id, input.mockId);
873
1213
  }
874
1214
  this.invalidateAssetRefs();
875
1215
  return comment;