Skip to content

Split fmodata count into count-only and counted list#210

Open
eluce2 wants to merge 3 commits intomainfrom
feature/split-fmodata-count-api
Open

Split fmodata count into count-only and counted list#210
eluce2 wants to merge 3 commits intomainfrom
feature/split-fmodata-count-api

Conversation

@eluce2
Copy link
Copy Markdown
Collaborator

@eluce2 eluce2 commented Apr 7, 2026

Summary

  • Add db.from(table).count() as a count-only flow against /$count.
  • Keep db.from(table).list().count() for one-request list plus total count, returning { records, count }.
  • Update query typing, URL building, response parsing, tests, docs, and README for the new split.
  • Add a minor changeset for @proofkit/fmodata.

Testing

  • Not run
  • Added/updated package tests for batch, mock, query strings, e2e, and TypeScript behavior.
  • Updated docs examples and method reference to match the new API.

Summary by CodeRabbit

  • New Features

    • Count API split: top-level count returns a number; list-based count returns { records, count } for paginated queries.
  • Documentation

    • Examples and docs updated to show the two count patterns and how to retrieve totals with or without rows.
  • Tests

    • Added unit/e2e/mock/type tests covering both count flows, batch behavior, error cases, and compile-time usage constraints.
  • Chores

    • Added changeset documenting the minor version bump and behavior change.

- add count-only `db.from(table).count()`
- keep `list().count()` for `{ records, count }`
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 7, 2026

🦋 Changeset detected

Latest commit: e78762a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@proofkit/fmodata Minor
@proofkit/better-auth Patch
@proofkit/typegen Patch
@proofkit/cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

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

Project Deployment Actions Updated (UTC)
proofkit-docs Ready Ready Preview Apr 7, 2026 6:56pm

Request Review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 7, 2026

Open in StackBlitz

@proofkit/better-auth

pnpm add https://pkg.pr.new/proofsh/proofkit/@proofkit/better-auth@210

@proofkit/cli

pnpm add https://pkg.pr.new/proofsh/proofkit/@proofkit/cli@210

create-proofkit

pnpm add https://pkg.pr.new/proofsh/proofkit/create-proofkit@210

@proofkit/fmdapi

pnpm add https://pkg.pr.new/proofsh/proofkit/@proofkit/fmdapi@210

@proofkit/fmodata

pnpm add https://pkg.pr.new/proofsh/proofkit/@proofkit/fmodata@210

@proofkit/typegen

pnpm add https://pkg.pr.new/proofsh/proofkit/@proofkit/typegen@210

@proofkit/webviewer

pnpm add https://pkg.pr.new/proofsh/proofkit/@proofkit/webviewer@210

commit: 0959f8d

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

Split the fmodata count API: db.from(table).count() issues a count-only /$count request returning a number, while db.from(table).list().count() performs a list query that returns { records, count }. Introduces CountBuilder, URL/path adjustments, type plumbing, response parsing, and tests.

Changes

