Skip to content

ADE-71: State/sync/config persistence & kvDb hardening: crash-safe writes, migration versioning, and god-file/dedup decomposition#495

Merged
arul28 merged 2 commits into
mainfrom
ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition
Jun 1, 2026
Merged

ADE-71: State/sync/config persistence & kvDb hardening: crash-safe writes, migration versioning, and god-file/dedup decomposition#495
arul28 merged 2 commits into
mainfrom
ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition

Conversation

@arul28
Copy link
Copy Markdown
Owner

@arul28 arul28 commented May 31, 2026

Refs ADE-71

Summary

Describe the change.

What Changed

Key files and behaviors.

Validation

How you tested.

Risks

Anything to watch.

Linked Linear issues

ADE   Open in ADE  ·  ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition branch  ·  PR #495

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced data persistence with atomic write operations to prevent state file corruption.
    • Improved database transaction handling for better concurrent access reliability.
  • Improvements

    • Strengthened configuration validation with centralized parsing logic.
    • Enhanced database migration stability with safer transaction management and idempotent column operations.

Greptile Summary

This PR hardens state and database persistence across three areas: globalState.ts gains a crash-safe atomic write (temp file → fsync → rename → dir-fsync); kvDb.ts replaces ~40 silent bare try/catch migration blocks with an idempotent safeAddColumn helper, extracts makeCrrAwareDb to remove duplicated CRR-wrap code, upgrades two deferred BEGIN transactions to BEGIN IMMEDIATE, and implements the previously no-op flushNow via pragma wal_checkpoint(TRUNCATE).

  • globalState.ts: atomic write with fd tracking and cleanup on error; test verifies no .tmp files survive a successful write.
  • kvDb.ts: safeAddColumn guards every ALTER TABLE … ADD COLUMN migration with a pre-check + TOCTOU catch, and throughDbVersion/createdAt are now captured inside the discardUnpublishedChangesForTables transaction for consistency.
  • sessionService.ts / operationService.ts / projectConfigService.ts: SQL-clause coupling, column-list deduplication, and Zod-backed config coercion with no behavioural change.

Confidence Score: 5/5

Safe to merge — all changes are additive hardening with no altered public contracts, and the migration path is protected by the new idempotency guards.

The atomic-write change in globalState and the safeAddColumn migration refactor are both well-implemented with appropriate error handling. The BEGIN IMMEDIATE upgrades and the discardUnpublishedChangesForTables fix are correctness improvements. The sessionService SQL-clause refactoring preserves parameter ordering exactly. The only observation is a defensive gap in safeAddColumn's fallback path for unparseable SQL, which cannot be triggered by any current migration string.

apps/desktop/src/main/services/state/kvDb.ts — the safeAddColumn fallback path warrants a second look if new migrations with unusual identifier syntax are ever added.

Important Files Changed

Filename Overview
apps/desktop/src/main/services/state/kvDb.ts Extracts makeCrrAwareDb factory to remove ~30 lines of duplicated CRR-wrap logic, introduces safeAddColumn for idempotent ADD COLUMN migrations replacing ~40 bare try/catch blocks, upgrades begin to BEGIN IMMEDIATE in two write transactions, implements flushNow via pragma wal_checkpoint(TRUNCATE), and moves throughDbVersion/createdAt inside the discardUnpublishedChangesForTables transaction for correctness.
apps/desktop/src/main/services/state/globalState.ts Replaces direct fs.writeFileSync with a crash-safe temp-file-then-rename pattern (open → writeFileSync → fsync → close → rename → optional dir-fsync) with proper fd/tempPath cleanup on error.
apps/desktop/src/main/services/sessions/sessionService.ts Refactors parallel SQL-string + params-array tracking into a SqlClause = { sql, params } type, joining them at the call site; parameter ordering is semantically identical to the original.
apps/desktop/src/main/services/config/projectConfigService.ts Centralises config-field coercion behind module-level Zod schemas, replacing five inline type-check functions with schema-backed equivalents whose behaviour is identical for all expected inputs.
apps/desktop/src/main/services/history/operationService.ts Extracts repeated column list into a single OPERATION_COLUMNS constant used in three queries; no logic change.
apps/desktop/src/main/services/state/globalState.test.ts Adds a test that round-trips writeGlobalStatereadGlobalState and asserts no .tmp files are left behind after a successful write.
apps/desktop/src/main/services/state/kvDb.test.ts Adds a test verifying that flushNow() does not throw and that a previously written key survives the checkpoint.

Sequence Diagram

