Skip to content

fix(expenses): purge suppressed rows already mirrored in D1 - #1608

Merged
jaywedgeworth22 merged 1 commit into
mainfrom
fix/expenses-suppression-purge
Oct 6, 2026
Merged

jaywedgeworth22 merged 1 commit into
mainfrom
fix/expenses-suppression-purge

Conversation

@jaywedgeworth22

@jaywedgeworth22 jaywedgeworth22 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Purges suppressed expense rows from the D1 mirror during sync, and simplifies the upstream dedupe flow.

Changes

Suppression purge

  • Before fetching upstream pages, syncExpenses now issues a single DELETE FROM expenses WHERE idempotency_key IN (SELECT idempotency_key FROM suppressed_expenses) to remove any rows already mirrored in D1 that have since been suppressed. Previously, the suppression filter only prevented re-upserts, so rows written before being suppressed could remain in the mirror.

Dedupe refactor

  • dedupeUpstreamRows now returns only the array of kept rows instead of { kept, dropped }. The per-loser DELETE FROM expenses WHERE idempotency_key = ? loop that ran for each deduped row has been removed.
  • dedupedCount is now computed as visible.length - unique.length rather than accumulated from the dropped list.
  • The fingerprint now uses the calendar day (occurredAt truncated to 10 characters) instead of the full timestamp, and no longer joins fields with NUL separators.

Tests

  • dedupeUpstreamRows tests updated for the new return shape (array instead of object).
  • The fingerprint test was updated to match the vendor/amount/day/label fingerprint.
  • Added a test asserting that the suppression purge issues a DELETE FROM expenses ... suppressed_expenses statement and that suppressed upstream rows are skipped.
  • A test fixture line in sync.test.mjs references the production hostname usage.jays.services (reported by review).

Review findings (recorded, not resolved here)

The automated review reported 7 findings:

  • critical (×3): the test fixture at sync.test.mjs:146 embeds a production hostname/endpoint in public source; recommended to use a synthetic origin.
  • high (×3): sync.mjs:180 maps externally supplied expense objects toward persistence without Zod validation; sync.mjs:178 no longer deletes dedupe-loser rows, leaving previously mirrored double-posts in D1 permanently; sync.mjs:32 day-granularity fingerprints can collapse distinct same-day charges and drop a real expense from the mirror.
  • medium: task description insufficient for business-logic validation.

Notes

  • Behavior change to be aware of: dedupe losers are no longer deleted from D1 by this path, and the fingerprint is now day-level rather than timestamp-level.

@jaywedgeworth22
jaywedgeworth22 enabled auto-merge (squash) October 6, 2026 05:34
@kody-ai

kody-ai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ✅

Access your configuration settings here.

​

@jaywedgeworth22
jaywedgeworth22 merged commit 4c252ac into main Oct 6, 2026
11 checks passed
@jaywedgeworth22
jaywedgeworth22 deleted the fix/expenses-suppression-purge branch October 6, 2026 05:38
@kody-ai

kody-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown

kody code-review Business Logic medium

🤔 Insufficient Task Context

I found a task linked to this PR, but it only contains minimal information (title only, no description or acceptance criteria). To perform a meaningful business rules validation, I need more details.

🔍 What I need to validate:

  • Business requirements and acceptance criteria
  • Expected behavior and business rules
  • Edge cases and constraints to consider

💡 How to improve the task context:

  • Add a description to the linked ticket
  • Include acceptance criteria or business rules
  • Describe the expected behavior after the change

⚠️ Important:

A task title alone is not sufficient to determine whether the implementation is correct or complete.


💡 This validation runs automatically only on the first review of a pull request. To run it again, comment @kody -v business-logic.

