Skip to content

feat: backend-persisted session sort mode with manual ordering sync#200

Open
gaius-codius wants to merge 6 commits intotiann:mainfrom
gaius-codius:pr/session-sort-backend-upstream
Open

feat: backend-persisted session sort mode with manual ordering sync#200
gaius-codius wants to merge 6 commits intotiann:mainfrom
gaius-codius:pr/session-sort-backend-upstream

Conversation

@gaius-codius
Copy link
Contributor

@gaius-codius gaius-codius commented Feb 21, 2026

Summary

  • add backend-persisted session sort preference (auto/manual) scoped by user + namespace
  • persist manual ordering in hub SQLite with optimistic concurrency (expectedVersion)
  • add SSE invalidation event for cross-client sync
  • wire web SessionList/menus to backend preference (no localStorage source-of-truth)
  • include Playwright e2e coverage for session-sort backend behavior

API

  • GET /api/preferences/session-sort
  • PUT /api/preferences/session-sort with expectedVersion, returns 409 version_mismatch + latest snapshot

Notes

  • includes small compatibility fixes needed for clean backport onto current tiann/hapi main:
    • flavor text helper export used by the updated SessionList
    • injectManifest.maximumFileSizeToCacheInBytes bump to keep web build passing with current bundle size

Validation

  • /home/gretus/.bun/bin/bun test (hub)
  • /home/gretus/.bun/bin/bun run --cwd web test
  • /home/gretus/.bun/bin/bun run --cwd web build
  • BUN_BIN=/home/gretus/.bun/bin/bun /home/gretus/.bun/bin/bun run --cwd web test:e2e:session-sort

- Remove macOS resource fork files, add ._* to .gitignore
- Add error logging in upsert catch block (sessionSortPreferences.ts)
- Fix GroupActionMenu/SessionActionMenu focus targeting disabled buttons
- Add .max() constraints to SessionManualOrderSchema arrays
- Extract duplicate sort toggle logic into shared useSortToggle hook
- Fix redundant reconcileManualOrder calls (applyManualOrder now takes
  pre-reconciled order)
- Extract duplicate SVG icons to shared SortIcons.tsx
- Remove unused createEmptyManualOrder export
- Add userId scoping to SSE session-sort-preference-updated broadcast
- Wrap runner integration test afterAll in try/finally
- Add ArrowUp/ArrowDown keyboard navigation to GroupActionMenu
- Add useSessionSortPreferenceMutation tests (optimistic update,
  rollback, API unavailable)

via [HAPI](https://hapi.run)
const manualOrderJson = JSON.stringify(preference.manualOrder)

db.prepare(`
INSERT INTO session_sort_preferences (

Choose a reason for hiding this comment

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

[MAJOR] [CORRECTNESS] Non-atomic expectedVersion check allows lost updates

Why this is a problem: The optimistic concurrency check happens before the write, but the INSERT ... ON CONFLICT update does not guard on version. Two clients with the same expectedVersion can both pass the pre-check and both writes succeed, silently dropping one update.

Suggested fix:

const baseVersion = expectedVersion ?? current.version
const nextVersion = baseVersion + 1
const info = db.prepare(`
    INSERT INTO session_sort_preferences (
        user_id,
        namespace,
        sort_mode,
        manual_order,
        version,
        created_at,
        updated_at
    ) VALUES (
        @user_id,
        @namespace,
        @sort_mode,
        @manual_order,
        @version,
        @created_at,
        @updated_at
    )
    ON CONFLICT(user_id, namespace)
    DO UPDATE SET
        sort_mode = excluded.sort_mode,
        manual_order = excluded.manual_order,
        version = excluded.version,
        updated_at = excluded.updated_at
    WHERE @expected_version IS NULL
       OR session_sort_preferences.version = @expected_version
`).run({
    user_id: userId,
    namespace,
    sort_mode: preference.sortMode,
    manual_order: manualOrderJson,
    version: nextVersion,
    created_at: current.createdAt || now,
    updated_at: now,
    expected_version: expectedVersion ?? null
})

if (expectedVersion !== undefined && info.changes === 0) {
    return { result: 'version-mismatch', preference: getSessionSortPreferenceByUser(db, userId, namespace) }
}

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Non-atomic expectedVersion check allows lost updates — optimistic concurrency check is pre-write only; concurrent updates can both succeed and drop one update, evidence hub/src/store/sessionSortPreferences.ts:89, hub/src/store/sessionSortPreferences.ts:101.
    Suggested fix:
const baseVersion = expectedVersion ?? current.version
const nextVersion = baseVersion + 1
const info = db.prepare(`
    INSERT INTO session_sort_preferences (
        user_id,
        namespace,
        sort_mode,
        manual_order,
        version,
        created_at,
        updated_at
    ) VALUES (
        @user_id,
        @namespace,
        @sort_mode,
        @manual_order,
        @version,
        @created_at,
        @updated_at
    )
    ON CONFLICT(user_id, namespace)
    DO UPDATE SET
        sort_mode = excluded.sort_mode,
        manual_order = excluded.manual_order,
        version = excluded.version,
        updated_at = excluded.updated_at
    WHERE @expected_version IS NULL
       OR session_sort_preferences.version = @expected_version
`).run({
    user_id: userId,
    namespace,
    sort_mode: preference.sortMode,
    manual_order: manualOrderJson,
    version: nextVersion,
    created_at: current.createdAt || now,
    updated_at: now,
    expected_version: expectedVersion ?? null
})

if (expectedVersion !== undefined && info.changes === 0) {
    return { result: 'version-mismatch', preference: getSessionSortPreferenceByUser(db, userId, namespace) }
}

Summary

  • 1 major correctness issue. Residual risk: concurrent update path not covered by tests.

Testing

  • Not run (automation)

HAPI Bot

@gaius-codius
Copy link
Contributor Author

Addressed the automated review findings:

  1. permissionMode typing mismatch
  • fixed by adding permissionMode to shared SessionSummary and mapping it in toSessionSummary.
  1. non-atomic expectedVersion check
  • fixed with an atomic SQLite upsert guard:
    • ON CONFLICT ... DO UPDATE ... WHERE session_sort_preferences.version = @expected_version (or unconditional when expectedVersion is omitted)
    • insert path also guarded for expectedVersion semantics.
    • returns version-mismatch when guarded write affects 0 rows.

Also added a store test for stale expectedVersion on first write.

Latest fix commit on this PR branch: 1a34e22.

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.

1 participant