Skip to content

Slice/m2.4#351

Open
failerko wants to merge 44 commits into
mainfrom
slice/M2.4
Open

Slice/m2.4#351
failerko wants to merge 44 commits into
mainfrom
slice/M2.4

Conversation

@failerko

@failerko failerko commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

Release Notes

  • New Features
    • New landing experience powered by a Story List (search, filters, sorting) and interactive Story Cards.
    • Added an AI-not-configured app banner with a setup CTA.
    • “New story” and opening drafts now flow through the wizard prompt flow.
  • Bug Fixes
    • Story card actions and labels are fully localized; the overflow menu only shows valid actions.
    • Favorite/Archive toggles now correctly reflect draft vs archived status.
  • Documentation
    • Updated Story List, Story Card, and story deletion documentation to match the current behavior.
  • Tests
    • Added coverage for story deletion cascades and story operational actions.

failerko and others added 30 commits June 14, 2026 01:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
filter×sort×search pipeline; favorite-first is Layer 0 of the comparator (floats within any filter, not a separate filter pass); archived rows appear only under the Archived filter; nulls-last for last-opened; case-insensitive search across title, description, genre label, and tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes storiesStore (read selectors + open-failure slot) and hydrateStories/rehydrateStories via lib/stores. apply stays internal; only the hydrate thunk is public, matching the appSettingsStore pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove "used by Y" comment from rehydrateStories (violates comment discipline)
- Remove redundant StoryRow type re-export from stories.ts (not re-exported from stores/index.ts; all imports come directly from @/lib/db)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-delta config-table writes for story operational state: setStoryFavorite, setStoryArchived (rejects drafts), touchStoryOpened, openStory (resolves branch, touches, sets navigation store, calls navigate callback). Each transaction followed by rehydrateStories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Story owns its branch-scoped graph; references() carry no onDelete so the cascade is fully manual child->parent. vault_calendars (shared) and assets blobs (content-addressed, GC-owned) are excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onEditInfo/onDuplicate/onExport are now optional; overflow menu hides each item when its callback is absent. M2 wires only Archive/Unarchive + Delete; the three deferred items return in M4.4/M6.6/M9.4. All hardcoded user-facing strings converted to t() against the new storyCard locale block in common.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Play-test queries now resolve names via t() so locale renames are caught at the source. MODE_LABEL_KEY replaces the inline ternary; satisfies Record<StoryMode,string> restores exhaustiveness for future modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persistent warn bar for app-level alerts (e.g. AI not configured).
Non-dismissible; state controls dismiss via removal. Warn-tinted via
bg-warning opacity-[.12] overlay + border-warning border-b, mirroring
collision-list-row + save-bar patterns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drafts cannot be archived (data-model gates archive on status='active').
The Archive/Unarchive overflow item now renders only when !story.isDraft.
Delete and favorite remain ungated. Adds DraftHidesArchive play story to
assert the item is absent; updates the per-state table in the pattern doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M2.4 replaced EmptyState with StoryList, retiring emptyTitle/emptyBody
in favor of list.welcomeTitle/welcomeBody. Remove the orphaned keys
and repoint the i18n test to a still-live landing key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plain View under ScreenShell does not scroll on native; cards below
the viewport fold were unreachable. Wraps the list in ScrollView with
contentContainerClassName="flex-grow" so the empty-state centering
(flex-1 items-center justify-center) still fills the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
failerko and others added 10 commits June 14, 2026 03:31
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the bespoke StoryCardVM into the canonical stories row plus the two genuinely-derived display strings (lastOpenedRelative, chapterLabel); the per-field transforms move to the card. Date-agnostic contract preserved: display strings still computed in the selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StoryCard now takes StoryCardData (the stories row plus the two derived display strings) and derives favorited/archived/isDraft/genreLabel/mode from the row, reading definition through a loose cast since drafts carry partial JSON. Story-list, dev route, and stories rebuilt on row-shaped fixtures; title moves to Compounds/Story/StoryCard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StoryCardData = Story row + lastOpenedRelative + chapterLabel; the card derives favorited/archived/isDraft/genreLabel/mode from the row while the two display strings stay pre-formatted in the selector. Inventory folder and Storybook title updated to components/story/; archived-draft is now impossible (status is a single lifecycle column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
last_opened_at was seconds while the rest of the app uses Date.now() ms;
align to ms and drop the UI-threaded clock so actions self-serve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the db-agnostic thunk hydrate + the dead readStoriesRows/hydrateStories
exports; rehydrate now reads+applies inline. No payoff to the seam for stories
(no boot composition, barrel pulls db anyway).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…9496d)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 267adb6e-262b-4004-8786-67e308053215

