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