Skip to content

fix(scale): single-flight the weather cache, index the crew outbox dr… - #601

Merged
smj1860 merged 1 commit into
mainfrom
claude/hostile-code-audit-rbz3f8
Aug 9, 2026
Merged

fix(scale): single-flight the weather cache, index the crew outbox dr…#601
smj1860 merged 1 commit into
mainfrom
claude/hostile-code-audit-rbz3f8

Conversation

@smj1860

@smj1860 smj1860 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

…ain, and close two write races

Weather cache stampede (confirmed). getWeatherForLocation was plain cache-aside, read by two PUBLIC guest pages as well as both nudge crons — so concurrent misses on one key are the normal case, not an edge one: the TTL expires at a fixed point for every guest at a property. N misses were N calls to Tomorrow.io, N times the rate-limit consumption, and N x WEATHER_TIMEOUT_MS of blocked render/step time exactly when the provider is struggling.

The lock is now lib/cache/single-flight.ts rather than inline: two token-refresh paths already had this open-coded and this would have been the third copy. refresh-lock.ts delegates to the same SETNX, so there is one implementation to reason about. Fails open — no Redis means produce, because failing closed turns a cache outage into a total one.

Crew outbox drain (confirmed, and the obvious fix is a trap). The drain materialised EVERY row — live and dead-lettered — then filtered in JS, on each enqueue, each reconnect and the 30-second tick; dead letters live for 30 days, so a device with a bad month re-loaded a growing table ~2,880 times a day.

Swapping to .where('failed').equals(0) alone would have BROKEN THE OUTBOX SILENTLY. enqueueMutationTx only ever wrote failed on the held-back branch, and IndexedDB omits a record from an index when the indexed property is undefined — so every ordinary queued mutation would have become invisible to the drain. Nothing would sync and nothing would say why. failed: 0 is now written on every row and schema v12 backfills the ones already queued; the fake Dexie double models the strict-equality index semantics so the hazard cannot come back untested.

Two write races:

  • dismissSuggestion had no compare-and-swap where acceptSuggestion has one. A dismiss landing after an accept overwrote suggestion_status unconditionally: the accepted assignment stayed while the suggestion read as dismissed, neither caller saw an error, and the negative training signal was recorded against a suggestion that had been accepted. The precondition is suggestion_status = 'pending' — NOT the SUGGESTION_ACCEPTABLE_STATUSES list, which holds turnover_status values; matching one against the other filters every row out and breaks dismissal entirely. trackAssignmentAgainstSuggestions' override write had the same read-then- write shape and gets the same guard.
  • The purchase-order-from-count insert now treats 23505 as the constraint working rather than a fault. Deliberately NOT "re-read and return the winner's id": this file's own pre-check is emphatic that a header row alone is not enough, so the race path lands back in the same repair logic. A PO whose items insert died is a restock order listing nothing, permanently.

CPA export: severity does not hold as written. The trigger given was rows "approaching the 200k fetchAllRows default" — an export is one row per active asset per tax year, production has 160 active assets across 27 properties, and CLAUDE.md's target user runs 10-50 properties. Reaching 200k needs ~33,000 properties in one org. The synchronous-CPU mechanism is real, so the route now bounds itself with a head-only count and returns a clear 413 instead of being killed mid-serialisation. Moving generation onto Inngest + Storage + polling is a real option but it is a feature change on a premise off by three orders of magnitude, so it is offered rather than built.

3473 tests / 345 files green. Lint 187/189, semgrep chokepoints exit 0, no ratchet count increased. All five canaried by reverting each individually.

Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR

Summary by CodeRabbit

  • New Features

    • Added protection against duplicate weather requests during concurrent lookups, improving reliability and reducing unnecessary provider calls.
    • Added safeguards for concurrent purchase-order creation and assignment suggestion updates.
  • Bug Fixes

    • Prevented outdated assignment actions from overriding accepted or resolved suggestions.
    • Improved offline synchronization by reliably processing pending changes while excluding failed items.
  • Performance

    • Added a 20,000-entry limit for CPA exports, with a clear error when the limit is exceeded.
    • Improved caching behavior during simultaneous requests.

…ain, and close two write races

Weather cache stampede (confirmed). getWeatherForLocation was plain
cache-aside, read by two PUBLIC guest pages as well as both nudge crons — so
concurrent misses on one key are the normal case, not an edge one: the TTL
expires at a fixed point for every guest at a property. N misses were N calls
to Tomorrow.io, N times the rate-limit consumption, and N x WEATHER_TIMEOUT_MS
of blocked render/step time exactly when the provider is struggling.

The lock is now lib/cache/single-flight.ts rather than inline: two token-refresh
paths already had this open-coded and this would have been the third copy.
refresh-lock.ts delegates to the same SETNX, so there is one implementation to
reason about. Fails open — no Redis means produce, because failing closed turns
a cache outage into a total one.