📥 Commits

Reviewing files that changed from the base of the PR and between bd8a450 and 86bbbe6.

📒 Files selected for processing (6)
  • app/index.tsx
  • lib/actions/stories/operational.test.ts
  • lib/actions/stories/operational.ts
  • lib/utils.test.ts
  • lib/utils.ts
  • locales/en/landing.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • locales/en/landing.json
  • lib/actions/stories/operational.ts
  • app/index.tsx

📝 Walkthrough

Walkthrough

Implements Slice 2.4 (story list as a real surface): adds a Zustand stories store with rehydration, StoryCardData view model, selectStoryCards selector (filter/search/sort), operational actions (setStoryFavorite, setStoryArchived, openStory), transactional deleteStory cascade, StoryList and updated StoryCard components with full i18n, a Banner primitive, AppBannerHost, wizard-session seam stubs, and wires the index screen to replace the former debug/empty-state UI.

Changes

Story List Surface + Stories Store

Layer / File(s) Summary
Stories store, view model, and relative-time util
lib/stores/stories/stories.ts, lib/stores/stories/view-model.ts, lib/stores/stories/relative-time.ts, lib/stores/stories/*.test.ts, lib/stores/index.ts, lib/stores/__tests__/namespace-shape.test.ts
Introduces Zustand stories store (rows, open-failure tracking, rehydrateStories), StoryCardData type and toStoryCardData mapper, formatRelativeTime utility, their tests, and barrel wiring including storiesStore.__reset() in resetAllStores.
StoryCardData selectors: filter, search, sort
lib/stores/stories/selectors.ts, lib/stores/stories/selectors.test.ts
Adds StoryFilter/StorySort/StoryListQuery types and selectStoryCards, filtering by status, searching across multiple fields, sorting with favorites-first priority, and mapping matching rows to StoryCardData.
Operational story actions: favorite, archive, open, delete
lib/actions/stories/operational.ts, lib/actions/stories/operational.test.ts, lib/actions/stories/delete-story.ts, lib/actions/stories/delete-story.test.ts, lib/actions/index.ts
Adds setStoryFavorite, setStoryArchived (draft guard), touchStoryOpened, and openStory (resolves branch, updates navigationStore, calls navigate); implements deleteStory with BRANCH_SCOPED cascade table list; all run in transactions and call rehydrateStories after. Tests confirm persistence, guardrails, survivor isolation, and cascade-table coverage.
StoryCard: StoryCardData contract, i18n, optional callbacks
components/story/story-card.tsx
Updates StoryCard to accept StoryCardData, derives mode/genreLabel/isDraft/archived at render time, internationalizes all user-facing strings, makes onEditInfo/onDuplicate/onExport optional with conditional action-menu rendering, removes Story from module exports.
StoryCard Storybook and pattern documentation
components/story/story-card.stories.tsx, docs/ui/patterns/story-card.md, docs/ui/component-inventory.md, locales/en/common.json
Updates Storybook stories to use makeStoryRow builder with new StoryCardData shape, updates play test assertions to use i18n keys, refactors GridResponsive/ThemeMatrix variants; updates StoryCard pattern doc to formalize StoryCardData contract, optional callbacks, and rendering rules; updates component inventory folder path; adds storyCard locale strings.
Banner primitive, AppBannerHost, wizard-session seam, toolbar fix
components/ui/banner.tsx, components/ui/banner.stories.tsx, components/story/app-banner-host.tsx, components/story/wizard-session-seam.tsx, components/compounds/toolbar.tsx
Adds Banner UI primitive (alert bar with CTA and accessibility roles), AppBannerHost component (renders Banner only when no providers configured), ConcurrentStatePrompt/useWizardSessionExists stubs for future wizard-session slice, and a min-w-[5.5rem] fix on ToolbarSort trigger width.
StoryList component and Storybook stories
components/story/story-list.tsx, components/story/story-list.stories.tsx
Implements StoryList with scrollable header, new-story button, empty/no-results states, Toolbar wired to search/filter/sort callbacks, and responsive card grid. Adds five Storybook story variants (Populated, Empty, WithDrafts, WithBanner, NoResults).
Landing page locales and i18n updates
locales/en/landing.json, lib/i18n/i18n.test.ts
Updates landing locale (banner/list/delete/error sections), updates landing i18n test assertion.
Index screen wiring and dev screen update
app/index.tsx, app/dev/story-card.tsx
Replaces debug/empty-state index screen with StoryList-driven landing: rehydrates stories on mount, computes memoized cards, wires cardHandlers through operational actions, renders AppBannerHost, ConcurrentStatePrompt for draft/new flows, and AlertDialog for delete confirmation. Updates dev screen to StoryCardData shape.
Implementation docs: data model, milestone, triage
docs/data-model.md, docs/implementation/milestones/.../04-story-list.md, docs/implementation/triage.md
Adds story-deletion design decision (transactional cascade, shared resource survival); updates milestone with implementation notes, scope, and acceptance criteria; adds triage inbox items for asset trashing and store rehydration API standardization.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant IndexScreen
  participant selectStoryCards
  participant storiesStore
  participant openStory
  participant setStoryFavorite
  participant deleteStory
  participant SQLiteDB

  rect rgba(100, 149, 237, 0.5)
    note over IndexScreen,storiesStore: Mount / hydration
    IndexScreen->>SQLiteDB: rehydrateStories(db)
    SQLiteDB-->>storiesStore: applyRows(rows)
    storiesStore-->>IndexScreen: StoryRow[]
    IndexScreen->>selectStoryCards: selectStoryCards(rows, query, nowMs)
    selectStoryCards-->>IndexScreen: StoryCardData[]
  end

  rect rgba(144, 238, 144, 0.5)
    note over User,openStory: Open story
    User->>IndexScreen: tap card
    IndexScreen->>openStory: openStory(id, ctx, navigate)
    openStory->>SQLiteDB: select currentBranchId
    alt draft (no branch)
      openStory-->>IndexScreen: { status: 'no-branch' }
      IndexScreen->>IndexScreen: show ConcurrentStatePrompt
    else has branch
      openStory->>SQLiteDB: update lastOpenedAt
      openStory-->>IndexScreen: { status: 'ok', branchId }
      IndexScreen->>IndexScreen: navigate to reader/composer
    end
  end

  rect rgba(255, 165, 0, 0.5)
    note over User,deleteStory: Delete story
    User->>IndexScreen: tap delete → confirm AlertDialog
    IndexScreen->>deleteStory: deleteStory(id, ctx)
    deleteStory->>SQLiteDB: DELETE branch-scoped rows + branches + story (transaction)
    deleteStory->>storiesStore: rehydrateStories(db)
    storiesStore-->>IndexScreen: updated StoryCardData[]
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Slice 2.4 — Story list as a real surface + stories store #338 — This PR directly implements Slice 2.4 (story list as a real surface + stories store) including the stories store, real story cards with search/filter/sort, favorite/archive/delete operations, and the AI-configuration banner specified in that issue's acceptance criteria.

Suggested reviewers

  • a-frazier

🐇 A story list has come alive,
With cards that search and sort and thrive!
Delete a tale? One tap will do—
The cascade cleans the whole DB through.
Favorites float, and banners warn,
A brand new story loop is born! 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title 'Slice/m2.4' is vague and uses a non-descriptive milestone reference without conveying meaningful information about the primary change. Consider using a more descriptive title that summarizes the main functionality added, such as 'Implement story list landing page with card management' or similar.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request replaces the temporary landing page with a fully featured story list and redesigned story cards, supporting search, filtering, sorting, and transactional story deletion. The review feedback highlights opportunities to improve error handling during story deletion, safely handle missing stories when archiving, optimize navigation snappiness by running touchStoryOpened asynchronously, and avoid potential sorting issues in the comparator by using 0 instead of -Infinity as a fallback timestamp.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread app/index.tsx
Comment thread lib/actions/stories/operational.ts
Comment thread lib/actions/stories/operational.ts
Comment thread lib/stores/stories/selectors.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 7

🧹 Nitpick comments (1)
lib/actions/stories/delete-story.ts (1)

29-45: ⚡ Quick win

Align the cascade comment with the actual table list.

The comment says assets are excluded, but entryAssets is included in BRANCH_SCOPED. Please update either the comment or the list so future edits don’t rely on contradictory guidance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/actions/stories/delete-story.ts` around lines 29 - 45, The comment in the
BRANCH_SCOPED declaration states that assets are excluded as they are handled
separately, but entryAssets is actually included in the array. Update the
comment to accurately reflect the current table list by clarifying which
specific assets (if any) are excluded from BRANCH_SCOPED, or remove entryAssets
from the array if it should not be included. Ensure the comment and the actual
list are consistent so future developers have correct guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/index.tsx`:
- Around line 88-90: The onArchiveToggle handler calls setStoryArchived without
checking if the row is in draft status, but setStoryArchived rejects draft
archival by design. Since this is a fire-and-forget call (void), the rejection
becomes unhandled. Add a guard condition before calling setStoryArchived to
check if row?.status is 'draft' and prevent the archive toggle from executing
when the row is in draft state, ensuring the call only proceeds for non-draft
rows.

In `@components/story/story-card.tsx`:
- Around line 50-57: The loose type cast on line 52 allows unexpected string
values to pass through for `def?.mode`, which then get used as keys in the
`MODE_DEFAULT_COLOR` and `MODE_LABEL_KEY` lookups on lines 62 and 65. When
assigning to the `mode` variable on line 57, add validation to guard against
invalid `StoryMode` values by checking if `def?.mode` is a valid member of the
`StoryMode` type or enum; if it is not valid, fall back to the default
'creative' value. This ensures only known `StoryMode` values are used in
subsequent object lookups.

In `@components/ui/banner.tsx`:
- Around line 34-43: The Pressable component in the banner.tsx file is removing
the default outline focus indicator with `outline-none` but providing no
replacement visible focus style, making it inaccessible for keyboard navigation.
Either remove the `outline-none` class from the web Platform.select conditional
to restore the default focus indicator, or add a focus-specific style (such as
focus:ring or focus:outline) to provide an alternative visible focus indicator
that keyboard-only users can see when navigating to the CTA.

In `@docs/implementation/triage.md`:
- Around line 22-34: The triage item's description of the story-delete cascade
asset-trashing requirement is incomplete—it currently only mentions the need to
trash orphaned assets from `entry_assets` junctions but does not mention
`stories.cover_asset_id`. Expand the documentation to explicitly state that when
M4/M9 implements refcount-trashing, the hook must cover both `entry_assets`
junction removals and `stories.cover_asset_id` field clearing or story deletion,
so that deleting a story with a cover asset does not leak the blob. Add this
requirement to the triage item's description of what the refcount-trashing slice
must address.

In `@docs/ui/patterns/story-card.md`:
- Around line 46-65: The documentation for `chapterLabel` claims it is a
pre-formatted string computed by the selector (toStoryCardData), but the actual
selector implementation in lib/stores/stories/view-model.ts returns null for
every row. Update the documentation to clarify the current contract for
`chapterLabel` by either marking it as currently deferred or optional (with null
values), or if the implementation will be completed, ensure the documentation
accurately reflects what the selector actually provides at render time.

In `@lib/actions/stories/operational.ts`:
- Around line 20-31: The draft-status guard in the `setStoryArchived` function
is performed outside the transaction before the `ctx.runInTransaction` call,
creating a race condition where concurrent writes can bypass the check. Move the
status validation into the same atomic transaction by adding the draft-status
check as part of the WHERE clause in the update statement, so the guard and the
actual update are enforced atomically together and cannot be separated by
concurrent operations.

In `@lib/stores/stories/stories.ts`:
- Around line 40-43: The getStories function returns direct references to store
properties rows and openFailures, allowing external code to mutate the internal
store state and bypass Zustand's state management. Create defensive copies of
both the rows and openFailures properties when returning from getStories to
ensure that external mutations of the returned object do not affect the
underlying store state.

---

Nitpick comments:
In `@lib/actions/stories/delete-story.ts`:
- Around line 29-45: The comment in the BRANCH_SCOPED declaration states that
assets are excluded as they are handled separately, but entryAssets is actually
included in the array. Update the comment to accurately reflect the current
table list by clarifying which specific assets (if any) are excluded from
BRANCH_SCOPED, or remove entryAssets from the array if it should not be
included. Ensure the comment and the actual list are consistent so future
developers have correct guidance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d554f30d-e7de-4f9a-b338-a10d2b58d2f0

📥 Commits

Reviewing files that changed from the base of the PR and between d99b9fc and ab100bc.

📒 Files selected for processing (34)
  • app/dev/story-card.tsx
  • app/index.tsx
  • components/compounds/toolbar.tsx
  • components/story/app-banner-host.tsx
  • components/story/story-card.stories.tsx
  • components/story/story-card.tsx
  • components/story/story-list.stories.tsx
  • components/story/story-list.tsx
  • components/story/wizard-session-seam.tsx
  • components/ui/banner.stories.tsx
  • components/ui/banner.tsx
  • docs/data-model.md
  • docs/implementation/milestones/02-first-user-loop/slices/04-story-list.md
  • docs/implementation/triage.md
  • docs/ui/component-inventory.md
  • docs/ui/patterns/story-card.md
  • lib/actions/index.ts
  • lib/actions/stories/delete-story.test.ts
  • lib/actions/stories/delete-story.ts
  • lib/actions/stories/operational.test.ts
  • lib/actions/stories/operational.ts
  • lib/i18n/i18n.test.ts
  • lib/stores/__tests__/namespace-shape.test.ts
  • lib/stores/index.ts
  • lib/stores/stories/relative-time.test.ts
  • lib/stores/stories/relative-time.ts
  • lib/stores/stories/selectors.test.ts
  • lib/stores/stories/selectors.ts
  • lib/stores/stories/stories.test.ts
  • lib/stores/stories/stories.ts
  • lib/stores/stories/view-model.test.ts
  • lib/stores/stories/view-model.ts
  • locales/en/common.json
  • locales/en/landing.json

Comment thread app/index.tsx
Comment thread components/story/story-card.tsx Outdated
Comment thread components/ui/banner.tsx
Comment thread docs/implementation/triage.md
Comment thread docs/ui/patterns/story-card.md
Comment on lines +20 to +31
const [row] = await ctx.db
.select({ status: stories.status })
.from(stories)
.where(eq(stories.id, id))
if (row?.status === 'draft') throw new Error('cannot archive a draft story')
await ctx.runInTransaction([
ctx.db
.update(stories)
.set({ status: archived ? 'archived' : 'active' })
.where(eq(stories.id, id))
.toSQL(),
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the draft-archive guard atomic.

setStoryArchived performs the draft-status check before the transactional update, so concurrent writes can invalidate the check and allow an unintended transition. Move the guard into the same atomic write path (or enforce it at DB constraint level) so “cannot archive draft” cannot be bypassed by timing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/actions/stories/operational.ts` around lines 20 - 31, The draft-status
guard in the `setStoryArchived` function is performed outside the transaction
before the `ctx.runInTransaction` call, creating a race condition where
concurrent writes can bypass the check. Move the status validation into the same
atomic transaction by adding the draft-status check as part of the WHERE clause
in the update statement, so the guard and the actual update are enforced
atomically together and cannot be separated by concurrent operations.

Comment on lines +40 to +43
function getStories(): StoriesSnapshot {
const s = store.getState()
return { rows: s.rows, openFailures: s.openFailures }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return defensive copies from getStories to avoid external mutation of store internals.

Line 42 currently leaks live references (rows, openFailures). Any consumer mutating that object can bypass Zustand updates and silently corrupt shared state.

Proposed fix
 function getStories(): StoriesSnapshot {
   const s = store.getState()
-  return { rows: s.rows, openFailures: s.openFailures }
+  return {
+    rows: [...s.rows],
+    openFailures: { ...s.openFailures },
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/stores/stories/stories.ts` around lines 40 - 43, The getStories function
returns direct references to store properties rows and openFailures, allowing
external code to mutate the internal store state and bypass Zustand's state
management. Create defensive copies of both the rows and openFailures properties
when returning from getStories to ensure that external mutations of the returned
object do not affect the underlying store state.

failerko and others added 4 commits June 14, 2026 17:18
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.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.

1 participant