Skip to content

Commit 28ed729

Browse files
committed
feat(daemon): persist team-collab comment anchor state
Storage layer for the read-only comment drift ladder (C lane, spec D2), building on the contract from #5222. - preview_comments gains anchor_state / anchored_version / author_member_id / last_good_position_json. The migration runs after the slide-key table rebuild so a legacy rebuild cannot drop the new columns. - upsert persists creation metadata (anchoredVersion, authorMemberId); resolved state stays null for the drift ladder to fill in. - updatePreviewCommentAnchor is the engine write-back: COALESCE keeps the last-good position/version on a lost resolve, and it never bumps updated_at. - contract: anchoredVersion on PreviewCommentTarget, authorMemberId on the upsert request (server-set), and PreviewCommentAnchorUpdateRequest. Tests cover fresh-db columns, creation round-trip, engine write-back with lost-keeps-last-good, and legacy-migration backfill.
1 parent 0380acf commit 28ed729

3 files changed

Lines changed: 174 additions & 2 deletions

File tree

apps/daemon/src/db.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ function migrate(db: SqliteDb): void {
156156
status TEXT NOT NULL,
157157
created_at INTEGER NOT NULL,
158158
updated_at INTEGER NOT NULL,
159+
anchor_state TEXT,
160+
anchored_version INTEGER,
161+
author_member_id TEXT,
162+
last_good_position_json TEXT,
159163
UNIQUE(project_id, conversation_id, file_path, element_id, slide_key),
160164
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
161165
FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
@@ -329,6 +333,21 @@ function migrate(db: SqliteDb): void {
329333
db.exec(`ALTER TABLE preview_comments ADD COLUMN slide_index INTEGER`);
330334
}
331335
migratePreviewCommentsSlideKey(db);
336+
// Team-collab anchor columns — added after the slide-key rebuild so a legacy
337+
// table rebuild cannot drop them.
338+
const previewCommentAnchorCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[];
339+
if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'anchor_state')) {
340+
db.exec(`ALTER TABLE preview_comments ADD COLUMN anchor_state TEXT`);
341+
}
342+
if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'anchored_version')) {
343+
db.exec(`ALTER TABLE preview_comments ADD COLUMN anchored_version INTEGER`);
344+
}
345+
if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'author_member_id')) {
346+
db.exec(`ALTER TABLE preview_comments ADD COLUMN author_member_id TEXT`);
347+
}
348+
if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'last_good_position_json')) {
349+
db.exec(`ALTER TABLE preview_comments ADD COLUMN last_good_position_json TEXT`);
350+
}
332351
const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[];
333352
if (!deploymentCols.some((c: DbRow) => c.name === 'status')) {
334353
db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`);
@@ -1610,6 +1629,8 @@ export function listPreviewComments(db: SqliteDb, projectId: string, conversatio
16101629
pod_members_json AS podMembersJson, style_json AS styleJson,
16111630
attachments_json AS attachmentsJson,
16121631
slide_index AS slideIndex,
1632+
anchor_state AS anchorState, anchored_version AS anchoredVersion,
1633+
author_member_id AS authorMemberId, last_good_position_json AS lastGoodPositionJson,
16131634
note, status, created_at AS createdAt, updated_at AS updatedAt
16141635
FROM preview_comments
16151636
WHERE project_id = ? AND conversation_id = ?
@@ -1643,6 +1664,15 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
16431664
: 0;
16441665
const slideIndex = Number.isFinite(target.slideIndex) ? Math.max(0, Math.round(target.slideIndex)) : null;
16451666
const slideKey = slideIndex ?? -1;
1667+
// Team-collab creation metadata. anchor_state / last_good_position stay null at
1668+
// creation — the drift ladder resolves and writes them back (updatePreviewCommentAnchor).
1669+
const anchoredVersion = Number.isFinite(target.anchoredVersion)
1670+
? Math.max(0, Math.round(target.anchoredVersion))
1671+
: null;
1672+
const authorMemberId =
1673+
typeof input?.authorMemberId === 'string' && input.authorMemberId.trim()
1674+
? input.authorMemberId.trim()
1675+
: null;
16461676
const now = Date.now();
16471677
const existing = db
16481678
.prepare(
@@ -1661,8 +1691,9 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
16611691
`INSERT INTO preview_comments
16621692
(id, project_id, conversation_id, file_path, element_id, selector, label,
16631693
text, position_json, html_hint, selection_kind, member_count, pod_members_json,
1664-
style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at)
1665-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1694+
style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at,
1695+
anchored_version, author_member_id)
1696+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16661697
ON CONFLICT(project_id, conversation_id, file_path, element_id, slide_key) DO UPDATE SET
16671698
selector = excluded.selector,
16681699
label = excluded.label,
@@ -1677,6 +1708,8 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
16771708
slide_index = excluded.slide_index,
16781709
note = excluded.note,
16791710
status = 'open',
1711+
anchored_version = excluded.anchored_version,
1712+
author_member_id = excluded.author_member_id,
16801713
updated_at = excluded.updated_at`,
16811714
).run(
16821715
id,
@@ -1700,6 +1733,8 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
17001733
'open',
17011734
createdAt,
17021735
now,
1736+
anchoredVersion,
1737+
authorMemberId,
17031738
);
17041739
return getPreviewComment(db, projectId, conversationId, id);
17051740
}
@@ -1715,6 +1750,41 @@ export function updatePreviewCommentStatus(db: SqliteDb, projectId: string, conv
17151750
return getPreviewComment(db, projectId, conversationId, id);
17161751
}
17171752

1753+
/**
1754+
* Team-collab drift-ladder write-back: persist how a comment resolved this render.
1755+
* `lastGoodPosition`/`anchoredVersion` are COALESCEd so a `lost` resolve (which omits
1756+
* them) keeps the last known-good values instead of wiping them. Does not bump
1757+
* `updated_at` — anchor resolution is a derived read, not a content edit.
1758+
*/
1759+
export function updatePreviewCommentAnchor(
1760+
db: SqliteDb,
1761+
projectId: string,
1762+
conversationId: string,
1763+
id: string,
1764+
input: DbRow,
1765+
) {
1766+
const anchorState = typeof input?.anchorState === 'string' ? input.anchorState : null;
1767+
const lastGoodPosition = input?.lastGoodPosition ? normalizePosition(input.lastGoodPosition) : null;
1768+
const anchoredVersion = Number.isFinite(input?.anchoredVersion)
1769+
? Math.max(0, Math.round(input.anchoredVersion))
1770+
: null;
1771+
db.prepare(
1772+
`UPDATE preview_comments
1773+
SET anchor_state = ?,
1774+
last_good_position_json = COALESCE(?, last_good_position_json),
1775+
anchored_version = COALESCE(?, anchored_version)
1776+
WHERE id = ? AND project_id = ? AND conversation_id = ?`,
1777+
).run(
1778+
anchorState,
1779+
lastGoodPosition ? JSON.stringify(lastGoodPosition) : null,
1780+
anchoredVersion,
1781+
id,
1782+
projectId,
1783+
conversationId,
1784+
);
1785+
return getPreviewComment(db, projectId, conversationId, id);
1786+
}
1787+
17181788
export function deletePreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) {
17191789
const result = db
17201790
.prepare(
@@ -1735,6 +1805,8 @@ function getPreviewComment(db: SqliteDb, projectId: string, conversationId: stri
17351805
pod_members_json AS podMembersJson, style_json AS styleJson,
17361806
attachments_json AS attachmentsJson,
17371807
slide_index AS slideIndex,
1808+
anchor_state AS anchorState, anchored_version AS anchoredVersion,
1809+
author_member_id AS authorMemberId, last_good_position_json AS lastGoodPositionJson,
17381810
note, status, created_at AS createdAt, updated_at AS updatedAt
17391811
FROM preview_comments
17401812
WHERE id = ? AND project_id = ? AND conversation_id = ?`,
@@ -1772,6 +1844,10 @@ function normalizePreviewComment(row: DbRow) {
17721844
status: row.status,
17731845
createdAt: row.createdAt,
17741846
updatedAt: row.updatedAt,
1847+
anchorState: typeof row.anchorState === 'string' ? row.anchorState : undefined,
1848+
anchoredVersion: Number.isFinite(row.anchoredVersion) ? row.anchoredVersion : undefined,
1849+
authorMemberId: typeof row.authorMemberId === 'string' ? row.authorMemberId : undefined,
1850+
lastGoodPosition: parseJsonOrUndef(row.lastGoodPositionJson),
17751851
};
17761852
}
17771853

apps/daemon/tests/comment-attachments.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
listMessages,
1414
listPreviewComments,
1515
openDatabase,
16+
updatePreviewCommentAnchor,
1617
updatePreviewCommentStatus,
1718
upsertMessage,
1819
upsertPreviewComment,
@@ -57,6 +58,67 @@ describe('preview comment persistence', () => {
5758
expect(critiqueTable?.name).toBe('critique_runs');
5859
});
5960

61+
it('adds the team-collab anchor columns on a fresh database', () => {
62+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-comments-'));
63+
const db = openDatabase(tempDir);
64+
expect(tableColumnNames(db.prepare(`PRAGMA table_info(preview_comments)`).all())).toEqual(
65+
expect.arrayContaining([
66+
'anchor_state',
67+
'anchored_version',
68+
'author_member_id',
69+
'last_good_position_json',
70+
]),
71+
);
72+
});
73+
74+
it('round-trips team-collab anchor creation metadata and defers resolved state', () => {
75+
const db = seededDb();
76+
const saved = upsertPreviewComment(db, 'project-1', 'conversation-1', {
77+
target: target({ elementId: 'hero-title', anchoredVersion: 7 }),
78+
note: 'Anchor me',
79+
authorMemberId: 'member-42',
80+
});
81+
if (!saved) throw new Error('comment upsert failed');
82+
// Creation metadata persists...
83+
expect(saved.anchoredVersion).toBe(7);
84+
expect(saved.authorMemberId).toBe('member-42');
85+
// ...while the resolved state is left for the drift ladder to fill in.
86+
expect(saved.anchorState).toBeUndefined();
87+
expect(saved.lastGoodPosition).toBeUndefined();
88+
// Survives the re-fetch (the list read path).
89+
const [listed] = listPreviewComments(db, 'project-1', 'conversation-1');
90+
expect(listed?.anchoredVersion).toBe(7);
91+
expect(listed?.authorMemberId).toBe('member-42');
92+
});
93+
94+
it('writes back resolved anchor state and keeps last-good position on a lost resolve', () => {
95+
const db = seededDb();
96+
const saved = upsertPreviewComment(db, 'project-1', 'conversation-1', {
97+
target: target({ elementId: 'hero-title' }),
98+
note: 'Anchor me',
99+
});
100+
if (!saved) throw new Error('comment upsert failed');
101+
102+
// Engine resolves it (anchored) and writes back a known-good position.
103+
const good = { x: 5, y: 15, width: 120, height: 40 };
104+
const anchored = updatePreviewCommentAnchor(db, 'project-1', 'conversation-1', saved.id, {
105+
anchorState: 'anchored',
106+
lastGoodPosition: good,
107+
anchoredVersion: 3,
108+
});
109+
expect(anchored?.anchorState).toBe('anchored');
110+
expect(anchored?.lastGoodPosition).toEqual(good);
111+
expect(anchored?.anchoredVersion).toBe(3);
112+
113+
// Later the element vanishes → 'lost' with no new position. Last-good must survive.
114+
const lost = updatePreviewCommentAnchor(db, 'project-1', 'conversation-1', saved.id, {
115+
anchorState: 'lost',
116+
});
117+
expect(lost?.anchorState).toBe('lost');
118+
expect(lost?.lastGoodPosition).toEqual(good); // COALESCE preserved it
119+
expect(lost?.anchoredVersion).toBe(3); // COALESCE preserved it
120+
});
121+
60122
it('upserts the latest comment by conversation, file, and element', () => {
61123
const db = seededDb();
62124
const first = upsertPreviewComment(db, 'project-1', 'conversation-1', {
@@ -275,6 +337,16 @@ describe('preview comment persistence', () => {
275337
expect(table?.sql).toMatch(/slide_key INTEGER NOT NULL DEFAULT -1/);
276338
expect(table?.sql).toMatch(/UNIQUE\(project_id, conversation_id, file_path, element_id, slide_key\)/);
277339
expect(listPreviewComments(db, 'project-1', 'conversation-1')[0]?.slideIndex).toBe(0);
340+
// Anchor columns are backfilled even though the table was rebuilt for the
341+
// slide-key migration (the ALTERs run after the rebuild).
342+
expect(tableColumnNames(db.prepare(`PRAGMA table_info(preview_comments)`).all())).toEqual(
343+
expect.arrayContaining([
344+
'anchor_state',
345+
'anchored_version',
346+
'author_member_id',
347+
'last_good_position_json',
348+
]),
349+
);
278350

279351
const secondSlide = upsertPreviewComment(db, 'project-1', 'conversation-1', {
280352
target: target({ elementId: 'hero-title', slideIndex: 1, text: 'Slide two title' }),

packages/contracts/src/api/comments.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ export interface PreviewCommentTarget {
8484
podMembers?: PreviewCommentMember[];
8585
/** Zero-based deck slide index when the comment was placed. */
8686
slideIndex?: number;
87+
/**
88+
* Team-collab: content version this anchor was captured against. Persisted as
89+
* {@link PreviewComment.anchoredVersion}; drives the drift ladder's
90+
* "based on older vN" badge.
91+
*/
92+
anchoredVersion?: number;
8793
}
8894

8995
export interface PreviewComment {
@@ -130,6 +136,24 @@ export interface PreviewCommentUpsertRequest {
130136
target: PreviewCommentTarget;
131137
note: string;
132138
attachments?: PreviewCommentAttachment[];
139+
/**
140+
* Team-collab: comment author's workspaceMemberId. Server-set from the request
141+
* identity (B token → member context); clients do not supply it.
142+
*/
143+
authorMemberId?: string;
144+
}
145+
146+
/**
147+
* Team-collab: drift-ladder write-back. The anchoring engine reports where a
148+
* comment resolved this render so the resolved state persists across sessions
149+
* (see {@link PreviewCommentAnchorState}).
150+
*/
151+
export interface PreviewCommentAnchorUpdateRequest {
152+
anchorState: PreviewCommentAnchorState;
153+
/** Written back on a successful (anchored/reanchored) resolve; the `lost` ghost pin renders here. */
154+
lastGoodPosition?: PreviewCommentPosition;
155+
/** Optional: refresh the anchored content version. */
156+
anchoredVersion?: number;
133157
}
134158

135159
export interface PreviewCommentStatusRequest {

0 commit comments

Comments
 (0)