Cohort / File(s) Summary
Changeset & Docs
.changeset/split-fmodata-count-api.md, apps/docs/content/docs/fmodata/methods.mdx, apps/docs/content/docs/fmodata/queries.mdx, packages/fmodata/README.md
Documented the API split and updated examples to show db.from(...).count() vs db.from(...).list().count() usage.
New Count Builder
packages/fmodata/src/client/count-builder.ts
Added CountBuilder implementing standalone /$count requests, filtering, query-string construction, execution, and numeric response parsing/validation.
EntitySet & Query URL
packages/fmodata/src/client/entity-set.ts, packages/fmodata/src/client/query/url-builder.ts
Added EntitySet.count() factory; QueryUrlBuilder.buildPath() gains isCount to render /$count suffix instead of query-parameter $count.
Query Builder & Types
packages/fmodata/src/client/query/query-builder.ts, packages/fmodata/src/client/query/types.ts, packages/fmodata/src/client/query/index.ts
Introduced IncludeCount generic, updated fluent method typings, prevented single()/maybeSingle() when count-enabled, and changed QueryReturnType to support CountedListResult. Re-exported CountedListResult.
Response Processing & State
packages/fmodata/src/client/builders/read-builder-state.ts, packages/fmodata/src/client/builders/response-processor.ts
Added includeCountMode state; processQueryResponse accepts includeCount and validates/parses @odata.count, returning { records, count } when enabled and enforcing structure invariants.
Types & Public Exports
packages/fmodata/src/types.ts, packages/fmodata/src/index.ts
Added CountedListResult<T> and adjusted type helpers (OData annotations, special columns); exported CountBuilder (type) and CountedListResult. Minor ExecutableBuilder.execute(options?) typing change.
Batch / Record / Generic plumbing
packages/fmodata/src/client/batch-builder.ts, packages/fmodata/src/client/record-builder.ts
Updated internal type helpers and ExtractTupleTypes to infer from processResponse signatures; adjusted generic argument placements when instantiating builders to thread new booleans.
Tests
packages/fmodata/tests/*.test.ts, packages/fmodata/tests/e2e/*.test.ts, packages/fmodata/tests/*
Added/updated tests for: list-with-count and top-level count behaviors, batch with mixed count types, mock response validation (structure errors), query-string generation for /$count vs ?$count=true, E2E assertions, and TypeScript ergonomics/type-constraint tests.

Sequence Diagram

sequenceDiagram
    participant Client
    participant EntitySet
    participant CountBuilder
    participant QueryBuilder
    participant ResponseProcessor
    participant FMODataServer

    rect rgba(100,200,150,0.5)
    Note over Client,FMODataServer: Count-Only Flow: db.from(table).count()
    Client->>EntitySet: count()
    EntitySet->>CountBuilder: new CountBuilder(occurrence, layer)
    Client->>CountBuilder: where(...)? / execute()
    CountBuilder->>CountBuilder: buildQueryString(isCount=true)
    CountBuilder->>FMODataServer: GET /table/$count[?$filter=...]
    FMODataServer-->>CountBuilder: "5" (plain text)
    CountBuilder->>ResponseProcessor: processResponse(response)
    ResponseProcessor-->>CountBuilder: { data: 5, error: undefined }
    CountBuilder-->>Client: Promise<Result<number>>
    end

    rect rgba(150,150,200,0.5)
    Note over Client,FMODataServer: List-With-Count Flow: db.from(table).list().count()
    Client->>EntitySet: list()
    EntitySet->>QueryBuilder: new QueryBuilder(...)
    Client->>QueryBuilder: top(...)/skip(...)/count()
    QueryBuilder->>QueryBuilder: buildQueryString(isCount=false, $count=true)
    QueryBuilder->>FMODataServer: GET /table?$top=...&$skip=...&$count=true[&$filter=...]
    FMODataServer-->>QueryBuilder: { "@odata.count": 150, "value": [ ... ] }
    QueryBuilder->>ResponseProcessor: processQueryResponse(response, includeCount=true)
    ResponseProcessor-->>QueryBuilder: { data: { records: [...], count: 150 }, error: undefined }
    QueryBuilder-->>Client: Promise<Result<CountedListResult<T>>>
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Split fmodata count into count-only and counted list" clearly and concisely summarizes the main change: splitting the count API into two distinct flows for standalone counting versus list-based counting with records.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/split-fmodata-count-api

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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/builders/response-processor.ts`:
- Around line 280-309: The counted-response branch currently ignores
processedResponse.error and can return a successful CountedListResult with
records undefined; update the includeCount handling in the function that
processes processODataResponse results to first check if processedResponse.error
exists and, if so, return { data: undefined, error: processedResponse.error }
before building the { records, count } result (preserve existing singleMode and
`@odata.count` validation and ResponseStructureError behavior). Ensure you
reference processedResponse, includeCount, singleMode, and the CountedListResult
wrapper when applying the guard so original validation/structure errors
propagate.

In `@packages/fmodata/src/client/count-builder.ts`:
- Around line 101-105: The navigated count URLs are built without the "/$count"
suffix because QueryUrlBuilder.build() returns navigation paths before applying
the isCount branch; update the code paths that construct count requests (methods
execute, getRequestConfig, and getQueryString usage) to call the path-building
logic that respects isCount or to use buildPath() for navigations so the isCount
flag results in a "/$count" segment; specifically, ensure
QueryUrlBuilder.build(queryString, { isCount: true, useEntityIds: ...,
navigation: this.navigationConfig }) produces a path with "/$count" by moving
the isCount handling ahead of the navigation-return branch or by delegating
navigation cases to buildPath() which already appends "/$count".

In `@packages/fmodata/src/types.ts`:
- Around line 11-12: The interface ExecutableBuilder<_T> is erasing its generic
by returning Result<unknown>; update the signatures to use the generic type
parameter (e.g., change execute(options?: ExecuteOptions):
Promise<Result<unknown>> to return Promise<Result<T>>) and likewise update
processResponse (and any other methods on ExecutableBuilder) to use T instead of
unknown so implementations like UpdateBuilder/QueryBuilder/InsertBuilder
correctly preserve their concrete type parameter through the API boundary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f62c5b5a-6052-4fab-9d4f-a2fc4b86ba76

📥 Commits

Reviewing files that changed from the base of the PR and between 175b0bd and ac7c9f4.

📒 Files selected for processing (21)
  • .changeset/split-fmodata-count-api.md
  • apps/docs/content/docs/fmodata/methods.mdx
  • apps/docs/content/docs/fmodata/queries.mdx
  • packages/fmodata/README.md
  • packages/fmodata/src/client/batch-builder.ts
  • packages/fmodata/src/client/builders/read-builder-state.ts
  • packages/fmodata/src/client/builders/response-processor.ts
  • packages/fmodata/src/client/count-builder.ts
  • packages/fmodata/src/client/entity-set.ts
  • packages/fmodata/src/client/query/index.ts
  • packages/fmodata/src/client/query/query-builder.ts
  • packages/fmodata/src/client/query/types.ts
  • packages/fmodata/src/client/query/url-builder.ts
  • packages/fmodata/src/client/record-builder.ts
  • packages/fmodata/src/index.ts
  • packages/fmodata/src/types.ts
  • packages/fmodata/tests/batch.test.ts
  • packages/fmodata/tests/e2e/e2e.test.ts
  • packages/fmodata/tests/mock.test.ts
  • packages/fmodata/tests/query-strings.test.ts
  • packages/fmodata/tests/typescript.test.ts

- preserve query errors before counted result shaping
- keep typed execute/processResponse returns
- fix navigated /$count URL generation
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
Occ extends FMTable<any, any>,
Selected,
> = Selected extends Record<string, Column<any, any, any>> // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The biome-ignore comment for noExplicitAny should be moved to the line above (line 71) instead of being at the end of line 72, so it properly suppresses the lint warning for the 'any' types in 'Column<any, any, any>'

Spotted by Graphite (based on CI logs)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 (2)

1015-1030: ⚠️ Potential issue | 🟡 Minor

Return the counted-list shape from the 204 fallback.

Once includeCountMode is on, processResponse() is typed to return { records, count }, but this branch still returns a bare array. Any caller that round-trips a counted list through toRequest()/processResponse() will get a runtime shape mismatch here.

🐛 Proposed fix
     if (response.status === 204) {
       // Return empty list for list queries, null for single queries
       if (this.readState.singleMode !== false) {
         if (this.readState.singleMode === "maybe") {
           // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
           return { data: null as any, error: undefined };
         }
         return {
           data: undefined,
           error: new RecordCountMismatchError("one", 0),
         };
       }
+      if (this.readState.includeCountMode) {
+        return {
+          data: { records: [], count: 0 } as any,
+          error: undefined,
+        };
+      }
       // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
       return { data: [] as any, error: undefined };
     }
🤖 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 1015 - 1030,
The 204-response branch in processResponse() returns a bare array for list
queries but must return the counted-list shape when includeCountMode is enabled;
update the list-return branch (when this.readState.singleMode is false) to check
this.readState.includeCountMode and return { records: [] , count: 0 } (typed to
the generic) instead of [] so callers that expect { records, count } get the
correct shape; keep existing handling for singleMode/“maybe” and the
RecordCountMismatchError for the single-item case.

689-728: ⚠️ Potential issue | 🟠 Major

Pin IncludeCount to false in expand() callback return type.

The callback return type permits any for the sixth generic parameter (IncludeCount), allowing builder.count() to type-check inside expand callbacks. However, expanded relations are modeled as plain nested records without a { records, count } wrapper, making this state unsupported end-to-end. This creates a silent type-safety gap where valid TypeScript code produces incorrect runtime behavior.

Enforce IncludeCount: false in both the callback return type and the factory-created QueryBuilder to prevent count mode in nested expands unless full end-to-end support is added.

♻️ Proposed fix
   callback?: (
     // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands in initial builder
     builder: QueryBuilder<TargetTable, keyof InferSchemaOutputFromFMTable<TargetTable>, false, false, {}, false>,
     // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration
-    ) => QueryBuilder<TargetTable, TSelected, any, any, TNestedExpands, any, any>,
+    ) => QueryBuilder<
+      TargetTable,
+      TSelected,
+      any,
+      any,
+      TNestedExpands,
+      false,
+      DatabaseIncludeSpecialColumns,
+      any
+    >,
   ): QueryBuilder<
@@
       callback as ((builder: TargetBuilder) => TargetBuilder) | undefined,
       () =>
         // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration
-        new QueryBuilder<TargetTable, any, any, any, any, any, DatabaseIncludeSpecialColumns, undefined>({
+        new QueryBuilder<TargetTable, any, any, any, any, false, DatabaseIncludeSpecialColumns, undefined>({
           occurrence: targetTable,
           layer: this.layer,
         }),
🤖 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 689 - 728,
The expand callback currently allows the sixth generic (IncludeCount) to be any,
enabling builder.count() inside expand; update the callback signature passed to
expand() and the QueryBuilder constructed in the processExpand call so the sixth
generic is explicitly false (i.e., change the callback type from
QueryBuilder<TargetTable, TSelected, any, any, TNestedExpands, any, any> to
QueryBuilder<TargetTable, TSelected, any, any, TNestedExpands, false, any> and
construct new QueryBuilder<TargetTable, any, any, any, any, false,
DatabaseIncludeSpecialColumns, undefined>), ensuring IncludeCount is pinned to
false in both the callback return type and the factory-created QueryBuilder used
in expandBuilder.processExpand.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/fmodata/src/client/query/query-builder.ts`:
- Around line 1015-1030: The 204-response branch in processResponse() returns a
bare array for list queries but must return the counted-list shape when
includeCountMode is enabled; update the list-return branch (when
this.readState.singleMode is false) to check this.readState.includeCountMode and
return { records: [] , count: 0 } (typed to the generic) instead of [] so
callers that expect { records, count } get the correct shape; keep existing
handling for singleMode/“maybe” and the RecordCountMismatchError for the
single-item case.
- Around line 689-728: The expand callback currently allows the sixth generic
(IncludeCount) to be any, enabling builder.count() inside expand; update the
callback signature passed to expand() and the QueryBuilder constructed in the
processExpand call so the sixth generic is explicitly false (i.e., change the
callback type from QueryBuilder<TargetTable, TSelected, any, any,
TNestedExpands, any, any> to QueryBuilder<TargetTable, TSelected, any, any,
TNestedExpands, false, any> and construct new QueryBuilder<TargetTable, any,
any, any, any, false, DatabaseIncludeSpecialColumns, undefined>), ensuring
IncludeCount is pinned to false in both the callback return type and the
factory-created QueryBuilder used in expandBuilder.processExpand.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 59a45ace-c721-4d3d-8114-d6a9b5d3bbd1

📥 Commits

Reviewing files that changed from the base of the PR and between e78762a and 0959f8d.

📒 Files selected for processing (1)
  • packages/fmodata/src/client/query/query-builder.ts

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant