feat: add a runtime parse boundary and six persisted-document namespaces - #10
Merged
Conversation
This package shipped types only. A TypeScript type is erased before any stored data is read, so it constrains what a caller writes and never what arrives, and the gap was being closed with casts and coercion. A cast is worse than an unchecked read: it suppresses the diagnostic that would have reported the mistake, and `Number(x ?? 0)` turns a malformed value into a `NaN` that loses every comparison it is subsequently used in without anything throwing. Adds, per namespace, a zod schema co-located with its `Interface`, plus `parse` and `safeParse` helpers that turn `unknown` into the interface or a typed failure. There is no branch that yields a typed value which was never validated. New shared module `src/interface/schema.ts`: - ParseResult / ParseError / parseResult / parseOrThrow - AssertSchemaOutput, a type-level proof that each schema produces its declared interface, so divergence is a compile error - field builders: finiteNumber, nonNegativeNumber, counter, documentId, nonEmptyString, token, epochSeconds, epochMillis, timestampLike, auditTimestamp, openValue, requiredKey Schemas added to Account, Block, EventData, MessagingEvent, Post, Price, BaseFirestore, MessageQueue, BasePlaceData and PlaceData. Six new namespaces for persisted shapes that had no declared document type: Idempotency, Ledger, Reservation, Entitlement, Capacity and MessageUsage. Notable decisions, all documented in the source: - Unknown keys are preserved, not dropped and not rejected. That matches the `[x: string]: any` index signature on BaseFirestore; a stripping schema would delete stored fields on a read-modify-write and a strict one would reject documents predating the schema. - Timestamps are validated structurally and passed through by reference, so a live Timestamp keeps its prototype and any TTL policy keyed on it still works. This is the runtime resolution of the fields typed `any`. - epochSeconds and epochMillis have deliberately disjoint ranges. Capacity.expiresAt is seconds and Reservation.expiresAt is milliseconds; a field name is not a unit. - Capacity enforces that expiresAt and expires denote the same instant whenever both are present. - `| null` is accepted only where an interface declares it, and rejected where the interface says only `?`. Asserted at runtime because strictNullChecks is off and the compiler cannot check it. Ledger.service is a validated string rather than an enum: the set of metered surfaces is owned by the metering service, and a partial copy here would reject legitimate records. 903 tests, all gates green, and lib/ is regenerated in this commit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
This package shipped types only. A TypeScript type is erased before any stored data is read, so it constrains what a caller writes and never what arrives — and the gap gets closed with a cast:
A cast is worse than an unchecked read. It does not merely skip the check, it suppresses the diagnostic that would have reported the mistake, so everything downstream is correctly typed against a value that was never verified.
Number('abc')isNaN,NaNloses every comparison it is subsequently used in, and nothing throws.This PR supplies the missing parse boundary. It does not invent a type system — the domain types already existed and are good.
What
Every namespace now exports a zod schema co-located with its
Interface, plusparseandsafeParse. There is no branch that yields a typed value which was never validated:ParseSuccesshas noissues,ParseFailurehas nodata.New shared module
src/interface/schema.ts—ParseResult/ParseError/parseResult/parseOrThrow, theAssertSchemaOutputcompile-time proof, and field builders (finiteNumber,nonNegativeNumber,counter,documentId,nonEmptyString,token,epochSeconds,epochMillis,timestampLike,auditTimestamp,openValue,requiredKey).Schemas added to
Account,Block,EventData,MessagingEvent,Post,Price,BaseFirestore,MessageQueue,BasePlaceData,PlaceData.Six new namespaces for persisted shapes with no declared document type:
Idempotency,Ledger,Reservation,Entitlement,Capacity,MessageUsage.Decisions worth reviewing
Unknown keys are preserved — not dropped, not rejected.
BaseFirestoredeclares[x: string]: any, so a strict schema would contradict the published type and reject documents predating it; a stripping schema (zod's default) silently deletes stored fields on a read-modify-write.z.looseObjectis the only option that loses nothing. Tested per namespace.Timestamps are validated structurally and passed through by reference. Validating a
Timestampwithz.object({seconds, nanoseconds})rebuilds it into a plain object and destroys the class instance — writing that back breaks any TTL policy keyed on the field, silently.z.customvalidates without reconstructing. This is the runtime resolution of the fields typedany: their honest type needs the server SDK'sFieldValue, which must not enter every consumer's dependency closure, so the constraint is enforced where it can actually be checked. A write payload carrying a server sentinel is deliberately rejected, with a documented widening escape (Schema.extend({created: openValue()})).epochSecondsandepochMillishave deliberately disjoint ranges.Capacity.expiresAtis seconds;Reservation.expiresAtis milliseconds. Same field name, two units, three orders of magnitude apart — a field name is not a unit. Neither value can now validate as the other.Capacityenforces thatexpiresAtandexpiresdenote the same instant whenever both are present (expires.seconds === Math.trunc(expiresAt)). Both failure modes are silent: a TTL derived with the wrong unit either deletes a live hold early or never deletes it at all.Capacity.ObjectSchemais exported for callers validating a hold whose TTL is not yet derived.| nullpolicy. WithstrictNullChecks: falsethese annotations are unenforced by our own compiler but are emitted into the.d.tsand enforced by a consumer compiling strictly. So the rule is asserted at runtime instead: a field annotated| nullacceptsnull; a field annotated only?rejects it, because?and| nullare different claims and a parse must not return a value the declared type says cannot occur..nullish()is used nowhere.test/interface/schema.test.tsdrivesnullinto every key of all 14 schemas and asserts the accepting set equals an explicit inventory — and does the same for required-vs-optional. Audit/event timestamps areany-typed and therefore decided explicitly: they rejectnull, since an explicitly null timestamp is not a time and reading it as epoch zero sorts it first and expires it immediately.Gates
npm run buildnpm testnpm run typechecknpm run lintgit status --porcelain -uall -- lib/.github/scripts/check-private-markers.shlib/is regenerated and committed in the same commit. The eight new modules land as untracked output, which is precisely the casegit diff --exit-codewould have missed — the-uallform catches them.Evidence
20 of 20 mutations detected. Each loosens one control, runs the suite, and is reverted; the substitution itself is verified before the result is trusted, so a no-op mutation cannot report a false green.
Price.amountmoney →anyLedger.amountmoney →anyCapacitydrop cross-field expiry checkCapacity.expiresAtseconds → unboundedReservation.expiresAtmillis → unboundedEntitlement.typeenum → stringIdempotency.stateenum → stringMessageUsage.periodclosed grammar → any stringAccount.statusenum → stringPost.typeenum → stringBlock.typeenum → stringEventData.amountmoney →anyMessagingEvent.user.idrequired → optionalplacedrop latitude boundsqueue.pendingcounter →anybase_db.createdaudit timestamp → open valuePrice.uiddropnullableexport *epochMillisoverlap the seconds rangelooseObject→object(silently strip unknown keys)And the asymmetry between the two gates, measured on this branch. Retyping
Ledger.Interface.consumed?: numbertostringwhile leaving the schema alone:npm testreports 903 passed, exit 0;npm run typecheckreports exit 2, naming the divergence. That is why both are required, and why the compile-time proof is worth having.Things the compile-time proof caught during development
Both were silent divergences that would have shipped a type contradicting its own schema:
Block.value— required, but zod infers az.unionkey as optional in this repo, becausestrictNullChecks: falsecollapses zod's internal"optional" | undefinedmarker to"optional".parse()would have returned a type claimingvaluemay be absent when at runtime it never is..nonoptional()and.pipe()do not help; both re-declare the same marker. Resolved with arequiredKeyhelper built onz.custom, whose marker is an optional property and therefore unaffected.Idempotency.Response.body— same class of bug viaz.nullable().Deliberately left out — please confirm
Ledger.serviceis a validatedstring, not an enum. The set of metered surfaces is owned by the metering service, not by this package. A partial copy here would reject legitimate records, and an allow-list that is minimal rather than complete is a capability regression wearing a validation costume. Some plausible members also describe billable surfaces not implied by any type this package already publishes, which is business detail rather than shape. Callers needing strict membership should check the parsed value against their own enum.By contrast,
Idempotency.State(in_progress/completed/failed) andEntitlement.Status(processing/complete/failed) are declared: both are generic lifecycle vocabularies carrying no business specifics.Entitlement.typeandCapacity.typereuse the already-publicPrice.Typerather than declaring a parallel enum, so the two cannot drift.No thresholds, rate limits or pricing rules are encoded anywhere. All example values are obviously synthetic.
Found but not fixed
MessagingEvent.Statusis declared but unreferenced — the enum exists and is documented as the lifecycle status of a messaging event, butMessagingEvent.Interfacehas nostatusfield. Adding one would be additive and safe, but it is outside this PR's scope and I would be guessing at its optionality. Unknown-key preservation means a storedstatusround-trips untouched today; there is a test asserting that.tests.instructions.md§3 saystest/"is not currently linted". Measured otherwise:eslint.config.jsscopes only its custom rules block tosrc/**/*.ts, whileeslint.configs.recommendedand thetypescript-eslintpresets apply to everything not inignores— andtest/is not ignored.npm run buildfailed on a@typescript-eslint/array-typeviolation in a new test file during this work. Sono-explicit-anyandno-unused-varsare on for tests even though they are off forsrc. Worth correcting in that doc.Not addressed
strictNullChecks/noImplicitAnyremain off; flipping them is its own change. The| nulldecisions above are made explicit and runtime-asserted specifically so that follow-up is boring.