Skip to content

feat: implement offline-first synchronization support using an idempo… - #288

Open
ali-ahnaf wants to merge 1 commit into
developfrom
feat/offline
Open

feat: implement offline-first synchronization support using an idempo…#288
ali-ahnaf wants to merge 1 commit into
developfrom
feat/offline

Conversation

@ali-ahnaf

@ali-ahnaf ali-ahnaf commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Added offline support for creating transactions and debts, with automatic syncing when connectivity returns.
    • Added queued-item indicators, offline status messaging, and support for viewing pending entries.
    • Added limited offline caching for reference data and offline app-shell routing.
    • Prevented duplicate entries when queued requests are replayed.
  • Bug Fixes

    • Improved handling of network failures while preserving server error messages.
    • Cleared cached data and queued changes when signing out.

…tent local outbox for debts and transactions.
@github-actions

Copy link
Copy Markdown
Contributor

Greetings, @ali-ahnaf. Thy scroll hath arrived unblemished — the runes align and no conflict bars the path. The council shall now convene over its contents, weighing each incantation by candlelight. Tarry a while, brave adventurer; we shall investigate and return to thee with our verdict.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Offline support is added for transaction and debt creation through local caching, a FIFO outbox, reconnect replay, queued placeholders, offline status UI, service-worker shell precaching, and clientRequestId-based API idempotency.

Offline support

Layer / File(s) Summary
Offline cache and outbox
documentation/offline-support.md, packages/ui/src/lib/offline/*, packages/ui/src/hooks/useOnlineStatus.ts, packages/shared/src/contracts/*
Allow-listed GET responses are cached, while transaction and debt creates are stored as typed outbox entries with placeholder DTOs and generated client request IDs.
Idempotent create API
packages/api/src/entities/*, packages/api/src/migrations/*, packages/api/src/repositories/*, packages/api/src/routes/*, packages/api/src/services/*, packages/api/src/tests/*, packages/ui/src/lib/api/ProfileApi.ts
Create contracts and routes accept UUID request IDs; entities persist them uniquely, and services return existing records during replay.
Offline transaction creation
packages/ui/src/components/LogResourceModal.tsx, packages/ui/src/components/LogResourceModal.test.tsx, packages/ui/src/app/page.tsx, packages/ui/e2e/offline.spec.ts
Transaction submission queues offline or on transport failure, restricts network-only entry controls, displays feedback, and renders queued transactions until synchronization.
Reconnect sync and debt UI
packages/ui/src/components/OfflineSync.tsx, packages/ui/src/app/debts/page.tsx, packages/ui/src/components/AppBar.tsx, packages/ui/src/app/layout.tsx, packages/ui/public/sw.js, packages/ui/src/hooks/*
Queued entries replay sequentially on reconnect; debt placeholders and refresh events update page state, while the app displays offline status and precaches the debts route.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant UI
  participant Outbox
  participant Sync
  participant API
  participant Database
  User->>UI: Create transaction or debt offline
  UI->>Outbox: Store queued operation
  Sync->>Outbox: Read queued operations on reconnect
  Sync->>API: Replay create with clientRequestId
  API->>Database: Find existing or insert record
  Database-->>API: Return record
  API-->>Sync: Return create result
  Sync->>Outbox: Remove completed operation
  Sync-->>UI: Dispatch refresh event
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#146] The backend sync endpoint and batch processor are missing; the PR only adds single-create idempotent replay paths. Add POST /api/users/:userId/sync with a shared sync DTO/Joi validation and a service that processes batches with per-operation results.
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% 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 is concise and accurately reflects the offline-first synchronization and idempotent outbox work.
Linked Issues check ✅ Passed [#145] Frontend offline support is implemented: app-shell/API caching, offline queueing, sync-on-reconnect, and offline status UI are all present.
Out of Scope Changes check ✅ Passed The changes stay within the offline-support scope and idempotent replay behavior; no clearly unrelated code paths were introduced.
✨ Finishing Touches
📝 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

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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/api/src/services/transactions.service.ts (1)

52-69: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persist the transaction and tag links atomically.

If save() succeeds but replaceTags() fails, a retry with this key returns the existing expense at Line 56 and permanently skips its requested tags. Wrap creation and tag replacement in one transaction, or make replay complete incomplete tag links.

🤖 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 `@packages/api/src/services/transactions.service.ts` around lines 52 - 69,
Update the transaction creation flow around createEntity, save, and replaceTags
so transaction persistence and tag-link replacement occur atomically, rolling
back both when either operation fails. Ensure clientRequestId replay handling
does not return an existing transaction until its requested tag links are
complete, preserving the existing replay behavior for fully persisted
transactions.
🤖 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 `@documentation/offline-support.md`:
- Around line 10-21: Update the offline-support document’s transaction-creation
section to reflect the current LogResourceModal.handleSubmit behavior: transport
failures are caught and the write is queued rather than producing an unhandled
rejection. Preserve the remaining offline limitations and identify the former
no-catch behavior as historical context only if it remains useful.

In
`@packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts`:
- Around line 14-25: Scope client request ID uniqueness to each user instead of
globally: in
packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts
lines 14-25, replace both single-column unique constraints with composite
constraints on userId and clientRequestId; in
packages/api/src/entities/Debt.entity.ts lines 32-37 and
packages/api/src/entities/Expense.entity.ts lines 46-51, remove unique: true
from clientRequestId and declare matching composite unique indexes.

In `@packages/api/src/services/debts.service.ts`:
- Around line 38-66: Make the lookup-and-create flow atomic in
packages/api/src/services/debts.service.ts:38-66 around the debt creation
method, using insert-on-conflict or catching the unique-key violation and
re-reading the persisted debt so concurrent replays return the winner. Apply the
same atomic handling to packages/api/src/services/transactions.service.ts:50-67
around its transaction creation flow, preserving existing response mapping and
replay behavior.

In `@packages/api/src/tests/debts.service.test.ts`:
- Around line 170-199: The DebtsService.create path must handle concurrent
clientRequestId races: if debts.save fails after the initial lookup, reload the
debt with debts.findOneByClientRequestId for the same user and return it when
found, while preserving normal error propagation when no existing debt is found.
Add a regression test covering two concurrent creates that miss the initial
lookup, one encounters the unique-constraint save failure, and the failed
request returns the persisted existing debt.

In `@packages/ui/src/app/debts/page.tsx`:
- Around line 88-124: Update handleCreate to accept the shared CreateDebtInput
type and generate one client request ID before the online attempt. Include that
ID in profileApi.createDebt and pass the same ID to enqueue when a NetworkError
triggers queueLocally, ensuring replay reuses the original request identity
instead of creating a new one.

In `@packages/ui/src/app/layout.tsx`:
- Line 6: Update the OfflineSync import in the layout module to use the named
export provided by OfflineSync.tsx, while preserving the existing component
usage.

In `@packages/ui/src/components/OfflineSync.tsx`:
- Around line 33-39: The offline synchronization flow must use the authenticated
batch sync contract rather than replaying direct create requests. In
packages/ui/src/components/OfflineSync.tsx lines 33-39, submit queued typed
operations to POST /api/users/:userId/sync and process each returned operation
result, including per-operation failures. In packages/ui/e2e/offline.spec.ts
lines 88-94 and 123-128, update the transaction and debt scenarios to assert
each operation is included in one sync request instead of asserting direct
transaction or debt POSTs. In documentation/offline-support.md lines 92-100,
describe the batch endpoint and its per-operation failure behavior.
- Around line 42-57: Update the sync flow in OfflineSync so OFFLINE_SYNCED_EVENT
is dispatched when entries are successfully synchronized or terminally removed.
Track whether any queue entry was removed in the non-NetworkError path and use
that alongside synced when deciding to dispatch the event, while preserving the
existing retry and ordering behavior.

In `@packages/ui/src/lib/api/ApiClient.ts`:
- Around line 100-109: Update the enqueue() transaction flow to generate and
persist a stable clientRequestId before the initial POST, including when the
request reaches the server but loses its response. Ensure the NetworkError
retry/replay path reuses that persisted id instead of creating a new one,
preventing duplicate rows.

In `@packages/ui/src/lib/offline/outbox.ts`:
- Around line 49-50: Update the persistence flow surrounding the localStorage
write in the outbox module to protect queued financial entries using the
existing session DEK or equivalent encrypted persistence before storage. Ensure
debt titles, amounts, notes, dates, and transaction details are never written as
plaintext under OUTBOX_STORAGE_KEY, while preserving the existing outbox queue
behavior and error handling.
- Around line 49-76: The enqueue flow must not report success when localStorage
persistence fails. Update write and enqueue so setItem errors propagate as a
storage failure, allowing callers to keep the form open and show an actionable
error; preserve successful queueing behavior and add coverage for the
persistence-failure path.

---

Outside diff comments:
In `@packages/api/src/services/transactions.service.ts`:
- Around line 52-69: Update the transaction creation flow around createEntity,
save, and replaceTags so transaction persistence and tag-link replacement occur
atomically, rolling back both when either operation fails. Ensure
clientRequestId replay handling does not return an existing transaction until
its requested tag links are complete, preserving the existing replay behavior
for fully persisted transactions.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fabccbc7-b36b-4bcb-a7c0-06b89d32740b

📥 Commits

Reviewing files that changed from the base of the PR and between 9b82a17 and 27952dc.

📒 Files selected for processing (35)
  • documentation/offline-support.md
  • packages/api/src/entities/Debt.entity.ts
  • packages/api/src/entities/Expense.entity.ts
  • packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts
  • packages/api/src/repositories/debts.repository.ts
  • packages/api/src/repositories/transactions.repository.ts
  • packages/api/src/routes/debts/post-debt.route.ts
  • packages/api/src/routes/transactions/post-transaction.route.ts
  • packages/api/src/services/debts.service.ts
  • packages/api/src/services/transactions.service.ts
  • packages/api/src/tests/debts.service.test.ts
  • packages/api/src/tests/transactions.service.test.ts
  • packages/shared/src/contracts/debts.ts
  • packages/shared/src/contracts/transactions.ts
  • packages/ui/e2e/offline.spec.ts
  • packages/ui/public/sw.js
  • packages/ui/src/app/debts/page.tsx
  • packages/ui/src/app/layout.tsx
  • packages/ui/src/app/page.tsx
  • packages/ui/src/components/AppBar.tsx
  • packages/ui/src/components/LogResourceModal.test.tsx
  • packages/ui/src/components/LogResourceModal.tsx
  • packages/ui/src/components/OfflineSync.tsx
  • packages/ui/src/components/index.ts
  • packages/ui/src/hooks/useAuth.ts
  • packages/ui/src/hooks/useDisplaySettings.ts
  • packages/ui/src/hooks/useOnlineStatus.ts
  • packages/ui/src/lib/api/ApiClient.test.ts
  • packages/ui/src/lib/api/ApiClient.ts
  • packages/ui/src/lib/api/ProfileApi.ts
  • packages/ui/src/lib/offline/cache.test.ts
  • packages/ui/src/lib/offline/cache.ts
  • packages/ui/src/lib/offline/outbox.test.ts
  • packages/ui/src/lib/offline/outbox.ts
  • packages/ui/src/lib/offline/sync-events.ts

Comment on lines +10 to +21
## 1. Does creating a transaction work offline today? No.

[`handleSubmit`](../packages/ui/src/components/LogResourceModal.tsx#L244-L268) calls `profileApi.createTransaction`, axios rejects with a transport error, and there is **no `catch`** — only `try/finally`. So offline the user sees: spinner flips back to `RECORD`, modal stays open, fields keep their values, nothing saved, an unhandled promise rejection in the console. No error message at all.

Two more things break in that modal before submit even happens:

- On open it fires `getTags` and `getVaults` ([lines 81-92](../packages/ui/src/components/LogResourceModal.tsx#L81-L92)) with `.then()` and no `.catch()`. Offline → both reject → tag list empty, `vaults.length === 0` so the vault picker is not rendered at all, and any submit would post `vaultId: null`.
- The AI prompt path calls `getAiCredentialStatus` and then OpenRouter — both need the network.

Debts are simpler: `AddDebtModal` is pure UI with no fetching, and `handleCreate` ([debts/page.tsx:74-78](../packages/ui/src/app/debts/page.tsx#L74-L78)) needs the server's returned `DebtDto` only to prepend it to the list — a value we can construct locally.

Also relevant: the service worker precaches only `/` ([sw.js](../packages/ui/public/sw.js)), so a cold offline launch straight into `/debts/` renders the wrong shell.

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

Update the document to describe the shipped behavior.

This says offline transaction creation fails with no catch, but LogResourceModal.tsx now catches transport failures and queues the write. Mark this as historical context or rewrite it as current behavior.

🤖 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 `@documentation/offline-support.md` around lines 10 - 21, Update the
offline-support document’s transaction-creation section to reflect the current
LogResourceModal.handleSubmit behavior: transport failures are caught and the
write is queued rather than producing an unhandled rejection. Preserve the
remaining offline limitations and identify the former no-catch behavior as
historical context only if it remains useful.

Comment on lines +14 to +25
`CREATE TABLE "temporary_expenses" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "title" varchar(200), "amount" decimal(10,2) NOT NULL DEFAULT (0), "type" varchar NOT NULL DEFAULT ('expense'), "date" date, "interval" varchar, "startDate" date, "endDate" date, "deletedAt" datetime, "vaultId" varchar, "sourceRecurringId" varchar, "createdAt" datetime DEFAULT (datetime('now')), "updatedAt" datetime DEFAULT (datetime('now')), "clientRequestId" varchar, CONSTRAINT "UQ_f833020de04e8bcfbf00ac86cfe" UNIQUE ("clientRequestId"), CONSTRAINT "FK_3d211de716f0f14ea7a8a4b1f2c" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e0d76d9858620c98a05e7785ef7" FOREIGN KEY ("vaultId") REFERENCES "vaults" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "temporary_expenses"("id", "userId", "title", "amount", "type", "date", "interval", "startDate", "endDate", "deletedAt", "vaultId", "sourceRecurringId", "createdAt", "updatedAt") SELECT "id", "userId", "title", "amount", "type", "date", "interval", "startDate", "endDate", "deletedAt", "vaultId", "sourceRecurringId", "createdAt", "updatedAt" FROM "expenses"`,
);
await queryRunner.query(`DROP TABLE "expenses"`);
await queryRunner.query(`ALTER TABLE "temporary_expenses" RENAME TO "expenses"`);
await queryRunner.query(
`CREATE TABLE "temporary_debts" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "title" varchar(200) NOT NULL, "amount" decimal(10,2) NOT NULL DEFAULT (0), "type" varchar NOT NULL DEFAULT ('expense'), "createdAt" datetime DEFAULT (datetime('now')), "updatedAt" datetime DEFAULT (datetime('now')), "deletedAt" datetime, "notes" text, "completed" boolean NOT NULL DEFAULT (0), "dueDate" date, "clientRequestId" varchar, CONSTRAINT "UQ_14272a37ffc2934919cb0d2fe1b" UNIQUE ("clientRequestId"), CONSTRAINT "FK_834960a509c776eb841644a9bac" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "temporary_debts"("id", "userId", "title", "amount", "type", "createdAt", "updatedAt", "deletedAt", "notes", "completed", "dueDate") SELECT "id", "userId", "title", "amount", "type", "createdAt", "updatedAt", "deletedAt", "notes", "completed", "dueDate" FROM "debts"`,

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 | 🟠 Major | ⚡ Quick win

Scope idempotency uniqueness to the user. Repository lookups use (userId, clientRequestId), but these constraints make clientRequestId globally unique. A key used by another user therefore raises a constraint error instead of creating that user’s independent request.

  • packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts#L14-L25: replace each single-column unique constraint with a composite unique constraint on ("userId", "clientRequestId").
  • packages/api/src/entities/Debt.entity.ts#L32-L37: remove unique: true from the column and declare the matching composite unique index.
  • packages/api/src/entities/Expense.entity.ts#L46-L51: remove unique: true from the column and declare the matching composite unique index.
📍 Affects 3 files
  • packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts#L14-L25 (this comment)
  • packages/api/src/entities/Debt.entity.ts#L32-L37
  • packages/api/src/entities/Expense.entity.ts#L46-L51
🤖 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
`@packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts`
around lines 14 - 25, Scope client request ID uniqueness to each user instead of
globally: in
packages/api/src/migrations/1785085924293-AddClientRequestIdToExpensesAndDebts.ts
lines 14-25, replace both single-column unique constraints with composite
constraints on userId and clientRequestId; in
packages/api/src/entities/Debt.entity.ts lines 32-37 and
packages/api/src/entities/Expense.entity.ts lines 46-51, remove unique: true
from clientRequestId and declare matching composite unique indexes.

Comment on lines +38 to 66
// A create queued while the client was offline may already have reached the
// server on its first attempt; return that row rather than inserting a twin.
if (input.clientRequestId) {
const existing = await this.debts.findOneByClientRequestId(userId, input.clientRequestId);
if (existing) {
logger.info('Returned existing debt for replayed clientRequestId', { userId, debtId: existing.id });
return {
id: existing.id,
userId: existing.userId,
title: existing.title,
amount: Number(existing.amount),
type: existing.type,
notes: existing.notes ?? null,
dueDate: existing.dueDate ?? null,
createdAt: existing.createdAt,
};
}
}

const debt = this.debts.createEntity({
userId,
title: input.title,
amount: input.amount,
type: input.type,
notes: input.notes ?? null,
dueDate: input.dueDate ?? null,
clientRequestId: input.clientRequestId ?? null,
});
const saved = await this.debts.save(debt);

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 | 🟠 Major | 🏗️ Heavy lift

Make replay handling atomic. Two simultaneous retries can both miss the initial lookup, then one insert fails on the unique key rather than returning the persisted record. Use an insert-on-conflict strategy, or catch the unique violation and re-read the winner.

  • packages/api/src/services/debts.service.ts#L38-L66: make the debt lookup-and-create sequence atomic.
  • packages/api/src/services/transactions.service.ts#L50-L67: make the transaction lookup-and-create sequence atomic.
📍 Affects 2 files
  • packages/api/src/services/debts.service.ts#L38-L66 (this comment)
  • packages/api/src/services/transactions.service.ts#L50-L67
🤖 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 `@packages/api/src/services/debts.service.ts` around lines 38 - 66, Make the
lookup-and-create flow atomic in
packages/api/src/services/debts.service.ts:38-66 around the debt creation
method, using insert-on-conflict or catching the unique-key violation and
re-reading the persisted debt so concurrent replays return the winner. Apply the
same atomic handling to packages/api/src/services/transactions.service.ts:50-67
around its transaction creation flow, preserving existing response mapping and
replay behavior.

Comment on lines +170 to +199
it('persists the clientRequestId of an offline-queued create', async () => {
const saved = buildDebt();
debts.findOneByClientRequestId.mockResolvedValue(null);
debts.save.mockResolvedValue(saved);

await service.create('user-1', { ...input, clientRequestId: 'b3f1c2d4-0000-4000-8000-000000000002' });

expect(debts.createEntity).toHaveBeenCalledWith(expect.objectContaining({ clientRequestId: 'b3f1c2d4-0000-4000-8000-000000000002' }));
});

it('returns the existing debt instead of inserting a duplicate when the clientRequestId was already used', async () => {
const existing = buildDebt({ id: 'debt-existing' });
debts.findOneByClientRequestId.mockResolvedValue(existing);

const result = await service.create('user-1', { ...input, clientRequestId: 'b3f1c2d4-0000-4000-8000-000000000002' });

expect(debts.findOneByClientRequestId).toHaveBeenCalledWith('user-1', 'b3f1c2d4-0000-4000-8000-000000000002');
expect(debts.createEntity).not.toHaveBeenCalled();
expect(debts.save).not.toHaveBeenCalled();
expect(result).toMatchObject({ id: 'debt-existing', userId: existing.userId, title: existing.title });
});

it('does not look up an idempotency key when the create carries none', async () => {
debts.save.mockResolvedValue(buildDebt());

await service.create('user-1', input);

expect(debts.findOneByClientRequestId).not.toHaveBeenCalled();
});

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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files
git ls-files 'packages/api/src/**/debts*' 'packages/api/src/**/debt*' 'packages/api/src/**/repositories/**' 'packages/api/src/**/services/**' 'packages/api/src/tests/**' | sed 's#^`#FILE` #'

echo '--- debts.service.test.ts ---'
cat -n packages/api/src/tests/debts.service.test.ts | sed -n '1,260p'

echo '--- search clientRequestId / duplicate key ---'
rg -n "clientRequestId|duplicate|unique constraint|23505|E11000|save\\(|findOneByClientRequestId|create\\(" packages/api/src --glob '!**/dist/**' --glob '!**/build/**'

Repository: ali-ahnaf/pocket_pixel

Length of output: 34816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files
git ls-files 'packages/api/src/**/debts*' 'packages/api/src/src/**' 'packages/api/src/**/repositories/**' 'packages/api/src/**/services/**' 'packages/api/src/tests/**' | sed 's#^`#FILE` #'

echo '--- debts.service.test.ts ---'
cat -n packages/api/src/tests/debts.service.test.ts | sed -n '1,260p'

echo '--- search clientRequestId / duplicate key ---'
rg -n "clientRequestId|duplicate|unique constraint|23505|E11000|save\\(|findOneByClientRequestId|create\\(" packages/api/src --glob '!**/dist/**' --glob '!**/build/**'

Repository: ali-ahnaf/pocket_pixel

Length of output: 34816


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- debts.service.test.ts ---'
cat -n packages/api/src/tests/debts.service.test.ts | sed -n '140,230p'

echo '--- debts service sources ---'
git ls-files 'packages/api/src/**/debts*.ts' 'packages/api/src/**/repositories/*.ts' 'packages/api/src/**/services/*.ts' | sed 's#^`#FILE` #'

echo '--- search clientRequestId / duplicate key handling ---'
rg -n "clientRequestId|findOneByClientRequestId|duplicate key|unique constraint|23505|E11000|QueryFailedError|createEntity|save\\(" packages/api/src --glob '!**/dist/**' --glob '!**/build/**'

Repository: ali-ahnaf/pocket_pixel

Length of output: 30272


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- debts.service.ts ---'
cat -n packages/api/src/services/debts.service.ts | sed -n '1,120p'

echo '--- transactions.service.ts (parallel idempotency pattern) ---'
cat -n packages/api/src/services/transactions.service.ts | sed -n '1,100p'

echo '--- debt entity unique constraint ---'
cat -n packages/api/src/entities/Debt.entity.ts | sed -n '1,90p'

Repository: ali-ahnaf/pocket_pixel

Length of output: 11680


Handle duplicate-key races in DebtsService.create. Two concurrent replays can both miss the lookup and one will fail on the unique clientRequestId constraint; catch that save error, reload the existing debt for the same user, and add a concurrent regression test.

🤖 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 `@packages/api/src/tests/debts.service.test.ts` around lines 170 - 199, The
DebtsService.create path must handle concurrent clientRequestId races: if
debts.save fails after the initial lookup, reload the debt with
debts.findOneByClientRequestId for the same user and return it when found, while
preserving normal error propagation when no existing debt is found. Add a
regression test covering two concurrent creates that miss the initial lookup,
one encounters the unique-constraint save failure, and the failed request
returns the persisted existing debt.

Comment on lines 88 to +124
const handleCreate = async (data: { title: string; amount: number; type: 'expense' | 'income'; notes: string | null; dueDate: string | null }) => {
if (!userId) return;
const created = await profileApi.createDebt(userId, data);
setDebts((prev) => [created, ...prev]);

// Every field of a due is known client-side, so an offline create can render
// exactly like an online one — only the id marks it as not-yet-synced.
const queueLocally = () => {
const entry = enqueue({ kind: 'create-debt', userId, payload: data });
setDebts((prev) => [
{
id: `${OFFLINE_ID_PREFIX}${entry.id}`,
userId,
title: data.title,
amount: data.amount,
type: data.type,
notes: data.notes,
dueDate: data.dueDate,
createdAt: new Date(),
completed: false,
discarded: false,
},
...prev,
]);
};

if (!navigator.onLine) {
queueLocally();
return;
}

try {
const created = await profileApi.createDebt(userId, data);
setDebts((prev) => [created, ...prev]);
} catch (err) {
// Only a transport failure is queued; a server rejection must surface.
if (!(err instanceof NetworkError)) throw err;
queueLocally();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Use one client request ID for the initial attempt and replay.

Line 118 posts a payload without clientRequestId. If the server commits but its response is lost, the fallback enqueues a new ID and replay inserts a duplicate. Generate the ID before the first POST, send it in that request, and preserve it when enqueuing the fallback. Type data as CreateDebtInput instead of redefining the request shape locally.

As per coding guidelines, UI request DTOs must use shared contracts and never be redefined locally.

🤖 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 `@packages/ui/src/app/debts/page.tsx` around lines 88 - 124, Update
handleCreate to accept the shared CreateDebtInput type and generate one client
request ID before the online attempt. Include that ID in profileApi.createDebt
and pass the same ID to enqueue when a NetworkError triggers queueLocally,
ensuring replay reuses the original request identity instead of creating a new
one.

Source: Coding guidelines

Comment on lines +33 to +39
for (const entry of entries) {
try {
if (entry.kind === 'create-transaction') {
await profileApi.createTransaction(entry.userId, entry.payload);
} else {
await profileApi.createDebt(entry.userId, entry.payload);
}

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 | 🟠 Major | 🏗️ Heavy lift

Implement the required batch synchronization contract.

The PR requires an authenticated POST /api/users/:userId/sync endpoint with typed operations and per-operation results. The current client instead replays direct create requests, so that contract is neither invoked nor tested.

  • packages/ui/src/components/OfflineSync.tsx#L33-L39: submit queued operations to the batch sync endpoint and process each returned operation result.
  • packages/ui/e2e/offline.spec.ts#L88-L94: assert the transaction operation is included in one sync request instead of asserting a direct transaction POST.
  • packages/ui/e2e/offline.spec.ts#L123-L128: assert the debt operation is included in one sync request instead of asserting a direct debt POST.
  • documentation/offline-support.md#L92-L100: update the design to describe the batch endpoint and its per-operation failure behavior.
📍 Affects 3 files
  • packages/ui/src/components/OfflineSync.tsx#L33-L39 (this comment)
  • packages/ui/e2e/offline.spec.ts#L88-L94
  • packages/ui/e2e/offline.spec.ts#L123-L128
  • documentation/offline-support.md#L92-L100
🤖 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 `@packages/ui/src/components/OfflineSync.tsx` around lines 33 - 39, The offline
synchronization flow must use the authenticated batch sync contract rather than
replaying direct create requests. In packages/ui/src/components/OfflineSync.tsx
lines 33-39, submit queued typed operations to POST /api/users/:userId/sync and
process each returned operation result, including per-operation failures. In
packages/ui/e2e/offline.spec.ts lines 88-94 and 123-128, update the transaction
and debt scenarios to assert each operation is included in one sync request
instead of asserting direct transaction or debt POSTs. In
documentation/offline-support.md lines 92-100, describe the batch endpoint and
its per-operation failure behavior.

Comment on lines +42 to +57
} catch (err) {
// Still no network: stop and leave the rest queued for the next
// `online` event, keeping the replay in order.
if (err instanceof NetworkError) break;

// The server answered and rejected it. Retrying forever would never
// succeed and would block everything behind it, so drop the entry.
console.error('Dropping unsyncable offline entry', entry.kind, err);
remove(entry.id);
}
}
} finally {
isSyncing.current = false;
}

if (synced > 0) window.dispatchEvent(new CustomEvent(OFFLINE_SYNCED_EVENT));

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the component structure first
ast-grep outline packages/ui/src/components/OfflineSync.tsx --view expanded || true

# Show the relevant section with line numbers
sed -n '1,220p' packages/ui/src/components/OfflineSync.tsx | cat -n

Repository: ali-ahnaf/pocket_pixel

Length of output: 3005


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf 'Inspecting OfflineSync.tsx...\n'
wc -l packages/ui/src/components/OfflineSync.tsx
sed -n '1,220p' packages/ui/src/components/OfflineSync.tsx | cat -n

Repository: ali-ahnaf/pocket_pixel

Length of output: 3002


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the sync component, network error type, and event consumers.
rg -n "class NetworkError|NetworkError|OFFLINE_SYNCED_EVENT|Dropping unsyncable offline entry|offline synced|syncing" packages/ui/src -S

Repository: ali-ahnaf/pocket_pixel

Length of output: 3213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the sync component and related definitions.
sed -n '1,220p' packages/ui/src/components/OfflineSync.tsx | cat -n

printf '\n--- NetworkError references ---\n'
rg -n "NetworkError|OFFLINE_SYNCED_EVENT" packages/ui/src -S

printf '\n--- files named around api/errors ---\n'
fd -a "NetworkError|error" packages/ui/src packages/ui/src/lib packages/ui/src/services 2>/dev/null || true

Repository: ali-ahnaf/pocket_pixel

Length of output: 5924


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Pull the smallest relevant slice and the error type definitions.
grep -n "catch (err)" -n packages/ui/src/components/OfflineSync.tsx
sed -n '35,70p' packages/ui/src/components/OfflineSync.tsx | cat -n

printf '\n--- definitions ---\n'
rg -n "class NetworkError|interface NetworkError|type NetworkError|new NetworkError|OFFLINE_SYNCED_EVENT" packages/ui/src -S

Repository: ali-ahnaf/pocket_pixel

Length of output: 2917


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- ApiClient error handling ---\n'
sed -n '1,180p' packages/ui/src/lib/api/ApiClient.ts | cat -n

printf '\n--- page sync consumer ---\n'
sed -n '180,255p' packages/ui/src/app/page.tsx | cat -n

printf '\n--- debts sync consumer ---\n'
sed -n '1,180p' packages/ui/src/app/debts/page.tsx | cat -n

Repository: ali-ahnaf/pocket_pixel

Length of output: 251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- ApiClient response mapping ---\n'
sed -n '80,130p' packages/ui/src/lib/api/ApiClient.ts | cat -n

printf '\n--- page sync consumer ---\n'
sed -n '200,250p' packages/ui/src/app/page.tsx | cat -n

printf '\n--- debts sync consumer ---\n'
sed -n '70,140p' packages/ui/src/app/debts/page.tsx | cat -n

Repository: ali-ahnaf/pocket_pixel

Length of output: 251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ApiClient response mapping ---'
sed -n '1,180p' packages/ui/src/lib/api/ApiClient.ts | cat -n

printf '%s\n' ''
printf '%s\n' '--- page sync consumer ---'
sed -n '190,250p' packages/ui/src/app/page.tsx | cat -n

printf '%s\n' ''
printf '%s\n' '--- debts sync consumer ---'
sed -n '70,140p' packages/ui/src/app/debts/page.tsx | cat -n

Repository: ali-ahnaf/pocket_pixel

Length of output: 12998


Dispatch OFFLINE_SYNCED_EVENT after terminal removals too. If every queued entry is rejected, synced stays 0 and the refetch event never fires, so the placeholder rows in page.tsx and debts/page.tsx can stay stale until something else triggers a reload.

🤖 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 `@packages/ui/src/components/OfflineSync.tsx` around lines 42 - 57, Update the
sync flow in OfflineSync so OFFLINE_SYNCED_EVENT is dispatched when entries are
successfully synchronized or terminally removed. Track whether any queue entry
was removed in the non-NetworkError path and use that alongside synced when
deciding to dispatch the event, while preserving the existing retry and ordering
behavior.

Comment on lines +100 to +109
// No response at all means the request never reached the server. For the
// few allow-listed reference GETs, serve the last known payload so the
// offline-capable forms can still be filled in. A real 4xx/5xx keeps
// throwing, so genuine server failures are never masked by stale data.
if (!error.response) {
if (cacheableGet) {
const cached = readCache<T>(cacheKey);
if (cached !== null) return cached;
}
throw new NetworkError(error.message ?? 'Network request failed', error);

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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

For Axios 1.16.0, can a timeout or connection reset after a server receives an HTTP request reject without error.response?

💡 Result:

In Axios 1.16.0, a timeout or connection reset generally results in an error where error.response is undefined [1][2]. Axios populates the error.response object only when the server has successfully returned an HTTP response (e.g., 4xx or 5xx status codes) [1][3]. When a request fails due to a timeout, network interruption, or connection reset before a response is received, Axios rejects the promise with an error (typically code ECONNABORTED for timeouts) that does not contain a response object [4][1][2]. However, there is a specific exception introduced and noted in version 1.16.0 regarding partial responses. Axios now preserves the partial response object on the AxiosError if a stream is aborted after the HTTP headers have already been received [5][6]. In this narrow scenario, error.response may exist even if the request was otherwise aborted or timed out, allowing access to headers or status information that arrived before the failure [5][6]. Except for this specific case of "headers-received" abortion, you should assume error.response is undefined for timeouts and connection resets, and your error-handling logic should check for the existence of error.response before attempting to access its properties [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
rg -n -C3 '\b(clientRequestId|createTransaction|createDebt|enqueue)\b' \
  packages/ui/src/lib/api/ProfileApi.ts \
  packages/ui/src/lib/offline/outbox.ts \
  packages/ui/src/components/LogResourceModal.tsx \
  packages/ui/src/app/debts/page.tsx

Repository: ali-ahnaf/pocket_pixel

Length of output: 7439


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== ProfileApi createTransaction / createDebt ==\n'
sed -n '160,250p' packages/ui/src/lib/api/ProfileApi.ts

printf '\n== outbox queue / replay ==\n'
sed -n '1,220p' packages/ui/src/lib/offline/outbox.ts

printf '\n== LogResourceModal transaction payload and catch branch ==\n'
sed -n '240,330p' packages/ui/src/components/LogResourceModal.tsx

printf '\n== shared transaction input DTOs ==\n'
rg -n -C2 'CreateTransactionInput|clientRequestId|CreateDebtInput' packages/shared packages/ui/src -g '!**/node_modules/**'

Repository: ali-ahnaf/pocket_pixel

Length of output: 19832


Keep a stable clientRequestId on the first transaction attempt. enqueue() creates a new id only after NetworkError, so a request that reaches the server but loses its response can be retried with a different id and create a duplicate row. Attach and persist the id before the initial POST, then reuse it for replay.

🤖 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 `@packages/ui/src/lib/api/ApiClient.ts` around lines 100 - 109, Update the
enqueue() transaction flow to generate and persist a stable clientRequestId
before the initial POST, including when the request reaches the server but loses
its response. Ensure the NetworkError retry/replay path reuses that persisted id
instead of creating a new one, preventing duplicate rows.

Comment on lines +49 to +50
try {
window.localStorage.setItem(OUTBOX_STORAGE_KEY, JSON.stringify(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.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Avoid persisting financial payloads in plaintext.

Queued debt titles, amounts, notes, dates, and transaction details remain in origin-wide localStorage after the browser session. Use the existing session DEK or equivalent protected persistence before writing this expanded financial dataset.

🤖 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 `@packages/ui/src/lib/offline/outbox.ts` around lines 49 - 50, Update the
persistence flow surrounding the localStorage write in the outbox module to
protect queued financial entries using the existing session DEK or equivalent
encrypted persistence before storage. Ensure debt titles, amounts, notes, dates,
and transaction details are never written as plaintext under OUTBOX_STORAGE_KEY,
while preserving the existing outbox queue behavior and error handling.

Comment on lines +49 to +76
try {
window.localStorage.setItem(OUTBOX_STORAGE_KEY, JSON.stringify(entries));
} catch {
// Out of quota: the write is lost, but throwing here would break the form
// the user just submitted. The queue stays consistent either way.
}
window.dispatchEvent(new CustomEvent(OUTBOX_CHANGED_EVENT));
}

function newId(): string {
// randomUUID needs a secure context; fall back so a plain-HTTP dev host still works.
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const random = (Math.random() * 16) | 0;
const value = char === 'x' ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}

/**
* Append an entry to the queue. The generated id doubles as the payload's
* `clientRequestId`, which is what makes the replay idempotent server-side.
*/
export function enqueue(entry: OutboxEntryInput): OutboxEntry {
const id = newId();
const queued = { ...entry, id, queuedAt: new Date().toISOString(), payload: { ...entry.payload, clientRequestId: id } } as OutboxEntry;
write([...read(), queued]);
return queued;

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 | 🟠 Major | 🏗️ Heavy lift

Do not acknowledge an offline save when persistence failed.

write() swallows setItem failures, while enqueue() still returns an entry and callers close the form as saved. Quota or storage-policy failures therefore silently lose the write. Return/throw a storage failure from enqueue() and keep the form open with an actionable error; add coverage for this path.

🤖 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 `@packages/ui/src/lib/offline/outbox.ts` around lines 49 - 76, The enqueue flow
must not report success when localStorage persistence fails. Update write and
enqueue so setItem errors propagate as a storage failure, allowing callers to
keep the form open and show an actionable error; preserve successful queueing
behavior and add coverage for the persistence-failure path.

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.

Offline support: sync endpoint and conflict resolution (Backend) Offline support: service worker, local caching, and sync on reconnect (Frontend)

1 participant