Skip to content

feat(fmodata): use Effect.ts internally#141

Open
eluce2 wants to merge 13 commits intomainfrom
effect-ts
Open

feat(fmodata): use Effect.ts internally#141
eluce2 wants to merge 13 commits intomainfrom
effect-ts

Conversation

@eluce2
Copy link
Collaborator

@eluce2 eluce2 commented Mar 15, 2026

feat(fmodata): use Effect.ts internally for error handling pipelines

Replace manual error-threading boilerplate in all builders with Effect
pipelines. Effect is used as an internal implementation detail — the
public API (Result) is unchanged.

Changes:

  • Add effect dependency, remove unused neverthrow
  • Add src/effect.ts with bridge utilities (fromResult, makeRequestEffect,
    runAsResult, tryEffect, fromValidation)
  • Refactor _makeRequest into an Effect pipeline with extracted helpers
    (_classifyError, _parseHttpError, _checkEmbeddedODataError)
  • Refactor InsertBuilder.execute() to use Effect.gen pipeline
  • Refactor UpdateBuilder.execute() to use Effect.gen pipeline
  • Refactor DeleteBuilder.execute() to use Effect.gen pipeline
  • Refactor QueryBuilder.execute() to use Effect pipe composition
  • Refactor BatchBuilder.execute() to use Effect.gen pipeline

All 873 tests pass with zero type errors.

https://claude.ai/code/session_01VdwR8FRDc9f1qS68z2Sfzo

feat(fmodata): add retry policies, validation accumulation, and tracing spans

Phase 2 of Effect.ts integration:

  • Add opt-in RetryPolicy for transient errors (SchemaLockedError, NetworkError, TimeoutError, HTTP 5xx)
    with exponential backoff and jitter via Effect Schedule
  • Accumulate all validation errors instead of fail-fast, returning all field issues in a single ValidationError
  • Add Effect.withSpan tracing to all builder execute() methods and HTTP requests for zero-cost observability

All changes remain non-breaking — public API (Result, error classes, type guards) is unchanged.

https://claude.ai/code/session_01VdwR8FRDc9f1qS68z2Sfzo

feat(fmodata): add Effect services, MockFMServerConnection, and migrate tests

Phase 3 of Effect.ts integration:

  • Define Effect Services (HttpClient, ODataConfig, ODataLogger) for dependency injection
  • Create MockFMServerConnection with router-style fetch handler and request spy
  • Export testing utilities via @proofkit/fmodata/testing subpath
  • Migrate 22 test files from createMockClient/fetchHandler to MockFMServerConnection
  • All 870 tests passing, 0 type errors, publint passes

https://claude.ai/code/session_01VdwR8FRDc9f1qS68z2Sfzo

fix(fmodata): limit retries to idempotent HTTP methods

fix(fmodata): address remaining PR review findings

fix(fmodata): harden write requests and batch response handling

refactor(fmodata): migrate all builders to Effect DI via Context/Layer pattern

Replace constructor parameter threading (ExecutionContext, databaseName,
useEntityIds, etc.) with Effect's Context.Tag + Layer pattern across all
builders. Each builder now receives an FMODataLayer and extracts config
synchronously via extractConfigFromLayer() for non-Effect methods.

Key changes:

  • All builders (Query, Record, Insert, Update, Delete, Batch) use layer-based DI
  • SchemaManager and WebhookManager converted to use Effect pipelines
  • Database.getMetadata/listTableNames/runScript use requestFromService
  • Removed deprecated makeRequestEffect() and runWithContext() from effect.ts
  • Simplified ExecutionContext interface to just _getLayer()
  • Removed unused BuilderConfig interface from shared-types.ts
  • createDatabaseLayer() enables database-scoped config overrides

https://claude.ai/code/session_01EpwtyTQeyjVu3Qykf6aMBd

fix(fmodata): resolve DI branch lint regressions

Summary by CodeRabbit

  • New Features

    • Route-based testing utilities with request spying and included testing entry in the build
    • New RetryPolicy type and exported isTransientError helper
  • Improvements

    • Layered, effect-driven request pipeline with improved error classification and retry support
    • Centralized runtime/configuration for entity-id and special-column handling; expanded service/type exports
  • Tests

    • Test suites migrated to use the new mock server and spy-driven patterns

@vercel
Copy link

vercel bot commented Mar 15, 2026

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

Project Deployment Actions Updated (UTC)
proofkit-docs Ready Ready Preview Mar 17, 2026 2:53pm

Request Review

@eluce2 eluce2 marked this pull request as ready for review March 15, 2026 18:36
Copy link
Collaborator Author

eluce2 commented Mar 15, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@eluce2 eluce2 changed the title feat(fmodata): use Effect.ts internally for error handling pipelines feat(fmodata): use Effect.ts internally Mar 15, 2026
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 15, 2026

Open in StackBlitz

@proofkit/better-auth

pnpm add https://pkg.pr.new/proofgeist/proofkit/@proofkit/better-auth@141

@proofkit/cli

pnpm add https://pkg.pr.new/proofgeist/proofkit/@proofkit/cli@141

create-proofkit

pnpm add https://pkg.pr.new/proofgeist/proofkit/create-proofkit@141

@proofkit/fmdapi

pnpm add https://pkg.pr.new/proofgeist/proofkit/@proofkit/fmdapi@141

@proofkit/fmodata

pnpm add https://pkg.pr.new/proofgeist/proofkit/@proofkit/fmodata@141

@proofkit/typegen

pnpm add https://pkg.pr.new/proofgeist/proofkit/@proofkit/typegen@141

@proofkit/webviewer

pnpm add https://pkg.pr.new/proofgeist/proofkit/@proofkit/webviewer@141

commit: ffb3b10

@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces ExecutionContext/fetch plumbing with an Effect-driven DI layer (FMODataLayer); adds service types, client runtime, effect helpers, mutation/read helper modules, and MockFMServerConnection testing utilities; migrates builders, Database, EntitySet, and request/response flows to layer/config-driven, Effect-based pipelines.

Changes