sequenceDiagram
    participant C as Caller
    participant K as openKvDb
    participant CR as makeCrrAwareDb
    participant M as migrate()
    participant DB as SQLite

    C->>K: openKvDb(dbPath, logger)
    K->>DB: open WAL database
    K->>CR: "makeCrrAwareDb({ getDb, isCrsqliteLoaded })"
    CR-->>K: crrAwareDb (run/get/all with CRR-alter wrapping)
    K->>DB: load crsqlite extension
    K->>M: migrate(crrAwareDb) — initial pass
    M->>CR: safeAddColumn(db, sql) × N
    CR->>DB: pragma table_info / ALTER TABLE / crsql_begin_alter+commit
    alt CRR table retrofit needed
        K->>M: migrate(crrAwareDb) — retrofit pass (idempotent via safeAddColumn)
    end
    K->>DB: pragma wal_checkpoint on flushNow()
    K-->>C: "AdeDb { run, get, all, sync, flushNow, close }"
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/main/services/state/kvDb.ts, line 464-471 (link)

    P2 Migration failure warning bypasses structured logger

    console.warn is used here, but the rest of kvDb.ts uses the logger instance (a Logger injected via openKvDb). A migration column-add failure logged via console.warn will be invisible to Sentry and to any file/structured log sink the app attaches to the Logger, making it hard to diagnose schema upgrade problems in production builds. Consider threading logger into safeAddColumn as a parameter, or moving the warn call to a site where logger is in scope.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/main/services/state/kvDb.ts
    Line: 464-471
    
    Comment:
    **Migration failure warning bypasses structured logger**
    
    `console.warn` is used here, but the rest of `kvDb.ts` uses the `logger` instance (a `Logger` injected via `openKvDb`). A migration column-add failure logged via `console.warn` will be invisible to Sentry and to any file/structured log sink the app attaches to the `Logger`, making it hard to diagnose schema upgrade problems in production builds. Consider threading `logger` into `safeAddColumn` as a parameter, or moving the warn call to a site where `logger` is in scope.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/desktop/src/main/services/state/kvDb.ts:1126-1131
**`safeAddColumn` parse-failure fallback is not idempotent across migration passes**

When `parseAlterTableAddColumn` returns `null`, `safeAddColumn` falls through to a bare `db.run(sql)` with no duplicate-column guard. Since `migrate()` can be called up to three times per `openKvDb` invocation (initial pass + up to two CRR-retrofit passes), any `ALTER TABLE … ADD COLUMN` statement that fails to match the regex on a second pass would throw "duplicate column name" and abort database initialisation entirely. All current migration strings are simple identifiers that the regex handles correctly, but a future maintainer adding a migration whose table or column name uses an unusual format would hit this silently broken path. A minimal safeguard — rethrowing only when the error is not a duplicate-column error — would prevent the initialisation failure.

Reviews (7): Last reviewed commit: "Document kvDb flush checkpoint semantics" | Re-trigger Greptile

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 31, 2026

ADE-71

@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview Jun 1, 2026 12:37am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

Warning

Review limit reached

@arul28, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 9 minutes and 28 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

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

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 12e0264d-0a15-499b-bf3a-07b9c2bd11ca

📥 Commits

Reviewing files that changed from the base of the PR and between bf1290e and ab42bc6.

📒 Files selected for processing (7)
  • apps/desktop/src/main/services/config/projectConfigService.ts
  • apps/desktop/src/main/services/history/operationService.ts
  • apps/desktop/src/main/services/sessions/sessionService.ts
  • apps/desktop/src/main/services/state/globalState.test.ts
  • apps/desktop/src/main/services/state/globalState.ts
  • apps/desktop/src/main/services/state/kvDb.test.ts
  • apps/desktop/src/main/services/state/kvDb.ts
📝 Walkthrough

Walkthrough

This PR hardens desktop services through validation improvements and database durability. Config coercion switches to Zod schemas; SQL queries deduplicate and refactor clause assembly; global state and database writes implement atomic temp-file + rename + fsync patterns with CRR-aware migrations and WAL checkpointing.

Changes

Desktop Services Enhancement

Layer / File(s) Summary
Config coercion with Zod schemas
apps/desktop/src/main/services/config/projectConfigService.ts
asString, asNumber, asBool, asStringArray, asComputeBackend, and asStringMap now parse via Zod schemas with finite-number and enum enforcement, replacing manual typeof/array checks.
Operation query column deduplication
apps/desktop/src/main/services/history/operationService.ts
New OPERATION_COLUMNS SQL constant unifies the SELECT clause across get, listHeadChanges, and list queries, eliminating duplicate column maintenance.
Session staleness SQL clause refactoring
apps/desktop/src/main/services/sessions/sessionService.ts
reconcileStaleRunningSessions builds tool-exclusion, owner-staleness, and activity guards as structured SqlClause objects with bundled SQL and parameters, replacing manual string concatenation and parameter arrays.

State and Database Atomicity

