Skip to content

parse datastream payloads through Fern serializers#122

Draft
bpapillon wants to merge 6 commits into
mainfrom
fix-datastream-payload-deserialization
Draft

parse datastream payloads through Fern serializers#122
bpapillon wants to merge 6 commits into
mainfrom
fix-datastream-payload-deserialization

Conversation

@bpapillon
Copy link
Copy Markdown
Contributor

@bpapillon bpapillon commented May 22, 2026

Incoming WebSocket payloads (rulesengine.Flag, Company, User) were applied to the cache via bare as Schematic.Foo casts. The Go server emits snake_case JSON; Fern's TS types and rulesengine-rust's WASM (#[serde(rename_all = "camelCase")]) both expect camelCase. So every camelCase property read on a cached entity returned undefined — silently breaks findCreditCondition() on the credit-lease check path, and the WASM eval receives input it interprets as all-defaults (empty rules, empty entitlements).

Run FULL payloads through parseOrThrow and have partialCompany / partialUser write camelCase target keys on PARTIAL updates, so the cache stays in a single canonical shape. Parse failures degrade to a warn-log + skip rather than poisoning the connection.

Incoming WebSocket payloads (`rulesengine.Flags`, `Company`, `User`) were
applied to the local cache via bare `message.data as Schematic.Foo`
casts, with no transformation between the wire format and the in-memory
type.

Two stacked bugs fall out of that:

1. snake_case vs camelCase. The Go server emits snake_case JSON
   (`event_subtype`, `condition_type`, `credit_id`), and the Fern-
   generated TS types use camelCase. Without running incoming payloads
   through the serializer, every camelCase property read on a cached
   entity returns `undefined`. That silently breaks any downstream
   consumer that walks the cached structure — most painfully
   `findCreditCondition()` on the credit-lease check path, which
   loops over `condition.conditionType` and `condition.eventSubtype`
   looking for a match and never finds one.

2. Go nil-slice → JSON null. `json.Marshal` serializes a nil slice as
   `null`, but the Fern serializers declare these as required
   `list(...)` and reject null with an opaque "Expected list. Received
   null." error. The same flag tree that fails the camelCase reads
   above also has `condition_groups: null` on rules that don't carry
   any groups, so even after wiring the serializer in the parse rejects
   the whole payload.

Fix: run flag / company / user payloads through `parseOrThrow` after
walking the raw object to coerce known list-shaped wire fields from
null to []. Parse failures degrade to a warn-log + skip rather than
poisoning the whole connection.
@bpapillon bpapillon self-assigned this May 22, 2026
@bpapillon bpapillon requested a review from a team as a code owner May 22, 2026 17:39
@bpapillon bpapillon marked this pull request as draft May 22, 2026 17:39
bpapillon added 5 commits May 22, 2026 13:42
… shape

partialCompany/partialUser were writing snake_case keys back onto a
camelCase cached entity, leaving the cache in a hybrid shape after any
partial update. Update both to write camelCase target keys consistently
so the cache stays in one canonical form.

Test fixture updates:
- Add `plan_version_ids: []` to all Company mocks. Required by the
  Fern schema and previously absent — once payloads route through
  parseOrThrow, the missing field caused ParseError and downstream
  test timeouts (the cache update silently failed and getCompany()
  hung waiting for a never-arriving WS response).
- Compute expected post-parse shapes via asCompany/asUser/asFlag
  helpers. Wire fixtures stay snake_case (matching what the server
  sends); assertions compare against the camelCase canonical shape.
- Switch partial-merge assertions from snake_case field names to
  camelCase to match the canonicalized merge output.
…onicalization

merge.test.ts: cached entities now model the post-parseOrThrow shape
(camelCase). Partial inputs stay snake_case (wire). Assertions check
the camelCase output keys partialCompany/partialUser now write.

datastream-client.test.ts: the two-level-caching metrics test was
providing a metric with only { eventSubtype, value } — parseOrThrow
rejects the company on the FULL message (RulesengineCompanyMetric
requires account_id/company_id/created_at/environment_id/event_subtype/
month_reset/period/value), so the cache never lands and the
subsequent getCompany hangs out the test timer. Provide a full metric.
rulesengine v0.1.16 migrated billing_product_ids, plan_ids,
plan_version_ids, and entitlements on the wire Company struct to
JSONSlice[T] — so they marshal as [] not null. But until the api
ships v0.1.16 (api PR #5535) the WebSocket still ships null for
these on any company without a plan/subscription, and parseOrThrow
on the SDK side rejects the message.

Existing NULLABLE_LIST_KEYS set missed the four names above; add
them so the coerce helper keeps the SDK working against pre-bump
APIs in the field. Once #5535 deploys the whole coerceNullArrays
hack can be deleted in one followup.
rulesengine v0.1.16 wire types use JSONSlice[T] so nil slices marshal
as [] not null, and the api bump (schematic-api #5535) is now live on
main. parseOrThrow on the WS payloads no longer encounters null where
a list is expected, so the coerce helper and its key allowlist have
nothing to do. Pass message.data straight into the serializers.
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