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