Layer / File(s) Summary
Atomic global state writes with fsync
apps/desktop/src/main/services/state/globalState.ts
writeGlobalState writes via a uniquely named temp file with explicit fsync and renameSync, plus error cleanup that closes file descriptors and deletes temp files on failure.
State and database write tests
apps/desktop/src/main/services/state/globalState.test.ts, apps/desktop/src/main/services/state/kvDb.test.ts
globalState.test.ts verifies temp-file rename semantics and absence of .tmp files; kvDb.test.ts confirms flushNow() checkpoints pending JSON writes.
CRR-aware migration infrastructure
apps/desktop/src/main/services/state/kvDb.ts
Introduces unquoteSqlIdentifier to strip SQL quoting, safeAddColumn for idempotent column addition (skip if present; tolerate duplicate errors only when confirmed), and makeCrrAwareDb to wrap ALTER TABLE in crsql_begin_alter / crsql_commit_alter when CRSQL clock tables exist.
Schema migration updates with safeAddColumn
apps/desktop/src/main/services/state/kvDb.ts
Migration steps replace try { ALTER TABLE ... } catch {} with safeAddColumn(...) across lanes, terminal_sessions, pull_requests, pull_request_snapshots, integration_proposals, queue_landing_state, worker_agents, linear_workflow_runs, review_findings, pr_issue_inventory, pr_pipeline_settings, and pr_convergence_state schemas.
CRR-aware migration and transaction wiring
apps/desktop/src/main/services/state/kvDb.ts
Wires crrAwareDb through all migration phases, transitions CRR transactions to BEGIN IMMEDIATE, implements flushNow to force WAL checkpointing via pragma wal_checkpoint(TRUNCATE), and updates type documentation for the flush behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes


Possibly related issues

  • arul28/ADE#460: This PR directly implements the fixes called out in the issue—Zod-based coercion in projectConfigService, OPERATION_COLUMNS extraction in operationService, SQL-clause refactoring in sessionService, atomic temp-file + fsync in globalState, and CRR-aware safeAddColumn / flushNow in kvDb.

Possibly related PRs

  • arul28/ADE#135: This PR's safeAddColumn and CRR-aware migration refactoring in kvDb.ts directly extend the migrate() changes in the linked PR that introduce integration_proposals.preferred_integration_lane_id.

Suggested labels

desktop

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed The title comprehensively describes the main changes across multiple service files: crash-safe writes for global state, migration versioning and hardening in kvDb, and refactoring of config/state decomposition.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition

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

❤️ Share

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

@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 31, 2026

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented May 31, 2026

@copilot review but do not make fixes

Comment thread apps/desktop/src/main/services/state/kvDb.ts
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented May 31, 2026

@copilot review but do not make fixes

@arul28 arul28 force-pushed the ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition branch from cbd8db7 to 184c2c8 Compare June 1, 2026 00:24
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Jun 1, 2026

@copilot review but do not make fixes

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Jun 1, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
apps/desktop/src/main/services/config/projectConfigService.ts (1)

95-104: 💤 Low value

Silent error handling may hide config validation issues.

All schemas use the .optional().catch(undefined) pattern, which silently returns undefined for any parsing errors. While this permissive approach may be intentional for config coercion (allowing partial config loading), it means malformed config values are discarded without warning.

Consider whether certain schema violations (e.g., type mismatches for critical fields) should be logged at debug level to aid troubleshooting, even if the value is ultimately coerced to undefined.

Also applies to: 126-172

🤖 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 `@apps/desktop/src/main/services/config/projectConfigService.ts` around lines
95 - 104, The current schemas (OPTIONAL_STRING_SCHEMA, OPTIONAL_NUMBER_SCHEMA,
OPTIONAL_BOOL_SCHEMA, STRING_ARRAY_SCHEMA and other similar optional schemas)
swallow all parse errors via .optional().catch(undefined), hiding malformed
config values; update each .catch to capture the thrown error and emit a
debug-level log (e.g., processLogger.debug or configured logger) with the schema
name and error details before returning undefined so malformed values are
visible during troubleshooting while preserving coercion behavior.
🤖 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 `@apps/desktop/src/main/services/config/projectConfigService.ts`:
- Around line 105-114: STRING_MAP_SCHEMA currently uses z.record(z.string(),
z.unknown()) with a transform and a final .catch(() => undefined), which means
arrays will be rejected by z.record but then silently turned into undefined (and
numeric array indices might be misinterpreted); either make the schema
explicitly accept arrays and convert them, or remove the silent catch so errors
surface. Fix by replacing STRING_MAP_SCHEMA with a union like
z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]) and update
the transform in STRING_MAP_SCHEMA to handle both shapes (for arrays, iterate
values and map numeric/indexed or string entries into the output
Record<string,string>), and remove or tighten the .catch(() => undefined) so
malformed configs produce validation errors instead of being swallowed.

In `@apps/desktop/src/main/services/state/globalState.test.ts`:
- Around line 69-84: The test currently doesn't verify the atomic-temp rename;
update the "writeGlobalState" test to assert the actual rename behavior by
spying/mocking fs.renameSync (or fs.promises.rename) when calling
writeGlobalState: create an initial file at filePath, call
writeGlobalState(filePath, state), and verify fs.renameSync was invoked with a
source path that endsWith(".tmp") and whose directory equals
path.dirname(filePath) and a destination equal to filePath; alternatively inject
a failure before rename and assert the original file content remains unchanged.
Reference writeGlobalState, readGlobalState and fs.renameSync (or
fs.promises.rename) when locating where to add the spy/assert.

