Skip to content

feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback#896

Open
heavygee wants to merge 6 commits into
tiann:mainfrom
heavygee:feat/scratchlist-v2
Open

feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback#896
heavygee wants to merge 6 commits into
tiann:mainfrom
heavygee:feat/scratchlist-v2

Conversation

@heavygee

Copy link
Copy Markdown
Collaborator

Closes #893. Tracking issue for v2.1 (delete with summarize-and-migrate): #894.

Summary

Promotes per-session scratchlist persistence from per-device localStorage to a hub-backed typed table so notes follow the operator across devices. The v1 panel UI / styling / FUE / keyboard shortcut / copy-to-clipboard surface are deliberately unchanged - this is a backend + sync-layer feature.

Hub side

  • New session_scratchlist typed table with (session_id, entry_id) PK, text, created_at, updated_at, FK on sessions(id) ON DELETE CASCADE, and (session_id, created_at DESC) index. Schema bumped V9 → V10; idempotent migration added to both the legacy backfill and step ladders.
  • REST CRUD under /api/sessions/:id/scratchlist[/:entryId], all routed through the existing requireSessionFromParam guard so namespace / ownership enforcement is identical to other session-scoped routes.
  • Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe even after a partial failure.
  • SessionPatchSchema extended with scratchlistUpdatedAt?: number; every successful mutation emits a session-updated SSE patch carrying the token. (Per the operator's piggyback decision in the brief - aligns with the parallel Performance: web client useSession refetch storm dominates hub access logs (~95% of syslog volume on busy installs) #884 patch-shape extension; either order merges cleanly.)

Web side

  • Hub becomes source of truth via TanStack Query (queryKeys.scratchlist(sessionId)); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error.
  • Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring CursorMigrationBanner) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent.
  • SSE handler queues a scratchlist invalidation when the patch carries scratchlistUpdatedAt, so cross-device + cross-tab updates land within an SSE round-trip.
  • Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted.

Out of scope