Cohort / File(s) Summary
Package manifest & build
packages/fmodata/package.json, packages/fmodata/vite.config.ts
Added public exports ./testing, ./services, ./types; added testing entry to build entries.
Service & Effect core
packages/fmodata/src/services.ts, packages/fmodata/src/effect.ts, packages/fmodata/src/errors.ts, packages/fmodata/src/index.ts
Introduce FMODataLayer, HttpClient/ODataConfig/ODataLogger services; new Effect integration (requestFromService, runLayerResult/runLayerOrThrow, retry/span helpers); add new error types and isTransientError; export RetryPolicy.
Client runtime
packages/fmodata/src/client/runtime.ts
Add ClientRuntime and createClientRuntime to extract config/logger from a layer for client modules.
HTTP orchestration
packages/fmodata/src/client/filemaker-odata.ts
Replace imperative _makeRequest with an Effect-based pipeline (_makeRequestEffect), add error classification/parsing, and delegate to requestFromService/runAsResult.
Builders & client API migration
packages/fmodata/src/client/*, packages/fmodata/src/client/database.ts, packages/fmodata/src/client/entity-set.ts
Many builders (query/record/insert/update/delete/webhook/batch) and Database/EntitySet constructors and internals now accept layer: FMODataLayer; derive ODataConfig/logger via runtime; execute flows rewritten to use Effects and shared helpers.
Mutation helpers & builders index
packages/fmodata/src/client/builders/mutation-helpers.ts, packages/fmodata/src/client/builders/index.ts
New mutation utilities: buildMutationUrl, resolveMutationTableId, parseRowIdFromLocationHeader, extractAffectedRows, mergeMutationExecuteOptions; re-exported via builders index.
Read state & URL building
packages/fmodata/src/client/builders/read-builder-state.ts, packages/fmodata/src/client/query/url-builder.ts, packages/fmodata/src/client/query/types.ts, packages/fmodata/src/client/query/index.ts
Add read-builder-state module for builder internal state; QueryUrlBuilder/useEntityIds updates and NavigationConfig entity-id fields; ExpandConfig moved to shared types; add ExpandedRelations type.
Validation & response processing
packages/fmodata/src/validation.ts, packages/fmodata/src/client/builders/response-processor.ts, deleted: packages/fmodata/src/client/query/response-processor.ts, packages/fmodata/src/client/response-processor.ts
Validation now aggregates per-field issues into a single ValidationError; older response-processor modules removed and replaced with smaller wrappers and shared helpers.
Testing utilities & tests
packages/fmodata/src/testing.ts, packages/fmodata/tests/*
Add MockFMServerConnection, MockRoute, RequestSpy and router-based fetch; update many tests to use MockFMServerConnection and spy-based assertions instead of inline fetch mocks.
Types & public surface
packages/fmodata/src/types.ts, packages/fmodata/src/index.ts
ExecutionContext now exposes _getLayer(): FMODataLayer; add RetryPolicy and retryPolicy on ExecuteOptions; export isTransientError and RetryPolicy.
Removals
packages/fmodata/src/client/query/expand-builder.ts, packages/fmodata/src/client/query/response-processor.ts, packages/fmodata/src/client/response-processor.ts
Deleted legacy expand-builder and response-processor modules; functionality relocated to shared helpers / new modules.
Minor CLI/doc/tests formatting
packages/fmodata/src/cli/commands/query.ts, packages/fmodata/skills/fmodata-client/SKILL.md
Small formatting/refactor and documentation wording update (neverthrow → Effect.ts).

Sequence Diagram(s)

sequenceDiagram
    rect rgba(135,206,235,0.5)
    participant Builder as Builder (Query/Mutation)
    end
    rect rgba(144,238,144,0.5)
    participant Effect as Effect Pipeline
    participant Layer as FMODataLayer
    end
    rect rgba(255,182,193,0.5)
    participant HTTP as HttpClient
    participant Parser as Response Parser
    end

    Builder->>Effect: execute(options) (uses layer)
    activate Effect
    Effect->>Layer: access HttpClient, ODataConfig, Logger
    Effect->>HTTP: requestFromService(url, opts)
    activate HTTP
    HTTP->>HTTP: apply retry / spans
    HTTP-->>Parser: raw response
    deactivate HTTP
    Parser->>Effect: parsed data or classified error
    Effect-->>Builder: Result<T>
    deactivate Effect
Loading
sequenceDiagram
    participant Test as Test Code
    participant Mock as MockFMServerConnection
    participant Router as Router Fetch
    participant Spy as RequestSpy
    participant DB as Database Instance
    participant Builder as Query Builder

    Test->>Mock: new MockFMServerConnection()
    Test->>Mock: addRoute(MockRoute)
    Mock->>Router: register route
    Test->>Mock: db = mock.database("db")
    DB->>Builder: Builder created from db
    Builder->>Router: HTTP request
    Router->>Spy: record request (if enabled)
    Router-->>Builder: mock response
    Builder-->>Test: Result<T>
    Test->>Spy: spy.forUrl(pattern)
    Spy-->>Test: calls
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch effect-ts
📝 Coding Plan
  • Generate coding plan for human review comments

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

Copy link

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

Caution

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

⚠️ Outside diff range comments (2)
packages/fmodata/src/client/query/query-builder.ts (1)

264-275: ⚠️ Potential issue | 🟠 Major

Serialize ORM filters with the effective useEntityIds value.

where() bakes the FilterExpression with this.config.useEntityIds, but execute() and getQueryString() can override that flag later per request. A query built under one mode and executed under the other will mix field names and FMFIDs in the same URL, which can produce invalid OData. Please defer toODataFilter() until the final useEntityIds value is known.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/query/query-builder.ts` around lines 264 - 275,
The where() method currently converts a FilterExpression to a string immediately
using this.config.useEntityIds; instead, keep the raw FilterExpression (or the
original string) in queryOptions (e.g., store as filterExpression or reuse
queryOptions.filter but as a FilterExpression type) and do not call
FilterExpression.toODataFilter there; update getQueryString() and execute() to
detect if queryOptions.filter is a FilterExpression and call
toODataFilter(effectively using the final useEntityIds value determined at
request time, including any overrides) to produce the actual filter string
before building the URL so the final serialization uses the correct effective
useEntityIds.
packages/fmodata/src/client/batch-builder.ts (1)

94-97: ⚠️ Potential issue | 🟠 Major

addRequest() returns this instead of a new type with the extended builder tuple, breaking type safety.

Builders is fixed when the batch is constructed (line 67). When you call addRequest<T>() and return this, the type remains BatchBuilder<Builders> even though the runtime builders array has grown. This means execute() returns BatchResult<ExtractTupleTypes<Builders>> based on the original tuple, not the dynamically added builders. For example:

const batch = db.batch([]);  // Type: BatchBuilder<[]>
batch.addRequest(someBuilder);  // Still type: BatchBuilder<[]>
const result = await batch.execute();  // Type: BatchResult<[]> ≠ runtime result

Return a new type that extends the tuple: BatchBuilder<[...Builders, ExecutableBuilder<T>]> to keep the type system in sync with runtime additions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/batch-builder.ts` around lines 94 - 97, The
addRequest method currently returns this and therefore preserves the original
generic tuple type (BatchBuilder<Builders>) while mutating the runtime builders
array; change its signature to return a new generic tuple type
(BatchBuilder<[...Builders, ExecutableBuilder<T>]>), update the implementation
to push the builder into the internal builders array and return the object typed
as BatchBuilder<[...Builders, ExecutableBuilder<T>]> (or construct and return a
new BatchBuilder instance with the extended tuple type) so that execute() and
BatchResult<ExtractTupleTypes<...>> reflect the added builder; update references
to addRequest, BatchBuilder, Builders, ExecutableBuilder, execute,
ExtractTupleTypes and BatchResult accordingly.
🧹 Nitpick comments (12)
packages/fmodata/src/validation.ts (3)

358-359: Inconsistent variable naming with the selectedFields branch.

The all-fields branch uses allIssuesAll/failedFieldsAll (lines 358-359), while the selectedFields branch uses allIssues/failedFields (lines 177-178). Since these are in separate branches with no collision risk, using the same names (allIssues/failedFields) would improve consistency and readability.

♻️ Suggested fix
-  const allIssuesAll: StandardSchemaV1.Issue[] = [];
-  const failedFieldsAll: string[] = [];
+  const allIssues: StandardSchemaV1.Issue[] = [];
+  const failedFields: string[] = [];

Then update all references (allIssuesAllallIssues, failedFieldsAllfailedFields) in lines 377-416.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/validation.ts` around lines 358 - 359, Rename the
variables in the all-fields branch to match the selectedFields branch by
replacing allIssuesAll → allIssues and failedFieldsAll → failedFields, and
update every reference to those symbols (currently lines around where
allIssuesAll and failedFieldsAll are used, previously suggested in lines
377-416) so the all-fields branch uses the same variable names as the
selectedFields branch for consistency and readability.

406-416: Same redundant cause pattern as the selectedFields branch.

Consistent with the earlier suggestion, cause: allIssuesAll (or allIssues after renaming) duplicates data already passed as the second parameter.

♻️ Suggested fix
       error: new ValidationError(
-        `Validation failed for field${failedFieldsAll.length > 1 ? "s" : ""} '${failedFieldsAll.join("', '")}'`,
-        allIssuesAll,
-        { field: failedFieldsAll[0], value: record, cause: allIssuesAll },
+        `Validation failed for field${failedFields.length > 1 ? "s" : ""} '${failedFields.join("', '")}'`,
+        allIssues,
+        { field: failedFields[0], value: record },
       ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/validation.ts` around lines 406 - 416, The
ValidationError construction in validation.ts is passing the same issues twice
by setting cause: allIssuesAll while also supplying allIssuesAll as the second
argument; update the error creation in the block that returns valid: false so it
does not duplicate data—remove the cause: allIssuesAll property (or set cause to
a distinct value such as the offending record or undefined) in the new
ValidationError call that uses ValidationError(..., allIssuesAll, { field:
failedFieldsAll[0], value: record, ... }), keeping the message, second-argument
issues array (allIssuesAll), and contextual metadata (field/ value) intact.

239-249: Redundant cause assignment duplicates the issues array.

The allIssues array is already passed as the second parameter to ValidationError. Setting cause: allIssues duplicates this data. The cause property is typically used for error chaining (linking to an originating error), not for repeating already-available information.

♻️ Suggested fix
       return {
         valid: false,
         error: new ValidationError(
           `Validation failed for field${failedFields.length > 1 ? "s" : ""} '${failedFields.join("', '")}'`,
           allIssues,
-          { field: failedFields[0], value: record, cause: allIssues },
+          { field: failedFields[0], value: record },
         ),
       };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/validation.ts` around lines 239 - 249, The
ValidationError construction in the validation routine is redundantly setting
cause: allIssues while already passing allIssues as the second parameter; remove
the cause property from the ValidationError options so only the error details
are passed once. Locate the ValidationError instantiation (uses symbols
allIssues, failedFields, record) and delete the cause: allIssues entry from the
options object, leaving field and value (or other relevant metadata) intact.
packages/fmodata/src/testing.ts (2)

123-128: Consider documenting first-match-wins behavior.

Route matching uses Array.find(), so the first matching route wins. This is a common and sensible approach, but it could be helpful to document this in the MockRoute interface or addRoute method JSDoc so test authors know that more specific routes should be added first.

📝 Suggested documentation addition
   /**
    * Add a route to the mock. Routes added after construction are picked up
    * automatically by subsequent requests.
+   * Routes are matched in order; the first matching route wins.
    */
   addRoute(route: MockRoute): this {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/testing.ts` around lines 123 - 128, Update the
docs/comments to state that route matching is first-match-wins because it uses
Array.find(): explicitly mention this behavior in the MockRoute interface JSDoc
and/or the addRoute method JSDoc so test authors know to register more specific
routes before generic ones; reference the matching logic that uses routes.find
and the urlPattern/method checks to clarify what constitutes a "match."

163-175: Accept header extraction could miss array-style headers.

The code handles Headers, plain objects, and Request inputs, but the array-style headers ([["Accept", "value"]]) are silently ignored on line 170. While this is an edge case, it could cause unexpected behavior if a consumer uses array headers.

📝 Suggested fix for array headers
       if (init.headers instanceof Headers) {
         acceptHeader = init.headers.get("Accept") ?? "";
       } else if (!Array.isArray(init.headers)) {
         acceptHeader =
           (init.headers as Record<string, string>).Accept ?? (init.headers as Record<string, string>).accept ?? "";
+      } else {
+        // Array-style headers: [["Accept", "value"]]
+        const acceptEntry = init.headers.find(([key]) => key.toLowerCase() === "accept");
+        acceptHeader = acceptEntry?.[1] ?? "";
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/testing.ts` around lines 163 - 175, The Accept header
extraction misses array-style headers when init.headers is an array; update the
logic around acceptHeader so that when init?.headers is an Array (e.g.,
Array<[string,string]>) you iterate the entries and pick the first header where
key equals "Accept" or "accept" to set acceptHeader; keep existing handling for
Request, Headers, and plain object paths and then compute shouldStripAnnotations
based on acceptHeader.includes("odata.metadata=none").
packages/fmodata/tests/list-methods.test.ts (1)

5-8: Consider per-test mock setup to prevent future state bleed.

mock/db are currently module-scoped. If this suite grows and starts mutating routes or spy state, tests can become order-dependent. Optional: move setup into beforeEach.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/tests/list-methods.test.ts` around lines 5 - 8, Tests use a
module-scoped MockFMServerConnection (mock), DB_NAME and db which can cause
state bleed between tests; move creation of MockFMServerConnection,
mock.addRoute({ urlPattern: DB_NAME, ... }) and mock.database(DB_NAME) into a
per-test setup (beforeEach) and reset/teardown in afterEach to ensure each test
gets a fresh MockFMServerConnection and route state, referencing the
MockFMServerConnection constructor, mock.addRoute and mock.database calls when
updating the test file.
packages/fmodata/src/index.ts (1)

117-118: Avoid expanding the barrel surface from index.ts.

These new re-exports increase coupling to the barrel entrypoint. Consider exposing and consuming these through explicit subpath exports (for example @proofkit/fmodata/services and @proofkit/fmodata/types) instead of growing index.ts.

As per coding guidelines, **/index.{js,jsx,ts,tsx}: Avoid barrel files (index files that re-export everything).

Also applies to: 130-130

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/index.ts` around lines 117 - 118, The index.ts barrel
now re-exports FMODataLayer, HttpClient, ODataConfig, and ODataLogger which
expands the barrel surface; remove these re-exports from index.ts and instead
expose them via explicit subpath exports (e.g., create/use a dedicated entry
like ./services and ./types and add corresponding export entries in the
package's "exports" mapping), update any internal imports to import from
"./services" or the package subpath (e.g., `@proofkit/fmodata/services`) rather
than from the root barrel, and ensure the unique symbols FMODataLayer,
HttpClient, ODataConfig, and ODataLogger are exported only from the
services/types modules and referenced there.
packages/fmodata/src/errors.ts (1)

236-238: Use narrowing instead of a direct type assertion for name.

The cast on Line 237 weakens type safety and can over-classify arbitrary objects. Narrow name via runtime checks instead.

Proposed refactor
-  if (error && typeof error === "object" && "name" in error) {
-    const name = (error as { name: string }).name;
-    if (name === "NetworkError" || name === "TimeoutError") {
-      return true;
-    }
-  }
+  if (error && typeof error === "object") {
+    const name = Reflect.get(error, "name");
+    if (typeof name === "string" && (name === "NetworkError" || name === "TimeoutError")) {
+      return true;
+    }
+  }

As per coding guidelines, **/*.{ts,tsx}: Leverage TypeScript's type narrowing instead of type assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/errors.ts` around lines 236 - 238, Replace the direct
type assertion (error as { name: string }).name with runtime narrowing: after
confirming error is an object and "name" in error, check that (error as
unknown)['name'] is a string (e.g. typeof (error as Record<string,
unknown>)['name'] === "string") and then assign that value to a const name
without asserting its type; update the conditional that compares name to
"NetworkError" or "TimeoutError" to use this narrowed name value (symbols: error
and name in errors.ts).
packages/fmodata/tests/insert.test.ts (1)

113-137: Keep the default-update branch type-only, or give it a count-specific mock.

The countResult branch is only used in expectTypeOf, but it still performs a real execute() against the insert-shaped mock configured above. That makes the test depend on the wrong response contract while still not asserting the default update runtime path. Either assert the type directly on execute without awaiting it, or add a dedicated count-returning PATCH mock and check the returned updatedCount.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/tests/insert.test.ts` around lines 113 - 137, The test
currently awaits countResult which hits the insert-shaped mock; either keep the
"default-update" branch type-only by not awaiting execute and performing the
type assertion directly on the returned promise (e.g. call
db.from(contacts).update(...).byId(...).execute() and run expectTypeOf on that
promise result type), or add a dedicated PATCH/count mock on
MockFMServerConnection that returns a count-shaped response and then await
countResult and assert countResult.data.updatedCount; update the test around the
countResult variable and the MockFMServerConnection routes (referencing
MockFMServerConnection, db.from(...).update, .byId, and .execute) accordingly.
packages/fmodata/tests/field-id-transforms.test.ts (1)

477-479: Consider removing or converting console.log to a test-appropriate assertion.

The console.log statement in the error branch may clutter test output. If this information is important, consider using a dedicated test logger or converting it to an assertion that documents the expected behavior.

       if (result.error) {
         // If validation failed, check raw response to ensure transformation occurred
-        console.log("Note: Validation failed for expanded data (expected - dynamic fields)");
+        // Validation failure is expected for expanded data with dynamic fields
+        expect(result.error).toBeDefined();
       } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/tests/field-id-transforms.test.ts` around lines 477 - 479,
Remove the stray console.log in the error branch of the test that checks
expanded data (the block that inspects result.error) and either delete it or
replace it with a test assertion that documents the expected behavior (e.g.,
assert that transformation occurred on the raw response or assert that
validation failed for dynamic fields); update the branch around result.error in
the field-id-transforms.test.ts test to use expect/other test helpers instead of
console.log so test output remains clean and meaningful.
packages/fmodata/tests/use-entity-ids-override.test.ts (1)

86-120: Consider extracting repeated localContactsTO definition to reduce duplication.

The localContactsTO table occurrence is defined identically in multiple test blocks (Lines 87-96, 123-132, 157-166). While per-test isolation is valid, extracting this to a shared constant at the describe block level would reduce repetition without compromising test independence.

+  // Shared table occurrence for tests that need local scope
+  const localContactsTO = fmTableOccurrence(
+    "contacts",
+    {
+      id: textField().primaryKey().entityId("FMFID:1"),
+      name: textField().entityId("FMFID:2"),
+    },
+    {
+      entityId: "FMTID:100",
+    },
+  );
+
   it("should work with insert operations", async () => {
-    const localContactsTO = fmTableOccurrence(
-      "contacts",
-      {
-        id: textField().primaryKey().entityId("FMFID:1"),
-        name: textField().entityId("FMFID:2"),
-      },
-      {
-        entityId: "FMTID:100",
-      },
-    );
-
     const mock = new MockFMServerConnection({ enableSpy: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/tests/use-entity-ids-override.test.ts` around lines 86 -
120, Extract the repeated localContactsTO definition (the fmTableOccurrence call
that uses textField().primaryKey().entityId("FMFID:1"),
textField().entityId("FMFID:2"), and entityId: "FMTID:100") into a single const
declared at the describe block level and reuse that constant in each it block;
if any test mutates localContactsTO, ensure tests get a fresh instance by
cloning or creating a helper function (e.g., makeLocalContactsTO()) that returns
the fmTableOccurrence to preserve per-test isolation while removing duplication.
packages/fmodata/tests/mock.test.ts (1)

16-16: Consider using vitest's assert instead of node:console.

Line 16 imports assert from node:console, but Line 19 already imports from vitest which provides assertion utilities. The assert from vitest (already imported on Line 19) would be more consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/tests/mock.test.ts` at line 16, Replace the Node console
assert import with Vitest's assert: remove or change the import statement that
reads import { assert } from "node:console" and use the already-available vitest
assertion utilities (the existing vitest import) so tests use vitest's assert
instead of node:console's; update any references to ensure they import from
vitest (or rely on the current vitest import) rather than node:console.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/fmodata/src/client/entity-set.ts`:
- Around line 111-113: The child builders (created in list(), get(), and
navigate()) currently bake request-scoped defaults by calling
builder.select(allColumns, systemColumns) and resolving relation/source IDs
using this.config.useEntityIds (e.g., assigned to selectedBuilder), which
prevents execute-time overrides like execute({ includeSpecialColumns: false })
or switching ID modes; change this by removing the eager injection/resolution:
do not pass systemColumns or pre-resolve IDs when creating the child builder
(leave systemColumns/ID-mode undefined on the builder), and instead surface
these defaults in the execute path so execute merges this.config and runtime
options before building the final select/navigation SQL; update builder.select
usage (and any pre-resolved navigation path code) in list(), get(), navigate()
and the other similar occurrences (the other two spots flagged) so defaults are
applied at execute-time rather than baked into selectedBuilder.

In `@packages/fmodata/src/client/filemaker-odata.ts`:
- Around line 321-327: The current code always wraps the pipeline with
withRetryPolicy using retryPolicy (variable retryPolicy), which can replay
unsafe write operations; change the logic in the filemaker-odata request flow so
you only call withRetryPolicy(pipeline, retryPolicy) when finalOptions.method is
one of the safe/idempotent methods (e.g., "GET", "HEAD", "OPTIONS", and
optionally "PUT") and retryPolicy is present; for write methods ("POST",
"PATCH", "DELETE") return withSpan(pipeline, "fmodata.request", {...}) without
applying withRetryPolicy; keep the tracing span wrapping via withSpan and
preserve the same attributes ("fmodata.url": url, "fmodata.method":
finalOptions.method ?? "GET") regardless of whether retries were applied.

In `@packages/fmodata/src/client/insert-builder.ts`:
- Around line 185-195: The insert request currently spreads mergedOptions after
method/headers/body so callers can override method, body, and required headers
(Content-Type/Prefer); to fix, ensure mergedOptions cannot clobber these by
extracting and excluding method, headers, and body from mergedOptions before
spreading (e.g., const { method, headers, body, ...rest } = mergedOptions) and
then pass ...rest into requestFromService while building headers by merging
caller headers into your headers with caller headers first and your headers last
(so Content-Type and Prefer from preferHeader win), and always set method:
"POST" and body: JSON.stringify(transformedData) explicitly when invoking
requestFromService (reference mergedOptions, preferHeader, transformedData, and
the requestFromService call that returns responseData).

In `@packages/fmodata/src/client/record-builder.ts`:
- Around line 248-250: The builder currently computes and stores frozen
identifiers early (see getSingleField and navigate setting
mutableBuilder.operationParam via
column.getFieldIdentifier(this.config.useEntityIds)), which breaks when
execute() is given a different request-scoped useEntityIds; change the builder
to store the column/relation metadata (e.g., keep operationColumn and a flag
indicating the operation type) instead of a precomputed operationParam, and in
execute() derive the final identifier by calling
column.getFieldIdentifier(requestUseEntityIds) (or the equivalent relation
identifier method) at request-build time before constructing the URL; update the
similar logic referenced around lines ~455-471 as well so all path segments are
resolved during execute(), not at builder construction.

In `@packages/fmodata/src/client/update-builder.ts`:
- Around line 163-183: buildUrl currently always calls
this.queryBuilder.getQueryString() without the resolved useEntityIds flag,
causing mismatched naming modes; update the function signature of buildUrl to
accept a boolean useEntityIds and in execute() pass mergedOptions.useEntityIds
when calling buildUrl; inside buildUrl call this.queryBuilder.getQueryString({
useEntityIds }) instead of the no-arg variant, and keep the same logic that
trims the leading /{tableId} or /{tableName} from the returned queryString to
form the final URL.

In `@packages/fmodata/src/effect.ts`:
- Around line 50-56: runAsResult currently only catches typed failures via
Effect.catchAll and lets defects (exceptions thrown inside
Effect.gen/Effect.sync, e.g. InvalidLocationHeaderError thrown by
parseLocationHeader used in insert-builder) escape as rejected promises; update
runAsResult to convert defects into the Result error channel by using
Effect.catchAllCause to inspect the Cause and map defects into a typed
FMODataErrorType (or wrap the risky call sites instead using
Effect.try/Effect.tryCatch around parseLocationHeader in insert-builder), so
that Effect.runPromise always resolves to Result<T> rather than rejecting;
specifically modify the runAsResult function (or wrap parseLocationHeader calls)
to handle defects and return Result.error for those cases.

In `@packages/fmodata/tests/validation.test.ts`:
- Around line 78-82: The test's expand callback currently uses an explicit "(b:
any)" annotation which disables type inference and bypasses type checking;
remove the ": any" so the callback is "b => b.select({ name: users.name,
fake_field: users.fake_field })" (i.e., use expand(users, b => ...) ) to let
TypeScript infer the builder type for the expand/select chain (references:
expand, select, users, contacts, db, execute).

---

Outside diff comments:
In `@packages/fmodata/src/client/batch-builder.ts`:
- Around line 94-97: The addRequest method currently returns this and therefore
preserves the original generic tuple type (BatchBuilder<Builders>) while
mutating the runtime builders array; change its signature to return a new
generic tuple type (BatchBuilder<[...Builders, ExecutableBuilder<T>]>), update
the implementation to push the builder into the internal builders array and
return the object typed as BatchBuilder<[...Builders, ExecutableBuilder<T>]> (or
construct and return a new BatchBuilder instance with the extended tuple type)
so that execute() and BatchResult<ExtractTupleTypes<...>> reflect the added
builder; update references to addRequest, BatchBuilder, Builders,
ExecutableBuilder, execute, ExtractTupleTypes and BatchResult accordingly.

In `@packages/fmodata/src/client/query/query-builder.ts`:
- Around line 264-275: The where() method currently converts a FilterExpression
to a string immediately using this.config.useEntityIds; instead, keep the raw
FilterExpression (or the original string) in queryOptions (e.g., store as
filterExpression or reuse queryOptions.filter but as a FilterExpression type)
and do not call FilterExpression.toODataFilter there; update getQueryString()
and execute() to detect if queryOptions.filter is a FilterExpression and call
toODataFilter(effectively using the final useEntityIds value determined at
request time, including any overrides) to produce the actual filter string
before building the URL so the final serialization uses the correct effective
useEntityIds.

---

Nitpick comments:
In `@packages/fmodata/src/errors.ts`:
- Around line 236-238: Replace the direct type assertion (error as { name:
string }).name with runtime narrowing: after confirming error is an object and
"name" in error, check that (error as unknown)['name'] is a string (e.g. typeof
(error as Record<string, unknown>)['name'] === "string") and then assign that
value to a const name without asserting its type; update the conditional that
compares name to "NetworkError" or "TimeoutError" to use this narrowed name
value (symbols: error and name in errors.ts).

In `@packages/fmodata/src/index.ts`:
- Around line 117-118: The index.ts barrel now re-exports FMODataLayer,
HttpClient, ODataConfig, and ODataLogger which expands the barrel surface;
remove these re-exports from index.ts and instead expose them via explicit
subpath exports (e.g., create/use a dedicated entry like ./services and ./types
and add corresponding export entries in the package's "exports" mapping), update
any internal imports to import from "./services" or the package subpath (e.g.,
`@proofkit/fmodata/services`) rather than from the root barrel, and ensure the
unique symbols FMODataLayer, HttpClient, ODataConfig, and ODataLogger are
exported only from the services/types modules and referenced there.

In `@packages/fmodata/src/testing.ts`:
- Around line 123-128: Update the docs/comments to state that route matching is
first-match-wins because it uses Array.find(): explicitly mention this behavior
in the MockRoute interface JSDoc and/or the addRoute method JSDoc so test
authors know to register more specific routes before generic ones; reference the
matching logic that uses routes.find and the urlPattern/method checks to clarify
what constitutes a "match."
- Around line 163-175: The Accept header extraction misses array-style headers
when init.headers is an array; update the logic around acceptHeader so that when
init?.headers is an Array (e.g., Array<[string,string]>) you iterate the entries
and pick the first header where key equals "Accept" or "accept" to set
acceptHeader; keep existing handling for Request, Headers, and plain object
paths and then compute shouldStripAnnotations based on
acceptHeader.includes("odata.metadata=none").

In `@packages/fmodata/src/validation.ts`:
- Around line 358-359: Rename the variables in the all-fields branch to match
the selectedFields branch by replacing allIssuesAll → allIssues and
failedFieldsAll → failedFields, and update every reference to those symbols
(currently lines around where allIssuesAll and failedFieldsAll are used,
previously suggested in lines 377-416) so the all-fields branch uses the same
variable names as the selectedFields branch for consistency and readability.
- Around line 406-416: The ValidationError construction in validation.ts is
passing the same issues twice by setting cause: allIssuesAll while also
supplying allIssuesAll as the second argument; update the error creation in the
block that returns valid: false so it does not duplicate data—remove the cause:
allIssuesAll property (or set cause to a distinct value such as the offending
record or undefined) in the new ValidationError call that uses
ValidationError(..., allIssuesAll, { field: failedFieldsAll[0], value: record,
... }), keeping the message, second-argument issues array (allIssuesAll), and
contextual metadata (field/ value) intact.
- Around line 239-249: The ValidationError construction in the validation
routine is redundantly setting cause: allIssues while already passing allIssues
as the second parameter; remove the cause property from the ValidationError
options so only the error details are passed once. Locate the ValidationError
instantiation (uses symbols allIssues, failedFields, record) and delete the
cause: allIssues entry from the options object, leaving field and value (or
other relevant metadata) intact.

In `@packages/fmodata/tests/field-id-transforms.test.ts`:
- Around line 477-479: Remove the stray console.log in the error branch of the
test that checks expanded data (the block that inspects result.error) and either
delete it or replace it with a test assertion that documents the expected
behavior (e.g., assert that transformation occurred on the raw response or
assert that validation failed for dynamic fields); update the branch around
result.error in the field-id-transforms.test.ts test to use expect/other test
helpers instead of console.log so test output remains clean and meaningful.

In `@packages/fmodata/tests/insert.test.ts`:
- Around line 113-137: The test currently awaits countResult which hits the
insert-shaped mock; either keep the "default-update" branch type-only by not
awaiting execute and performing the type assertion directly on the returned
promise (e.g. call db.from(contacts).update(...).byId(...).execute() and run
expectTypeOf on that promise result type), or add a dedicated PATCH/count mock
on MockFMServerConnection that returns a count-shaped response and then await
countResult and assert countResult.data.updatedCount; update the test around the
countResult variable and the MockFMServerConnection routes (referencing
MockFMServerConnection, db.from(...).update, .byId, and .execute) accordingly.

In `@packages/fmodata/tests/list-methods.test.ts`:
- Around line 5-8: Tests use a module-scoped MockFMServerConnection (mock),
DB_NAME and db which can cause state bleed between tests; move creation of
MockFMServerConnection, mock.addRoute({ urlPattern: DB_NAME, ... }) and
mock.database(DB_NAME) into a per-test setup (beforeEach) and reset/teardown in
afterEach to ensure each test gets a fresh MockFMServerConnection and route
state, referencing the MockFMServerConnection constructor, mock.addRoute and
mock.database calls when updating the test file.

In `@packages/fmodata/tests/mock.test.ts`:
- Line 16: Replace the Node console assert import with Vitest's assert: remove
or change the import statement that reads import { assert } from "node:console"
and use the already-available vitest assertion utilities (the existing vitest
import) so tests use vitest's assert instead of node:console's; update any
references to ensure they import from vitest (or rely on the current vitest
import) rather than node:console.

In `@packages/fmodata/tests/use-entity-ids-override.test.ts`:
- Around line 86-120: Extract the repeated localContactsTO definition (the
fmTableOccurrence call that uses textField().primaryKey().entityId("FMFID:1"),
textField().entityId("FMFID:2"), and entityId: "FMTID:100") into a single const
declared at the describe block level and reuse that constant in each it block;
if any test mutates localContactsTO, ensure tests get a fresh instance by
cloning or creating a helper function (e.g., makeLocalContactsTO()) that returns
the fmTableOccurrence to preserve per-test isolation while removing duplication.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8feaec2f-52a8-4fb7-9fea-553d632d72c8

📥 Commits

Reviewing files that changed from the base of the PR and between 37c3c1f and ab0cf3d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (47)
  • packages/fmodata/package.json
  • packages/fmodata/skills/fmodata-client/SKILL.md
  • packages/fmodata/src/cli/commands/query.ts
  • packages/fmodata/src/client/batch-builder.ts
  • packages/fmodata/src/client/builders/shared-types.ts
  • packages/fmodata/src/client/builders/table-utils.ts
  • packages/fmodata/src/client/database.ts
  • packages/fmodata/src/client/delete-builder.ts
  • packages/fmodata/src/client/entity-set.ts
  • packages/fmodata/src/client/filemaker-odata.ts
  • packages/fmodata/src/client/insert-builder.ts
  • packages/fmodata/src/client/query/query-builder.ts
  • packages/fmodata/src/client/query/url-builder.ts
  • packages/fmodata/src/client/record-builder.ts
  • packages/fmodata/src/client/schema-manager.ts
  • packages/fmodata/src/client/update-builder.ts
  • packages/fmodata/src/client/webhook-builder.ts
  • packages/fmodata/src/effect.ts
  • packages/fmodata/src/errors.ts
  • packages/fmodata/src/index.ts
  • packages/fmodata/src/services.ts
  • packages/fmodata/src/testing.ts
  • packages/fmodata/src/types.ts
  • packages/fmodata/src/validation.ts
  • packages/fmodata/tests/batch-error-messages.test.ts
  • packages/fmodata/tests/batch.test.ts
  • packages/fmodata/tests/delete.test.ts
  • packages/fmodata/tests/errors.test.ts
  • packages/fmodata/tests/expands.test.ts
  • packages/fmodata/tests/field-id-transforms.test.ts
  • packages/fmodata/tests/filters.test.ts
  • packages/fmodata/tests/fmids-validation.test.ts
  • packages/fmodata/tests/include-special-columns.test.ts
  • packages/fmodata/tests/insert.test.ts
  • packages/fmodata/tests/list-methods.test.ts
  • packages/fmodata/tests/metadata.test.ts
  • packages/fmodata/tests/mock.test.ts
  • packages/fmodata/tests/navigate.test.ts
  • packages/fmodata/tests/query-strings.test.ts
  • packages/fmodata/tests/record-builder-select-expand.test.ts
  • packages/fmodata/tests/scripts.test.ts
  • packages/fmodata/tests/typescript.test.ts
  • packages/fmodata/tests/update.test.ts
  • packages/fmodata/tests/use-entity-ids-override.test.ts
  • packages/fmodata/tests/validation.test.ts
  • packages/fmodata/tests/webhooks.test.ts
  • packages/fmodata/vite.config.ts
💤 Files with no reviewable changes (1)
  • packages/fmodata/src/client/builders/shared-types.ts

@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

No open human review comments were found in this PR to create a plan for.

Copy link

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

Caution

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

⚠️ Outside diff range comments (2)
packages/fmodata/src/client/record-builder.ts (2)

289-297: ⚠️ Potential issue | 🟡 Minor

Entity-ID path segments are resolved at builder construction, not execution.

getSingleField() freezes operationParam using this.config.useEntityIds at builder construction time (line 292), but execute() accepts a request-scoped useEntityIds override. If a caller builds the builder under one mode and executes under another, the field identifier part of the URL uses the stale mode while the rest of the request uses the new one.

Consider storing the column reference and resolving the identifier at execute time:

     mutableBuilder.operation = "getSingleField";
     mutableBuilder.operationColumn = column;
-    mutableBuilder.operationParam = column.getFieldIdentifier(this.config.useEntityIds);
+    // Defer identifier resolution to execute() time

Then in execute() and getRequestConfig(), resolve using the effective useEntityIds:

     if (this.operation === "getSingleField" && this.operationColumn) {
-      url += `/${this.operationParam}`;
+      url += `/${this.operationColumn.getFieldIdentifier(mergedOptions.useEntityIds ?? this.config.useEntityIds)}`;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/record-builder.ts` around lines 289 - 297,
getSingleField currently resolves and stores operationParam at builder
construction using this.config.useEntityIds, which can become stale if execute()
is called with a different request-scoped useEntityIds; instead, keep the column
reference and resolve the identifier at execution time. Change getSingleField to
set operation = "getSingleField" and store operationColumn = column (remove
resolving operationParam here), keeping the navigation flags
isNavigateFromEntitySet, navigateRelation, and navigateSourceTableName as-is;
then update execute() and getRequestConfig() to derive the effective
useEntityIds (request override or this.config.useEntityIds) and compute the
field identifier from operationColumn.getFieldIdentifier(effectiveUseEntityIds)
when building the URL or request payload.

500-518: ⚠️ Potential issue | 🟡 Minor

Same entity-ID freeze issue in navigate().

Line 502 resolves relationId using this.config.useEntityIds at builder construction time. If the resulting QueryBuilder is later executed with a different useEntityIds override, the navigation path segment will use the stale identifier.

Consider deferring the relation identifier resolution to the QueryBuilder's execute time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/record-builder.ts` around lines 500 - 518, The
relation identifier is being resolved eagerly in RecordBuilder.navigate (see
resolveTableId and relationId) using this.config.useEntityIds, causing stale IDs
if QueryBuilder execution overrides useEntityIds; instead, stop resolving to a
frozen relationId in RecordBuilder.navigate — store the raw relationName (and
for the source table/store the original table reference rather than a resolved
ID) and move the call to resolveTableId into the QueryBuilder execution path
(e.g., QueryBuilder.execute or the method that builds the request URL) so
resolution uses the effective useEntityIds at execute time; update any places
that expect relationId to instead call resolveTableId(targetTable, relationName,
effectiveUseEntityIds) during execution.
♻️ Duplicate comments (4)
packages/fmodata/src/effect.ts (1)

50-57: ⚠️ Potential issue | 🟠 Major

runAsResult may leak defects as rejected promises.

Effect.catchAll only catches typed failures in the error channel, not defects (exceptions thrown inside Effect.gen/Effect.sync). If code like parseLocationHeader() throws inside a generator, the exception becomes a defect that bypasses catchAll, causing runPromise to reject rather than returning Result.error.

Consider using Effect.catchAllCause to also handle defects:

 export function runAsResult<T>(effect: Effect.Effect<T, FMODataErrorType>): Promise<Result<T>> {
   return Effect.runPromise(
     effect.pipe(
       Effect.map((data): Result<T> => ({ data, error: undefined })),
-      Effect.catchAll((error) => Effect.succeed<Result<T>>({ data: undefined, error })),
+      Effect.catchAllCause((cause) => {
+        const error = Cause.failureOption(cause).pipe(
+          Option.getOrElse(() => {
+            // Handle defects by extracting the first defect
+            const defect = Cause.defects(cause)[0];
+            return defect instanceof Error ? defect : new Error(String(defect));
+          }),
+        );
+        return Effect.succeed<Result<T>>({ data: undefined, error: error as FMODataErrorType });
+      }),
     ),
   );
 }

Alternatively, wrap risky synchronous calls at call sites with Effect.try.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/effect.ts` around lines 50 - 57, runAsResult currently
uses Effect.catchAll so defects (exceptions thrown inside Effect.sync or
Effect.gen) bypass the handler and cause Effect.runPromise to reject; update the
implementation of runAsResult (the function named runAsResult that calls
Effect.runPromise and uses Effect.catchAll) to handle defects as well—either
replace Effect.catchAll with Effect.catchAllCause and map both failures and
causes into the Result error path, or explicitly wrap risky sync calls at their
sites with Effect.try/Effect.tryOrElse (e.g., where parseLocationHeader might
throw) so that exceptions become typed failures that catchAll will handle;
ensure the final returned Promise always resolves to a Result<T> by converting
defects to Result.error rather than letting runPromise reject.
packages/fmodata/src/client/entity-set.ts (2)

303-305: ⚠️ Potential issue | 🟠 Major

Defer navigation ID-mode resolution until request build time.

Line 303 and Line 304 resolve names with this.config.useEntityIds during navigate(), so later execute({ useEntityIds: ... }) can produce mixed path/query modes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/entity-set.ts` around lines 303 - 305, navigate()
is resolving table IDs eagerly using this.config.useEntityIds (via calls to
resolveTableId and producing resolvedRelation/resolvedSourceName), which causes
mixed ID/name modes when execute({ useEntityIds: ... }) is later called; change
navigate() to stop calling resolveTableId and instead store the raw targetTable,
relationName and source table name (e.g., keep occurrence/getTableName values)
and perform resolveTableId only during request build/execute (inside execute or
the request builder) using the runtime flag passed to execute({ useEntityIds:
... }) so all path/query resolution is consistent with the execute-time
useEntityIds setting.

124-127: ⚠️ Potential issue | 🟠 Major

Do not bake includeSpecialColumns defaults into child builders.

Line 124 and Line 195 eagerly pass system columns into select(...), which makes execute({ includeSpecialColumns: false }) ineffective on those builders.

Also applies to: 195-198

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/entity-set.ts` around lines 124 - 127, The child
builders are being created with baked-in system column defaults by passing
systemColumns into builder.select(...) (see selectedBuilder and the
applyNavigationContext(...) call), which prevents execute({
includeSpecialColumns: false }) from working; remove the eager systemColumns
argument so the select() call does not hardcode ROWID/ROWMODID and instead
allows includeSpecialColumns to be applied at execution time (or alternatively
read includeSpecialColumns from the execution options when constructing the
final projection), ensuring the QueryBuilder returned by selectedBuilder does
not permanently include system columns.
packages/fmodata/src/client/insert-builder.ts (1)

151-161: ⚠️ Potential issue | 🟠 Major

Prevent execute options from clobbering the insert request contract.

Line 160 spreads ...mergedOptions after method, headers, and body, so callers can override POST semantics and required headers.

Suggested fix
-      const responseData = yield* requestFromService<any>(url, {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-          Prefer: preferHeader,
-          // biome-ignore lint/suspicious/noExplicitAny: Type assertion for headers object
-          ...((mergedOptions as any)?.headers || {}),
-        },
-        body: JSON.stringify(transformedData),
-        ...mergedOptions,
-      });
+      const { method: _method, body: _body, headers: customHeaders, ...requestOptions } = mergedOptions;
+      const headers = new Headers(customHeaders as HeadersInit | undefined);
+      headers.set("Content-Type", "application/json");
+      headers.set("Prefer", preferHeader);
+
+      const responseData = yield* requestFromService<any>(url, {
+        ...requestOptions,
+        method: "POST",
+        headers,
+        body: JSON.stringify(transformedData),
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/insert-builder.ts` around lines 151 - 161, The
request currently spreads ...mergedOptions after method/headers/body so callers
can override POST, Content-Type, and Prefer; fix by preventing mergedOptions
from clobbering those fields: destructure mergedOptions (e.g. const { method,
headers: optHeaders, body, ...restOptions } = mergedOptions as any) and then
call requestFromService with ...restOptions and explicit method: "POST", body:
JSON.stringify(transformedData), and headers: { "Content-Type":
"application/json", Prefer: preferHeader, ...((optHeaders || {})) } (or
alternatively spread mergedOptions first then explicitly set method/body/headers
after) so requestFromService and the insert builder (insert-builder.ts,
requestFromService, mergedOptions) cannot override required semantics.
🧹 Nitpick comments (3)
packages/fmodata/src/client/builders/index.ts (1)

6-8: Please avoid widening this barrel further.

Adding more re-exports here increases the public surface of an already broad index.ts entry point. I’d keep mutation-helpers and read-builder-state on concrete module paths instead of extending the barrel again.

As per coding guidelines, **/index.{js,jsx,ts,tsx}: Avoid barrel files (index files that re-export everything).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/builders/index.ts` around lines 6 - 8, The barrel
index.ts currently re-exports "./query-string-builder", widening the public
surface; remove the export of "query-string-builder" from this file (leave
exports for "mutation-helpers" and "read-builder-state" intact) and update any
consumers to import from the concrete module path
"packages/fmodata/src/client/builders/query-string-builder" (or its
package-relative path) instead of from the barrel; ensure CI compiles and all
imports referencing query-string-builder are updated to the explicit module
path.
packages/fmodata/src/client/query/index.ts (1)

5-6: Avoid routing ExpandConfig through another index.ts alias.

Re-exporting this from the query barrel couples the query entry point back to builder internals and grows the barrel surface again. Prefer a concrete export path for the shared type.

As per coding guidelines, **/index.{js,jsx,ts,tsx}: Avoid barrel files (index files that re-export everything).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/query/index.ts` around lines 5 - 6, Remove the
re-export of ExpandConfig from the query index (the export type { ExpandConfig }
line) and stop routing that shared builder type through this barrel; instead
update any consumers to import ExpandConfig directly from the builders'
shared-types module (i.e., the canonical shared-types export in the builders
package) so the query entry point no longer couples to builder internals and the
barrel surface is not expanded.
packages/fmodata/src/effect.ts (1)

177-187: Consider simplifying the attributes conditional.

The ternary attributes ? attributes : undefined is redundant since undefined is already the falsy case.

   return effect.pipe(
     Effect.withSpan(name, {
-      attributes: attributes ? attributes : undefined,
+      attributes,
     }),
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/effect.ts` around lines 177 - 187, In withSpan, simplify
the redundant conditional by passing the attributes value directly into
Effect.withSpan; update the call in the withSpan function (the
Effect.withSpan(name, { attributes: ... }) object) to use the shorthand property
(attributes) so that undefined is passed naturally instead of using the ternary
attributes ? attributes : undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/fmodata/src/client/batch-builder.ts`:
- Around line 142-157: In failAllResults, firstErrorIndex is hardcoded to 0
which is invalid when errorCount (derived from this.builders.length) is 0;
change firstErrorIndex to be null when there are no failed items (e.g. set
firstErrorIndex = errorCount === 0 ? null : 0) and update the BatchResult typing
or cast accordingly so firstErrorIndex accepts number | null; refer to function
name failAllResults, variable errorCount, and property firstErrorIndex to locate
and adjust the code.

In `@packages/fmodata/src/client/builders/mutation-helpers.ts`:
- Around line 51-52: When mode === "byId" in the build path that returns
`/${databaseName}/${tableId}('${recordId}')`, validate that recordId is present
and non-empty and otherwise throw a clear error (fail fast) similar to the
filter-mode branch; update the function in mutation-helpers.ts that uses
mode/databaseName/tableId/recordId to check recordId and raise an exception (or
return an error) instead of emitting `'undefined'`.

In `@packages/fmodata/src/client/builders/read-builder-state.ts`:
- Around line 55-75: In cloneRecordReadBuilderState, when incoming changes
includes selectedFields or fieldMapping you must clone those inputs instead of
keeping the caller's reference; change the branches that set selectedFields and
fieldMapping to assign a shallow copy (e.g., selectedFields =
changes?.selectedFields ? [...changes.selectedFields] : undefined and
fieldMapping = changes?.fieldMapping ? { ...changes.fieldMapping } : undefined)
so the returned state does not retain mutable references to caller-provided
arrays/maps while preserving the existing cloning of expandConfigs.
- Around line 25-39: cloneQueryReadBuilderState currently clones expandConfigs
but leaves fieldMapping copied by reference via the spread of state; update
cloneQueryReadBuilderState to clone fieldMapping as well so callers don't
observe mutations: when building the returned object set fieldMapping to a
shallow copy of changes.fieldMapping if provided or a shallow copy of
state.fieldMapping otherwise (e.g. use an object spread to create a new object),
preserving the existing behavior for other fields and keeping the function name
and returned type QueryReadBuilderState<TSchema> intact.

In `@packages/fmodata/src/client/delete-builder.ts`:
- Around line 120-123: The call to requestFromService lets caller options in
mergedOptions override DELETE semantics; change the options construction so
mergedOptions cannot override method or provide a body: spread mergedOptions
first (or remove body from it), then explicitly set method: "DELETE" and ensure
body is cleared (e.g., set body to undefined) before passing to
requestFromService; update the code around requestFromService(url, {
...mergedOptions, method: "DELETE", body: undefined }) or remove
mergedOptions.body beforehand to guarantee requestFromService always sees a
DELETE with no body.

In `@packages/fmodata/src/client/query/query-builder.ts`:
- Around line 341-343: The code currently calls
expression.toODataFilter(this.config.useEntityIds) and patches the query with a
rendered filter string, which locks in compile-time naming; instead, store the
FilterExpression object (e.g., keep the variable expression or a new property
like this.pendingFilterExpression) rather than calling toODataFilter here, stop
patching a concrete filter string in patchQueryOptions, and defer rendering to
buildQueryString(...) where you have access to the resolved runtime flag
(mergedOptions.useEntityIds) used by execute(); update buildQueryString to
detect a pending FilterExpression and call
expression.toODataFilter(resolvedUseEntityIds) there so filter naming respects
execute-time overrides.

In `@packages/fmodata/src/client/update-builder.ts`:
- Around line 177-182: The PATCH call allows callers to override critical
request invariants because ...mergedOptions is spread last; fix by ensuring
method, headers, and body cannot be overridden: when calling
requestFromService(url, { ... }), either spread ...mergedOptions first and then
explicitly set method: "PATCH", headers, body: JSON.stringify(transformedData)
last, or remove method/headers/body from mergedOptions (e.g., omit those keys)
before merging; update the call site that constructs the request (the
requestFromService invocation in update-builder.ts that uses url, mergedOptions,
headers, and transformedData) so the PATCH invariants are always enforced.

---

Outside diff comments:
In `@packages/fmodata/src/client/record-builder.ts`:
- Around line 289-297: getSingleField currently resolves and stores
operationParam at builder construction using this.config.useEntityIds, which can
become stale if execute() is called with a different request-scoped
useEntityIds; instead, keep the column reference and resolve the identifier at
execution time. Change getSingleField to set operation = "getSingleField" and
store operationColumn = column (remove resolving operationParam here), keeping
the navigation flags isNavigateFromEntitySet, navigateRelation, and
navigateSourceTableName as-is; then update execute() and getRequestConfig() to
derive the effective useEntityIds (request override or this.config.useEntityIds)
and compute the field identifier from
operationColumn.getFieldIdentifier(effectiveUseEntityIds) when building the URL
or request payload.
- Around line 500-518: The relation identifier is being resolved eagerly in
RecordBuilder.navigate (see resolveTableId and relationId) using
this.config.useEntityIds, causing stale IDs if QueryBuilder execution overrides
useEntityIds; instead, stop resolving to a frozen relationId in
RecordBuilder.navigate — store the raw relationName (and for the source
table/store the original table reference rather than a resolved ID) and move the
call to resolveTableId into the QueryBuilder execution path (e.g.,
QueryBuilder.execute or the method that builds the request URL) so resolution
uses the effective useEntityIds at execute time; update any places that expect
relationId to instead call resolveTableId(targetTable, relationName,
effectiveUseEntityIds) during execution.

---

Duplicate comments:
In `@packages/fmodata/src/client/entity-set.ts`:
- Around line 303-305: navigate() is resolving table IDs eagerly using
this.config.useEntityIds (via calls to resolveTableId and producing
resolvedRelation/resolvedSourceName), which causes mixed ID/name modes when
execute({ useEntityIds: ... }) is later called; change navigate() to stop
calling resolveTableId and instead store the raw targetTable, relationName and
source table name (e.g., keep occurrence/getTableName values) and perform
resolveTableId only during request build/execute (inside execute or the request
builder) using the runtime flag passed to execute({ useEntityIds: ... }) so all
path/query resolution is consistent with the execute-time useEntityIds setting.
- Around line 124-127: The child builders are being created with baked-in system
column defaults by passing systemColumns into builder.select(...) (see
selectedBuilder and the applyNavigationContext(...) call), which prevents
execute({ includeSpecialColumns: false }) from working; remove the eager
systemColumns argument so the select() call does not hardcode ROWID/ROWMODID and
instead allows includeSpecialColumns to be applied at execution time (or
alternatively read includeSpecialColumns from the execution options when
constructing the final projection), ensuring the QueryBuilder returned by
selectedBuilder does not permanently include system columns.

In `@packages/fmodata/src/client/insert-builder.ts`:
- Around line 151-161: The request currently spreads ...mergedOptions after
method/headers/body so callers can override POST, Content-Type, and Prefer; fix
by preventing mergedOptions from clobbering those fields: destructure
mergedOptions (e.g. const { method, headers: optHeaders, body, ...restOptions }
= mergedOptions as any) and then call requestFromService with ...restOptions and
explicit method: "POST", body: JSON.stringify(transformedData), and headers: {
"Content-Type": "application/json", Prefer: preferHeader, ...((optHeaders ||
{})) } (or alternatively spread mergedOptions first then explicitly set
method/body/headers after) so requestFromService and the insert builder
(insert-builder.ts, requestFromService, mergedOptions) cannot override required
semantics.

In `@packages/fmodata/src/effect.ts`:
- Around line 50-57: runAsResult currently uses Effect.catchAll so defects
(exceptions thrown inside Effect.sync or Effect.gen) bypass the handler and
cause Effect.runPromise to reject; update the implementation of runAsResult (the
function named runAsResult that calls Effect.runPromise and uses
Effect.catchAll) to handle defects as well—either replace Effect.catchAll with
Effect.catchAllCause and map both failures and causes into the Result error
path, or explicitly wrap risky sync calls at their sites with
Effect.try/Effect.tryOrElse (e.g., where parseLocationHeader might throw) so
that exceptions become typed failures that catchAll will handle; ensure the
final returned Promise always resolves to a Result<T> by converting defects to
Result.error rather than letting runPromise reject.

---

Nitpick comments:
In `@packages/fmodata/src/client/builders/index.ts`:
- Around line 6-8: The barrel index.ts currently re-exports
"./query-string-builder", widening the public surface; remove the export of
"query-string-builder" from this file (leave exports for "mutation-helpers" and
"read-builder-state" intact) and update any consumers to import from the
concrete module path "packages/fmodata/src/client/builders/query-string-builder"
(or its package-relative path) instead of from the barrel; ensure CI compiles
and all imports referencing query-string-builder are updated to the explicit
module path.

In `@packages/fmodata/src/client/query/index.ts`:
- Around line 5-6: Remove the re-export of ExpandConfig from the query index
(the export type { ExpandConfig } line) and stop routing that shared builder
type through this barrel; instead update any consumers to import ExpandConfig
directly from the builders' shared-types module (i.e., the canonical
shared-types export in the builders package) so the query entry point no longer
couples to builder internals and the barrel surface is not expanded.

In `@packages/fmodata/src/effect.ts`:
- Around line 177-187: In withSpan, simplify the redundant conditional by
passing the attributes value directly into Effect.withSpan; update the call in
the withSpan function (the Effect.withSpan(name, { attributes: ... }) object) to
use the shorthand property (attributes) so that undefined is passed naturally
instead of using the ternary attributes ? attributes : undefined.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3b0befae-3f51-4d54-9e6d-9b1a6700623f

📥 Commits

Reviewing files that changed from the base of the PR and between ab0cf3d and 3692d28.

📒 Files selected for processing (26)
  • packages/fmodata/src/client/batch-builder.ts
  • packages/fmodata/src/client/builders/index.ts
  • packages/fmodata/src/client/builders/mutation-helpers.ts
  • packages/fmodata/src/client/builders/read-builder-state.ts
  • packages/fmodata/src/client/builders/response-processor.ts
  • packages/fmodata/src/client/database.ts
  • packages/fmodata/src/client/delete-builder.ts
  • packages/fmodata/src/client/entity-set.ts
  • packages/fmodata/src/client/insert-builder.ts
  • packages/fmodata/src/client/query/expand-builder.ts
  • packages/fmodata/src/client/query/index.ts
  • packages/fmodata/src/client/query/query-builder.ts
  • packages/fmodata/src/client/query/response-processor.ts
  • packages/fmodata/src/client/query/types.ts
  • packages/fmodata/src/client/record-builder.ts
  • packages/fmodata/src/client/response-processor.ts
  • packages/fmodata/src/client/runtime.ts
  • packages/fmodata/src/client/schema-manager.ts
  • packages/fmodata/src/client/update-builder.ts
  • packages/fmodata/src/client/webhook-builder.ts
  • packages/fmodata/src/effect.ts
  • packages/fmodata/src/errors.ts
  • packages/fmodata/src/services.ts
  • packages/fmodata/tests/effect-layer-execution.test.ts
  • packages/fmodata/tests/mutation-helpers.test.ts
  • packages/fmodata/tests/response-processor.test.ts
💤 Files with no reviewable changes (3)
  • packages/fmodata/src/client/query/expand-builder.ts
  • packages/fmodata/src/client/response-processor.ts
  • packages/fmodata/src/client/query/response-processor.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/fmodata/tests/mutation-helpers.test.ts

Copy link

@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

Caution

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

⚠️ Outside diff range comments (5)
packages/fmodata/src/validation.ts (2)

591-600: ⚠️ Potential issue | 🟠 Major

Guard response before reading response.value.

Line 591 dereferences response.value before the null/empty-response check, so validateSingleResponse(undefined, ...) throws a TypeError instead of returning the documented result union. Move the !response branch ahead of the count checks, or use optional chaining here. As per coding guidelines, Use optional chaining (?.) and nullish coalescing (??) for safer property access.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/validation.ts` around lines 591 - 600, The code
dereferences response.value before checking whether response is null, causing a
TypeError; update the checks in validateSingleResponse to guard against
null/undefined by either moving the if (!response) branch above the count check
or using optional chaining and nullish coalescing—e.g., use
response?.value?.length when testing for > 1 and (response?.value?.length ?? 0)
when testing for === 0—and keep the RecordCountMismatchError and mode ===
"exact" logic unchanged so behavior remains identical for non-null responses.

296-314: ⚠️ Potential issue | 🟠 Major

Prefix expanded-relation issue paths before rethrowing.

These branches forward itemValidation.error.issues unchanged, so callers get nested paths like ["name"] instead of ["users", 0, "name"] or ["users", "name"]. That makes the aggregated ValidationError.issues payload ambiguous once expands are involved.

Also applies to: 321-338, 463-480, 488-505

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/validation.ts` around lines 296 - 314, The validation
branches rethrow itemValidation.error.issues unchanged, so issue paths lose
their expanded-context; update those error issues before constructing the new
ValidationError: map over itemValidation.error.issues and prepend the expand
path (use expandConfig.relation and the index i for array expands) to each
issue.path (e.g., newPath = [expandConfig.relation, i, ...oldPath] or
[expandConfig.relation, ...oldPath] when not indexing), then pass the
transformed issues into the ValidationError constructor instead of the raw
itemValidation.error.issues; apply the same fix for the other similar branches
referenced (around validateRecord returns at the other ranges).
packages/fmodata/tests/field-id-transforms.test.ts (1)

475-496: ⚠️ Potential issue | 🟠 Major

Don't let this test pass on the error path.

The if (result.error) branch only asserts that an error exists, so a regression that breaks expanded-relation transformation still produces a passing test. If failure is expected here, make it a dedicated error test; otherwise fail fast on result.error and keep the transformation assertions unconditional.

Suggested change
-      if (result.error) {
-        expect(result.error).toBeDefined();
-      } else {
-        expect(result.data).toBeDefined();
-        expect(result.data).toHaveLength(1);
-        if (result.data?.[0]) {
-          const contact = result.data[0];
-          expect(contact).toMatchObject({
-            PrimaryKey: "contact-1",
-            name: "Contact One",
-            id_user: "550e8400-e29b-41d4-a716-446655440001",
-          });
-          // Check expanded relation was transformed
-          expect(contact.users).toHaveLength(1);
-          expect(contact.users[0]).toMatchObject({
-            id: "550e8400-e29b-41d4-a716-446655440001",
-            name: "Alice",
-          });
-        }
-      }
+      expect(result.error).toBeUndefined();
+      if (!result.data?.[0]) {
+        throw new Error("Expected expanded relation data");
+      }
+      const contact = result.data[0];
+      expect(contact).toMatchObject({
+        PrimaryKey: "contact-1",
+        name: "Contact One",
+        id_user: "550e8400-e29b-41d4-a716-446655440001",
+      });
+      expect(contact.users).toHaveLength(1);
+      expect(contact.users[0]).toMatchObject({
+        id: "550e8400-e29b-41d4-a716-446655440001",
+        name: "Alice",
+      });
packages/fmodata/src/client/batch-builder.ts (1)

216-224: ⚠️ Potential issue | 🟡 Minor

Set firstErrorIndex when the batch is truncated.

If the first failure is a missing parsed response, errorCount increments but firstErrorIndex stays null. That leaves the summary metadata inconsistent with results.

Suggested fix
         if (!parsed) {
-          const failedAtIndex = firstErrorIndex ?? i;
+          if (firstErrorIndex === null) {
+            firstErrorIndex = i;
+          }
+          const failedAtIndex = firstErrorIndex;
           results.push({
             data: undefined,
             error: new BatchTruncatedError(i, failedAtIndex),
             status: 0,
           });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/batch-builder.ts` around lines 216 - 224, When
encountering a missing parsed response inside the batch loop (the branch that
pushes a BatchTruncatedError into results), ensure you set firstErrorIndex when
it is currently null so summary metadata matches results; specifically, update
the variable firstErrorIndex (used later for summary) to i when it is null
before incrementing errorCount and continuing. Locate the branch that creates
new BatchTruncatedError and modify the logic around firstErrorIndex, errorCount,
and results to assign firstErrorIndex = i (or the null-coalesced equivalent) so
the summary reflects the true first failure index.
packages/fmodata/src/client/update-builder.ts (1)

206-243: ⚠️ Potential issue | 🟠 Major

toRequest() still hard-codes the builder's ID mode.

execute() resolves shouldUseIds from call-site options, but getRequestConfig() / toRequest() always use this.config.useEntityIds for both the URL and body transformation. Batched updates built via BatchBuilder.execute({ useEntityIds: ... }) can therefore serialize table IDs and field names in a different mode than the actual request options.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/update-builder.ts` around lines 206 - 243,
getRequestConfig() and toRequest() must resolve the effective "use entity IDs"
from the call-site options instead of always using this.config.useEntityIds: add
an optional parameter (e.g., options?: ExecuteOptions or useEntityIds?: boolean)
to getRequestConfig and thread that through to toRequest (or accept options in
toRequest and pass it into getRequestConfig), compute const shouldUseIds =
options?.useEntityIds ?? this.config.useEntityIds, then use shouldUseIds for
resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder"), for
transformFieldNamesToIds(this.data, this.table) and for buildMutationUrl(...,
useEntityIds: shouldUseIds, ...), ensuring URL and body transformation use the
same resolved flag so Batched updates respect the per-call option.
🧹 Nitpick comments (2)
packages/fmodata/tests/use-entity-ids-override.test.ts (1)

29-40: Consider moving helper outside describe block for consistency.

The makeLocalContactsTO factory is defined inside the describe block but contactsTO is at module level. Both serve similar purposes. This is a minor inconsistency but acceptable given the test isolation needs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/tests/use-entity-ids-override.test.ts` around lines 29 - 40,
The factory function makeLocalContactsTO is defined inside the describe block
while contactsTO is defined at module scope; extract makeLocalContactsTO to
module scope (move its declaration out of the describe) so both helpers share
the same scope and consistency; ensure any references to makeLocalContactsTO
inside the describe remain unchanged and that imports/exports (if any) still
resolve after moving the function.
packages/fmodata/src/client/entity-set.ts (1)

136-136: Extract the default page size into a shared constant.

list() now hard-codes 1000 in three places, while packages/fmodata/src/client/query/query-builder.ts already defines the same default. Pulling this into one shared constant will keep the implicit page size from drifting between entry points.

As per coding guidelines, "Use meaningful variable names instead of magic numbers - extract constants with descriptive names".

Also applies to: 150-152, 168-168

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/entity-set.ts` at line 136, The hard-coded page
size 1000 in EntitySet.list (seen in the selectedBuilder assignment using
this.applyNavigationContext(...).top(1000)) should be replaced with a shared
constant; import or re-export the existing default from
packages/fmodata/src/client/query/query-builder.ts (the QueryBuilder default
page size constant) or create a single shared constant (e.g., DEFAULT_PAGE_SIZE)
and use it wherever .top(1000) is used in this file (also replace the
occurrences around lines 150-152 and 168). Update the selectedBuilder call and
the other two .top(1000) calls to use that constant name so the implicit page
size is defined in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/fmodata/src/client/batch-builder.ts`:
- Around line 178-184: The batch assembly currently calls the synchronous
builders.map(b => b.toRequest(...)) which bypasses new async
validation/transforms; update the pipeline generator to invoke the builders'
async request preparation (e.g., an async
toRequest/prepareRequest/validateAndTransform method on the builder) and await
them before formatting the batch. Specifically, in the Effect.gen block where
pipeline is created (symbol: pipeline), replace the synchronous map with an
Effect-level parallel/sequence (e.g., Effect.all or Effect.forEach) that yields
the array of Request objects from each builder's async preparation method, then
pass the awaited requests into formatBatchRequestFromNative as before; keep
using yield* Effect.tryPromise for the formatBatchRequestFromNative call.

In `@packages/fmodata/src/client/query/query-builder.ts`:
- Around line 755-761: The request config path (toRequest() ->
getRequestConfig()) ignores per-call batch/time overrides (like useEntityIds and
includeSpecialColumns) so BatchBuilder.execute(options) can render URLs with
wrong naming mode; update toRequest() and getRequestConfig() to accept an
overrides/options parameter and merge those into the builder state before
building the query string (i.e., merge options.useEntityIds and
options.includeSpecialColumns into this.config / this.readState or pass them
into urlBuilder.build), and ensure BatchBuilder.execute(options) passes its
options through to toRequest(); reference functions: toRequest,
getRequestConfig, BatchBuilder.execute, urlBuilder.build, readState, and config.

In `@packages/fmodata/src/client/update-builder.ts`:
- Around line 179-186: callerHeaders (a HeadersInit) is being spread into an
object which loses entries for Headers instances or tuple arrays; fix by
normalizing callerHeaders via the Headers constructor in update-builder.ts
before merging: create a Headers from callerHeaders (e.g., new
Headers(callerHeaders)) and copy its entries into the final headers set used in
the requestFromService call (using Headers.forEach or for..of and .set()) so
that all header types (object, Headers, tuple[]) are preserved when building the
headers passed to requestFromService.

In `@packages/fmodata/src/effect.ts`:
- Around line 50-62: In runAsResult, replace the unsafe instanceof Error cast
with a proper domain-type check: use isFMODataError(defect) to determine if the
thrown defect already matches FMODataErrorType and return it directly, otherwise
wrap non-domain defects with new BuilderInvariantError("runAsResult",
String(defect)) and return that as the FMODataErrorType; update the catch block
in the runAsResult function so it calls isFMODataError(defect) and only treats
the defect as FMODataErrorType when that guard succeeds, using
BuilderInvariantError for all other cases.
- Around line 79-88: The cast erases environment requirements; remove the "as
Effect.Effect<T, FMODataErrorType>" assertion on the provided variable and
instead constrain the effect's environment so the layer satisfies it. Change the
effect parameter type from Effect.Effect<T, FMODataErrorType, R> to
Effect.Effect<T, FMODataErrorType, FMODataLayer> (or make R extend FMODataLayer)
and let const provided = Effect.provide(withOptionalSpan(effect, spanName,
attributes), layer) infer its correct type without assertions.

---

Outside diff comments:
In `@packages/fmodata/src/client/batch-builder.ts`:
- Around line 216-224: When encountering a missing parsed response inside the
batch loop (the branch that pushes a BatchTruncatedError into results), ensure
you set firstErrorIndex when it is currently null so summary metadata matches
results; specifically, update the variable firstErrorIndex (used later for
summary) to i when it is null before incrementing errorCount and continuing.
Locate the branch that creates new BatchTruncatedError and modify the logic
around firstErrorIndex, errorCount, and results to assign firstErrorIndex = i
(or the null-coalesced equivalent) so the summary reflects the true first
failure index.

In `@packages/fmodata/src/client/update-builder.ts`:
- Around line 206-243: getRequestConfig() and toRequest() must resolve the
effective "use entity IDs" from the call-site options instead of always using
this.config.useEntityIds: add an optional parameter (e.g., options?:
ExecuteOptions or useEntityIds?: boolean) to getRequestConfig and thread that
through to toRequest (or accept options in toRequest and pass it into
getRequestConfig), compute const shouldUseIds = options?.useEntityIds ??
this.config.useEntityIds, then use shouldUseIds for
resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder"), for
transformFieldNamesToIds(this.data, this.table) and for buildMutationUrl(...,
useEntityIds: shouldUseIds, ...), ensuring URL and body transformation use the
same resolved flag so Batched updates respect the per-call option.

In `@packages/fmodata/src/validation.ts`:
- Around line 591-600: The code dereferences response.value before checking
whether response is null, causing a TypeError; update the checks in
validateSingleResponse to guard against null/undefined by either moving the if
(!response) branch above the count check or using optional chaining and nullish
coalescing—e.g., use response?.value?.length when testing for > 1 and
(response?.value?.length ?? 0) when testing for === 0—and keep the
RecordCountMismatchError and mode === "exact" logic unchanged so behavior
remains identical for non-null responses.
- Around line 296-314: The validation branches rethrow
itemValidation.error.issues unchanged, so issue paths lose their
expanded-context; update those error issues before constructing the new
ValidationError: map over itemValidation.error.issues and prepend the expand
path (use expandConfig.relation and the index i for array expands) to each
issue.path (e.g., newPath = [expandConfig.relation, i, ...oldPath] or
[expandConfig.relation, ...oldPath] when not indexing), then pass the
transformed issues into the ValidationError constructor instead of the raw
itemValidation.error.issues; apply the same fix for the other similar branches
referenced (around validateRecord returns at the other ranges).

---

Nitpick comments:
In `@packages/fmodata/src/client/entity-set.ts`:
- Line 136: The hard-coded page size 1000 in EntitySet.list (seen in the
selectedBuilder assignment using this.applyNavigationContext(...).top(1000))
should be replaced with a shared constant; import or re-export the existing
default from packages/fmodata/src/client/query/query-builder.ts (the
QueryBuilder default page size constant) or create a single shared constant
(e.g., DEFAULT_PAGE_SIZE) and use it wherever .top(1000) is used in this file
(also replace the occurrences around lines 150-152 and 168). Update the
selectedBuilder call and the other two .top(1000) calls to use that constant
name so the implicit page size is defined in one place.

In `@packages/fmodata/tests/use-entity-ids-override.test.ts`:
- Around line 29-40: The factory function makeLocalContactsTO is defined inside
the describe block while contactsTO is defined at module scope; extract
makeLocalContactsTO to module scope (move its declaration out of the describe)
so both helpers share the same scope and consistency; ensure any references to
makeLocalContactsTO inside the describe remain unchanged and that
imports/exports (if any) still resolve after moving the function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2fd18f28-26c6-4f56-abcf-815e37a6f974

📥 Commits

Reviewing files that changed from the base of the PR and between 3692d28 and ab2b09f.

📒 Files selected for processing (23)
  • packages/fmodata/package.json
  • packages/fmodata/src/client/batch-builder.ts
  • packages/fmodata/src/client/builders/mutation-helpers.ts
  • packages/fmodata/src/client/builders/read-builder-state.ts
  • packages/fmodata/src/client/delete-builder.ts
  • packages/fmodata/src/client/entity-set.ts
  • packages/fmodata/src/client/filemaker-odata.ts
  • packages/fmodata/src/client/insert-builder.ts
  • packages/fmodata/src/client/query/query-builder.ts
  • packages/fmodata/src/client/query/url-builder.ts
  • packages/fmodata/src/client/record-builder.ts
  • packages/fmodata/src/client/update-builder.ts
  • packages/fmodata/src/effect.ts
  • packages/fmodata/src/errors.ts
  • packages/fmodata/src/index.ts
  • packages/fmodata/src/testing.ts
  • packages/fmodata/src/validation.ts
  • packages/fmodata/tests/field-id-transforms.test.ts
  • packages/fmodata/tests/insert.test.ts
  • packages/fmodata/tests/list-methods.test.ts
  • packages/fmodata/tests/mock.test.ts
  • packages/fmodata/tests/use-entity-ids-override.test.ts
  • packages/fmodata/tests/validation.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/fmodata/src/index.ts
  • packages/fmodata/src/client/query/url-builder.ts
  • packages/fmodata/package.json

Copy link

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

♻️ Duplicate comments (1)
packages/fmodata/src/client/entity-set.ts (1)

136-140: ⚠️ Potential issue | 🟠 Major

includeSpecialColumns is still baked into child builders in list() and get().

Line 137 and Line 208 eagerly apply ROWID/ROWMODID from config before execute() options are known. That can still make execute-time overrides ineffective for schema-default selection flows.

💡 Directional fix
-        const selectedBuilder = this.applyNavigationContext(
-          this.config.includeSpecialColumns
-            ? builder.select(allColumns, { ROWID: true, ROWMODID: true })
-            : builder.select(allColumns),
-        ).top(1000);
+        const selectedBuilder = this.applyNavigationContext(builder.select(allColumns)).top(1000);
-        const selectedBuilder = this.applyNavigationContext(
-          this.config.includeSpecialColumns
-            ? builder.select(allColumns, { ROWID: true, ROWMODID: true })
-            : builder.select(allColumns),
-        );
+        const selectedBuilder = this.applyNavigationContext(builder.select(allColumns));

Then apply includeSpecialColumns when building the final request in execute() (after merging runtime options).

Also applies to: 207-211

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/client/entity-set.ts` around lines 136 - 140, The child
builders in list() and get() currently bake config.includeSpecialColumns into
the builder via builder.select(..., { ROWID: true, ROWMODID: true }) inside
applyNavigationContext, preventing execute() runtime options from overriding
selection; change the flow to stop adding ROWID/ROWMODID in the child builders
(remove the select with special columns in the code paths building
selectedBuilder in list() and get()), and instead apply includeSpecialColumns
when constructing the final request inside execute() after merging runtime
options so that execute() can decide whether to include ROWID/ROWMODID; look for
applyNavigationContext, builder.select, list(), get(), execute(), and
config.includeSpecialColumns to make this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/fmodata/src/errors.ts`:
- Around line 115-116: The ValidationError constructor currently defaults the
Error `cause` to the `issues` array; change it so `cause` is taken only from
options (const cause = options?.cause) and only pass a cause to super when an
actual cause is provided (call super(message) if cause is undefined, otherwise
super(message, { cause })); leave this.issues = issues untouched and do not
assign the issues array as the Error cause to preserve proper error-chain
semantics for the ValidationError class.

---

Duplicate comments:
In `@packages/fmodata/src/client/entity-set.ts`:
- Around line 136-140: The child builders in list() and get() currently bake
config.includeSpecialColumns into the builder via builder.select(..., { ROWID:
true, ROWMODID: true }) inside applyNavigationContext, preventing execute()
runtime options from overriding selection; change the flow to stop adding
ROWID/ROWMODID in the child builders (remove the select with special columns in
the code paths building selectedBuilder in list() and get()), and instead apply
includeSpecialColumns when constructing the final request inside execute() after
merging runtime options so that execute() can decide whether to include
ROWID/ROWMODID; look for applyNavigationContext, builder.select, list(), get(),
execute(), and config.includeSpecialColumns to make this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 582aa99e-c55d-48cd-aa4b-016b59b22a05

📥 Commits

Reviewing files that changed from the base of the PR and between ab2b09f and 3198e0f.

📒 Files selected for processing (3)
  • packages/fmodata/src/client/builders/expand-builder.ts
  • packages/fmodata/src/client/entity-set.ts
  • packages/fmodata/src/errors.ts

Comment on lines +115 to +116
const cause = options?.cause ?? issues;
super(message, { cause });
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether downstream code assumes `error.cause` is Error-like.
# Expected: no hard assumptions that `cause` always has Error properties when handling ValidationError.

rg -nP --type=ts -C3 '\bValidationError\b|\.cause\b|cause\?\.(message|stack|name)|instanceof\s+Error'

Repository: proofgeist/proofkit

Length of output: 50375


🏁 Script executed:

# Check the ValidationError class implementation
sed -n '100,130p' packages/fmodata/src/errors.ts

Repository: proofgeist/proofkit

Length of output: 1020


🏁 Script executed:

# Search for places where .cause property is accessed on ValidationError
rg -n 'error\.cause' packages/fmodata/src/ --type ts -A 2 -B 2

Repository: proofgeist/proofkit

Length of output: 1511


🏁 Script executed:

# Search for specific patterns that treat cause as Error or array-like
rg -n '\.cause\s*instanceof|cause\?.flatten|cause\[' packages/ --type ts

Repository: proofgeist/proofkit

Length of output: 1206


Remove the default of cause to the issues array in ValidationError.

The current code sets cause to an array when no underlying exception exists, which breaks error-chain consistency. Downstream code expects error.cause to be an Error-like object (or undefined), not an array. Additionally, when ValidationError instances are chained internally, passing an array as the cause option violates JavaScript error semantics.

Keep issues on this.issues only, and pass cause to the parent constructor only when an actual cause is provided:

Suggested fix
-    const cause = options?.cause ?? issues;
-    super(message, { cause });
+    super(message, options?.cause ? { cause: options.cause } : undefined);
📝 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 cause = options?.cause ?? issues;
super(message, { cause });
super(message, options?.cause ? { cause: options.cause } : undefined);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fmodata/src/errors.ts` around lines 115 - 116, The ValidationError
constructor currently defaults the Error `cause` to the `issues` array; change
it so `cause` is taken only from options (const cause = options?.cause) and only
pass a cause to super when an actual cause is provided (call super(message) if
cause is undefined, otherwise super(message, { cause })); leave this.issues =
issues untouched and do not assign the issues array as the Error cause to
preserve proper error-chain semantics for the ValidationError class.

Copy link

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/fmodata/src/client/update-builder.ts`:
- Around line 167-171: The error-mapping currently uses "instanceof Error" then
force-casts results to FMODataErrorType (in the handler that constructs new
BuilderInvariantError("ExecutableUpdateBuilder.execute", ...)) which lets
non-BuilderInvariantError values slip through; change the runtime check to
"instanceof BuilderInvariantError" so TypeScript can narrow the union correctly
and remove the unsafe casts. Apply the same change to the other error mapping
(the block around the referenced 286–291 region), ensuring both mappings return
a value of type FMODataErrorType without using "as any" or "as
FMODataErrorType".

In `@packages/fmodata/src/effect.ts`:
- Around line 30-35: The fromResult and fromValidation functions currently cast
unknown rejections to FMODataErrorType with assertions; replace that with a
small domain-aware error-narrowing helper that uses isFMODataError and
BuilderInvariantError: when the catch receives e, if isFMODataError(e) return e,
otherwise wrap/convert the unknown error into a BuilderInvariantError (or
another appropriate FMODataErrorType) and return that; then call that helper
from the Effect.tryPromise catch handlers in fromResult and fromValidation so
the error channel only emits properly narrowed FMODataErrorType instances
instead of blind casts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cdd7ad24-4348-48a2-8054-1306a946aa5e

📥 Commits

Reviewing files that changed from the base of the PR and between 3198e0f and 7eb6c4f.

📒 Files selected for processing (2)
  • packages/fmodata/src/client/update-builder.ts
  • packages/fmodata/src/effect.ts

claude and others added 11 commits March 17, 2026 09:50
Replace manual error-threading boilerplate in all builders with Effect
pipelines. Effect is used as an internal implementation detail — the
public API (Result<T>) is unchanged.

Changes:
- Add `effect` dependency, remove unused `neverthrow`
- Add `src/effect.ts` with bridge utilities (fromResult, makeRequestEffect,
  runAsResult, tryEffect, fromValidation)
- Refactor `_makeRequest` into an Effect pipeline with extracted helpers
  (_classifyError, _parseHttpError, _checkEmbeddedODataError)
- Refactor InsertBuilder.execute() to use Effect.gen pipeline
- Refactor UpdateBuilder.execute() to use Effect.gen pipeline
- Refactor DeleteBuilder.execute() to use Effect.gen pipeline
- Refactor QueryBuilder.execute() to use Effect pipe composition
- Refactor BatchBuilder.execute() to use Effect.gen pipeline

All 873 tests pass with zero type errors.

https://claude.ai/code/session_01VdwR8FRDc9f1qS68z2Sfzo
…ng spans

Phase 2 of Effect.ts integration:
- Add opt-in RetryPolicy for transient errors (SchemaLockedError, NetworkError, TimeoutError, HTTP 5xx)
  with exponential backoff and jitter via Effect Schedule
- Accumulate all validation errors instead of fail-fast, returning all field issues in a single ValidationError
- Add Effect.withSpan tracing to all builder execute() methods and HTTP requests for zero-cost observability

All changes remain non-breaking — public API (Result<T>, error classes, type guards) is unchanged.

https://claude.ai/code/session_01VdwR8FRDc9f1qS68z2Sfzo
…te tests

Phase 3 of Effect.ts integration:
- Define Effect Services (HttpClient, ODataConfig, ODataLogger) for dependency injection
- Create MockFMServerConnection with router-style fetch handler and request spy
- Export testing utilities via @proofkit/fmodata/testing subpath
- Migrate 22 test files from createMockClient/fetchHandler to MockFMServerConnection
- All 870 tests passing, 0 type errors, publint passes

https://claude.ai/code/session_01VdwR8FRDc9f1qS68z2Sfzo
…r pattern

Replace constructor parameter threading (ExecutionContext, databaseName,
useEntityIds, etc.) with Effect's Context.Tag + Layer pattern across all
builders. Each builder now receives an FMODataLayer and extracts config
synchronously via extractConfigFromLayer() for non-Effect methods.

Key changes:
- All builders (Query, Record, Insert, Update, Delete, Batch) use layer-based DI
- SchemaManager and WebhookManager converted to use Effect pipelines
- Database.getMetadata/listTableNames/runScript use requestFromService
- Removed deprecated makeRequestEffect() and runWithContext() from effect.ts
- Simplified ExecutionContext interface to just _getLayer()
- Removed unused BuilderConfig interface from shared-types.ts
- createDatabaseLayer() enables database-scoped config overrides

https://claude.ai/code/session_01EpwtyTQeyjVu3Qykf6aMBd
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