In `@apps/desktop/src/main/services/state/kvDb.test.ts`:
- Around line 220-229: The test currently only verifies reads from the same open
connection so it doesn't prove a checkpoint occurred; update the "checkpoints
pending writes when flushed" test to assert a WAL-side observable effect after
calling db.flushNow(): e.g., call db.flushNow(), then either close the DB
(db.close()), and assert the WAL file for dbPath (dbPath + "-wal") is absent or
truncated on disk, or run a SQLite checkpoint query (PRAGMA wal_checkpoint or
PRAGMA wal_checkpoint(TRUNCATE)) via the DB handle and assert the returned
checkpoint stats indicate a completed checkpoint; use openKvDb, db.flushNow,
db.close and dbPath to locate the WAL and ensure the test fails if no checkpoint
happened.

In `@apps/desktop/src/main/services/state/kvDb.ts`:
- Around line 1158-1171: If an ALTER is targeted at a CRR-managed table
(alterTable is set and rawHasTable(db, `${alterTable}__crsql_clock`) is true)
but isCrsqliteLoaded() is false, do not fall back to raw runStatement; instead
abort with a clear error so CRR metadata won't become inconsistent. Update the
branch around isCrsqliteLoaded()/rawHasTable check in kvDb.ts to detect this
case and throw a descriptive error (or return a failure) referencing alterTable
and advising to load cr-sqlite, rather than calling runStatement; keep existing
behavior when isCrsqliteLoaded() is true (use getRow("select
crsql_begin_alter(...)"), runStatement, then getRow("select
crsql_commit_alter(...)")).
- Around line 3343-3345: flushNow currently calls getRow(db, "pragma
wal_checkpoint(TRUNCATE)") and discards the result; change it to capture the
returned row from getRow in flushNow, inspect the returned "busy" field (or
equivalent column) and if busy !== 0 treat the checkpoint as failed by logging
an error via the existing logger and/or throwing/returning an error so callers
(including shutdown paths) can observe failure; ensure the change references the
flushNow function and the getRow(db, "pragma wal_checkpoint(TRUNCATE)") call and
preserves existing error handling semantics when the query itself errors.

---

Nitpick comments:
In `@apps/desktop/src/main/services/config/projectConfigService.ts`:
- Around line 95-104: The current schemas (OPTIONAL_STRING_SCHEMA,
OPTIONAL_NUMBER_SCHEMA, OPTIONAL_BOOL_SCHEMA, STRING_ARRAY_SCHEMA and other
similar optional schemas) swallow all parse errors via
.optional().catch(undefined), hiding malformed config values; update each .catch
to capture the thrown error and emit a debug-level log (e.g.,
processLogger.debug or configured logger) with the schema name and error details
before returning undefined so malformed values are visible during
troubleshooting while preserving coercion behavior.
🪄 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

Run ID: 12826bfb-7786-4aec-aad3-22c476508edb

📥 Commits

Reviewing files that changed from the base of the PR and between 13972d3 and bf1290e.

📒 Files selected for processing (7)
  • apps/desktop/src/main/services/config/projectConfigService.ts
  • apps/desktop/src/main/services/history/operationService.ts
  • apps/desktop/src/main/services/sessions/sessionService.ts
  • apps/desktop/src/main/services/state/globalState.test.ts
  • apps/desktop/src/main/services/state/globalState.ts
  • apps/desktop/src/main/services/state/kvDb.test.ts
  • apps/desktop/src/main/services/state/kvDb.ts

Comment on lines +105 to +114
const STRING_MAP_SCHEMA = z.record(z.string(), z.unknown())
.transform((value) => {
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string") out[key] = entry;
}
return out;
})
.optional()
.catch(() => undefined);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Test whether z.record accepts arrays in Zod 4

cat > /tmp/test_zod_record.mjs << 'EOF'
import { z } from 'zod';

const STRING_MAP_SCHEMA = z.record(z.string(), z.unknown())
  .transform((value) => {
    const out = {};
    for (const [key, entry] of Object.entries(value)) {
      if (typeof entry === "string") out[key] = entry;
    }
    return out;
  })
  .optional()
  .catch(() => undefined);

// Test cases
const testCases = [
  { input: { a: "x", b: "y" }, label: "object with strings" },
  { input: { a: "x", b: 123 }, label: "object with mixed types" },
  { input: ["x", "y", "z"], label: "array of strings" },
  { input: [1, 2, 3], label: "array of numbers" },
  { input: "not-an-object", label: "string" },
  { input: undefined, label: "undefined" },
];

console.log("Testing STRING_MAP_SCHEMA with Zod 4:\n");
for (const { input, label } of testCases) {
  try {
    const result = STRING_MAP_SCHEMA.parse(input);
    console.log(`✓ ${label}:`, JSON.stringify(result));
  } catch (error) {
    console.log(`✗ ${label}: ERROR -`, error.message);
  }
}
EOF

cd /tmp && node test_zod_record.mjs
rm test_zod_record.mjs

Repository: arul28/ADE

Length of output: 1052


🏁 Script executed:

#!/bin/bash
# Check if zod is available from the repo and print its resolved path/version
node -e "try{console.log('zod resolve:',require.resolve('zod'))}catch(e){console.error('require.resolve failed:',e.message);process.exit(0)}"
node -e "try{const {z}=require('zod'); console.log('zod loaded (commonjs)');}catch(e){console.error('require(zod) failed:',e.message)}"
ls -la node_modules/zod 2>/dev/null || true

# Print zod version from package.json/lock if present
node -e "const fs=require('fs'); const p=['package.json','package-lock.json','npm-shrinkwrap.json','yarn.lock','pnpm-lock.yaml']; for(const f of p){ if(fs.existsSync(f)){ console.log('found',f); break}}"
rg -n "\"zod\"|zod@" package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null || true

Repository: arul28/ADE

Length of output: 272


🌐 Web query:

Zod 4 z.record array input accepts or rejects Array.isArray behavior record schema

💡 Result:

In Zod, including in version 4, z.record() is designed specifically to validate objects (the Record<K, V> type in TypeScript) and does not inherently treat arrays as valid input [1][2]. If you pass an array to a z.record() schema, it will be rejected during validation [1]. While JavaScript arrays are technically objects, z.record() explicitly expects an object structure with keys and values that match the provided schemas [1]. If you need to validate a structure that might be an array, you should use z.array() [1][3][4]. If you need to support both types of input for the same field, you can use z.union([z.array(...), z.record(...)]) or z.discriminatedUnion(), depending on your requirements. For context, z.record() is used to validate objects where you define the schema for the keys and the schema for the values (e.g., z.record(z.string(), z.number())) [1]. It is not a generic collection validator for both arrays and objects [1][2].

Citations:


Fix/clarify STRING_MAP_SCHEMA array-handling vs PR summary

  • z.record(z.string(), z.unknown()) is designed for object inputs; passing an array should fail validation (so with .catch(() => undefined) the result should be undefined, not partially accepted numeric keys).
  • Either update the PR summary/tests to reflect arrays are rejected, or change the schema to explicitly accept arrays (e.g., z.union([z.array(...), z.record(...)]) plus conversion), if that’s the intended behavior.
  • Reconsider .catch(() => undefined) here if you want malformed config fields to be diagnosable rather than silently ignored.
🤖 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 `@apps/desktop/src/main/services/config/projectConfigService.ts` around lines
105 - 114, STRING_MAP_SCHEMA currently uses z.record(z.string(), z.unknown())
with a transform and a final .catch(() => undefined), which means arrays will be
rejected by z.record but then silently turned into undefined (and numeric array
indices might be misinterpreted); either make the schema explicitly accept
arrays and convert them, or remove the silent catch so errors surface. Fix by
replacing STRING_MAP_SCHEMA with a union like z.union([z.record(z.string(),
z.unknown()), z.array(z.unknown())]) and update the transform in
STRING_MAP_SCHEMA to handle both shapes (for arrays, iterate values and map
numeric/indexed or string entries into the output Record<string,string>), and
remove or tighten the .catch(() => undefined) so malformed configs produce
validation errors instead of being swallowed.

Comment on lines +69 to +84
describe("writeGlobalState", () => {
it("persists state through an atomic temp-file rename", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-global-state-"));
const filePath = path.join(dir, "global-state.json");
const state: GlobalState = {
lastProjectRoot: "/repo/ade",
recentProjects: [
{ rootPath: "/repo/ade", displayName: "ADE", lastOpenedAt: "2026-05-31T00:00:00.000Z" },
],
};

writeGlobalState(filePath, state);

expect(readGlobalState(filePath)).toEqual(state);
expect(fs.readdirSync(dir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test doesn't actually prove the temp-file rename path.

A direct overwrite of filePath would still pass these assertions, so the atomic-write contract can regress unnoticed. Please assert the renameSync call from a .tmp path in the same directory, or inject a failure before rename and verify the original file stays intact.

🧪 One way to lock down the rename behavior
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
@@
   it("persists state through an atomic temp-file rename", () => {
     const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-global-state-"));
     const filePath = path.join(dir, "global-state.json");
+    const renameSpy = vi.spyOn(fs, "renameSync");
     const state: GlobalState = {
@@
     writeGlobalState(filePath, state);
 
     expect(readGlobalState(filePath)).toEqual(state);
+    expect(renameSpy).toHaveBeenCalledTimes(1);
+    const [from, to] = renameSpy.mock.calls[0]!;
+    expect(path.dirname(from)).toBe(dir);
+    expect(from.endsWith(".tmp")).toBe(true);
+    expect(to).toBe(filePath);
     expect(fs.readdirSync(dir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
+    renameSpy.mockRestore();
   });
📝 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
describe("writeGlobalState", () => {
it("persists state through an atomic temp-file rename", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-global-state-"));
const filePath = path.join(dir, "global-state.json");
const state: GlobalState = {
lastProjectRoot: "/repo/ade",
recentProjects: [
{ rootPath: "/repo/ade", displayName: "ADE", lastOpenedAt: "2026-05-31T00:00:00.000Z" },
],
};
writeGlobalState(filePath, state);
expect(readGlobalState(filePath)).toEqual(state);
expect(fs.readdirSync(dir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
});
describe("writeGlobalState", () => {
it("persists state through an atomic temp-file rename", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-global-state-"));
const filePath = path.join(dir, "global-state.json");
const renameSpy = vi.spyOn(fs, "renameSync");
const state: GlobalState = {
lastProjectRoot: "/repo/ade",
recentProjects: [
{ rootPath: "/repo/ade", displayName: "ADE", lastOpenedAt: "2026-05-31T00:00:00.000Z" },
],
};
writeGlobalState(filePath, state);
expect(readGlobalState(filePath)).toEqual(state);
expect(renameSpy).toHaveBeenCalledTimes(1);
const [from, to] = renameSpy.mock.calls[0]!;
expect(path.dirname(from)).toBe(dir);
expect(from.endsWith(".tmp")).toBe(true);
expect(to).toBe(filePath);
expect(fs.readdirSync(dir).filter((entry) => entry.endsWith(".tmp"))).toEqual([]);
renameSpy.mockRestore();
});
🤖 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 `@apps/desktop/src/main/services/state/globalState.test.ts` around lines 69 -
84, The test currently doesn't verify the atomic-temp rename; update the
"writeGlobalState" test to assert the actual rename behavior by spying/mocking
fs.renameSync (or fs.promises.rename) when calling writeGlobalState: create an
initial file at filePath, call writeGlobalState(filePath, state), and verify
fs.renameSync was invoked with a source path that endsWith(".tmp") and whose
directory equals path.dirname(filePath) and a destination equal to filePath;
alternatively inject a failure before rename and assert the original file
content remains unchanged. Reference writeGlobalState, readGlobalState and
fs.renameSync (or fs.promises.rename) when locating where to add the spy/assert.

Comment on lines +220 to +229
it("checkpoints pending writes when flushed", async () => {
const projectRoot = makeProjectRoot("ade-kvdb-flush-");
const dbPath = path.join(projectRoot, ".ade", "ade.db");
const db = await openKvDb(dbPath, createLogger() as any);
activeDisposers.push(async () => db.close());

db.setJson("flush:probe", { ok: true });
expect(() => db.flushNow()).not.toThrow();
expect(db.getJson<{ ok: boolean }>("flush:probe")).toEqual({ ok: true });
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This assertion doesn't prove a checkpoint happened.

The same connection can read uncheckpointed WAL data, so this still passes if flushNow() becomes a no-op. Please assert an observable checkpoint effect instead, such as a truncated/absent -wal file after flushNow() or checkpoint stats from SQLite.

🧪 Example of checking the WAL side effect directly
   db.setJson("flush:probe", { ok: true });
   expect(() => db.flushNow()).not.toThrow();
   expect(db.getJson<{ ok: boolean }>("flush:probe")).toEqual({ ok: true });
+  const walPath = `${dbPath}-wal`;
+  if (fs.existsSync(walPath)) {
+    expect(fs.statSync(walPath).size).toBe(0);
+  }
🤖 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 `@apps/desktop/src/main/services/state/kvDb.test.ts` around lines 220 - 229,
The test currently only verifies reads from the same open connection so it
doesn't prove a checkpoint occurred; update the "checkpoints pending writes when
flushed" test to assert a WAL-side observable effect after calling
db.flushNow(): e.g., call db.flushNow(), then either close the DB (db.close()),
and assert the WAL file for dbPath (dbPath + "-wal") is absent or truncated on
disk, or run a SQLite checkpoint query (PRAGMA wal_checkpoint or PRAGMA
wal_checkpoint(TRUNCATE)) via the DB handle and assert the returned checkpoint
stats indicate a completed checkpoint; use openKvDb, db.flushNow, db.close and
dbPath to locate the WAL and ensure the test fails if no checkpoint happened.

Comment on lines +1158 to +1171
if (alterTable && isCrsqliteLoaded() && rawHasTable(db, `${alterTable}__crsql_clock`)) {
getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]);
try {
runStatement(db, sql, params);
} catch (error) {
// Commit the alter even on failure so the CRR state stays consistent,
// then re-throw so callers can handle duplicate-column upgrades.
getRow(db, "select crsql_commit_alter(?) as ok", [alterTable]);
throw error;
}
getRow(db, "select crsql_commit_alter(?) as ok", [alterTable]);
return;
}
runStatement(db, sql, params);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Prevent raw ALTER TABLE on CRR-managed tables when cr-sqlite is unavailable

kvDb.ts only uses the crsql_begin_alter/crsql_commit_alter flow when isCrsqliteLoaded() is true; otherwise it falls back to plain runStatement(db, sql, params), even if ${alterTable}__crsql_clock exists—silently risking CRR metadata inconsistency.

Suggested guard
     run: (sql: string, params: SqlValue[] = []) => {
       const db = getDb();
       const alterTable = parseAlterTableTarget(sql);
-      if (alterTable && isCrsqliteLoaded() && rawHasTable(db, `${alterTable}__crsql_clock`)) {
+      if (alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) {
+        if (!isCrsqliteLoaded()) {
+          throw new Error(`Cannot alter CRR table "${alterTable}" without cr-sqlite loaded`);
+        }
         getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]);
         try {
           runStatement(db, sql, params);
         } catch (error) {
           // Commit the alter even on failure so the CRR state stays consistent,
           // then re-throw so callers can handle duplicate-column upgrades.
           getRow(db, "select crsql_commit_alter(?) as ok", [alterTable]);
           throw error;
         }
         getRow(db, "select crsql_commit_alter(?) as ok", [alterTable]);
         return;
       }
       runStatement(db, sql, params);
🤖 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 `@apps/desktop/src/main/services/state/kvDb.ts` around lines 1158 - 1171, If an
ALTER is targeted at a CRR-managed table (alterTable is set and rawHasTable(db,
`${alterTable}__crsql_clock`) is true) but isCrsqliteLoaded() is false, do not
fall back to raw runStatement; instead abort with a clear error so CRR metadata
won't become inconsistent. Update the branch around
isCrsqliteLoaded()/rawHasTable check in kvDb.ts to detect this case and throw a
descriptive error (or return a failure) referencing alterTable and advising to
load cr-sqlite, rather than calling runStatement; keep existing behavior when
isCrsqliteLoaded() is true (use getRow("select crsql_begin_alter(...)"),
runStatement, then getRow("select crsql_commit_alter(...)")).

Comment on lines +3343 to +3345
flushNow: () => {
getRow(db, "pragma wal_checkpoint(TRUNCATE)");
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/desktop/src/main/services/state/kvDb.ts"
# Ensure file exists
test -f "$FILE"

# Show the exact section around the reported lines
python3 - <<'PY'
import itertools
path="apps/desktop/src/main/services/state/kvDb.ts"
start=3320
end=3380
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if start<=i<=end:
            print(f"{i:5d}:{line.rstrip()}")
        if i>end:
            break
PY

# Locate definition of getRow and its typing/behavior
rg -n "function getRow|const getRow|getRow<|getRow\(" apps/desktop/src/main/services/state/kvDb.ts

# Extract the getRow implementation block (approximate)
python3 - <<'PY'
import re
path="apps/desktop/src/main/services/state/kvDb.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()

def find(pattern):
    for idx,l in enumerate(lines):
        if re.search(pattern,l):
            return idx
    return None

idx=find(r"\bgetRow\b")
print("First getRow occurrence line:", (idx+1) if idx is not None else None)
PY

# Show larger context around first getRow occurrence
python3 - <<'PY'
path="apps/desktop/src/main/services/state/kvDb.ts"
target=None
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if "getRow" in line and ("function getRow" in line or "const getRow" in line):
            target=i
            break
print("Implementation start line:", target)
if target:
    start=max(1,target-40)
    end=target+120
    with open(path,'r',encoding='utf-8') as f:
        for i,line in enumerate(f, start=1):
            if start<=i<=end:
                print(f"{i:5d}:{line.rstrip()}")
            if i>end:
                break
PY

# Find docblock describing flushNow durability/behavior
rg -n "flushNow" apps/desktop/src/main/services/state/kvDb.ts
rg -n "WAL|wal_checkpoint|checkpoint" apps/desktop/src/main/services/state/kvDb.ts

Repository: arul28/ADE

Length of output: 10095


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"

FILE="apps/desktop/src/main/services/state/kvDb.ts"

# 1) show docblock around flushNow + interface signature
python3 - <<'PY'
path="apps/desktop/src/main/services/state/kvDb.ts"
start=60
end=110
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if start<=i<=end:
            print(f"{i:5d}:{line.rstrip()}")
        if i>end:
            break
PY

# 2) find all call sites
rg -n "flushNow\s*\(" apps/desktop/src/main/services/state apps/desktop/src/main --hidden || true
rg -n "\.flushNow\s*\(" apps/desktop/src/main || true
rg -n "flushNow" apps/desktop/src/main | head -n 50 || true

Repository: arul28/ADE

Length of output: 3547


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"

# Show flushNow usage contexts
python3 - <<'PY'
import re
path="apps/desktop/src/main/main.ts"
targets=[4484,4629,5228]
with open(path,'r',encoding='utf-8') as f:
    lines=list(f.readlines())
for t in targets:
    start=max(1,t-35)
    end=min(len(lines),t+25)
    print("\n--- main.ts around",t,"---")
    for i in range(start,end+1):
        if start<=i<=end:
            print(f"{i:5d}:{lines[i-1].rstrip()}")
PY

# Show kvDb.test.ts around flushNow expectation
python3 - <<'PY'
path="apps/desktop/src/main/services/state/kvDb.test.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=list(f.readlines())
# locate line containing flushNow
for idx,l in enumerate(lines, start=1):
    if "db.flushNow" in l:
        t=idx
        start=max(1,t-30); end=min(len(lines),t+30)
        print("\n--- kvDb.test.ts around",t,"---")
        for i in range(start,end+1):
            print(f"{i:5d}:{lines[i-1].rstrip()}")
        break
PY

# Check sqlite PRAGMA wal_checkpoint return shape in code (if any helpers exist)
rg -n "wal_checkpoint\\(TRUNCATE\\)|wal_checkpoint\\(" apps/desktop/src/main/services/state/kvDb.ts

Repository: arul28/ADE

Length of output: 8940


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"

FILE="apps/desktop/src/main/services/state/kvDb.ts"

# Find openKvDb and logger usage
rg -n "function openKvDb|openKvDb\s*\(" "$FILE"
rg -n "logger" "$FILE" | head -n 80

# Show the section where the db object is created (around where flushNow is defined)
python3 - <<'PY'
path="apps/desktop/src/main/services/state/kvDb.ts"
# print around the return { ... flushNow ... } block
start=3260
end=3380
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f, start=1):
        if start<=i<=end:
            print(f"{i:5d}:{line.rstrip()}")
PY

Repository: arul28/ADE

Length of output: 6976


🌐 Web query:

SQLite PRAGMA wal_checkpoint(TRUNCATE) return columns busy log checkpointed

💡 Result:

In SQLite, PRAGMA wal_checkpoint(TRUNCATE) returns three columns in a single result row: busy, log, checkpointed. [1] 1) busy - 0 means the checkpoint proceeded; 1 means it did not complete (the checkpoint was blocked / SQLITE_BUSY). [1][2] 2) log - The number of pages still in the WAL file after the checkpoint attempt (i.e., remaining “log” pages). [2] 3) checkpointed - The number of WAL pages that were checkpointed (copied/merged into the main database) by that call. [2] Notes relevant to “busy log checkpointed” - The wal_checkpoint interface uses checkpoint locking semantics; in FULL/RESTART/TRUNCATE modes it can block (and invoke the busy-handler for reader/writer conditions), and it can return SQLITE_BUSY if it cannot obtain required locks or must stop due to contention. [1] - In TRUNCATE mode specifically, the checkpoint is performed and then the WAL file is truncated to zero length when the checkpoint completes. [3][2]

Citations:


Check wal_checkpoint(TRUNCATE) result and surface failure in flushNow()

PRAGMA wal_checkpoint(TRUNCATE) returns busy/log/checkpointed (where busy != 0 means the checkpoint didn’t complete). flushNow() currently ignores the returned row and just returns success (getRow(...) result is discarded), while shutdown call sites swallow any thrown errors—so a blocked/unfinished checkpoint can fail silently even on the shutdown path this method is meant to protect. Update flushNow to inspect busy and log/return failure (or otherwise propagate it) when the checkpoint doesn’t complete.

🤖 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 `@apps/desktop/src/main/services/state/kvDb.ts` around lines 3343 - 3345,
flushNow currently calls getRow(db, "pragma wal_checkpoint(TRUNCATE)") and
discards the result; change it to capture the returned row from getRow in
flushNow, inspect the returned "busy" field (or equivalent column) and if busy
!== 0 treat the checkpoint as failed by logging an error via the existing logger
and/or throwing/returning an error so callers (including shutdown paths) can
observe failure; ensure the change references the flushNow function and the
getRow(db, "pragma wal_checkpoint(TRUNCATE)") call and preserves existing error
handling semantics when the query itself errors.

@arul28 arul28 force-pushed the ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition branch from bf1290e to baa4f5b Compare June 1, 2026 01:02
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 1, 2026

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/arul28s-projects?upgradeToPro=build-rate-limit

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Jun 1, 2026

@copilot review but do not make fixes

@arul28 arul28 force-pushed the ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition branch from adb2d11 to ab42bc6 Compare June 1, 2026 01:27
@arul28 arul28 merged commit 5a29653 into main Jun 1, 2026
7 of 9 checks passed
@arul28 arul28 deleted the ade-71-state-sync-config-persistence-kvdb-hardening-crash-safe-writes-migration-versioning-and-god-file-dedup-decomposition branch June 1, 2026 18:06
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