feat(core)!: the token grammars become parse/format pairs - #753
Merged
Conversation
`parseDuration`, `parseBytes` and `parseRate` are replaced by `duration`, `size` and `rate` — one namespace per dimension, each carrying both directions, in the shape `bytes` uses (`bytes.parse` / `bytes.format`). The grammars only ever decoded. Making them public (#746) gave them callers who need to write a token back — a CLI printing the cap it enforced, a config round-trip, an error message quoting a limit in the grammar its author wrote — and each would have hand-rolled an encoder, which is the drift the export exists to prevent, running backwards. `format` is the EXACT inverse of `parse`: `parse(format(v))` returns `v` unchanged for every value `parse` can produce, pinned as a property over the whole numeric range rather than a table of cases. This is where the pair departs from `ms`, whose `ms(90_000)` is `'2m'` and reads back as 120_000 — a lossy encode is fine in a log line and disqualifying in anything that writes a value back, and P25's "a typo can never widen a cap" only holds if the encode direction cannot widen one either. Where no unit divides cleanly the base unit wins: `90_001` is `'90001ms'`, `1537` is `'1537b'` and not the exact-but- unreadable `'1.5009765625kb'`. The `ms`/`bytes` one-function overload is structurally unavailable here: P17/P25 widen every authored field to `number | string` and each read site funnels through the parser, so `parse(5_000)` must return `5_000`. Overloading the number arm would have broken every call site core makes of its own rule. Hard break, no alias (P19 scopes that obligation to the GA channel; this is rc). The old names are pinned absent from the barrel so they cannot drift back. Bundle: +0.41 / +0.20 KB gzip (entry / `import { stitch }`). Two thirds of the naive cost was recovered first — internals call plain functions with the namespaces as a thin facade, and each `format`'s unit table lives inside its function, since at module scope the minifier merges adjacent tables into one `var` statement that a live parse-side declarator then pins. What remains is ~0.1 KB: esbuild will not split an object literal to drop a dead property, so `format` ships wherever `parse` is live. The advertised whole-entry figure crosses 24 → 25 kB; propagated across the six `bundle-advertised-size` sites. Reference docs for the pair land in #746, which documented the parsers this replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rejifald
enabled auto-merge (squash)
August 16, 2026 13:14
rejifald
added a commit
that referenced
this pull request
Aug 16, 2026
#753 replaced `parseDuration`/`parseBytes`/`parseRate` with the `duration`/ `size`/`rate` parse/format namespaces. This branch bound the old three, so after rebasing onto it the playground would have offered three names core no longer exports and hidden three it now does. Caught by the coverage guard this same branch adds, one commit after the rename landed: ✗ 3 core export(s) are neither bound in the playground nor listed as deliberately absent: duration rate size That is precisely the class it was written for — a core export growing or changing without the hand-curated browser surface following. The old binding test could not have caught the new names (they were never in its list), and it would have reported the three dead ones only as "unbound", with no hint that a rename was the cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rejifald
added a commit
that referenced
this pull request
Aug 16, 2026
…d guard the gap (#751) * fix(sandbox): bind the 20 core exports the playground was missing, and guard the gap The snippet scope is the spread of whatever `stitch-browser.ts` re-exports (worker-main.ts), and that list is hand-curated. Anything core exports but this file forgets is not a binding at all — so a snippet naming it dies with `X is not defined`. This already bit us once: the four auth strategies were re-exported from the wrong entry, resolved to nothing, and `bearer('…')` threw for anyone copying the auth guide (#545 / ADR 0021). That was fixed as a one-off; the class was not swept. Probing the real Worker for every core export missing from the surface found 20 more unbound — and ten of them appear in snippets we ship: `StitchError` in nine doc/blog files, `xhrAdapter` in five, `axiosAdapter` and `fileSink` in four each, plus `RateLimitError`, `consoleSink`, `loggerSink`, `graphqlSurface`, `isStitch`, `verdictOf`. Doc snippets are not executable in place, so the failure path is a reader copying one into /playground and hitting a ReferenceError on our own documented API. Eighteen are re-exported verbatim: core is browser-isomorphic (no static `node:*` imports, guarded `nodeFs()`), so they were never unsafe — just forgotten. `xhrAdapter` is browser-NATIVE, and `axiosAdapter` takes a caller-supplied client (core never imports axios), so both belong here. Two could not be raw: - `consoleSink()` is `createTrace({ console: true })`, which re-enables core's console path — the exact thing the R1 must-know forbids in the browser. - `fileSink(path)` resolves no `node:fs` in a Worker and writes nowhere. Silent is the problem: a trace-sinks snippet would look like it worked. Both now route through the browser `createTrace` and emit a RunNotice, matching `env` / `cookieSession` / the OTLP pair. The durable half is playground-surface-coverage.test.ts: it asserts the DECLARED surface COVERS core's barrel, the half nothing tested. The existing binding test checks that names in the list resolve — it could never catch the list itself drifting, which is what both this and #545 were. New core exports must now be bound or given a reason in INTENTIONALLY_ABSENT. Also loosens both binding assertions from `=== 'function'` to `!== 'undefined'`. They coincided only while every surface name was callable; the surface now also carries plain objects (`httpSurface`, `graphqlSurface`, `systemClock`), which a function-only check rejects for being exactly right. Verified: node 13/13, browser 4/4, and a direct Worker probe showing all 20 previously-unbound names resolve plus a real errors-doc snippet running end to end (`e instanceof StitchError` true, `compact({a:1,b:undefined})` → `{"a":1}`). Guard confirmed to fail when a name is dropped from the surface list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sandbox): follow #753's rename — bind `duration`/`size`/`rate` #753 replaced `parseDuration`/`parseBytes`/`parseRate` with the `duration`/ `size`/`rate` parse/format namespaces. This branch bound the old three, so after rebasing onto it the playground would have offered three names core no longer exports and hidden three it now does. Caught by the coverage guard this same branch adds, one commit after the rename landed: ✗ 3 core export(s) are neither bound in the playground nor listed as deliberately absent: duration rate size That is precisely the class it was written for — a core export growing or changing without the hand-curated browser surface following. The old binding test could not have caught the new names (they were never in its list), and it would have reported the three dead ones only as "unbound", with no hint that a rename was the cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
The three house token grammars only ever decoded. #746 made them public contract, which changed who else needs them: a peer package that parses an authored value the way core does will eventually want to write one back — a CLI printing the cap it enforced, a config round-trip, an error message quoting a limit in the grammar its author used — and had nothing to call. Every such caller hand-rolls an encoder, which is the drift the export exists to prevent, running in the opposite direction.
Each dimension is now one namespace carrying both directions, following
bytes's shape (bytes.parse/bytes.format) rather than adding three more verb-prefixed names to the barrel:parseDuration(d)duration.parse(d)duration.format(90_000)→'1.5m'parseBytes(s)size.parse(s)size.format(1_048_576)→'1mb'parseRate(r)rate.parse(r)rate.format({ count: 2, per: 1000 })→'2/s'The decode direction is byte-for-byte what it was — same grammars, same 1024-based size units, same
undefinedfallback for a bad duration or size token, same throw for a bad rate. Only the spelling moved. ARatetype is now exported for the{ count, per }pair.Why not the
ms(…)one-function overload it resemblesmsandbytesswitch on the argument's type — a string decodes, a number encodes. That inversion is structurally unavailable here, and the reason is this repo's own rule: P17/P25 widen every authored field tonumber | string, and every read site funnels the value through the parser. Soparse(5_000)must return5_000:An overload would have broken every call site core makes of its own rule. The explicit pair is the half of those libraries' contract that survives the constraint — and it is the half
bytesitself exposes as.parse/.format.formatis exact wheremsroundsms(90_000)is'2m', which parses back to 120 000. A 33% widening is harmless in a log line and disqualifying in anything that writes a value back — and P25's "a typo can never widen a cap" only holds if the encode direction cannot widen one either.So
parse(format(v))returnsvunchanged for every valueparsecan produce. Exactness alone would still allow unreadable output (1537 / 1024is1.5009765625, which multiplies back perfectly and helps nobody), so a unit is used only when the quotient is both exact and short; otherwise the next smaller unit is tried, down to the base unit where both tests always pass:Pinned as a property over the whole numeric range in
parsers-properties.spec.ts, not a table of pretty cases, and the expectations assertparse(format(n)) === nagainst the independently-tested decoder rather than re-running the encoder's own arithmetic. Verified non-vacuous by reintroducingms-style rounding and watching all three round-trip properties plus both readability pins fail.Two edge cases worth flagging for review, both found by the property run rather than reasoned about:
Number('0') || undefinedisundefined, so a bare'0'is the one integer that does not survive a round-trip. Zero therefore carries a unit ('0ms'/'0b') — the single case where "largest exact unit" is not what makes the round-trip work.rate.formatdoes not reduce a rate to an equivalent one.'2/500ms'≡'4/s'is the design (ADR 0023), but{ count: 2, per: 500 }comes back as'2/500ms'— the numbers the author wrote, so the round-trip is equality rather than equivalence.Bundle: +0.41 / +0.20 KB gzip, and the advertised figure moves
Measured against a
mainat 24.18 / 21.62 / 5.22. Two thirds of the naive cost was recovered rather than budgeted for, both measured:parseDuration/parseBytes/parseRatefunctions and the public objects are a thin facade over them, so a consumer's bundle is not routed through the namespace and the encoder can shake out (stitchapi/auth5.44 → 5.22, back to its own baseline and inside the unchanged 5.35 ceiling);format's unit table lives inside the function. At module scope the minifier merges adjacent tables into onevarstatement, where the parse-side lookup being live pinned the encoder's table for every consumer ofstitch. Deriving one table from the other withObject.fromEntriesmeasured worse still, for the same reason.What is left is ~0.1 KB and is a real cost of the namespace form, not an oversight:
formatDurationis referenced by thedurationfacade object, whose other half is live on thestitchpath, and esbuild will not split an object literal to drop the dead half. A consumer who never formats a duration still carries the formatter. Shaking it would mean not shipping the pair as an object — which is the shape of the API. Recorded in the budget note rather than left for someone to rediscover.mainimport { stitch }stitchapi/authThe conventional ~0.2 KB step, not a minimum one — this is a new capability on the public surface, not a fix squeezing past a ceiling.
The advertised whole-entry figure moves ~24 → ~25 kB (25183 B is 24.59 KB, which rounds up where
main's 24.18 rounded down). Propagated across all sixbundle-advertised-sizesites — both READMEs, the installation and principles pages, the home-page metrics component, and the docs' own source blurb.import { stitch }is unchanged at 22. Verified by the yakir tether in the pre-commit hook (3 tethers, 0 drift), not assumed.Migration
A rename at every call site; no alias (P19 scopes that obligation to the GA channel and this is
rc). The old names are pinned absent from the barrel, so a stale import fails at build rather than resolving to something else.Internal churn is smaller than the rename suggests: because the implementations keep their original names as module functions,
auth.ts,cache.ts,test-mock.tsandtesting.tsare byte-identical tomain.resilience.tsandstore.tschange only because they declared a localconst rate = parseRate(opts.rate)that the new import would shadow — renamed topaced, which the compiler caught rather than a reviewer.Reviewer notes
Bytes/Charshazard got quieter.parseBytessaid "bytes" at the call site;sizedoes not.stream.buffer.charsandtrace.body.charsstill reject a token at compile time, so the type still holds the line — but the name no longer warns first. Stated on the namespace's JSDoc and in P25, and it is the change here I would most want a second opinion on.formatmust not touch emitted values. P17's complement is unchanged: an emitted duration is raw ms. Formatting one for a CLI table is display; writing one into an emitted field is a violation this pair cannot see and will not stop. Called out in the JSDoc, in P17, and as an anti-pattern callout in the docs.CONTRACT.mdP17/P25 gain the pairing clause; the §6 migration record carries the full reasoning. ADR 0023 is annotated, not rewritten — every decision in it stands, only the spelling moved, so the body stays as the 2026-08-04 record with a note in its Status block.Docs
Reference docs land in #746, which documented the parsers this replaces and is stacked on this branch. That PR is where the pair is written up for readers; merge this one first.
Gates
pnpm -r testpnpm check:types/check:types-dpnpm check:lintpnpm check:sizepnpm check:contractpnpm check:changelogpnpm check:docs-linkspnpm check:exports/check:unknown-keys/check:formatRound-trip additionally fuzzed outside the suite over 220k magnitudes × 2 and 100k rates — no failures.
🤖 Generated with Claude Code