Skip to content

Feat/offline first powersync - #8

Merged
elcokiin merged 6 commits into
mainfrom
feat/offline-first-powersync
Aug 9, 2026
Merged

Feat/offline first powersync#8
elcokiin merged 6 commits into
mainfrom
feat/offline-first-powersync

Conversation

@elcokiin

@elcokiin elcokiin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Added offline-first support for viewing, creating, editing, pinning, and deleting ideas.
    • Changes made offline now synchronize automatically when connectivity returns.
    • Added improved handling for cached sessions during offline use.
  • Bug Fixes
    • Prevented emoji suggestions from updating after the editor is closed.
    • Improved recovery when session information is temporarily unavailable.
  • Documentation
    • Added setup and troubleshooting guidance for offline synchronization and required configuration.

Copilot AI lite review requested due to automatic review settings August 9, 2026 02:13
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
trojes Ready Ready Preview, v0 Aug 9, 2026 2:57am

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@elcokiin, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 667e71c7-a744-4790-991f-fa7def2d09a6

📥 Commits

Reviewing files that changed from the base of the PR and between 4a539f0 and 1fc382a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • LEARNINGS.md
  • MISTAKES.md
  • app/api/powersync/upload/route.ts
  • components/providers/powersync-provider.tsx
  • components/settings/settings-account.tsx
  • db/ideas.ts
  • hooks/use-ideas.ts
  • hooks/use-pinned-ideas.ts
  • lib/create-idea.ts
  • package.json
  • tests/components/powersync-provider.test.tsx
  • tests/components/settings-dialog.test.tsx
  • tests/integration/use-ideas.test.tsx
  • tests/lib/create-idea.test.ts
📝 Walkthrough

Walkthrough

The change introduces PowerSync-backed offline storage for ideas, cached offline identity resolution, authenticated token and upload routes, local database mutations, provider integration, and updated tests and documentation.

Changes

PowerSync synchronization

Layer / File(s) Summary
Local database and offline identity foundation
.env.example, lib/powersync/*, lib/offline-identity.ts, db/ideas.ts, hooks/use-hydrated.ts, package.json
The application defines the PowerSync schema, initializes the local database, maps rows to ideas, and resolves user IDs from NextAuth or cached browser storage.
Authenticated synchronization backend
app/api/powersync/*, lib/powersync/connector.ts, components/providers/*, app/layout.tsx, README.md, notes/references.md
The application issues PowerSync JWTs, processes queued CRUD operations, connects the provider, and documents synchronization requirements.
PowerSync-backed idea operations
hooks/use-ideas.ts, hooks/use-pinned-ideas.ts, lib/create-idea.ts, lib/api-client.ts
Idea queries and mutations now use the local database instead of SWR API requests and cache updates.
PowerSync test and runtime support
tests/helpers/powersync-fake.ts, tests/integration/*, tests/lib/*, components/editor/plugins/emoji-picker-plugin.tsx
Tests use a reactive fake database for local queries and mutations. Offline identity and asynchronous emoji loading behavior also receive coverage and error handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant IdeaHook
  participant PowerSyncDatabase
  participant BackendConnector
  participant TokenRoute
  participant UploadRoute
  participant IdeasDatabase

  User->>IdeaHook: create or update idea
  IdeaHook->>PowerSyncDatabase: write local row
  PowerSyncDatabase-->>IdeaHook: updated query result
  BackendConnector->>TokenRoute: request credentials
  TokenRoute-->>BackendConnector: PowerSync JWT
  BackendConnector->>UploadRoute: upload queued CRUD operations
  UploadRoute->>IdeasDatabase: apply database changes
  IdeasDatabase-->>UploadRoute: operation result
  UploadRoute-->>BackendConnector: upload response
  BackendConnector-->>PowerSyncDatabase: complete transaction
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding offline-first PowerSync support.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/offline-first-powersync

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.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

React Doctor found 7 issues in 5 files · 7 warnings · score 81 / 100 (Needs work) · vs main

7 warnings

app/api/powersync/upload/route.ts

  • ⚠️ L164 await inside a loop async-await-in-loop

components/editor/plugins/emoji-picker-plugin.tsx

  • ⚠️ L110 Unescaped dynamic string in RegExp constructor no-unescaped-dynamic-string-in-regexp
  • ⚠️ L113 Unescaped dynamic string in RegExp constructor no-unescaped-dynamic-string-in-regexp

components/settings/settings-account.tsx

  • ⚠️ L27 Pure function rebuilt every render prefer-module-scope-pure-function

hooks/use-hydrated.ts

  • ⚠️ L13 useEffect setState flashes on mount rendering-hydration-no-flicker
  • ⚠️ L14 State initialized from a mount effect no-initialize-state

public/sw.js

  • ⚠️ L41 fetch Response consumed without status check no-fetch-response-used-without-status-check
⚠️ Warning: .github/workflows/react-doctor.yml is configured incorrectly. See below to fix.

React Doctor compares against main to report only the issues this pull request introduces. This run couldn't complete that comparison (usually a shallow CI checkout with no merge base), so it listed every issue in the changed files, including ones that already existed on main.

Add fetch-depth: 0 to the actions/checkout step in .github/workflows/react-doctor.yml so the checkout includes the history React Doctor needs:

 jobs:
   react-doctor:
     steps:
       - uses: actions/checkout@v5
+        with:
+          fetch-depth: 0

       - uses: millionco/react-doctor@v2

To silence this warning, set silence-missing-baseline-warning: true on the React Doctor action.

Reviewed by React Doctor for commit 1fc382a. See inline comments for fixes.

Copilot AI 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.

Pull request overview

This PR converts Trojes’ idea workflows to an offline-first model using PowerSync (local SQLite mirror + bidirectional sync), replacing the prior SWR + REST-driven idea list/mutations and adding server endpoints to mint PowerSync credentials and accept upload batches.

Changes:

  • Introduces PowerSync client schema/db/connector + provider wiring, and moves ideas/pins hooks to PowerSync useQuery.
  • Adds offline identity caching + local-first idea insert/update/delete paths.
  • Adds PowerSync server routes (/api/powersync/token, /api/powersync/upload) and updates docs/config/test scaffolding accordingly.

Reviewed changes

Copilot reviewed 33 out of 36 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/lib/offline-identity.test.ts Adds unit coverage for cached offline user id resolution paths.
tests/lib/create-idea.test.ts Updates tests to validate local DB insert behavior instead of MSW API posting.
tests/integration/use-ideas.test.tsx Reworks integration tests to use a PowerSync fake DB + mocked useQuery.
tests/integration/synchronization.test.tsx Updates cross-hook synchronization tests for local DB-backed reads/writes.
tests/integration/auth-callbacks.test.ts Adjusts callback test typing and callback extraction.
tests/helpers/powersync-fake.ts Adds an in-memory SQL-surface fake DB for PowerSync-backed hooks/tests.
README.md Documents PowerSync offline sync behavior and required env vars.
public/sw.js Bumps service worker cache version.
package.json Adds PowerSync dependencies and a postinstall to copy worker assets.
notes/references.md Records the PowerSync SDK versions and testing approach reference notes.
next.config.mjs Enables turbopack config object.
MISTAKES.md Captures offline identity fallback and DB id-type drift learnings.
lib/swr-helpers.ts Removes SWR cache helper utilities now replaced by local DB queries.
lib/powersync/schema.ts Defines the PowerSync schema for the ideas table.
lib/powersync/mappers.ts Adds row→domain mapping (including tag parsing).
lib/powersync/db.ts Creates the PowerSync database instance and exports a backend connector.
lib/powersync/connector.ts Implements credential fetch + upload of queued CRUD ops to the server.
lib/offline-identity.ts Implements cached user id storage + offline-aware user id resolution.
lib/create-idea.ts Switches create/insert to local SQLite writes via PowerSync.
lib/api-client.ts Removes ideasApi REST helpers (ideas now use PowerSync).
LEARNINGS.md Documents NextAuth offline session fetch behavior/logging constraints.
hooks/use-pinned-ideas.ts Replaces SWR pinned ideas fetch with a PowerSync query.
hooks/use-ideas.ts Replaces SWR infinite list with PowerSync query + local mutation methods.
hooks/use-hydrated.ts Adds hydration guard helper to suppress SSR/hydration loading mismatch.
db/users.ts Tightens return typing for findUserIdByEmail.
db/ideas.ts Adds upsert types and a new upsertIdea used by PowerSync upload.
components/providers/session-provider.tsx Sets refetchWhenOffline={false} on NextAuth SessionProvider.
components/providers/powersync-provider.tsx Wires PowerSync connect/clear behavior to auth status + offline identity.
components/editor/plugins/emoji-picker-plugin.tsx Makes emoji list dynamic import resilient when offline.
app/layout.tsx Wraps the app in the new PowerSync provider.
app/api/powersync/upload/route.ts Adds server endpoint to apply client-uploaded CRUD operations.
app/api/powersync/token/route.ts Adds server endpoint to mint PowerSync JWT credentials.
.gitignore Ignores generated PowerSync worker assets and local agent folders.
.env.example Documents required PowerSync env vars.
bun.lock Locks newly added PowerSync + wa-sqlite + jose dependencies.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/create-idea.ts
Comment thread db/ideas.ts
Comment on lines +106 to +126
export async function upsertIdea({
id,
userId,
values,
}: {
id: string
userId: string
values: IdeaUpsert
}) {
const db = getDb()
const [idea] = await db
.insert(ideas)
.values({ id, user_id: userId, ...values })
.onConflictDoUpdate({
target: ideas.id,
set: values,
})
.returning()

return idea ?? null
}
Comment on lines +49 to +56
const result = (await res.json()) as { success: boolean; errors?: string[] }

if (!result.success) {
console.warn("Upload had errors:", result.errors)
}

await transaction.complete()
} catch (ex) {
@@ -0,0 +1,60 @@
import { UpdateType } from "@powersync/web"
Comment thread hooks/use-ideas.ts
Comment on lines +23 to +27
const hydrated = useHydrated()
const { data: session } = useSession()
const userId = session?.user?.id ?? getCachedUserId()
const [size, setSize] = useState(1)

@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: 8

🧹 Nitpick comments (12)
lib/powersync/db.ts (1)

1-3: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider marking this module client-only.

The database is constructed at import time. Add "use client" or wrap the construction in a lazy getter. This prevents an accidental server import from instantiating a browser-only SQLite database during SSR or prerender.

🤖 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/powersync/db.ts` around lines 1 - 3, Mark the module containing the
import-time PowerSyncDatabase construction as client-only by adding the
appropriate "use client" directive at the top of the module. Keep the existing
PowerSyncDatabase initialization and exports unchanged.
lib/powersync/connector.ts (2)

56-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op try/catch.

The catch block only rethrows. Delete both the try and the catch.

♻️ Proposed change
-    } catch (ex) {
-      throw ex
-    }
   }
🤖 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/powersync/connector.ts` around lines 56 - 58, Remove the no-op try/catch
surrounding the affected logic in the connector flow, including the catch block
that only rethrows ex, and leave the enclosed statements executing directly with
their existing behavior.

11-27: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Add a timeout and validate the token response body.

The fetch has no AbortSignal. A hung token request stalls the credential refresh. The response body is also cast without a check. If endpoint or token is missing, PowerSync receives undefined credentials and fails with an opaque error.

♻️ Proposed change
-    const res = await fetch("/api/powersync/token", { cache: "no-store" })
+    const res = await fetch("/api/powersync/token", {
+      cache: "no-store",
+      signal: AbortSignal.timeout(10_000),
+    })
 
     if (!res.ok) {
       throw new Error(`Failed to get PowerSync credentials: ${res.status}`)
     }
 
     const body = (await res.json()) as {
       endpoint: string
       token: string
     }
 
+    if (!body?.endpoint || !body?.token) {
+      throw new Error("PowerSync token response is missing endpoint or token")
+    }
+
     return {
       endpoint: body.endpoint,
       token: body.token,
     }
🤖 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/powersync/connector.ts` around lines 11 - 27, Update fetchCredentials to
use an AbortSignal timeout for the token fetch, ensuring hung requests
terminate. Validate the parsed response body before returning credentials,
requiring endpoint and token to be present and valid; reject malformed responses
with a clear error instead of returning undefined values.
lib/powersync/mappers.ts (1)

19-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

parseTags is duplicated in the upload route.

app/api/powersync/upload/route.ts lines 18-34 implements the same JSON tag parsing. Extract one shared helper. See the consolidated comment for the full set of sites.

🤖 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/powersync/mappers.ts` around lines 19 - 27, Extract the JSON tag parsing
logic from parseTags in mappers.ts into a shared helper, then update both
parseTags and the upload route’s duplicate implementation to reuse it. Preserve
the existing null handling, JSON parsing behavior, and string-only filtering.
app/api/powersync/upload/route.ts (1)

156-174: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Apply the batch in one transaction and bound its size.

The loop applies each operation with a separate database round trip and no enclosing transaction. A failure part way through leaves the batch half applied. operations is also unbounded, so a single request can hold a serial chain of writes open for an arbitrary time.

Wrap the loop in a database transaction and reject batches above a maximum operation count.

🤖 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 `@app/api/powersync/upload/route.ts` around lines 156 - 174, Update the upload
handling around operations and applyOp to reject batches exceeding the
established maximum operation count, then execute the entire batch within one
database transaction so all operations commit or roll back together. Preserve
the existing per-operation error collection and response behavior while ensuring
transaction failures do not leave partial writes.
package.json (1)

14-14: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the build against skipped install scripts.

public/@powersync/ is git-ignored and produced only by postinstall. If a deployment or CI job installs with --ignore-scripts, /@powersync/worker.js is missing and lib/powersync/db.ts fails at runtime. Add the same copy-assets call to the build script, or verify that every install path runs lifecycle scripts. trustedDependencies only covers Bun, so npm/pnpm installs need separate confirmation.

♻️ Proposed change
-    "postinstall": "powersync-web copy-assets -o public",
+    "postinstall": "powersync-web copy-assets -o public",
+    "prebuild": "powersync-web copy-assets -o public",

Also applies to: 89-92

🤖 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 `@package.json` at line 14, Update the package.json build workflow to invoke
powersync-web copy-assets -o public in addition to the postinstall hook,
ensuring public/@powersync/worker.js exists when dependencies were installed
with skipped lifecycle scripts. Preserve the existing postinstall behavior and
integrate the asset-copy command into the build script.
tests/helpers/powersync-fake.ts (2)

108-110: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

setError does not affect execute.

select returns [] while currentError is set, but execute ignores it. Tests that need a failing write must replace execute by hand, as tests/integration/use-ideas.test.tsx does at lines 143-146. Rejecting from execute while an error is set would make the fake consistent and simplify those tests.

♻️ Proposed change
   async function execute(sql: string, params: unknown[]): Promise<{ rowsAffected: number }> {
+    if (currentError) throw currentError
     const trimmed = sql.trim()
🤖 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 `@tests/helpers/powersync-fake.ts` around lines 108 - 110, Update the fake’s
execute function to reject with currentError when setError has configured an
error, before processing the SQL; otherwise preserve the existing execution
behavior and return value.

100-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Tighten the single-row detection in getOptional.

sql.includes("id = ?") also matches user_id = ?. Any list query that filters by user_id takes the single-row branch and matches params[0] against r.id. No current caller hits this, but the helper is shared and the failure would be silent.

Match the full clause instead.

♻️ Proposed change
-    if (sql.includes("id = ?")) {
+    if (/\bWHERE\s+id\s*=\s*\?/i.test(sql)) {
🤖 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 `@tests/helpers/powersync-fake.ts` around lines 100 - 106, Update getOptional
to detect the exact id = ? clause rather than using a substring match that also
accepts user_id = ?. Preserve the existing direct row lookup for genuine id
filters and route other queries, including user_id filters, through select.
tests/integration/synchronization.test.tsx (2)

34-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

isLoading is always false in this mock.

holder.db is assigned in beforeEach, so !db never evaluates to true. Every waitFor(() => expect(result.current.isLoading).toBe(false)) in the suites passes on the first tick and asserts nothing. Either drive isLoading from an explicit flag on the fake, or drop those assertions and wait on the data instead.

🤖 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 `@tests/integration/synchronization.test.tsx` around lines 34 - 40, Update the
mock query state returned by the fake around holder.db so isLoading reflects an
explicit loading flag rather than the always-initialized db value, or remove the
ineffective isLoading assertions and wait for the expected data instead. Ensure
synchronization tests still verify completion meaningfully.

6-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared PowerSync test harness.

Lines 6-74 duplicate tests/integration/use-ideas.test.tsx lines 6-73 almost exactly: the holder object, the three vi.mock calls, the row factory, and seed. Move them into tests/helpers/, next to powersync-fake.ts, and import them in both suites. The mock factories must stay hoisted, so export a setup function that the suites call.

🤖 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 `@tests/integration/synchronization.test.tsx` around lines 6 - 74, Extract the
duplicated holder, PowerSync/NextAuth mock setup, row factory, and seed helpers
into a shared module under tests/helpers next to powersync-fake.ts. Export a
hoisted-safe setup function for the vi.mock factories, then invoke it from both
synchronization and use-ideas suites while importing the shared row and seed
helpers; preserve their existing behavior and types.
tests/lib/offline-identity.test.ts (1)

10-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The backing store is never read.

localStorageMock.getItem is replaced in beforeEach with an implementation that builds its own Map. The store created at line 11 therefore only receives setItem and removeItem writes and never serves a read. The rebuilt Map is also allocated on every getItem call.

Reset one shared Map in beforeEach and keep the original getItem implementation. That also lets you assert the round trip after resolveUserId refreshes the cache.

♻️ Proposed change
 const localStorageMock = vi.hoisted(() => {
   const store = new Map<string, string>()
   return {
+    store,
     getItem: vi.fn((key: string) => store.get(key) ?? null),
     setItem: vi.fn((key: string, value: string) => store.set(key, value)),
     removeItem: vi.fn((key: string) => store.delete(key)),
   }
 })
 
 beforeEach(() => {
   vi.clearAllMocks()
-  localStorageMock.getItem.mockImplementation((key: string) => {
-    const store = new Map<string, string>([
-      ["trojes:offline-user-id", "cached-user"],
-    ])
-    return store.get(key) ?? null
-  })
+  localStorageMock.store.clear()
+  localStorageMock.store.set("trojes:offline-user-id", "cached-user")

The last test then clears the store instead of overriding getItem:

-    localStorageMock.getItem.mockImplementation(() => null)
+    localStorageMock.store.clear()
🤖 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 `@tests/lib/offline-identity.test.ts` around lines 10 - 26, Update the shared
store setup in the localStorageMock fixture and beforeEach: clear and seed the
existing Map before each test, while preserving the original getItem
implementation instead of replacing it with a per-call Map. Adjust the final
test to clear the shared store when simulating an empty cache, so
setItem/getItem round-trip behavior remains testable.
hooks/use-ideas.ts (1)

89-151: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the repeated mutation wrapper.

updatePin, updateColor, updateContent, and permanentDelete share the same shape: build a timestamp, run one statement, log on failure, and return { ok }. A small helper removes the duplication and keeps the error handling consistent.

♻️ Example helper
+  const run = useCallback(
+    async (label: string, sql: string, params: unknown[]): Promise<{ ok: boolean }> => {
+      try {
+        await db.execute(sql, params)
+        return { ok: true }
+      } catch (error) {
+        console.error(`Failed to ${label}:`, error)
+        return { ok: false }
+      }
+    },
+    [],
+  )
+
+  const updatePin = useCallback(
+    (id: string, pinned: boolean) =>
+      run("update pin", "UPDATE ideas SET pinned = ?, updated_at = ? WHERE id = ?", [
+        pinned ? 1 : 0,
+        new Date().toISOString(),
+        id,
+      ]),
+    [run],
+  )
🤖 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 `@hooks/use-ideas.ts` around lines 89 - 151, Extract the shared database
mutation and error-handling flow from updatePin, updateColor, updateContent, and
permanentDelete into a small helper, parameterized by the SQL statement, values,
and operation label. Refactor each callback to use the helper while preserving
timestamp updates, existing SQL parameters, error logging context, and the { ok:
boolean } result contract.
🤖 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/api/powersync/upload/route.ts`:
- Around line 44-53: Update the normalization logic for opData.status and
opData.source to reject values not present in VALID_STATUSES or VALID_SOURCES
instead of defaulting to "inbox" or "web". Return a per-operation error
containing the invalid value, while preserving accepted values and the existing
handling for omitted fields.
- Around line 126-154: Update app/api/powersync/upload/route.ts in POST (lines
126-154) to return retryable non-2xx responses for authentication and
infrastructure failures, while retaining 2xx responses only for permanently
invalid operations. Update lib/powersync/connector.ts in the upload transaction
handling (lines 51-55) to throw on retryable failure responses instead of
calling transaction.complete(), allowing PowerSync to retry the queued
transaction.

In `@components/providers/powersync-provider.tsx`:
- Around line 28-42: Remove the unauthenticated-state database clearing from the
status handling around isOnline(), including clearCachedUserId() and
db.disconnectAndClear(). Preserve the local PowerSync mirror and pending writes
for all session-fetch failures; move cleanup to the existing explicit confirmed
logout or user-switch flow, if available.

In `@db/ideas.ts`:
- Around line 106-126: Update upsertIdea’s onConflictDoUpdate configuration to
restrict conflict updates to rows whose ideas.user_id matches the supplied
userId, while preserving the existing insert and values update behavior. Reuse
the ownership-filtering pattern already used by PATCH, UPDATE, and DELETE so
client-supplied IDs cannot modify another user’s idea.

In `@hooks/use-ideas.ts`:
- Around line 23-28: Guard cached identity resolution with hydrated state in
hooks/use-ideas.ts lines 23-28 and hooks/use-pinned-ideas.ts lines 10-12: update
each userId expression so getCachedUserId() is only evaluated when hydrated is
true, while preserving the session user ID fallback and null otherwise.

In `@lib/create-idea.ts`:
- Around line 13-16: Fix the parameter binding in createIdea by ensuring the
INSERT statement’s six placeholders align with all six values, with deleted_at
explicitly bound as NULL rather than now. Update tests around insertIdea to
assert the complete parameter list and verify the inserted record has a null
deleted_at value.

In `@tests/integration/use-ideas.test.tsx`:
- Around line 102-114: The create() integration test should assert the persisted
created idea’s deleted_at, created_at, and updated_at values, not just its
content and collection count. Extend the assertions in the “create() inserts
locally and returns ok” test to locate “New test idea” and verify deleted_at is
null and both timestamp fields are populated, catching any parameter-binding
shift.

In `@tests/lib/create-idea.test.ts`:
- Around line 33-36: Update the assertions for the executeMock call in the
create-idea test to verify the complete parameter array: assert its length
equals the number of SQL ? placeholders and retain value checks for the expected
parameters. Use the captured sql and params symbols without changing the
production create-idea implementation.

---

Nitpick comments:
In `@app/api/powersync/upload/route.ts`:
- Around line 156-174: Update the upload handling around operations and applyOp
to reject batches exceeding the established maximum operation count, then
execute the entire batch within one database transaction so all operations
commit or roll back together. Preserve the existing per-operation error
collection and response behavior while ensuring transaction failures do not
leave partial writes.

In `@hooks/use-ideas.ts`:
- Around line 89-151: Extract the shared database mutation and error-handling
flow from updatePin, updateColor, updateContent, and permanentDelete into a
small helper, parameterized by the SQL statement, values, and operation label.
Refactor each callback to use the helper while preserving timestamp updates,
existing SQL parameters, error logging context, and the { ok: boolean } result
contract.

In `@lib/powersync/connector.ts`:
- Around line 56-58: Remove the no-op try/catch surrounding the affected logic
in the connector flow, including the catch block that only rethrows ex, and
leave the enclosed statements executing directly with their existing behavior.
- Around line 11-27: Update fetchCredentials to use an AbortSignal timeout for
the token fetch, ensuring hung requests terminate. Validate the parsed response
body before returning credentials, requiring endpoint and token to be present
and valid; reject malformed responses with a clear error instead of returning
undefined values.

In `@lib/powersync/db.ts`:
- Around line 1-3: Mark the module containing the import-time PowerSyncDatabase
construction as client-only by adding the appropriate "use client" directive at
the top of the module. Keep the existing PowerSyncDatabase initialization and
exports unchanged.

In `@lib/powersync/mappers.ts`:
- Around line 19-27: Extract the JSON tag parsing logic from parseTags in
mappers.ts into a shared helper, then update both parseTags and the upload
route’s duplicate implementation to reuse it. Preserve the existing null
handling, JSON parsing behavior, and string-only filtering.

In `@package.json`:
- Line 14: Update the package.json build workflow to invoke powersync-web
copy-assets -o public in addition to the postinstall hook, ensuring
public/@powersync/worker.js exists when dependencies were installed with skipped
lifecycle scripts. Preserve the existing postinstall behavior and integrate the
asset-copy command into the build script.

In `@tests/helpers/powersync-fake.ts`:
- Around line 108-110: Update the fake’s execute function to reject with
currentError when setError has configured an error, before processing the SQL;
otherwise preserve the existing execution behavior and return value.
- Around line 100-106: Update getOptional to detect the exact id = ? clause
rather than using a substring match that also accepts user_id = ?. Preserve the
existing direct row lookup for genuine id filters and route other queries,
including user_id filters, through select.

In `@tests/integration/synchronization.test.tsx`:
- Around line 34-40: Update the mock query state returned by the fake around
holder.db so isLoading reflects an explicit loading flag rather than the
always-initialized db value, or remove the ineffective isLoading assertions and
wait for the expected data instead. Ensure synchronization tests still verify
completion meaningfully.
- Around line 6-74: Extract the duplicated holder, PowerSync/NextAuth mock
setup, row factory, and seed helpers into a shared module under tests/helpers
next to powersync-fake.ts. Export a hoisted-safe setup function for the vi.mock
factories, then invoke it from both synchronization and use-ideas suites while
importing the shared row and seed helpers; preserve their existing behavior and
types.

In `@tests/lib/offline-identity.test.ts`:
- Around line 10-26: Update the shared store setup in the localStorageMock
fixture and beforeEach: clear and seed the existing Map before each test, while
preserving the original getItem implementation instead of replacing it with a
per-call Map. Adjust the final test to clear the shared store when simulating an
empty cache, so setItem/getItem round-trip behavior remains testable.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d360d87b-58e0-41fa-99d4-2baadfdf558f

📥 Commits

Reviewing files that changed from the base of the PR and between c1f549b and 4a539f0.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (35)
  • .env.example
  • .gitignore
  • LEARNINGS.md
  • MISTAKES.md
  • README.md
  • app/api/powersync/token/route.ts
  • app/api/powersync/upload/route.ts
  • app/layout.tsx
  • components/editor/plugins/emoji-picker-plugin.tsx
  • components/providers/powersync-provider.tsx
  • components/providers/session-provider.tsx
  • db/ideas.ts
  • db/users.ts
  • hooks/use-hydrated.ts
  • hooks/use-ideas.ts
  • hooks/use-pinned-ideas.ts
  • lib/api-client.ts
  • lib/create-idea.ts
  • lib/offline-identity.ts
  • lib/powersync/connector.ts
  • lib/powersync/db.ts
  • lib/powersync/mappers.ts
  • lib/powersync/schema.ts
  • lib/swr-helpers.ts
  • next.config.mjs
  • notes/references.md
  • package.json
  • public/sw.js
  • tests/helpers/powersync-fake.ts
  • tests/integration/auth-callbacks.test.ts
  • tests/integration/synchronization.test.tsx
  • tests/integration/use-ideas.test.tsx
  • tests/lib/create-idea.test.ts
  • tests/lib/offline-identity.test.ts
  • tsconfig.tsbuildinfo
💤 Files with no reviewable changes (2)
  • lib/swr-helpers.ts
  • lib/api-client.ts

Comment on lines +44 to +53
if (opData.source !== undefined) {
normalized.source = VALID_SOURCES.includes(opData.source as IdeaSource)
? (opData.source as IdeaSource)
: "web"
}
if (opData.status !== undefined) {
normalized.status = VALID_STATUSES.includes(opData.status as IdeaStatus)
? (opData.status as IdeaStatus)
: "inbox"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reject invalid status and source instead of coercing them.

An unrecognized status becomes "inbox". That silently moves an archived or deleted idea back to the inbox. An unrecognized source becomes "web". Return a per-operation error so the caller sees the bad value.

🤖 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 `@app/api/powersync/upload/route.ts` around lines 44 - 53, Update the
normalization logic for opData.status and opData.source to reject values not
present in VALID_STATUSES or VALID_SOURCES instead of defaulting to "inbox" or
"web". Return a per-operation error containing the invalid value, while
preserving accepted values and the existing handling for omitted fields.

Comment on lines +126 to +154
export async function POST(request: NextRequest) {
let userId: string | null
try {
userId = await getAuthenticatedUserId(request)
} catch (error) {
console.error("Failed to authenticate upload:", error)
return NextResponse.json(
{ success: false, error: "Authentication failed" },
{ status: 200 },
)
}

if (!userId) {
return NextResponse.json(
{ success: false, error: "Unauthorized" },
{ status: 200 },
)
}

let body: { operations?: UploadOp[] }
try {
body = await request.json()
} catch (error) {
console.error("Invalid upload payload:", error)
return NextResponse.json(
{ success: false, error: "Invalid JSON payload" },
{ status: 200 },
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Queued offline writes are discarded when an upload fails. The upload route signals every failure with HTTP 200 and a success: false body, and the connector treats any 200 as delivered and completes the CRUD transaction. Together these two halves erase the local queue for failures that should be retried, including an expired session after a period offline.

  • app/api/powersync/upload/route.ts#L126-L154: return a retryable non-2xx status for authentication failures and infrastructure errors; keep 2xx only for operations that are permanently invalid.
  • lib/powersync/connector.ts#L51-L55: throw instead of calling transaction.complete() when the response reports a retryable failure, so PowerSync retries the same transaction.
📍 Affects 2 files
  • app/api/powersync/upload/route.ts#L126-L154 (this comment)
  • lib/powersync/connector.ts#L51-L55
🤖 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 `@app/api/powersync/upload/route.ts` around lines 126 - 154, Update
app/api/powersync/upload/route.ts in POST (lines 126-154) to return retryable
non-2xx responses for authentication and infrastructure failures, while
retaining 2xx responses only for permanently invalid operations. Update
lib/powersync/connector.ts in the upload transaction handling (lines 51-55) to
throw on retryable failure responses instead of calling transaction.complete(),
allowing PowerSync to retry the queued transaction.

Comment thread components/providers/powersync-provider.tsx Outdated
Comment thread db/ideas.ts
Comment thread hooks/use-ideas.ts
Comment thread lib/create-idea.ts
Comment on lines +102 to 114
it("create() inserts locally and returns ok", async () => {
const { result } = renderHook(() => useIdeas({ status: "inbox" }))
await waitFor(() => expect(result.current.isLoading).toBe(false))

await expect(
act(async () => result.current.create("New test idea")),
).resolves.toEqual({ ok: true })

await waitFor(() => expect(result.current.ideas).toHaveLength(3))
expect(
result.current.ideas.some((i) => i.content === "New test idea"),
).toBe(true)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the persisted field values, not only the count.

The test checks the row count and the content. It does not check deleted_at, created_at, or updated_at. That gap hides the placeholder/parameter mismatch in lib/create-idea.ts lines 13-16, where deleted_at is bound to a timestamp and updated_at is left unbound. Add assertions on the created idea so a binding shift fails the suite.

💚 Suggested assertions
     await waitFor(() => expect(result.current.ideas).toHaveLength(3))
-    expect(
-      result.current.ideas.some((i) => i.content === "New test idea"),
-    ).toBe(true)
+    const created = result.current.ideas.find((i) => i.content === "New test idea")
+    expect(created).toBeDefined()
+    expect(created?.deleted_at).toBeNull()
+    expect(created?.updated_at).toBeTruthy()
🤖 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 `@tests/integration/use-ideas.test.tsx` around lines 102 - 114, The create()
integration test should assert the persisted created idea’s deleted_at,
created_at, and updated_at values, not just its content and collection count.
Extend the assertions in the “create() inserts locally and returns ok” test to
locate “New test idea” and verify deleted_at is null and both timestamp fields
are populated, catching any parameter-binding shift.

Comment thread tests/lib/create-idea.test.ts
- Bind deleted_at as literal NULL so the ideas INSERT no longer has a
  placeholder/param mismatch that left updated_at unbound.
- Guard upsertIdea's conflict-update with a user_id ownership condition so
  one account cannot overwrite another user's idea.
- Return non-2xx from the upload route for auth/infrastructure failures so
  the client keeps its local queue and retries instead of completing and
  erasing offline writes; keep 2xx only for permanently invalid operations.
- Stop clearing the local mirror on session 'unauthenticated' (which a
  transient fetch failure also produces); wipe only on explicit sign-out and
  on a server-confirmed switch to a different user.
- Gate getCachedUserId() behind useHydrated in use-ideas and
  use-pinned-ideas to avoid a hydration mismatch.
- Add tests covering placeholder/param alignment, persisted field values,
  and provider user-switch clearing.
Fixes two security advisories affecting <=4.24.14: an OAuth state cookie
provider binding issue (GHSA-x445-f3h2-j279) and a malformed-Bearer
getToken DoS.

for (const op of operations) {
try {
const result = await applyOp(op, userId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/async-await-in-loop (warning)

This makes the for…of loop slow because each await runs one after another, so collect the independent calls & run them together with await Promise.all(items.map(...))

Fix → Collect the items, then use await Promise.all(items.map(...)) so independent work runs at the same time

Docs

.toUpperCase()
.slice(0, 2) || "U";

const handleSignOut = async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/prefer-module-scope-pure-function (warning)

handleSignOut inside SettingsAccount uses no local state but is rebuilt on every render, so it wastes work & breaks memoized children. Move it to the top of the file, outside the component.

Fix → Move the function above the component, at the top of the file. It doesn't use local state, so rebuilding it each update is wasted work.

Docs

Comment thread hooks/use-hydrated.ts
export function useHydrated(): boolean {
const [hydrated, setHydrated] = useState(false)

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/rendering-hydration-no-flicker (warning)

useEffect(setState, []) runs after the first paint, so users can see the initial state flash. Initialize from a render-safe value or use useSyncExternalStore for external values.

Fix → Initialize state from a render-safe value before the first paint, or read external mutable values with useSyncExternalStore.

Docs

Comment thread hooks/use-hydrated.ts
const [hydrated, setHydrated] = useState(false)

useEffect(() => {
setHydrated(true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-initialize-state (warning)

Your users see an extra render with empty "hydrated" because a useEffect sets its starting value.

Fix → Pass the initial value directly to useState() instead of setting it from a mount-only useEffect. For SSR hydration, prefer useSyncExternalStore().

Docs

@elcokiin
elcokiin merged commit df312d3 into main Aug 9, 2026
5 checks passed
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.

2 participants