Crew outbox drain (confirmed, and the obvious fix is a trap). The drain
materialised EVERY row — live and dead-lettered — then filtered in JS, on each
enqueue, each reconnect and the 30-second tick; dead letters live for 30 days,
so a device with a bad month re-loaded a growing table ~2,880 times a day.

Swapping to `.where('failed').equals(0)` alone would have BROKEN THE OUTBOX
SILENTLY. enqueueMutationTx only ever wrote `failed` on the held-back branch,
and IndexedDB omits a record from an index when the indexed property is
undefined — so every ordinary queued mutation would have become invisible to
the drain. Nothing would sync and nothing would say why. `failed: 0` is now
written on every row and schema v12 backfills the ones already queued; the fake
Dexie double models the strict-equality index semantics so the hazard cannot
come back untested.

Two write races:
  - dismissSuggestion had no compare-and-swap where acceptSuggestion has one.
    A dismiss landing after an accept overwrote suggestion_status
    unconditionally: the accepted assignment stayed while the suggestion read
    as dismissed, neither caller saw an error, and the negative training signal
    was recorded against a suggestion that had been accepted. The precondition
    is `suggestion_status = 'pending'` — NOT the SUGGESTION_ACCEPTABLE_STATUSES
    list, which holds turnover_status values; matching one against the other
    filters every row out and breaks dismissal entirely.
    trackAssignmentAgainstSuggestions' override write had the same read-then-
    write shape and gets the same guard.
  - The purchase-order-from-count insert now treats 23505 as the constraint
    working rather than a fault. Deliberately NOT "re-read and return the
    winner's id": this file's own pre-check is emphatic that a header row alone
    is not enough, so the race path lands back in the same repair logic. A
    PO whose items insert died is a restock order listing nothing, permanently.

CPA export: severity does not hold as written. The trigger given was rows
"approaching the 200k fetchAllRows default" — an export is one row per active
asset per tax year, production has 160 active assets across 27 properties, and
CLAUDE.md's target user runs 10-50 properties. Reaching 200k needs ~33,000
properties in one org. The synchronous-CPU mechanism is real, so the route now
bounds itself with a head-only count and returns a clear 413 instead of being
killed mid-serialisation. Moving generation onto Inngest + Storage + polling is
a real option but it is a feature change on a premise off by three orders of
magnitude, so it is offered rather than built.

3473 tests / 345 files green. Lint 187/189, semgrep chokepoints exit 0, no
ratchet count increased. All five canaried by reverting each individually.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Zk7eKd7UeNCoWxHSVxAFR

@greptile-apps greptile-apps 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.

smj1860 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
fieldstay Ready Ready Preview Aug 9, 2026 3:28pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds concurrency guards for suggestions and purchase orders, limits CPA exports, introduces Redis single-flight caching, and changes Dexie outbox draining to use a normalized indexed failure flag. Tests cover the new race, limit, cache, and replay behavior.

Changes

Suggestion compare-and-swap

Layer / File(s) Summary
Pending suggestion guards
app/(dashboard)/turnovers/actions.ts, unit/turnovers/turnovers-actions.test.ts
Suggestion updates now require suggestion_status = 'pending'. Dismissals with no updated row do not record an outcome.

CPA export limit

Layer / File(s) Summary
Count guard and bounded retrieval
app/api/assets/cpa-export/route.ts, unit/route-handlers/assets-cpa-export.test.ts
The route performs an exact count before fetching rows, returns HTTP 413 above 20,000 entries, and applies the same limit to pagination.

Redis cache coordination

Layer / File(s) Summary
Single-flight lock and producer flow
lib/cache/single-flight.ts, unit/lib/single-flight.test.ts
The new utility coordinates cache misses with Redis locks, polling, fallback production, configurable timing, and fail-open behavior.
Weather and refresh-lock integration
lib/weather/tomorrow.ts, lib/integrations/refresh-lock.ts, unit/lib/weather-single-flight.test.ts, unit/lib/hospitable-token-lock.test.ts
Weather requests use single-flight coordination. Refresh locking delegates Redis operations to the shared helpers.

Indexed Dexie outbox draining

