From 17982d879425246c009d2b580937335152bd3dd4 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 15 Jun 2026 15:45:32 -0400 Subject: [PATCH 1/6] fix(deck-sync): honor deck tombstones in merge (LWW delete propagation) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/deck-sync-merge.spec.ts | 124 +++++++++++++++++- src/services/deck-sync-merge.ts | 91 ++++++++++--- src/types/column.d.ts | 5 + 3 files changed, 199 insertions(+), 21 deletions(-) diff --git a/src/services/__tests__/deck-sync-merge.spec.ts b/src/services/__tests__/deck-sync-merge.spec.ts index b1bcde7..6413a07 100644 --- a/src/services/__tests__/deck-sync-merge.spec.ts +++ b/src/services/__tests__/deck-sync-merge.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { TAccountWorkspace, TColumn, TDeck, TPairedAgent } from '@/types/column' -import { mergePairedAgents, mergeRemoteWorkspace } from '@/services/deck-sync-merge' +import { DECK_TOMBSTONE_TTL_MS, mergePairedAgents, mergeRemoteWorkspace } from '@/services/deck-sync-merge' const col = (id = 'c'): TColumn => ({ id, viewContext: 'pk', signingIdentity: 'pk', type: 'home' }) const deck = (over: Partial & { id: string }): TDeck => ({ @@ -49,7 +49,7 @@ describe('mergeRemoteWorkspace', () => { expect(conflicts.map((d) => d.id)).toEqual(['a']) }) - it('keeps a local-only deck not present remotely (no delete propagation)', () => { + it('keeps a local-only deck with no tombstone (newly created, not yet synced)', () => { const local = ws('a', [deck({ id: 'a' }), deck({ id: 'local-only' })]) const remote = ws('a', [deck({ id: 'a' })]) expect(mergeRemoteWorkspace(local, remote).merged.decks.map((d) => d.id)).toContain('local-only') @@ -60,6 +60,126 @@ describe('mergeRemoteWorkspace', () => { const remote = ws('a', [deck({ id: 'a' })]) expect(mergeRemoteWorkspace(local, remote).merged.activeDeckId).toBe('a') }) + + const NOW = 1_700_000_000_000 // fixed "now" for deterministic GC + + it('drops a tombstoned deck that was deleted after its last save (delete propagates)', () => { + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' })], + deletedDecks: { b: NOW - 1000 } + } + const remote = ws('a', [deck({ id: 'a' }), deck({ id: 'b', lastSavedAt: NOW - 5000 })]) + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toEqual(['a']) + expect(merged.deletedDecks).toEqual({ b: NOW - 1000 }) + }) + + it('does not resurrect: remote re-introduces a deck the local tombstone covers', () => { + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' }), deck({ id: 'b', lastSavedAt: NOW - 5000 })], + deletedDecks: { b: NOW - 1000 } + } + const remote = ws('a', [deck({ id: 'a' })]) + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toEqual(['a']) + }) + + it('resurrects a tombstoned deck saved AFTER the delete (LWW) and clears its tombstone', () => { + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' })], + deletedDecks: { b: NOW - 1000 } + } + const remote = ws('a', [deck({ id: 'a' }), deck({ id: 'b', lastSavedAt: NOW })]) + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toEqual(['a', 'b']) + expect(merged.deletedDecks).toBeUndefined() + }) + + it('keeps a tombstoned-but-dirty local deck and reports it as a conflict', () => { + const dirty = deck({ + id: 'b', + columns: [col('x'), col('y')], + savedColumns: [col('x')], + lastSavedAt: NOW - 5000 + }) + const local: TAccountWorkspace = { + activeDeckId: 'b', + decks: [deck({ id: 'a' }), dirty], + deletedDecks: { b: NOW - 1000 } + } + const remote = ws('a', [deck({ id: 'a' })]) + const { merged, conflicts } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toContain('b') + expect(conflicts.map((d) => d.id)).toContain('b') + expect(merged.deletedDecks).toBeUndefined() + }) + + it('keeps a local-only deck that is tombstoned but dirty, reporting it as a conflict', () => { + const dirty = deck({ + id: 'b', + columns: [col('x'), col('y')], + savedColumns: [col('x')], + lastSavedAt: NOW - 5000 + }) + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' }), dirty], // 'b' is local-only (absent from remote) + tombstoned + deletedDecks: { b: NOW - 1000 } + } + const remote = ws('a', [deck({ id: 'a' })]) + const { merged, conflicts } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toContain('b') + expect(conflicts.map((d) => d.id)).toContain('b') + expect(merged.deletedDecks).toBeUndefined() + }) + + it('unions tombstones taking the max timestamp', () => { + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' })], + deletedDecks: { x: NOW - 100, y: NOW - 9000 } + } + const remote: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' })], + deletedDecks: { x: NOW - 5000, z: NOW - 7000 } + } + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.deletedDecks).toEqual({ x: NOW - 100, y: NOW - 9000, z: NOW - 7000 }) + }) + + it('garbage-collects tombstones older than the TTL (using injected now)', () => { + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a' })], + deletedDecks: { fresh: NOW - 1000, stale: NOW - DECK_TOMBSTONE_TTL_MS - 1000 } + } + const remote = ws('a', [deck({ id: 'a' })]) + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.deletedDecks).toEqual({ fresh: NOW - 1000 }) + }) + + it('never returns zero decks: keeps the newest candidate even if all are tombstoned', () => { + const local: TAccountWorkspace = { + activeDeckId: 'a', + decks: [deck({ id: 'a', lastSavedAt: 1 }), deck({ id: 'b', lastSavedAt: 9 })], + deletedDecks: { a: NOW, b: NOW } + } + const remote = ws('a', []) + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toEqual(['b']) + }) + + it('treats a remote workspace with no deletedDecks as no tombstones (back-compat)', () => { + const local: TAccountWorkspace = { activeDeckId: 'a', decks: [deck({ id: 'a' })] } + const remote = ws('a', [deck({ id: 'a' }), deck({ id: 'b' })]) + const { merged } = mergeRemoteWorkspace(local, remote, NOW) + expect(merged.decks.map((d) => d.id)).toEqual(['a', 'b']) + expect(merged.deletedDecks).toBeUndefined() + }) }) const agent = ( diff --git a/src/services/deck-sync-merge.ts b/src/services/deck-sync-merge.ts index 345f34d..df7ec4d 100644 --- a/src/services/deck-sync-merge.ts +++ b/src/services/deck-sync-merge.ts @@ -10,50 +10,103 @@ function deckDirty(deck: TDeck): boolean { export type TMergeResult = { /** The merged workspace to write to storage (safe parts applied). */ merged: TAccountWorkspace - /** Local decks kept because they had unsaved edits while the remote also changed — need explicit approval. */ + /** Local decks kept despite a remote tombstone or a conflicting remote change (unsaved local edits) — need explicit approval. */ conflicts: TDeck[] } +/** Tombstones older than this are garbage-collected on merge. 90 days: long + * enough that a device offline for months won't resurrect a deleted deck, + * cheap enough (each entry ~50 bytes) that growth is a non-issue. */ +export const DECK_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000 + +/** Union two tombstone maps, keeping the latest deletion timestamp per id. */ +function unionTombstones( + a: Record | undefined, + b: Record | undefined +): Record { + const out: Record = { ...(a ?? {}) } + for (const [id, ts] of Object.entries(b ?? {})) { + if (out[id] === undefined || ts > out[id]) out[id] = ts + } + return out +} + /** - * Per-deck merge of a remote workspace into the local workspace (pull side). Non-destructive: + * Per-deck merge of a remote workspace into the local workspace (pull side). * - * - remote deck **not present** locally → **add** it - * - local deck with **unsaved edits** (dirty) → **keep local**, report as a conflict - * - local deck clean, remote's `lastSavedAt` **newer** → **update** to remote (per-deck LWW) - * - local deck clean, local **newer/equal** → **keep local** (don't downgrade) - * - **local-only** deck (absent from remote) → **keep** (delete-propagation is a separate backlog item) + * Decks: remote-not-local → add; locally-dirty → keep + conflict; clean with + * newer remote → take remote (per-deck LWW); else keep local; local-only → keep. * - * `activeDeckId`: keep local's if its deck survives, else remote's, else the first merged deck. + * Tombstones (`deletedDecks`): unioned across both sides (max timestamp per id). + * A candidate deck whose id is tombstoned is DROPPED unless it was saved after + * the delete (`lastSavedAt > deletedAt`, LWW resurrection) or has unsaved local + * edits (kept + reported as a conflict). A deck that survives its tombstone + * clears it. Tombstones older than `DECK_TOMBSTONE_TTL_MS` are GC'd. If the + * result would be empty, the newest candidate is kept regardless (never strand + * the user deckless). `now` is injectable for deterministic GC tests. + * + * `activeDeckId`: keep local's if its deck survives, else remote's, else first. * A `null`/absent local workspace means "fresh device" → take remote wholesale. */ export function mergeRemoteWorkspace( local: TAccountWorkspace | undefined, - remote: TAccountWorkspace + remote: TAccountWorkspace, + now: number = Date.now() ): TMergeResult { if (!local) return { merged: remote, conflicts: [] } const localById = new Map(local.decks.map((d) => [d.id, d])) const seen = new Set() const conflicts: TDeck[] = [] - const mergedDecks: TDeck[] = [] + const candidateDecks: TDeck[] = [] for (const rd of remote.decks) { seen.add(rd.id) const ld = localById.get(rd.id) if (!ld) { - mergedDecks.push(rd) // new on another device + candidateDecks.push(rd) // new on another device } else if (deckDirty(ld)) { - mergedDecks.push(ld) // unsaved local edits win + candidateDecks.push(ld) // unsaved local edits win conflicts.push(ld) } else if (rd.lastSavedAt > ld.lastSavedAt) { - mergedDecks.push(rd) // remote is newer + candidateDecks.push(rd) // remote is newer } else { - mergedDecks.push(ld) // local newer or equal + candidateDecks.push(ld) // local newer or equal } } - for (const ld of local.decks) { - if (!seen.has(ld.id)) mergedDecks.push(ld) // local-only → keep (no delete propagation) + if (!seen.has(ld.id)) candidateDecks.push(ld) // local-only + } + + // Apply tombstones (LWW, with a dirty exception). Survivors clear their tombstone. + const tombstones = unionTombstones(local.deletedDecks, remote.deletedDecks) + const survivors: TDeck[] = [] + for (const d of candidateDecks) { + const ts = tombstones[d.id] + if (ts === undefined) { + survivors.push(d) + } else if (d.lastSavedAt > ts) { + survivors.push(d) + delete tombstones[d.id] // saved after delete → resurrect + } else if (deckDirty(d)) { + survivors.push(d) + delete tombstones[d.id] // unsaved edits → keep, surface as conflict + if (!conflicts.includes(d)) conflicts.push(d) + } + // else: dropped (stays deleted) + } + + // Never strand the user with zero decks. + let mergedDecks = survivors + if (mergedDecks.length === 0 && candidateDecks.length > 0) { + const newest = candidateDecks.reduce((a, b) => (b.lastSavedAt > a.lastSavedAt ? b : a)) + mergedDecks = [newest] + delete tombstones[newest.id] + } + + // GC aged tombstones. + for (const [id, ts] of Object.entries(tombstones)) { + if (ts < now - DECK_TOMBSTONE_TTL_MS) delete tombstones[id] } const ids = new Set(mergedDecks.map((d) => d.id)) @@ -65,16 +118,16 @@ export function mergeRemoteWorkspace( } const mergedPairedAgents = mergePairedAgents(local.pairedAgents, remote.pairedAgents) - // allowSiblingExposure: remote indicates a recent edit by another device — if - // the user toggled on the other device, that's authoritative. const allowSiblingExposure = remote.allowSiblingExposure ?? local.allowSiblingExposure + const hasTombstones = Object.keys(tombstones).length > 0 return { merged: { activeDeckId, decks: mergedDecks, ...(mergedPairedAgents.length > 0 ? { pairedAgents: mergedPairedAgents } : {}), - ...(allowSiblingExposure !== undefined ? { allowSiblingExposure } : {}) + ...(allowSiblingExposure !== undefined ? { allowSiblingExposure } : {}), + ...(hasTombstones ? { deletedDecks: tombstones } : {}) }, conflicts } diff --git a/src/types/column.d.ts b/src/types/column.d.ts index 546b292..8c5e384 100644 --- a/src/types/column.d.ts +++ b/src/types/column.d.ts @@ -182,6 +182,11 @@ export type TAccountWorkspace = { * posture). Toggled via Settings → Agents → "Allow agents to see your other * paired accounts" with a disclosure dialog. See spec §10. */ allowSiblingExposure?: boolean + /** deckId → deletedAt (unix ms). A deck whose id is here is suppressed on + * merge unless a later save (lastSavedAt > deletedAt) resurrects it, or it + * has unsaved local edits (kept + reported as a conflict). GC'd after + * DECK_TOMBSTONE_TTL_MS. Optional for backward compat. */ + deletedDecks?: Record } /** A paired AI agent. v1 is read-only scope; v2 will add 'full'. See spec §6.1. */ From e8dac0ccab68b187e591e387ac032a15c387a036 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 15 Jun 2026 16:14:30 -0400 Subject: [PATCH 2/6] feat(deck-sync): encode/decode deck tombstones on the wire Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/deck-sync-codec.spec.ts | 53 +++++++++++++++++++ src/services/deck-sync-codec.ts | 21 +++++++- src/types/deck-sync.d.ts | 2 + 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/services/__tests__/deck-sync-codec.spec.ts b/src/services/__tests__/deck-sync-codec.spec.ts index 83a3278..81ac102 100644 --- a/src/services/__tests__/deck-sync-codec.spec.ts +++ b/src/services/__tests__/deck-sync-codec.spec.ts @@ -102,3 +102,56 @@ describe('deck-sync-codec', () => { expect(d.columns[0].config).toEqual(d.savedColumns[0].config) }) }) + +describe('deck-sync-codec — deletedDecks tombstones', () => { + it('round-trips deletedDecks', () => { + const decoded = decodeWorkspace(encodeWorkspace(workspace({ deletedDecks: { x: 111, y: 222 } }))) + expect(decoded.ok).toBe(true) + if (!decoded.ok) return + expect(decoded.workspace.deletedDecks).toEqual({ x: 111, y: 222 }) + }) + + it('omits deletedDecks from the wire when empty', () => { + const json = encodeWorkspace(workspace({ deletedDecks: {} })) + expect(json).not.toContain('deletedDecks') + const decoded = decodeWorkspace(json) + expect(decoded.ok).toBe(true) + if (!decoded.ok) return + expect(decoded.workspace.deletedDecks).toBeUndefined() + }) + + it('decodes a blob without deletedDecks (back-compat) and keeps version 1', () => { + const json = encodeWorkspace(workspace()) + expect(JSON.parse(json).version).toBe(1) + const decoded = decodeWorkspace(json) + expect(decoded.ok).toBe(true) + if (!decoded.ok) return + expect(decoded.workspace.deletedDecks).toBeUndefined() + }) + + it('ignores a non-object deletedDecks (array) rather than producing index keys', () => { + const json = JSON.stringify({ + version: 1, + activeDeckId: 'd1', + decks: [{ id: 'd1', name: 'My Deck', createdAt: 1, updatedAt: 1, lastSavedAt: 1, columns: [] }], + deletedDecks: [123, 456] + }) + const decoded = decodeWorkspace(json) + expect(decoded.ok).toBe(true) + if (!decoded.ok) return + expect(decoded.workspace.deletedDecks).toBeUndefined() + }) + + it('sanitizes malformed deletedDecks (non-number values dropped) rather than failing', () => { + const json = JSON.stringify({ + version: 1, + activeDeckId: 'd1', + decks: [{ id: 'd1', name: 'My Deck', createdAt: 1, updatedAt: 1, lastSavedAt: 1, columns: [] }], + deletedDecks: { good: 123, bad: 'oops', alsoBad: null } + }) + const decoded = decodeWorkspace(json) + expect(decoded.ok).toBe(true) + if (!decoded.ok) return + expect(decoded.workspace.deletedDecks).toEqual({ good: 123 }) + }) +}) diff --git a/src/services/deck-sync-codec.ts b/src/services/deck-sync-codec.ts index 5d3fafb..8d18fde 100644 --- a/src/services/deck-sync-codec.ts +++ b/src/services/deck-sync-codec.ts @@ -3,6 +3,17 @@ import type { TDecodeResult, TWireColumn, TWireDeck, TWireWorkspace } from '@/ty const WIRE_VERSION = 1 +/** Coerce an unknown value into a clean deckId→ms map, dropping non-number + * entries. Returns undefined when nothing valid remains. */ +function sanitizeDeletedDecks(v: unknown): Record | undefined { + if (typeof v !== 'object' || v === null || Array.isArray(v)) return undefined + const out: Record = {} + for (const [k, val] of Object.entries(v as Record)) { + if (typeof val === 'number' && Number.isFinite(val)) out[k] = val + } + return Object.keys(out).length > 0 ? out : undefined +} + function toWireColumn(c: TColumn): TWireColumn { const wire: TWireColumn = { id: c.id, @@ -37,6 +48,9 @@ export function encodeWorkspace(workspace: TAccountWorkspace): string { columns: d.savedColumns.filter((c) => !c.transient).map(toWireColumn) })) } + if (workspace.deletedDecks && Object.keys(workspace.deletedDecks).length > 0) { + wire.deletedDecks = { ...workspace.deletedDecks } + } return JSON.stringify(wire) } @@ -81,9 +95,14 @@ export function decodeWorkspace(json: string): TDecodeResult { columns: wd.columns.map(fromWireColumn), savedColumns: wd.columns.map(fromWireColumn) })) + const deletedDecks = sanitizeDeletedDecks(parsed.deletedDecks) return { ok: true, - workspace: { activeDeckId: parsed.activeDeckId, decks }, + workspace: { + activeDeckId: parsed.activeDeckId, + decks, + ...(deletedDecks ? { deletedDecks } : {}) + }, version: parsed.version } } diff --git a/src/types/deck-sync.d.ts b/src/types/deck-sync.d.ts index 0428a41..fa1a1c5 100644 --- a/src/types/deck-sync.d.ts +++ b/src/types/deck-sync.d.ts @@ -20,6 +20,8 @@ export type TWireWorkspace = { version: 1 activeDeckId: string decks: TWireDeck[] + /** deckId → deletedAt (unix ms). Omitted when empty. */ + deletedDecks?: Record } export type TDecodeResult = From a0944cba1e511a5562d19c84161d074975008e4d Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 15 Jun 2026 17:49:21 -0400 Subject: [PATCH 3/6] fix(decks): record a tombstone (and preserve workspace fields) on deck delete Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/local-storage.service.spec.ts | 59 +++++++++++++++++++ src/services/local-storage.service.ts | 7 ++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/services/__tests__/local-storage.service.spec.ts b/src/services/__tests__/local-storage.service.spec.ts index fc4ce7b..79232f7 100644 --- a/src/services/__tests__/local-storage.service.spec.ts +++ b/src/services/__tests__/local-storage.service.spec.ts @@ -366,3 +366,62 @@ describe('read-notifications storage', () => { expect(storage.getReadNotifications('pk2')).toEqual(['x', 'y']) }) }) + +describe('LocalStorageService.deleteDeck — tombstones', () => { + beforeEach(() => window.localStorage.clear()) + + const mkDeck = (id: string): TDeck => ({ + id, + name: id, + columns: [], + savedColumns: [], + createdAt: 1, + updatedAt: 1, + lastSavedAt: 1 + }) + + it('writes a tombstone and preserves other workspace fields on delete', () => { + storage.setWorkspacesByAccount({ + pk: { + activeDeckId: 'a', + decks: [mkDeck('a'), mkDeck('b')], + pairedAgents: [{ pubkey: 'h', npub: 'npub1x', scope: 'read-only', pairedAt: 1 }], + allowSiblingExposure: true + } + }) + storage.setActiveAccountPubkey('pk') + const before = Date.now() + storage.deleteDeck('b') + + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.decks.map((d) => d.id)).toEqual(['a']) + expect(ws.deletedDecks?.['b']).toBeGreaterThanOrEqual(before) + expect(ws.pairedAgents).toHaveLength(1) // regression: optional fields not dropped + expect(ws.allowSiblingExposure).toBe(true) + }) + + it('tombstones the deleted deck even when the last-deck guard fires', () => { + storage.setWorkspacesByAccount({ pk: { activeDeckId: 'only', decks: [mkDeck('only')] } }) + storage.setActiveAccountPubkey('pk') + storage.deleteDeck('only') + + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.deletedDecks?.['only']).toBeTypeOf('number') + expect(ws.decks).toHaveLength(1) + expect(ws.decks[0].name).toBe('Untitled deck') + }) + + it('accumulates tombstones across sequential deletes', () => { + storage.setWorkspacesByAccount({ + pk: { activeDeckId: 'a', decks: [mkDeck('a'), mkDeck('b'), mkDeck('c')] } + }) + storage.setActiveAccountPubkey('pk') + storage.deleteDeck('b') + storage.deleteDeck('c') + + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.deletedDecks?.['b']).toBeTypeOf('number') + expect(ws.deletedDecks?.['c']).toBeTypeOf('number') + expect(ws.decks.map((d) => d.id)).toEqual(['a']) + }) +}) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index e7667d4..06b34e4 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -1491,10 +1491,11 @@ class LocalStorageService { this.mutateActiveWorkspace((workspace) => { const idx = workspace.decks.findIndex((d) => d.id === deckId) if (idx < 0) return workspace + const now = Date.now() + const deletedDecks = { ...(workspace.deletedDecks ?? {}), [deckId]: now } const nextDecks = workspace.decks.filter((d) => d.id !== deckId) // Last-deck guard. if (nextDecks.length === 0) { - const now = Date.now() const untitled: TDeck = { id: randomId(), name: 'Untitled deck', @@ -1504,13 +1505,13 @@ class LocalStorageService { updatedAt: now, lastSavedAt: now } - return { decks: [untitled], activeDeckId: untitled.id } + return { ...workspace, decks: [untitled], activeDeckId: untitled.id, deletedDecks } } let nextActiveId = workspace.activeDeckId if (workspace.activeDeckId === deckId) { nextActiveId = nextDecks[Math.min(idx, nextDecks.length - 1)].id } - return { decks: nextDecks, activeDeckId: nextActiveId } + return { ...workspace, decks: nextDecks, activeDeckId: nextActiveId, deletedDecks } }) } From b3f183b131e9a5a0a71d061d9f051fedb2c60724 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 15 Jun 2026 18:00:07 -0400 Subject: [PATCH 4/6] fix(decks): clear tombstone on undo via storage.restoreDeck Co-Authored-By: Claude Opus 4.8 (1M context) --- src/providers/ColumnsProvider.tsx | 22 +-------- .../__tests__/local-storage.service.spec.ts | 46 +++++++++++++++++++ src/services/local-storage.service.ts | 29 ++++++++++++ 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/providers/ColumnsProvider.tsx b/src/providers/ColumnsProvider.tsx index 548ed8e..52b4cd1 100644 --- a/src/providers/ColumnsProvider.tsx +++ b/src/providers/ColumnsProvider.tsx @@ -1097,27 +1097,9 @@ export function ColumnsProvider({ children }: { children: ReactNode }) { recentlyDeletedRef.current = null const current = storage.getWorkspacesByAccount() - const workspace = current[snap.workspaceKey] - if (!workspace) return false - - // Re-insert at the original index. If last-deck guard fired (workspace - // has a single Untitled+empty deck post-delete), drop it; otherwise keep. - let nextDecks = [...workspace.decks] - const lastDeckGuardDeck = nextDecks.find( - (d) => d.name === 'Untitled deck' && d.columns.length === 0 && d.savedColumns.length === 0 - ) - if (lastDeckGuardDeck && nextDecks.length === 1) { - nextDecks = [] - } - nextDecks.splice(snap.restorationIndex, 0, snap.deck) + if (!current[snap.workspaceKey]) return false - storage.setWorkspacesByAccount({ - ...current, - [snap.workspaceKey]: { - decks: nextDecks, - activeDeckId: snap.deck.id - } - }) + storage.restoreDeck(snap.workspaceKey, snap.deck, snap.restorationIndex) refreshWorkspacesByAccount() setColumns(storage.getColumns()) void deckSyncService.publishWorkspace(snap.workspaceKey) diff --git a/src/services/__tests__/local-storage.service.spec.ts b/src/services/__tests__/local-storage.service.spec.ts index 79232f7..7b1143a 100644 --- a/src/services/__tests__/local-storage.service.spec.ts +++ b/src/services/__tests__/local-storage.service.spec.ts @@ -367,6 +367,52 @@ describe('read-notifications storage', () => { }) }) +describe('LocalStorageService.restoreDeck — undo clears tombstone', () => { + beforeEach(() => window.localStorage.clear()) + + const mkDeck = (id: string): TDeck => ({ + id, + name: id, + columns: [], + savedColumns: [], + createdAt: 1, + updatedAt: 1, + lastSavedAt: 1 + }) + + it('re-inserts the deck at the index, clears its tombstone, preserves other fields', () => { + storage.setWorkspacesByAccount({ + pk: { + activeDeckId: 'a', + decks: [mkDeck('a')], + deletedDecks: { b: 123, c: 456 }, + allowSiblingExposure: true + } + }) + storage.setActiveAccountPubkey('pk') + storage.restoreDeck('pk', mkDeck('b'), 1) + + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.decks.map((d) => d.id)).toEqual(['a', 'b']) + expect(ws.activeDeckId).toBe('b') + expect(ws.deletedDecks).toEqual({ c: 456 }) // 'b' cleared, 'c' kept + expect(ws.allowSiblingExposure).toBe(true) + }) + + it('drops the lone Untitled guard deck when restoring', () => { + const untitled = { ...mkDeck('u'), name: 'Untitled deck' } + storage.setWorkspacesByAccount({ + pk: { activeDeckId: 'u', decks: [untitled], deletedDecks: { b: 123 } } + }) + storage.setActiveAccountPubkey('pk') + storage.restoreDeck('pk', mkDeck('b'), 0) + + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.decks.map((d) => d.id)).toEqual(['b']) + expect(ws.deletedDecks).toBeUndefined() // last tombstone cleared → key omitted + }) +}) + describe('LocalStorageService.deleteDeck — tombstones', () => { beforeEach(() => window.localStorage.clear()) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 06b34e4..56e9227 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -1515,6 +1515,35 @@ class LocalStorageService { }) } + /** + * Re-insert a previously-deleted deck (undo). Drops the lone last-deck-guard + * "Untitled deck" placeholder if it's the only deck, splices `deck` back in at + * `index`, makes it active, clears its tombstone, and preserves the + * workspace's other fields. No-op if the workspace is gone. + */ + restoreDeck(workspaceKey: string, deck: TDeck, index: number): void { + const all = this.getWorkspacesByAccount() + const workspace = all[workspaceKey] + if (!workspace) return + + let nextDecks = [...workspace.decks] + const guard = nextDecks.find( + (d) => d.name === 'Untitled deck' && d.columns.length === 0 && d.savedColumns.length === 0 + ) + if (guard && nextDecks.length === 1) nextDecks = [] + nextDecks.splice(index, 0, deck) + + const deletedDecks = { ...(workspace.deletedDecks ?? {}) } + delete deletedDecks[deck.id] + const hasTombstones = Object.keys(deletedDecks).length > 0 + + const next: TAccountWorkspace = { ...workspace, decks: nextDecks, activeDeckId: deck.id } + if (hasTombstones) next.deletedDecks = deletedDecks + else delete next.deletedDecks + + this.setWorkspacesByAccount({ ...all, [workspaceKey]: next }) + } + /** * Dirty check: are the deck's live `columns` different from its * `savedColumns`? Transient columns are session-only chrome — they don't From aaf952c120cfd2632df08f83b2f86d1bb04032d6 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 15 Jun 2026 18:11:26 -0400 Subject: [PATCH 5/6] fix(decks): preserve deletedDecks + workspace fields in saveActiveDeckAs and addEmptyDeck mutateActiveWorkspace replaces (not merges), so these two callbacks were dropping deletedDecks/pairedAgents/allowSiblingExposure. With tombstones now load-bearing for cross-device delete propagation, a Save-As or Add-Deck right after a remote delete-merge could wipe tombstones and resurrect the deck. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/local-storage.service.spec.ts | 46 +++++++++++++++++++ src/services/local-storage.service.ts | 2 + 2 files changed, 48 insertions(+) diff --git a/src/services/__tests__/local-storage.service.spec.ts b/src/services/__tests__/local-storage.service.spec.ts index 7b1143a..a1557b9 100644 --- a/src/services/__tests__/local-storage.service.spec.ts +++ b/src/services/__tests__/local-storage.service.spec.ts @@ -471,3 +471,49 @@ describe('LocalStorageService.deleteDeck — tombstones', () => { expect(ws.decks.map((d) => d.id)).toEqual(['a']) }) }) + +describe('LocalStorageService — addEmptyDeck/saveActiveDeckAs preserve workspace fields', () => { + beforeEach(() => window.localStorage.clear()) + + const mkDeck = (id: string): TDeck => ({ + id, + name: id, + columns: [], + savedColumns: [], + createdAt: 1, + updatedAt: 1, + lastSavedAt: 1 + }) + + const seed = () => + storage.setWorkspacesByAccount({ + pk: { + activeDeckId: 'a', + decks: [mkDeck('a')], + deletedDecks: { b: 123 }, + pairedAgents: [{ pubkey: 'h', npub: 'npub1x', scope: 'read-only', pairedAt: 1 }], + allowSiblingExposure: true + } + }) + + it('addEmptyDeck preserves deletedDecks, pairedAgents, allowSiblingExposure', () => { + seed() + storage.setActiveAccountPubkey('pk') + storage.addEmptyDeck({ name: 'New' }) + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.deletedDecks).toEqual({ b: 123 }) + expect(ws.pairedAgents).toHaveLength(1) + expect(ws.allowSiblingExposure).toBe(true) + expect(ws.decks.map((d) => d.id)).toContain('a') + }) + + it('saveActiveDeckAs preserves deletedDecks, pairedAgents, allowSiblingExposure', () => { + seed() + storage.setActiveAccountPubkey('pk') + storage.saveActiveDeckAs({ name: 'Copy' }) + const ws = storage.getWorkspacesByAccount()['pk'] + expect(ws.deletedDecks).toEqual({ b: 123 }) + expect(ws.pairedAgents).toHaveLength(1) + expect(ws.allowSiblingExposure).toBe(true) + }) +}) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 56e9227..e71412d 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -1422,6 +1422,7 @@ class LocalStorageService { lastSavedAt: now } return { + ...workspace, decks: [...workspace.decks, newDeck], activeDeckId: newDeck.id // switches active to new } @@ -1442,6 +1443,7 @@ class LocalStorageService { lastSavedAt: now } return { + ...workspace, decks: [...workspace.decks, newDeck], activeDeckId: newDeck.id } From cded8abc96eed1e6a4dba6c3cf5d2f731ff83247 Mon Sep 17 00:00:00 2001 From: DocNR Date: Mon, 15 Jun 2026 20:27:48 -0400 Subject: [PATCH 6/6] chore(release): v26.14.2 deck delete propagation Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/release-notes.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fcb7095..d1d19bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jank", - "version": "26.14.1", + "version": "26.14.2", "description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient", "type": "module", "author": "DocNR", diff --git a/src/release-notes.ts b/src/release-notes.ts index c6ace8c..e2a2c87 100644 --- a/src/release-notes.ts +++ b/src/release-notes.ts @@ -14,6 +14,13 @@ export type ReleaseNote = { } export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: '26.14.2', + date: '2026-06-15', + highlights: [ + 'Deleting a deck now sticks across your devices. A deck you remove on one device no longer reappears on another after it syncs, and the brief Undo still brings it back if you change your mind.' + ] + }, { version: '26.14.1', date: '2026-06-14',