Skip to content

feat(agent-bff): publish typed record schemas in the openapi document - #1867

Open
Tonours wants to merge 6 commits into
mainfrom
feat/prd-1105-typed-list-records
Open

feat(agent-bff): publish typed record schemas in the openapi document#1867
Tonours wants to merge 6 commits into
mainfrom
feat/prd-1105-typed-list-records

Conversation

@Tonours

@Tonours Tonours commented Aug 31, 2026

Copy link
Copy Markdown
Member

What

The unfolded OpenAPI document emits a Record_<collection> schema per collection with a known field set, typed from the capabilities field types and published nullable — the capabilities never report nullability — and a ListResponse_<collection> wrapper referencing it. A relation list operation answers with the FOREIGN collection's response schema. A degraded collection keeps the shared untyped ListResponse, for its own list and for relations pointing at it.

No runtime behaviour change: the record shape, the key transform and the id string form are untouched. This documents them.

fixes PRD-1105

Three traps the document now names

  • Fields are published under the key the response carries, not the schema name. agent-client deserializes JSON:API with keyForAttribute: 'camelCase', so a first_name column is projected under that name and returned as firstName. The transform comes from inflected — the same library jsonapi-serializer uses — so parity holds by construction rather than through a hand-rolled mirror that would drift silently.
  • id is a string on every record, even when the key column is a Number. It is the JSON:API resource id. __forest.primaryKey carries the same id typed, so the two forms of one record disagree by construction, and comparing them without coercion fails. Stated on the shared ForestRecordMeta and ListResponse too, so the generic document carries it, including the |-joined packed form of a composite key.
  • Every field is nullable. The capabilities report a column type and never its nullability, so a nullable column answers null against a type a non-null schema would reject. firstName is ['string', 'null']; a Json column stays unconstrained because it already accepted null, and nested items and sub-properties are widened too, since the same holds at every depth. Publishing non-null would have been a claim the runtime breaks on the first empty column.

Verification

  • yarn workspace @forestadmin/agent-bff test — includes redocly spec validity, the generated-client suite, and a real-agent integration suite pinning id: "8" (string) against __forest.primaryKey.id: 8 (number), and first_name returned as firstName.
  • lint, build, yarn install --frozen-lockfile: clean.

The live-agent harness those integration suites share (findFreePort, buildApp, the schema transport) lives in test/data/fixtures/live-agent-harness.ts rather than being copied per suite.

Known limitations

  • The camelCase transform is lossy: fields differing only by case or separator (first_name vs firstName) collapse onto one response key. The record schema publishes that key unconstrained and names the fields that reach it. The underlying data loss is out of scope and reported separately.
  • The transform recurses into attribute values, so the inner keys of a Json column are camelized too.
  • packages/workflow-executor/src/adapters/agent-client-agent-port.ts transcribes this transform by hand; the monorepo now holds both that transcription and a direct use of inflected. Unifying them is tracked as PRD-1113.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

PRD-1105

Comment thread packages/agent-bff/src/openapi/record-schemas.ts Outdated
@qltysh

qltysh Bot commented Aug 31, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/unfolded-paths.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/collect-unfolding.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/names.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/schemas.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/openapi/record-schemas.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two-axis review (standards + spec against PRD-1105). The spec side holds up: parity with jsonapi-serializer's camelCase branch is real, the id string-vs-number contract is pinned by a genuine real-agent test, degraded collections are covered on both halves, and "no runtime change" checks out. One blocking question on nullability, one on DRY in the new integration test.

Comment thread packages/agent-bff/src/openapi/record-schemas.ts
Comment thread packages/agent-bff/test/data/record-contract.integration.test.ts Outdated
Comment thread global.d.ts Outdated
@Tonours

Tonours commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Review pass done. Two cleanups from it, both mine from the last push:

  • The collapse rationale ("several fields can collapse to the same key… the type is left open") had ended up stacked above nullable() instead of propertySchema, so it read as an explanation of the nullability helper. Moved back.
  • global.d.ts was left with a stray blank line after the hand-written inflected declaration came out. It is byte-identical to main again, which is the right end state now that @types/inflected covers it.

Worth recording from the pass, since it is not obvious from the diff: record-contract.integration.test.ts passes on revert, by design. It pins the runtime the schema mirrors — id: "8" against __forest.primaryKey.id: 8, and first_name returned as firstName — and never reads the OpenAPI output. That is what makes it useful: if the agent or agent-client ever changes either behaviour, the test fails and the published schema is then known to be wrong. The schema side is pinned separately by record-schemas.test.ts and the unfolded document tests, which do fail on revert.

Two ceilings named rather than fixed:

  • nullable() only wraps the top-level type. A nullable item inside an array column, or a nested property of an object column, is still published non-null. The capabilities carry no nullability at any depth, so the same argument applies one level down — but a Json column is already unconstrained, and the array/object cases are rare enough that widening them now would be guesswork rather than a correction.
  • The degraded branch keys on projectable.length === 0 rather than degraded !== null. Equivalent for everything collect-unfolding produces, since a degraded collection reports no projectable field; a hand-built Unfolding with degraded set and fields would get a typed record. No caller does that.

id stays non-nullable and required on purpose: response-mappers.ts throws on a missing or null id, so a record without one never reaches a response.

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Round 2. The three findings from the first pass are confirmed fixed: the harness extraction is clean (the search suite keeps the same secrets, timezone and middleware order), @types/inflected sits correctly in devDependencies and matches the pinned 1.x, and the nullable fix is the right call. One follow-up on how deep it goes.

One thing outside the diff: the PR body still says properties are "typed from the capabilities field types" and never mentions nullability, though a generated client's field types went from string to string | null since the first revision — worth a line.

Comment on lines +37 to +41
* declare non-null — a generated client validating the response would reject what the runtime
* really sends. An unconstrained schema already accepts null and is left alone.
*/
function nullable(schema: SchemaObject): SchemaObject {
if (typeof schema.type !== 'string') return schema;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nullable() stops at the container, so items and a nested object's properties stay non-null — the same failure mode one level down, while the record prose says a null is always possible whatever the type says. Recurse, or narrow the prose to the top level?

Comment on lines +17 to +24
export const TIMEZONE = 'Europe/Paris';
export const AUTH_SECRET = 'b0bdf0a639c16bae8851dd24ee3d79ef0a352e957c5b86cb';
export const ENV_SECRET = 'ceba742f5bc73946b34da192816a4d7177b3233fee7769955c29c0e90fd584f2';
export const BOOT_TIMEOUT_MS = 60_000;

// agent-testing only deletes a schema file whose name carries this prefix, so reusing it keeps the
// temporary schema cleaned up by `agent.stop()` even though the path is chosen here.
export const RESERVED_SCHEMA_PREFIX = 'reserved-forestadmin-schema-test-';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TIMEZONE and noopLogger are exported but neither suite imports them.

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