vi.stubGlobal("fetch", fetchMock);
try {
const result = await syncExpenses({
UPSTREAM_URL: "https://usage.jays.services",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules critical

Production request risk in workers/expenses-site/src/__tests__/sync.test.mjs: the unit test configures UPSTREAM_URL as https://usage.jays.services despite mocking fetch. Use the reserved synthetic origin https://expenses-upstream.test so the suite cannot target production.

Kody rule violation: No secrets, tokens, or production credentials in Playwright specs or fixtures

Prompt for LLM

File workers/expenses-site/src/__tests__/sync.test.mjs:

Line 146:

Production request risk in `workers/expenses-site/src/__tests__/sync.test.mjs`: the unit test configures `UPSTREAM_URL` as `https://usage.jays.services` despite mocking `fetch`. Use the reserved synthetic origin `https://expenses-upstream.test` so the suite cannot target production.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

vi.stubGlobal("fetch", fetchMock);
try {
const result = await syncExpenses({
UPSTREAM_URL: "https://usage.jays.services",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules critical

Private production hostname exposure in workers/expenses-site/src/__tests__/sync.test.mjs: the new test configuration embeds https://usage.jays.services in UPSTREAM_URL. Replace it with the synthetic reserved domain https://expenses-upstream.test and retain the real hostname only in private operations records.

Kody rule violation: Keep credentials out of public source and verify UI changes with automated screenshots

Prompt for LLM

File workers/expenses-site/src/__tests__/sync.test.mjs:

Line 146:

Private production hostname exposure in `workers/expenses-site/src/__tests__/sync.test.mjs`: the new test configuration embeds `https://usage.jays.services` in `UPSTREAM_URL`. Replace it with the synthetic reserved domain `https://expenses-upstream.test` and retain the real hostname only in private operations records.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

vi.stubGlobal("fetch", fetchMock);
try {
const result = await syncExpenses({
UPSTREAM_URL: "https://usage.jays.services",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules critical

Private production API endpoint exposure in workers/expenses-site/src/__tests__/sync.test.mjs: the added source line exposes https://usage.jays.services as UPSTREAM_URL even though the test makes no real request. Use the reserved synthetic origin https://expenses-upstream.test to keep the endpoint non-production.

Kody rule violation: Never edit in the owner's integration tree — work only in your agent lane worktree

Prompt for LLM

File workers/expenses-site/src/__tests__/sync.test.mjs:

Line 146:

Private production API endpoint exposure in `workers/expenses-site/src/__tests__/sync.test.mjs`: the added source line exposes `https://usage.jays.services` as `UPSTREAM_URL` even though the test makes no real request. Use the reserved synthetic origin `https://expenses-upstream.test` to keep the endpoint non-production.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

}
const rows = unique.kept.map(mapUpstreamExpense);
dedupedCount += visible.length - unique.length;
const rows = unique.map(mapUpstreamExpense);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

Trust-boundary validation gap in workers/expenses-site/src/sync.mjs: response.json() data reaches fingerprinting, filtering, and mapping without a Zod check before objects derived from it are sent toward D1. Define a strict expense-response schema, call safeParse immediately after the upstream fetch, reject malformed payloads, and derive expenses, visible, unique, and rows exclusively from parsed.data.

Kody rule violation: Validate every untrusted input with a zod schema at the trust boundary

Prompt for LLM

File workers/expenses-site/src/sync.mjs:

Line 180:

Trust-boundary validation gap in `workers/expenses-site/src/sync.mjs`: `response.json()` data reaches fingerprinting, filtering, and mapping without a Zod check before objects derived from it are sent toward D1. Define a strict expense-response schema, call `safeParse` immediately after the upstream fetch, reject malformed payloads, and derive `expenses`, `visible`, `unique`, and `rows` exclusively from `parsed.data`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Comment on lines 178 to +180
const unique = dedupeUpstreamRows(visible);
dedupedCount += unique.dropped.length;
// Remove the losers from D1 too: a double-post mirrored before this
// deploy would otherwise keep rendering next to the winner.
for (const loser of unique.dropped) {
await env.EXPENSES_DB.prepare(
"DELETE FROM expenses WHERE idempotency_key = ?"
)
.bind(loser.idempotencyKey)
.run();
}
const rows = unique.kept.map(mapUpstreamExpense);
dedupedCount += visible.length - unique.length;
const rows = unique.map(mapUpstreamExpense);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Bug high

Dedupe persistence regression in workers/expenses-site/src/sync.mjs: removing the per-loser DELETE FROM expenses WHERE idempotency_key = ? loop and the dropped list from dedupeUpstreamRows leaves losers already mirrored in D1 undeleted; day-granularity fingerprints also create fresh losers when pairs distinct under the old full-timestamp fingerprint collapse. Restore the returned losers and delete each as before, because the suppression purge at sync.mjs:159-161 is now the only DELETE FROM against expenses across workers/, and the dashboard's unsuppressed expenses query at index.mjs:33-37 otherwise retains double-post pairs from earlier cron runs indefinitely, extending the suppressed-key failure to deduped keys.

const { rows: unique, losers } = dedupeUpstreamRows(visible);
dedupedCount += losers.length;
// Remove the losers from D1 too: a double-post mirrored before this
// deploy would otherwise keep rendering next to the winner.
for (const loser of losers) {
  await env.EXPENSES_DB.prepare(
    "DELETE FROM expenses WHERE idempotency_key = ?"
  )
    .bind(loser.idempotencyKey)
    .run();
}
const rows = unique.map(mapUpstreamExpense);
Prompt for LLM

File workers/expenses-site/src/sync.mjs:

Line 178 to 180:

Dedupe persistence regression in `workers/expenses-site/src/sync.mjs`: removing the per-loser `DELETE FROM expenses WHERE idempotency_key = ?` loop and the `dropped` list from `dedupeUpstreamRows` leaves losers already mirrored in D1 undeleted; day-granularity fingerprints also create fresh losers when pairs distinct under the old full-timestamp fingerprint collapse. Restore the returned losers and delete each as before, because the suppression purge at `sync.mjs:159-161` is now the only `DELETE FROM` against `expenses` across `workers/`, and the dashboard's unsuppressed `expenses` query at `index.mjs:33-37` otherwise retains double-post pairs from earlier cron runs indefinitely, extending the suppressed-key failure to deduped keys.

Suggested Code:

      const { rows: unique, losers } = dedupeUpstreamRows(visible);
      dedupedCount += losers.length;
      // Remove the losers from D1 too: a double-post mirrored before this
      // deploy would otherwise keep rendering next to the winner.
      for (const loser of losers) {
        await env.EXPENSES_DB.prepare(
          "DELETE FROM expenses WHERE idempotency_key = ?"
        )
          .bind(loser.idempotencyKey)
          .run();
      }
      const rows = unique.map(mapUpstreamExpense);

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Comment on lines 32 to 39
export function expenseFingerprint(expense) {
const day = String(expense.occurredAt ?? "").slice(0, 10);
return [
String(expense.vendor ?? "").toLowerCase(),
Number(expense.amountUsd).toFixed(2),
String(expense.occurredAt ?? ""),
day,
String(expense.label ?? "").toLowerCase(),
].join("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Bug high

Deduplication collision in expenseFingerprint: slice(0, 10) makes distinct same-day charges with the same vendor, amount, and label—including identical vendor top-ups or metered invoices—collapse in dedupeUpstreamRows, silently omitting one from a fresh D1 mirror and under-reporting totalUsd/totalCount, or double-counting it when a loser lingers in an existing D1. Keep the full timestamp in the fingerprint (or add the time component back) so only identical postings collapse, and restore the deleted otherTime case in sync.test.mjs, which has no replacement.

const stamp = String(expense.occurredAt ?? "");
return [
  String(expense.vendor ?? "").toLowerCase(),
  Number(expense.amountUsd).toFixed(2),
  stamp,
  String(expense.label ?? "").toLowerCase(),
].join("\u0000");
Prompt for LLM

File workers/expenses-site/src/sync.mjs:

Line 32 to 39:

Deduplication collision in `expenseFingerprint`: `slice(0, 10)` makes distinct same-day charges with the same vendor, amount, and label—including identical vendor top-ups or metered invoices—collapse in `dedupeUpstreamRows`, silently omitting one from a fresh D1 mirror and under-reporting `totalUsd`/`totalCount`, or double-counting it when a loser lingers in an existing D1. Keep the full timestamp in the fingerprint (or add the time component back) so only identical postings collapse, and restore the deleted `otherTime` case in `sync.test.mjs`, which has no replacement.

Suggested Code:

  const stamp = String(expense.occurredAt ?? "");
  return [
    String(expense.vendor ?? "").toLowerCase(),
    Number(expense.amountUsd).toFixed(2),
    stamp,
    String(expense.label ?? "").toLowerCase(),
  ].join("\u0000");

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant