Skip to content

fix(utils): make hash32 total instead of throwing - #12

Merged
sdcorejs merged 1 commit into
mainfrom
fix/total-bucket-hash
Sep 18, 2026
Merged

sdcorejs merged 1 commit into
mainfrom
fix/total-bucket-hash

Conversation

@sdcorejs

Copy link
Copy Markdown
Owner

Why

hash32 is a bucket key for runtime data, not a serialization, yet it inherited stableStringify's strict JSON domain. It threw UnsupportedSerializationTypeError for undefined at any depth, non-finite numbers, bigint, functions, symbols, sparse arrays, accessors, class instances, Map/Set, Blob/File and cycles.

That is the wrong contract for this function. Callers key caches, rows and groups by a hash of caller-supplied data, where an undefined field is ordinary and where nothing needs to be recovered from the key. Losing the whole operation to one such field pushes every consumer into writing the same wrapper.

Measured downstream: bumping @sd-angular/core from 1.1.4 to 1.2.1 turned 105 specs red on the strictness alone, across select, autocomplete, the API service, upload and the table group/tree/filter paths. The dominant causes were $.value: undefined (78), $.body: undefined (60), $.filter.externalFilterPerRow: undefined (54), $.checksum: undefined (41), $: File (13) and $.click: function (11). Fixing that in the consumer meant a local adapter plus 43 call-site edits — work every other consumer would repeat.

Change

A value inside the strict domain is still hashed straight through stableStringify, so every key that exists today is unchanged. That matters beyond compatibility: these keys reach persistent storage, and switching the encoding wholesale would silently orphan stored data.

Only when the strict attempt throws is the value re-hashed from a copy in which each rejected value is replaced by its own namespaced placeholder (@sdcorejs/hash/v1:…), so those inputs hash deterministically and still differ from one another.

Two details the substitution has to get right:

  • a sparse hole stays distinct from a real undefined;
  • Array.prototype.map skips holes while copying them into its result, so the indices are walked explicitly, otherwise the array stays sparse and is rejected again.

Accessors are never invoked while hashing.

Not changed

stableStringify and canonicalStringify still reject. Callers using them to validate that data is serializable keep that signal, and the canonical domain keeps its collision-safety guarantee. Only the explicitly collision-prone bucket hash becomes total.

Verification

  • npm run validate — exit 0 (typecheck, coverage, build, publint, attw, package validation)
  • npm run validate:site — exit 0
  • 836 tests pass; new cases cover totality over all rejected kinds, the unchanged strict-domain key, placeholder distinctness, sparse-hole vs undefined, determinism, non-invocation of accessors, and that the strict serializers still throw

🤖 Generated with Claude Code

hash32 is a bucket key for runtime data, not a serialization, yet it inherited
stableStringify's strict JSON domain and threw UnsupportedSerializationTypeError
for undefined at any depth, non-finite numbers, bigint, functions, symbols,
sparse arrays, accessors, class instances, Map/Set, Blob/File and cycles.

That is the wrong contract for this function. Callers key caches, rows and
groups by a hash of caller-supplied data, where an undefined field is ordinary
and where nothing needs to be recovered from the key. Losing the whole operation
to one such field pushed every consumer into writing the same wrapper around
this function; a downstream Angular library hit 105 failing specs from the
strictness alone before adding one.

A value inside the strict domain is still hashed straight through
stableStringify, so every key that exists today is unchanged. That matters
beyond compatibility: these keys reach persistent storage, and switching the
encoding wholesale would silently orphan stored data. Only when the strict
attempt throws is the value re-hashed from a copy in which each rejected value
is replaced by its own namespaced placeholder, so those inputs hash
deterministically and still differ from one another.

Two details the substitution has to get right: a sparse hole stays distinct from
a real undefined, and Array.prototype.map skips holes while copying them into
its result, so the indices are walked explicitly or the array stays sparse and
is rejected again. Accessors are never invoked.

stableStringify and canonicalStringify are unchanged and still reject, so
callers using them to validate that data is serializable keep that signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sdcorejs
sdcorejs merged commit 275a7c0 into main Sep 18, 2026
18 checks passed
@sdcorejs
sdcorejs deleted the fix/total-bucket-hash branch September 18, 2026 03:25
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