Layer / File(s) Summary
Failed-flag migration and indexed drain
lib/dexie/schema.ts, lib/dexie/syncService.ts, unit/dexie/fake-dexie.ts
Dexie version 12 normalizes failed values. New mutations set failed: 0, and draining queries the failed index in ID order.
Outbox fixture and ordering coverage
unit/dexie/*
Tests initialize indexed failure flags, exclude dead letters, and verify insertion-order replay.

Purchase-order creation race handling

Layer / File(s) Summary
Unique-conflict recovery and tests
lib/inngest/functions/inventory-events.ts, unit/inngest/inventory-events-po.test.ts
23505 conflicts trigger a re-read. Complete orders return successfully, empty headers receive items, and unverifiable races throw for retry.

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

Sequence Diagram(s)

sequenceDiagram
  participant WeatherCaller
  participant singleFlight
  participant Redis
  participant TomorrowIO
  WeatherCaller->>singleFlight: read rounded-location cache
  singleFlight->>Redis: acquire NX lock
  singleFlight->>TomorrowIO: fetch on lock winner
  TomorrowIO-->>singleFlight: weather data
  singleFlight->>Redis: cache data and release lock
  singleFlight-->>WeatherCaller: return weather data
Loading

Possibly related PRs

  • smj1860/fieldstay#555: Both changes modify Dexie outbox draining, failed mutations, and replay ordering.
  • smj1860/fieldstay#557: Both changes normalize and index numeric failed flags for outbox draining.
  • smj1860/fieldstay#600: This change introduces shared Redis locking that the other PR uses for token synchronization.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% 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 identifies the weather single-flight change and outbox indexing change, which are major parts of the pull request.
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 claude/hostile-code-audit-rbz3f8

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.

@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (1)
lib/dexie/schema.ts (1)

433-439: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a v11-to-v12 migration test.

This upgrade normalizes absent failed fields on legacy queued rows. Add a migration regression test that seeds version-11 mutations and pending_photo_uploads rows without failed, upgrades to version 12, and asserts failed: 0 for both outbox tables.

🤖 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/dexie/schema.ts` around lines 433 - 439, The version(12) upgrade
migration lacks regression coverage. Add a migration test that creates a
version-11 database, seeds `mutations` and `pending_photo_uploads` rows without
`failed`, upgrades through the schema to version 12, and asserts both rows
contain `failed: 0` after migration.

Source: Coding guidelines

🤖 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/`(dashboard)/turnovers/actions.ts:
- Around line 74-77: Update the compare-and-swap update in the turnover outcome
flow to return the IDs of rows it actually changed, then filter the subsequent
priorSuggestionRows construction to those returned IDs instead of the stale
overridden list. Preserve the existing upsert behavior for matched rows and add
a race test covering a concurrent accept or dismiss where the update returns no
rows, ensuring no incorrect was_accepted: false record is written.

In `@lib/cache/single-flight.ts`:
- Line 55: Replace the key-bearing warning in the single-flight lock-unavailable
path with a non-sensitive lock category, ensuring the log never includes key or
userId values. Update the related Hospitable test assertion to expect the
category-based warning instead of the lock key.
- Around line 114-121: Update the lock-loser wait path in the single-flight
method to retry acquireLock() after each opts.read() while the lock remains
live, and do not call opts.produce() until the lock has expired or no valid
holder remains. Preserve returning settled values immediately, and update the
fallback test to model a slow winner whose lock remains valid.
- Line 1: Add the side-effect-only server-only import before existing imports in
lib/cache/single-flight.ts (line 1), lib/integrations/refresh-lock.ts (line 1),
and lib/weather/tomorrow.ts (line 3), ensuring all modules that access or
transitively use getRedisIfConfigured are explicitly marked server-only.
- Around line 45-70: Update acquireLock and releaseLock to use ownership leases:
generate and store a unique token with SET NX EX, return that token (or an
equivalent lease) only when the lock is acquired, and avoid granting a
releasable lease when Redis is unavailable. Change releaseLock to accept the
lease and use `@upstash/redis` eval with a compare-and-delete script so the key is
deleted only when its stored token matches; preserve the existing non-throwing
behavior.

In `@lib/inngest/functions/inventory-events.ts`:
- Around line 367-392: Make the 23505 recovery in the purchase-order creation
flow single-writer: do not treat an empty purchase_order_items list as proof
that no writer is active. Update the logic around the race re-read,
insertPoItems, and markPoSent to atomically claim completion or use a
conflict-safe database operation that creates the header and items together,
preserving idempotency and preventing the losing invocation from duplicating the
winner’s items.

In `@unit/inngest/inventory-events-po.test.ts`:
- Around line 206-226: The success-path tests around
handleInventoryCountSubmitted must not swallow handler failures with catch(() =>
{}). Stub the later email/downstream steps that are outside the test scope, then
await the handler directly and assert the purchase-order creation flow resolves
successfully, including the 23505 race handling and the insertPoItems/markPoSent
path.

In `@unit/turnovers/turnovers-actions.test.ts`:
- Around line 1013-1016: The dismissSuggestion test should verify the exact
NOTHING_UPDATED conflict response instead of only asserting that success is
absent. Update the assertion on result after dismissSuggestion('t_1') to match
the expected error state and preserve the assertion that assignment_outcomes is
not accessed.

---

Nitpick comments:
In `@lib/dexie/schema.ts`:
- Around line 433-439: The version(12) upgrade migration lacks regression
coverage. Add a migration test that creates a version-11 database, seeds
`mutations` and `pending_photo_uploads` rows without `failed`, upgrades through
the schema to version 12, and asserts both rows contain `failed: 0` after
migration.
🪄 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: 6c3ddc28-0ffb-40be-91e4-dc7d2d4a538e

📥 Commits

Reviewing files that changed from the base of the PR and between dc39de3 and ec25172.

📒 Files selected for processing (22)
  • app/(dashboard)/turnovers/actions.ts
  • app/api/assets/cpa-export/route.ts
  • lib/cache/single-flight.ts
  • lib/dexie/schema.ts
  • lib/dexie/syncService.ts
  • lib/inngest/functions/inventory-events.ts
  • lib/integrations/refresh-lock.ts
  • lib/weather/tomorrow.ts
  • unit/dexie/asset-scan-request-durability.test.ts
  • unit/dexie/fake-dexie.ts
  • unit/dexie/offline-write-durability.test.ts
  • unit/dexie/outbox-drain-scheduling.test.ts
  • unit/dexie/sync-outbox-backoff.test.ts
  • unit/dexie/sync-outbox-durability.test.ts
  • unit/dexie/sync-outbox-ordering.test.ts
  • unit/dexie/sync-shadow-and-prune.test.ts
  • unit/inngest/inventory-events-po.test.ts
  • unit/lib/hospitable-token-lock.test.ts
  • unit/lib/single-flight.test.ts
  • unit/lib/weather-single-flight.test.ts
  • unit/route-handlers/assets-cpa-export.test.ts
  • unit/turnovers/turnovers-actions.test.ts

Comment on lines 74 to +77
const { error: overrideError } = await service.from('turnovers')
.update({ suggestion_status: 'overridden' })
.eq('org_id', orgId)
.eq('suggestion_status', 'pending')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use only rows changed by the compare-and-swap for outcome writes.

The update can match zero rows after a concurrent accept or dismiss. priorSuggestionRows still uses the stale overridden list. The subsequent upsert can then record was_accepted: false for a suggestion that was accepted.

Select the updated IDs. Build priorSuggestionRows only from turnovers whose IDs were returned. Add a race test for this path.

Proposed fix
-      const { error: overrideError } = await service.from('turnovers')
+      const { data: overriddenRows, error: overrideError } = await service.from('turnovers')
         .update({ suggestion_status: 'overridden' })
         .eq('org_id', orgId)
         .eq('suggestion_status', 'pending')
         .in('id', overridden.map(t => t.id))
+        .select('id')
       if (overrideError) {
         console.error('[trackAssignmentAgainstSuggestions] override update failed', overrideError)
         reportError(overrideError, { site: 'serverAction.turnovers.trackAssignmentAgainstSuggestions.override', orgId })
       }
 
-      const priorSuggestionRows = overridden.flatMap(t =>
+      const overriddenIds = new Set(overriddenRows?.map(row => row.id))
+      const priorSuggestionRows = overridden
+        .filter(t => overriddenIds.has(t.id))
+        .flatMap(t =>
         (t.suggested_crew_ids ?? []).map(suggestedCrewId => ({
           turnover_id:      t.id,
           org_id:           orgId,
           crew_member_id:   suggestedCrewId,
           was_accepted:     false,
           override_reason:  `${crewName} assigned instead of the suggestion`,
         }))
-      )
+        )

As per coding guidelines, use atomic database constraints or conditional updates for race-prone load-then-write operations, and provide cleanup or rollback for partial multi-step writes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { error: overrideError } = await service.from('turnovers')
.update({ suggestion_status: 'overridden' })
.eq('org_id', orgId)
.eq('suggestion_status', 'pending')
const { data: overriddenRows, error: overrideError } = await service.from('turnovers')
.update({ suggestion_status: 'overridden' })
.eq('org_id', orgId)
.eq('suggestion_status', 'pending')
.in('id', overridden.map(t => t.id))
.select('id')
if (overrideError) {
console.error('[trackAssignmentAgainstSuggestions] override update failed', overrideError)
reportError(overrideError, { site: 'serverAction.turnovers.trackAssignmentAgainstSuggestions.override', orgId })
}
const overriddenIds = new Set(overriddenRows?.map(row => row.id))
const priorSuggestionRows = overridden
.filter(t => overriddenIds.has(t.id))
.flatMap(t =>
(t.suggested_crew_ids ?? []).map(suggestedCrewId => ({
turnover_id: t.id,
org_id: orgId,
crew_member_id: suggestedCrewId,
was_accepted: false,
override_reason: `${crewName} assigned instead of the suggestion`,
}))
)
🤖 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/`(dashboard)/turnovers/actions.ts around lines 74 - 77, Update the
compare-and-swap update in the turnover outcome flow to return the IDs of rows
it actually changed, then filter the subsequent priorSuggestionRows construction
to those returned IDs instead of the stale overridden list. Preserve the
existing upsert behavior for matched rows and add a race test covering a
concurrent accept or dismiss where the update returns no rows, ensuring no
incorrect was_accepted: false record is written.

Source: Coding guidelines

@@ -0,0 +1,135 @@
import { getRedisIfConfigured } from '@/lib/redis'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
for f in lib/cache/single-flight.ts lib/integrations/refresh-lock.ts lib/weather/tomorrow.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ($(wc -l < "$f") lines) ---"
    sed -n '1,120p' "$f"
  else
    echo "MISSING: $f"
  fi
done

echo "== server-only usage in candidates =="
rg -n "import 'server-only'|refreshLock|singleFlight|getRedisIfConfigured|acquireLock|releaseLock|secret|tomorrow" lib/cache/single-flight.ts lib/integrations/refresh-lock.ts lib/weather/tomorrow.ts || true

echo "== ts config / client boundaries hints =="
fd -H '^(tsconfig.*\.json|package\.json)$' . -x sh -c 'echo "--- $1 ---"; sed -n "1,220p" "$1"' sh {}

Repository: smj1860/fieldstay

Length of output: 19105


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect exported usages from the three modules and imports into app/src packages,
# plus whether server-only marker appears near file top.
python3 - <<'PY'
import pathlib, re
files = [
    pathlib.Path('lib/cache/single-flight.ts'),
    pathlib.Path('lib/integrations/refresh-lock.ts'),
    pathlib.Path('lib/weather/tomorrow.ts'),
]
for p in files:
    text = p.read_text()
    imports = re.findall(r"^import\s+(['\"][^'\"]+['\"])", text, flags=re.M)
    marked = bool(re.search(r"^import\s+['\"]server-only['\"]", text, flags=re.M))
    print(f"{p}: marked={marked}, line_count={len(text.splitlines())}, first_imports={imports[:5]}")
    for i, line in enumerate(text.splitlines(), 1):
        if "import 'server-only'" in line:
            print(f"  server-only marker line {i}: {line.strip()}")
PY

echo "== references =="
rg -n "from ['\"]`@/lib/cache/single-flight`|from ['\"]`@/lib/integrations/refresh-lock`|from ['\"]`@/lib/weather/tomorrow`|singleFlight|refreshLock|getRedisIfConfigured|acquireLock|releaseLock" -g '!node_modules' .

Repository: smj1860/fieldstay

Length of output: 7387


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== lib/redis.ts server marker and relevant implementation =="
wc -l lib/redis.ts
sed -n '1,140p' lib/redis.ts

echo "== lib/weather/tomorrow.ts env/token/body excerpt =="
sed -n '120,229p' lib/weather/tomorrow.ts

echo "== server-only markers in related redis integrations =="
rg -n "import 'server-only'|getRedis\\(|getRedisIfConfigured\\(" lib/integrations lib/cache lib/weather lib -g '*.ts'

echo "== relevant exported imports into lib only =="
rg -n "from ['\"]`@/lib/`(cache/single-flight|integrations/refresh-lock)\"" lib -g '*.ts' || true

Repository: smj1860/fieldstay

Length of output: 11641


Mark the server-only modules explicitly.

lib/cache/single-flight.ts reads Upstash Redis config/client via getRedisIfConfigured(), and lib/integrations/refresh-lock.ts and lib/weather/tomorrow.ts transitively read the same server-only path. Add import 'server-only' before other imports in those modules.

📍 Affects 3 files
  • lib/cache/single-flight.ts#L1-L1 (this comment)
  • lib/integrations/refresh-lock.ts#L1-L1
  • lib/weather/tomorrow.ts#L3-L3
🤖 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/cache/single-flight.ts` at line 1, Add the side-effect-only server-only
import before existing imports in lib/cache/single-flight.ts (line 1),
lib/integrations/refresh-lock.ts (line 1), and lib/weather/tomorrow.ts (line 3),
ensuring all modules that access or transitively use getRedisIfConfigured are
explicitly marked server-only.

Source: Coding guidelines

Comment on lines +45 to +70
export async function acquireLock(
key: string,
ttlSeconds: number = DEFAULT_LOCK_TTL_SECONDS,
): Promise<boolean> {
const redis = getRedisIfConfigured()
if (!redis) return true

try {
return (await redis.set(key, '1', { nx: true, ex: ttlSeconds })) === 'OK'
} catch (err) {
console.warn(`[single-flight] lock unavailable for ${key}, proceeding unlocked:`, err)
return true
}
}

/** Release early so the next caller doesn't wait out the TTL. Never throws. */
export async function releaseLock(key: string): Promise<void> {
const redis = getRedisIfConfigured()
if (!redis) return

try {
await redis.del(key)
} catch {
// Non-fatal — the TTL expires it.
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)single-flight\.ts$|(^|/)package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$' || true

echo
echo "== single-flight outline =="
if [ -f lib/cache/single-flight.ts ]; then
  ast-grep outline lib/cache/single-flight.ts || true
  echo
  echo "== single-flight lines =="
  nl -ba lib/cache/single-flight.ts | sed -n '1,220p'
fi

echo
echo "== lock usages =="
rg -n "acquireLock|releaseLock|singleFlight|single-fl|getRedisIfConfigured|REDIS" -S . || true

Repository: smj1860/fieldstay

Length of output: 747


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== single-flight lines =="
awk '{printf "%6d\t%s\n",NR,$0}' lib/cache/single-flight.ts | sed -n '1,180p'

echo
echo "== package deps for redis =="
python3 - <<'PY'
import json
p=json.load(open('package.json'))
for sec in ['dependencies','devDependencies']:
    if sec in p:
        vals=p[sec]
        print(sec, {k:v for k,v in vals.items() if 'redis' in k.lower() or 'upstash' in k.lower() or k in ['`@vercel/kv`','redis']})
PY

echo
echo "== lock usages =="
grep -RInE "acquireLock|releaseLock|singleFlight|single-fl|getRedisIfConfigured|REDIS" . || true

echo
echo "== pnpm relevant redis/upstash entries =="
grep -nE "`@upstash/redis`|redis" pnpm-lock.yaml | sed -n '1,120p'

Repository: smj1860/fieldstay

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== single-flight file size =="
wc -l lib/cache/single-flight.ts

echo
echo "== single-flight lines 1-180 =="
python3 - <<'PY'
from pathlib import Path
p=Path('lib/cache/single-flight.ts')
lines=p.read_text().splitlines()
for i,l in enumerate(lines[:180],1):
    print(f"{i:6d}\t{l}")
PY

echo
echo "== package redis dependency =="
python3 - <<'PY'
import json
p=json.load(open('package.json'))
for sec in ['dependencies','devDependencies','peerDependencies']:
    v=p.get(sec,{})
    matches={k:vv for k,vv in v.items() if 'redis' in k.lower() or 'upstash' in k.lower() or k=='redis'}
    if matches:
        print(sec, matches)
PY

echo
echo "== lock usages (limited) =="
grep -RInE "acquireLock|releaseLock|singleFlight|single-fl|getRedisIfConfigured|REDIS" . || true

echo
echo "== lock-related file paths =="
git ls-files | grep -nE '(^|/)(cache|weather|openweather|single-flight|REDIS).*' || true

Repository: smj1860/fieldstay

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== single-flight actual lines 1-180 =="
python3 <<'PY'
from pathlib import Path
for i, line in enumerate(Path('lib/cache/single-flight.ts').read_text().splitlines()[:180], 1):
    print(f"{i:04d} | {line}")
PY

echo
echo "== relevant single-flight symbols =="
grep -n "acquireLock\\|releaseLock\\|singleFlight\\|DEFAULT_LOCK_TTL_SECONDS\\|crypto.randomUUID\\|return redis.set\\|await redis.del\\|eval\\|redis\\.eval\\|getRedisIfConfigured" lib/cache/single-flight.ts || true

echo
echo "== package redis dependency =="
python3 <<'PY'
import json
p=json.load(open('package.json'))
for sec in ['dependencies','devDependencies','peerDependencies']:
    v=p.get(sec,{})
    matches={k:vv for k,vv in v.items() if 'redis' in k.lower() or 'upstash' in k.lower() or k=='redis'}
    if matches:
        print(sec, matches)
PY

echo
echo "== lock usages =="
grep -RIn "acquireLock\\|releaseLock\\|singleFlight\\|single-fl\\|getRedisIfConfigured\\|REDIS" --exclude-dir=.git . || true

Repository: smj1860/fieldstay

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== single-flight first 220 lines, byte-range safe =="
awk '{printf "%04d | %s\n", NR, $0}' lib/cache/single-flight.ts | awk '{if(NR<=220) print}'

echo
echo "== single-flight target symbols =="
grep -n "acquireLock\\|releaseLock\\|singleFlight\\|DEFAULT_LOCK_TTL_SECONDS\\|DEFAULT_WAIT_MS\\|crypto.randomUUID\\|redis.set\\|redis.del\\|redis.eval\\|eval\\|getRedisIfConfigured" lib/cache/single-flight.ts || true

echo
echo "== package manifest redis-related deps =="
python3 <<'PY'
import json
p=json.load(open('package.json'))
for sec in ['dependencies','devDependencies','peerDependencies']:
    v=p.get(sec,{})
    matches={k:vv for k,vv in v.items() if 'redis' in k.lower() or 'upstash' in k.lower() or k=='redis'}
    if matches: print(sec, json.dumps(matches, sort_keys=True, indent=2))
PY

echo
echo "== lock-related file candidates =="
git ls-files | grep -nE 'single-flight|cache|weather|REDIS|redis' || true

Repository: smj1860/fieldstay

Length of output: 7644


🌐 Web query:

@upstash/redis TypeScript redis.eval command usage

💡 Result:

The @upstash/redis TypeScript SDK provides the redis.eval command to execute Lua scripts server-side in an atomic operation [1][2]. Basic Usage The command signature is redis.eval(script, keys, args), where the SDK automatically handles the numkeys argument required by standard Redis [2]. await redis.eval( "return ARGV[1]", // Lua script [], // Keys array (accessible as KEYS in Lua) ["Hello World"] // Arguments array (accessible as ARGV in Lua)); Type Safety You can provide a generic type parameter to specify the expected return type of the script [3]: const result = await redis.eval( "return tonumber(ARGV[1])", [], ["42"]); Optimized Execution (Script Class) For frequently used scripts, it is recommended to use the Script class, which automates EVALSHA caching to reduce bandwidth by only sending the script content when necessary [1][3][4]. const script = redis.createScript(local current = redis.call('GET', KEYS[1]) or 0 local next = current + tonumber(ARGV[1]) redis.call('SET', KEYS[1], next) return next); // exec optimistically tries EVALSHA, falls back to EVAL if needed const result = await script.exec(["counter"], ["1"]); Key Differences: - redis.eval: Sends the full script to the server every time [1][4]. - redis.createScript.exec: Attempts to use EVALSHA (via cached SHA-1 hash), falling back to EVAL only if the script is not yet cached on the server [3][4]. - Read-Only Scripts: For read-only operations, use { readOnly: true } in createScript or the ScriptRO class to utilize EVAL_RO/EVALSHA_RO, which can be executed on read replicas [1][4].

Citations:


Preserve lock ownership during release.

releaseLock() deletes by key only, so an expired owner can delete a newer owner’s valid lock. acquireLock() also returns true on Redis failure without a lease, which makes the same fail-over producer able to delete another caller’s lock after Redis recovers.

Store a unique token with SET NX EX, return that ownership lease from acquireLock(), and delete only when the stored token matches it in one atomic Redis script. Use @upstash/redis eval(script, keys, args) for this compare-and-delete operation.

🤖 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/cache/single-flight.ts` around lines 45 - 70, Update acquireLock and
releaseLock to use ownership leases: generate and store a unique token with SET
NX EX, return that token (or an equivalent lease) only when the lock is
acquired, and avoid granting a releasable lease when Redis is unavailable.
Change releaseLock to accept the lease and use `@upstash/redis` eval with a
compare-and-delete script so the key is deleted only when its stored token
matches; preserve the existing non-throwing behavior.

try {
return (await redis.set(key, '1', { nx: true, ex: ttlSeconds })) === 'OK'
} catch (err) {
console.warn(`[single-flight] lock unavailable for ${key}, proceeding unlocked:`, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log lock keys.

key can contain userId through lib/integrations/refresh-lock.ts Line 41. This warning logs that identifier when Redis is unavailable. Log a non-sensitive lock category instead. Update the Hospitable test to assert that category.

As per coding guidelines, “Never log PII, guest phone numbers, SMS bodies, actual financial costs, Stripe tokens, secrets, or API keys.”

🤖 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/cache/single-flight.ts` at line 55, Replace the key-bearing warning in
the single-flight lock-unavailable path with a non-sensitive lock category,
ensuring the log never includes key or userId values. Update the related
Hospitable test assertion to expect the category-based warning instead of the
lock key.

Source: Coding guidelines

Comment on lines +114 to +121
for (let i = 0; i < maxWaits; i++) {
await new Promise((resolve) => setTimeout(resolve, waitMs))
const settled = await opts.read()
if (settled !== null && settled !== undefined) return settled
}
// Winner died, or is slower than our patience. Produce rather than fail —
// but do NOT release a lock we never held.
return opts.produce()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Do not produce while a live lock still exists.

The default wait budget is 900 ms. The weather producer has an 8-second request timeout and a 13-second lock TTL. A normal provider response that takes longer than 900 ms causes every lock loser to call produce() while the winner still holds the lock.

Wait until the lock can expire, or retry acquireLock() after each cache re-read. Only fall back to production after no valid holder can remain. Update the fallback test to simulate a slow but live winner.

🤖 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/cache/single-flight.ts` around lines 114 - 121, Update the lock-loser
wait path in the single-flight method to retry acquireLock() after each
opts.read() while the lock remains live, and do not call opts.produce() until
the lock has expired or no valid holder remains. Preserve returning settled
values immediately, and update the fallback test to model a slow winner whose
lock remains valid.

Comment on lines +367 to +392
if (poError?.code === '23505') {
const raceRes = await supabase
.from('purchase_orders')
.select('id, purchase_order_items(id)')
.eq('source_count_id', count_id)
.eq('org_id', org_id)
.maybeSingle()

if (raceRes.error) {
throw new Error(`purchase_orders race re-read failed: ${raceRes.error.message}`)
}
// Gone again means the winner was rolled back — let the step retry
// rather than silently reporting a PO that does not exist.
if (!raceRes.data) throw new Error('purchase order vanished after a 23505 — retrying')

if ((raceRes.data.purchase_order_items ?? []).length > 0) {
return { purchaseOrderId: raceRes.data.id, alreadyExisted: true }
}

logger.warn(
`Count ${count_id}: lost the create race to ${raceRes.data.id}, which has zero line ` +
'items — completing it rather than treating it as done.'
)
await insertPoItems(supabase, raceRes.data.id, belowParItems)
await markPoSent(supabase, raceRes.data.id)
return { purchaseOrderId: raceRes.data.id, alreadyExisted: false }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make empty-header repair single-writer.

A 23505 proves that the header insert committed. It does not prove that the winning invocation stopped. The winner can still be between its header insert and insertPoItems().

The losing invocation can repair the empty header at Line 390. The original winner can then continue at Line 404 and insert the same rows. This can duplicate purchase-order items or cause a child unique-conflict retry.

Claim completion atomically, or create the header and item rows in one conflict-safe database operation. Do not use an empty item list as proof that no writer owns the purchase order.

As per coding guidelines, “Database-creating Inngest steps must be idempotent: check source_reference_id for owner transactions or use conflict-safe insertion for other records.”

🤖 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/inngest/functions/inventory-events.ts` around lines 367 - 392, Make the
23505 recovery in the purchase-order creation flow single-writer: do not treat
an empty purchase_order_items list as proof that no writer is active. Update the
logic around the race re-read, insertPoItems, and markPoSent to atomically claim
completion or use a conflict-safe database operation that creates the header and
items together, preserving idempotency and preventing the losing invocation from
duplicating the winner’s items.

Source: Coding guidelines

Comment on lines +206 to +226
it('treats a 23505 as already-handled when the winner wrote a COMPLETE purchase order', async () => {
const supabase = makeSupabase({
existingPoQueue: [
null, // pre-check: nothing yet
{ id: 'po_winner', purchase_order_items: [{ id: 'x' }] },// race re-read: complete
],
poInsertError: { code: '23505', message: 'duplicate key value violates unique constraint' },
})
;(createServiceClient as ReturnType<typeof vi.fn>).mockReturnValue(supabase)

await invokeHandler(handleInventoryCountSubmitted, {
event, step: makeStep([belowParItem]),
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}).catch(() => { /* later email steps are not under test */ })

// No duplicate items, and no throw that would burn a retry.
expect(
supabase.calls.some((c) => c.table === 'purchase_order_items' && c.method === 'insert'),
'the winner already wrote the items — re-inserting them would duplicate the restock order',
).toBe(false)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not discard handler failures in success-path tests.

The catch(() => {}) calls at Lines 219 and 244 hide failures from the new race path. The first test can pass when the handler throws before completing the 23505 handling. The second test can pass when insertPoItems() succeeds but markPoSent() fails.

Stub downstream steps, then assert that the purchase-order creation path resolves successfully.

Also applies to: 228-252

🤖 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 `@unit/inngest/inventory-events-po.test.ts` around lines 206 - 226, The
success-path tests around handleInventoryCountSubmitted must not swallow handler
failures with catch(() => {}). Stub the later email/downstream steps that are
outside the test scope, then await the handler directly and assert the
purchase-order creation flow resolves successfully, including the 23505 race
handling and the insertPoItems/markPoSent path.

Comment on lines +1013 to +1016
const result = await dismissSuggestion('t_1')

expect(result).not.toEqual({ success: true })
expect(supabase.calls.some((c) => c.table === 'assignment_outcomes')).toBe(false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the exact NOTHING_UPDATED result.

The current assertion also passes for an unrelated database or operation error. Assert the expected error state so this test verifies the conflict response contract.

Proposed fix
-      expect(result).not.toEqual({ success: true })
+      expect(result).toEqual({
+        error: 'You do not have permission to make this change, or the turnover no longer exists.',
+      })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = await dismissSuggestion('t_1')
expect(result).not.toEqual({ success: true })
expect(supabase.calls.some((c) => c.table === 'assignment_outcomes')).toBe(false)
const result = await dismissSuggestion('t_1')
expect(result).toEqual({
error: 'You do not have permission to make this change, or the turnover no longer exists.',
})
expect(supabase.calls.some((c) => c.table === 'assignment_outcomes')).toBe(false)
🤖 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 `@unit/turnovers/turnovers-actions.test.ts` around lines 1013 - 1016, The
dismissSuggestion test should verify the exact NOTHING_UPDATED conflict response
instead of only asserting that success is absent. Update the assertion on result
after dismissSuggestion('t_1') to match the expected error state and preserve
the assertion that assignment_outcomes is not accessed.

@smj1860
smj1860 merged commit 8d008c8 into main Aug 9, 2026
10 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