The "delete with summarize-and-migrate" UX flow (separate tracking issue #894) is not implemented here. v2.0 does the simpler "This will also delete N scratchlist entries" confirm-copy variant only.

Test plan

  • Hub: V9→V10 migration covered (fresh DB, multi-hop V8/V9 legacy, idempotent reopen, FK cascade-delete), ScratchlistStore CRUD + ordering + cross-session isolation.
  • Hub routes: happy paths, 400 (invalid body / missing entryId), 403 (cross-namespace), 404 (unknown session / entry), 409 (cap reached), idempotent duplicate POST.
  • Hub sync: SyncEngine mutations emit one session-updated patch per change with scratchlistUpdatedAt.
  • Web hook: optimistic add / delete / update with rollback, cap enforcement, local-only reorder, localStorage→hub migration with retry-safe duplicates, banner state machine.
  • Web banner component: renders only on migrationStatus === 'completed', dismissal persists.
  • Existing Playwright e2e (10 tests covering panel UI / inert / persistence / promote / shortcut / regression) - all pass unchanged, confirming no UI regression.
  • bun typecheck clean across cli / web / hub.
  • bun run test clean for hub (456) and web (982); cli (964 passing) - one pre-existing runner.integration.test.ts failure on upstream/main is unrelated.

Made with Cursor

…dated piggyback (tiann#893)

Promotes scratchlist persistence from per-device localStorage to a hub-
backed typed table so entries follow the operator across devices. v1
panel UI / FUE / shortcut / styling are deliberately unchanged - this is
a backend + sync-layer feature.

Hub side
- New `session_scratchlist` typed table (sessionId, entryId, text,
  createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from
  sessions. Schema bumped V9 -> V10; idempotent migration added to the
  legacy + step ladders.
- REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed
  through the existing `requireSessionFromParam` guard so namespace /
  ownership enforcement is identical to other session-scoped routes.
- Per-session 200-entry cap enforced on POST. Duplicate entryId reported
  idempotently (200) so the migration retry path is safe.
- `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`;
  every successful mutation emits a `session-updated` SSE patch with the
  token. (Following operator's piggyback decision; aligns with the
  parallel tiann#884 patch-shape extension.)

Web side
- Hub becomes source of truth via TanStack Query
  (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline
  cache. Add / delete / update mutations are optimistic with rollback
  on error.
- Silent first-load migration: existing localStorage entries are pushed
  to the hub preserving id + createdAt, and a one-time banner (mirroring
  `CursorMigrationBanner`) tells the operator their notes are now in
  the hub. Banner dismissal is per-session and persistent.
- SSE handler queues a `scratchlist` invalidation when the patch carries
  `scratchlistUpdatedAt`, so cross-device + cross-tab updates land
  within an SSE round-trip.
- Delete-session confirm copy now includes a count of scratchlist
  entries that will be cascade-deleted.

Out of scope (separate tracking issue tiann#894): "delete with summarize-and-
migrate" UX flow.

Tests
- Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen
  + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes
  (happy path + 400/403/404/409), SyncEngine SSE emission.
- Web: hook covers initial fetch, optimistic add/delete/update with
  rollback, localStorage migration + banner, cap enforcement,
  local-only reorder. Banner component renders only on `'completed'`.
- Existing Playwright e2e (10 tests, panel UI regression) all pass
  unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Migration can erase local-only notes after a partial upload - the migration loop swallows each failed POST and still writes the migrated flag; meanwhile the offline-cache effect mirrors the initial empty hub response back into hapi.scratchlist.v1.<sessionId>, so a transient error or cap rejection can leave entries neither on the hub nor in localStorage. Evidence web/src/lib/use-hub-scratchlist.ts:242.
    Suggested fix:
    const failedEntries: ScratchlistEntry[] = []
    for (const entry of localEntries) {
        try {
            await api.createScratchlistEntry(sessionId, {
                text,
                entryId: entry.id,
                createdAt: entry.createdAt
            })
        } catch {
            failedEntries.push(entry)
        }
    }
    if (failedEntries.length > 0) {
        persistScratchlist(sessionId, failedEntries)
        migrationAttemptedRef.current = false
        setMigrationStatus("idle")
        return
    }
    writeMigrationFlag(sessionId)
  • [Major] Duplicate migration retry is blocked once the session is at the cap - the POST route checks count >= SCRATCHLIST_MAX_ENTRIES before asking the store whether the supplied entryId already exists, so retrying an already-created entry at 200 rows returns 409 instead of the documented idempotent 200. Evidence hub/src/web/routes/sessions.ts:661.
    Suggested fix:
    if (parsed.data.entryId) {
        const existing = engine.getScratchlistEntry(sessionResult.sessionId, parsed.data.entryId)
        if (existing) {
            return c.json({ entry: existing }, 200)
        }
    }
    
    const currentCount = engine.countScratchlistEntries(sessionResult.sessionId)
    if (currentCount >= SCRATCHLIST_MAX_ENTRIES) {
        return c.json({
            error: `Scratchlist is at its ${SCRATCHLIST_MAX_ENTRIES}-entry cap`,
            code: "scratchlist_at_cap"
        }, 409)
    }

Summary

  • Review mode: initial
  • Found two Major issues in the new scratchlist migration/API path: one can lose local-only migrated notes on partial upload failure, and one breaks the duplicate POST idempotency contract at the cap boundary.

Testing

  • Not run (automation)

HAPI Bot

entryId: entry.id,
createdAt: entry.createdAt
})
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] The migration loop treats every per-entry POST failure as non-fatal and still writes the migrated flag. Because the later offline-cache effect mirrors the initial empty hub response into hapi.scratchlist.v1.<sessionId>, a transient failure or cap rejection can leave entries neither uploaded nor preserved locally. Keep failed entries in localStorage and do not set the migration flag until all intended entries are accepted.

Suggested fix:

const failedEntries: ScratchlistEntry[] = []
for (const entry of localEntries) {
    try {
        await api.createScratchlistEntry(sessionId, {
            text,
            entryId: entry.id,
            createdAt: entry.createdAt
        })
    } catch {
        failedEntries.push(entry)
    }
}
if (failedEntries.length > 0) {
    persistScratchlist(sessionId, failedEntries)
    migrationAttemptedRef.current = false
    setMigrationStatus("idle")
    return
}
writeMigrationFlag(sessionId)

// (e.g. direct REST call) hits this guard. Bumped only with the
// shared SCRATCHLIST_MAX_ENTRIES constant.
const currentCount = engine.countScratchlistEntries(sessionResult.sessionId)
if (currentCount >= SCRATCHLIST_MAX_ENTRIES) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This cap check runs before duplicate detection, so a retry for an entry that already exists returns 409 once the session has 200 rows. That breaks the route contract that duplicate entryId POSTs are idempotent for migration retries. Check an existing caller-supplied entryId before enforcing the new-entry cap.

Suggested fix:

if (parsed.data.entryId) {
    const existing = engine.getScratchlistEntry(sessionResult.sessionId, parsed.data.entryId)
    if (existing) {
        return c.json({ entry: existing }, 200)
    }
}

const currentCount = engine.countScratchlistEntries(sessionResult.sessionId)
if (currentCount >= SCRATCHLIST_MAX_ENTRIES) {
    return c.json({
        error: `Scratchlist is at its ${SCRATCHLIST_MAX_ENTRIES}-entry cap`,
        code: "scratchlist_at_cap"
    }, 409)
}

Two real data-correctness paths the bot caught on the initial review.

1. Migration partial-failure data loss
   The migration loop swallowed each failed POST and still wrote the
   `migrated` flag, while the offline-cache effect mirrored the
   (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
   so a transient error or cap rejection could leave entries neither
   on the hub nor in localStorage. Fix:
   - Track failed entries during migration and persist them back to
     localStorage; do NOT advance the flag if any entry failed, so a
     future mount retries.
   - Gate the offline-cache effect on the migration flag. Pre-
     migration, localStorage holds the v1 entries the migration
     reads; mirroring an empty hub fetch over them was the wipe.
   - Drop the "skip migration when hub is non-empty" gate. Combined
     with the duplicate-idempotent POST short-circuit (below), a
     retry against a session that another device already populated
     is a safe union.

2. Duplicate POST returned 409 at cap
   The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
   the store whether the supplied `entryId` already existed, so an
   idempotent migration retry against a 200-row session returned 409
   instead of 200. Fix: check duplicate first via a new
   `SyncEngine.getScratchlistEntry`, return the existing row with 200,
   and only run the cap check for genuinely new ids.

Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
  + new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
  localStorage and leaves the flag unset; offline-cache effect does
  not wipe pre-migration localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

Copy link
Copy Markdown
Collaborator Author

Thanks - both Major findings were valid. Pushed 29a0f6ee addressing both:

Major 1 (migration data loss): the migration loop now tracks failed entries, persists them back to hapi.scratchlist.v1.<sessionId>, and leaves the migrated flag unset so a future mount retries. The offline-cache effect is now gated on the migration flag too, so an empty initial hub fetch can no longer clobber the v1 entries the migration is about to read. I also relaxed the "skip migration when hub is non-empty" gate; combined with the idempotent duplicate POST short-circuit (Major 2 below), a retry against a session another device populated is now a safe union.

Major 2 (duplicate at cap returns 409): the route now checks duplicate via a new SyncEngine.getScratchlistEntry BEFORE the cap check, returning 200 with the existing row. Cap check still fires for genuinely new ids.

Tests added:

  • sessions-scratchlist.test.ts: at-cap + duplicate entryId returns 200 (not 409); at-cap + new entryId still 409.
  • use-hub-scratchlist.test.tsx: partial-migration failure persists the failed entries back to localStorage and leaves the flag unset; the offline-cache effect does not wipe pre-migration localStorage.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No issues found.

Summary

  • Review mode: follow-up after new commits
  • Full PR diff reviewed at the latest head. The two prior Major findings were addressed in the follow-up changes; no new high-confidence correctness, security, data-loss, performance, or maintainability issues found in the added/modified lines.
  • Residual risk: web coverage is unit-focused around the hook and banner; no browser/e2e run was performed in this review pass.

Testing

  • Not run (automation)

HAPI Bot

Surfaces the smart-relative time the entry was last saved on every
scratchlist row, mirroring the bucketing used in the session list:
just-now -> Nm -> Nh -> Nd -> absolute date.

Implementation:
- Extract the existing `formatRelativeTime` helper out of SessionList
  into `web/src/lib/relative-time.ts` so the panel can reuse the
  same buckets and i18n keys (no copy-paste drift between surfaces).
  Also add `formatAbsoluteDateTime` for the precise-stamp tooltip
  line.
- Add `updatedAt?: number` to the local `ScratchlistEntry` shape.
  v1-only callers stay valid (the field is optional and `isEntry`
  now accepts rows that omit it). The hub hook forwards the hub's
  `updatedAt` so the indicator reflects edits, not just creation.
- New `EntryAgeIndicator` component: clock SVG in the same style as
  the existing action icons, rendered inside both panel surfaces
  (the older `ScratchlistList` and the drawer variant). Falls back
  to `createdAt` when `updatedAt` is missing (legacy v1 rows during
  the migration window) and renders nothing if neither timestamp is
  usable.
- Tooltip carries the relative bucket plus the absolute timestamp
  on a second line; aria-label carries the relative bucket only so
  screen readers stay terse.
- Mirror `updatedAt` into the localStorage offline cache so an
  offline reload still has accurate ages.

Tests:
- `relative-time.test.ts`: bucket math, seconds-vs-ms detection,
  non-finite guard.
- `ScratchlistPanel.test.tsx`: indicator renders with the right
  smart-relative bucket, falls back to `createdAt` when `updatedAt`
  is absent, and renders nothing when both timestamps are zero.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Bound client-supplied scratchlist entry IDs — entryId is validated only as non-empty while the new scratchlist text and entry-count limits are bounded. Because POST persists this client-supplied value as part of the SQLite primary key, an authenticated/direct client can store oversized keys and grow the table/index beyond the intended scratchlist limits. Evidence shared/src/apiTypes.ts:176.
    Suggested fix:
    export const SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128
    
    export const ScratchlistEntryCreateRequestSchema = z.object({
        entryId: z.string().min(1).max(SCRATCHLIST_MAX_ENTRY_ID_LENGTH).optional(),
        text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH),
        createdAt: z.number().int().nonnegative().optional()
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Full current PR diff reviewed. Prior bot findings remain addressed; one low-severity server-side validation gap remains in the scratchlist create schema.
  • Residual risk: no local test run in this review pass.

Testing

  • Not run (automation)

HAPI Bot

Comment thread shared/src/apiTypes.ts Outdated
* tree. New entries created post-v2 can omit this and let the hub
* generate one.
*/
entryId: z.string().min(1).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] entryId only has .min(1), but POST persists this client-supplied value as part of the session_scratchlist primary key. The new entry-count/text caps do not bound ID size, so an authenticated/direct client can store oversized keys and bloat the SQLite table/index beyond the intended scratchlist limits. Bound it near UUID/fallback ID sizes.

Suggested fix:

export const SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128

export const ScratchlistEntryCreateRequestSchema = z.object({
    entryId: z.string().min(1).max(SCRATCHLIST_MAX_ENTRY_ID_LENGTH).optional(),
    text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH),
    createdAt: z.number().int().nonnegative().optional()
})

…iann#896)

The POST /api/sessions/:id/scratchlist body validator left `entryId`
unbounded (`z.string().min(1)`), but that string is persisted as part
of the SQLite primary key. An authenticated/direct client could grow
the table and its index well beyond the intended scratchlist limits
by submitting oversized keys.

Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a
UUID's 36 chars plus any prefix scheme we might layer on later) and
applies `.max(...)` to the optional `entryId` in
`ScratchlistEntryCreateRequestSchema`. Anything longer is rejected
with 400 before the row hits SQLite.

Test pins the new behavior: a 129-char id returns 400 and never reaches
the engine.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Reloading before dismissing suppresses the migration banner — successful migration writes the migrated flag, but initialization maps a migrated/not-dismissed session to pre-migrated; ScratchlistMigrationBanner only renders for completed, so a user who refreshes before clicking dismiss never sees the banner again and the dismissal flag is never required. Evidence web/src/lib/use-hub-scratchlist.ts:193.
    Suggested fix:
    if (readBannerDismissed(sessionId)) return 'dismissed'
    if (readMigrationFlag(sessionId)) return 'completed'
    
    // No local entries migrated, so opt this session out of the banner.
    if (localEntries.length === 0) {
        writeBannerDismissed(sessionId)
        writeMigrationFlag(sessionId)
        return
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Full current PR diff reviewed. The prior entryId validation finding is addressed; one minor banner state-machine issue remains.
  • Residual risk: no local test run in this review pass.

Testing

  • Not run (automation). Suggested: add a hook test for “migration completed, reload before dismiss” so the banner remains visible until the dismiss flag is written.

HAPI Bot

}
if (readBannerDismissed(sessionId)) {
setMigrationStatus('dismissed')
} else if (readMigrationFlag(sessionId)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] After a successful migration writes hapi.scratchlist.v2.migrated.*, a reload before the operator clicks the banner dismiss button enters this pre-migrated branch. Since ScratchlistMigrationBanner renders only for completed, the banner is then suppressed permanently without the persistent dismissal flag being written. Keep migrated-but-not-dismissed sessions in the banner-visible state, and explicitly opt out only when there were no local entries to migrate.

Suggested fix:

if (readBannerDismissed(sessionId)) return 'dismissed'
if (readMigrationFlag(sessionId)) return 'completed'

// No local entries migrated, so opt this session out of the banner.
if (localEntries.length === 0) {
    writeBannerDismissed(sessionId)
    writeMigrationFlag(sessionId)
    return
}

…dismissed (HAPI Bot, PR tiann#896)

The previous state machine swallowed the migration banner if the
operator reloaded the page before clicking dismiss: the migration flag
was set on success, and on remount the init logic mapped a
flag-set/dismiss-not-set session to 'pre-migrated', a state the banner
explicitly refuses to render. Net effect: a migrated session never
prompted for affirmative dismissal.

Fixes:

- Drop the 'pre-migrated' state. The dismissal flag is now the only
  signal that suppresses the banner; the migration flag alone means
  'banner shows until dismissed' (now or after a reload).
- Sessions that had nothing to migrate (no v1 entries in localStorage)
  pre-emptively write BOTH flags - migrated AND dismissed - so the bot's
  banner-stickiness fix doesn't surface a banner that has nothing to
  announce on freshly-created v2 sessions.

Tests:

- New `reload-before-dismiss leaves the banner visible` test pins the
  fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2
  on the same session reads the localStorage flags and stays
  'completed'.
- New `opts fresh sessions out of the banner pre-emptively` test pins
  the no-v1-entries shortcut.
- Existing `does not re-migrate on a mount where the migrated flag is
  already set` updated to assert 'completed' (not the dropped
  'pre-migrated').
- Existing `skips migration when localStorage is empty` updated to
  assert the new 'dismissed' status + the banner-dismissed flag.
- Banner test for the 'pre-migrated -> nothing' case removed (the state
  no longer exists).

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Full current PR diff reviewed. The previous banner state-machine finding is addressed in the new commits, including coverage for reload-before-dismiss and fresh-session opt-out.
  • Residual risk: scratchlist sync now spans hub storage, REST, SSE invalidation, and localStorage migration, so cross-device behavior still depends on the full automation matrix passing.

Testing

  • Not run (automation).

HAPI Bot

heavygee added a commit to heavygee/hapi that referenced this pull request Jun 15, 2026
In soup integration, feat/session-list-rich-tooltips ships an internal
web/src/lib/relativeTime.ts (camelCase) and v2 (soup/scratchlist-v2-v11)
ships web/src/lib/relative-time.ts (kebab-case) for sharing with
ScratchlistPanel's entry-age tooltip. The textual three-way merge takes
both imports cleanly (no conflict markers; they're at different lines)
and the TS compiler then errors with TS2300 'Duplicate identifier
formatRelativeTime'.

This soup-only branch (do NOT submit upstream; the camelCase file is the
upstream-correct source for session-list-rich-tooltips) redirects this
single import to the kebab-case path, which v2's relative-time.ts also
exports via the same name.

Standalone, this branch will not compile because @/lib/relative-time
does not exist outside the v2-merged tree. Only valid as a soup layer
applied AFTER soup/scratchlist-v2-v11.

Drop this layer when EITHER session-list-rich-tooltips merges upstream
(at which point we either pick a winner there or leave the dual-files
in soup as long as upstream tolerates it) OR v2 (tiann#896) merges upstream
(at which point session-list-rich-tooltips needs to rebase onto the
kebab-case file directly).

Co-authored-by: Cursor <cursoragent@cursor.com>
…e does not strand them (closes tiann#920 for v2.0)

`mergeSessionData` in `sessionCache.ts` ends every merge codepath with
`deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every
FK-tied table. `session_scratchlist.session_id` is FK'd with cascade,
so without an explicit transfer step every dedup (tiann#448 agent-id
collision) and every resume-of-inactive (`syncEngine.resumeSession` ->
mergeSessions) silently destroys the operator's per-session notes.

This is the gap upstream-discovery agent flagged on tiann#920 against PR
tiann#896. With the 2026-06-15 hub-restart cascade incident as evidence
(23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id
rotations across 2 bounces), unmitigated this would violate v2.0's
"survives reloads / second laptop / clear-site-data" promise the
first time the operator hits a hub bounce.

Fix:

- New `transferScratchlistEntries(db, fromSessionId, toSessionId)`
  in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT.
  Uses `UPDATE OR IGNORE` so rows that would collide on
  PRIMARY KEY (session_id, entry_id) simply do not move - the
  dedup target's copy wins, matching the operator's mental
  model that the consolidated session is authoritative. Cleans
  up any collision-loser rows so the no-delete codepath
  (`mergeSessionHistory`) is symmetric with the delete path.
- Wired into `mergeSessionData` BEFORE the `deleteSession()`
  call, alongside the existing message-merge step. Both
  `mergeSessions` (deleteOld=true) and `mergeSessionHistory`
  (deleteOld=false) get coverage because both can rotate the
  visible session id.
- Emits `session-updated{scratchlistUpdatedAt}` on the new
  session so any web client looking at the consolidated id
  invalidates and refetches; for the keep-old codepath the
  emit also fires on the old id since it stays alive but is
  now empty of scratchlist.

Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases):

- mergeSessions (deleteOld=true): rows move, old is gone, no
  stranded rows.
- mergeSessions PK collision: dedup target wins, unique-to-old
  rows still come across.
- mergeSessions SSE: exactly one scratchlist patch on the new id.
- mergeSessions no-op: zero rows -> zero emits.
- mergeSessionHistory (deleteOld=false): rows move, old session
  stays alive but empty of scratchlist.
- mergeSessionHistory SSE: emits on BOTH old and new ids.
- Cascade-delete safety smoke: post-merge, an explicit operator
  delete of the new session DOES cascade-delete its scratchlist
  (i.e. the FK cascade we want is intact; the bug was triggering
  it on the wrong id).

Web layer note:
v1 localStorage is keyed by HAPI session id; on rotation the old
key is orphaned but no longer represents data loss because the
hub now holds the canonical state and the offline-cache mirror
re-populates `hapi.scratchlist.v1.<newId>` on first read of the
consolidated session. Documented as a known limitation; not a
blocker for v2.0 because the hub is the source of truth.

tiann#894 (v2.1 migrate-on-delete) inherits a related concern about
operator-Delete vs merge-Delete consent flow - flagged in the
upstream-discovery handoff, separate scope.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits
Full current PR diff reviewed, including the prior bot context and the new session-merge scratchlist transfer delta. No high-confidence correctness, security, data-loss, or regression findings to report.
Residual risk: scratchlist sync crosses hub storage, REST, SSE invalidation, migration flags, and offline localStorage cache; local validation could not run in this runner because bun is unavailable.

Testing

  • Not run: bun typecheck and bun run test failed because bun was not found in the runner PATH.

HAPI Bot

heavygee added a commit to heavygee/hapi that referenced this pull request Jun 16, 2026
…, v2.1 tracking tiann#894); cross-link with Fix B' SessionPatchSchema coordination

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback

1 participant