Skip to content

Export versionOf so consumers can compute a document's version token - #20

Merged
coddingtonbear merged 5 commits into
mainfrom
feat/export-version-of
Oct 6, 2026
Merged

coddingtonbear merged 5 commits into
mainfrom
feat/export-version-of

Conversation

@coddingtonbot

Copy link
Copy Markdown
Collaborator

obsidian-local-rest-api is adding HTTP conditional writes (ETag / If-Match / If-None-Match) to all of its vault write endpoints, not just PATCH, and those ETags need to be the same token as the document map's version so the two are interchangeable. Until now, the only way to get that token was buildModel(content).version, which parses the whole document just to hash it.

This exports versionOf from the package root:

  • Contract: The doc comment (and the README's "Optimistic concurrency" section) spells out the derivation: sha256 over the UTF-8 bytes, lowercase hex, truncated to 6 characters. It's the same value as PublicMap.version and the ifMatch token, and it won't change outside a major release. The string path is byte-for-byte the old implementation, so tokens clients are already holding keep validating.
  • Bytes: It also accepts a Uint8Array (or Buffer) and hashes those bytes as-is, so a consumer holding a binary file or undecoded bytes gets the same token a text consumer gets for the same bytes. I went with a single string | Uint8Array parameter rather than overloads since the return type doesn't vary.
  • Playground shim: site/crypto-shim.ts only accepted strings. Handed bytes, it would have hashed their "1,2,3" stringification and returned a plausible-looking but wrong token, so it now hashes a Uint8Array the same way Node does.

The new tests in src/tests/version.test.ts pin the derivation to values computed with sha256sum rather than by the library itself, check that it agrees with buildModel(...).version for empty, CRLF, frontmatter, and non-ASCII documents, and check that a string and its UTF-8 bytes produce the same token.

This adds public API, so it should go out as 2.1.0. I've left package.json alone since releases here bump it in their own commit.

🤖 Generated with Claude Code

coddingtonbot and others added 3 commits October 5, 2026 19:45
obsidian-local-rest-api is moving its ETags onto the document map's
version token, so the token becomes something clients hold across
requests and compare later. These tests pin the derivation to values
computed outside the library (sha256sum), check it agrees with
buildModel(...).version for CRLF, frontmatter, and empty documents, and
cover a Uint8Array form that hashes bytes verbatim.

They exercise a versionOf export that the next commit adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
obsidian-local-rest-api is adding ETag / If-Match to every vault write
endpoint, and the ETag has to be the same token as the document map's
version. The only way to get that token was buildModel(content).version,
which parses the whole document to produce a hash of its bytes.

versionOf is now public, with its derivation documented as contract:
sha256 over the UTF-8 bytes, lowercase hex, first 6 characters. The
string path is unchanged, so every token clients already hold still
validates. It also takes a Uint8Array, hashed verbatim, so a consumer
holding a binary file or undecoded bytes gets the same token a text
consumer gets for the same bytes.

The playground's browser stand-in for crypto accepted only strings; given
bytes it would have hashed their comma-joined decimal rendering. It now
hashes a Uint8Array as Node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@coddingtonbot coddingtonbot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed the diff, ran tsc, the new test suites, and typedoc (all clean), and verified the three pinned tokens against sha256sum. Nothing blocking. Three inline notes below: one small correctness nit in the shim, one design question about freezing a 24-bit token as a public ETag contract, and one optional simplification.

Comment thread site/crypto-shim.ts Outdated
update(data: string | Uint8Array, encoding: ShimEncoding = "utf8"): Hash {
if (typeof data !== "string") {
// Copied, so a caller reusing its buffer cannot change what we hash.
chunks.push(data.slice());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The "Copied" claim doesn't hold when data is a Node Buffer: Buffer.prototype.slice is an alias for subarray and returns a view of the same memory (it's also deprecated for exactly this reason), so a caller that reuses its buffer between update and digest would change what gets hashed. Only plain Uint8Array.prototype.slice copies.

In the browser bundle there is no Buffer, so this is latent there, but the shim's own tests run under Node and the Hash type advertises accepting a Uint8Array, which a Buffer is. new Uint8Array(data) copies for both kinds and matches the comment:

chunks.push(new Uint8Array(data));

A one-line test pairing with the existing accepts a Buffer case (mutate the buffer after update, assert the digest is unchanged) would pin it.

Comment thread src/model.ts
* bytes yield the same token.
*
* The derivation is part of the public contract: tokens held by clients must
* keep validating, so it will not change without a major version bump.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Design question rather than a defect: this PR promotes the 6-hex-character truncation from an internal detail to a public contract that can only change in a major release, and the stated consumer is HTTP If-Match on write endpoints. A 24-bit token means a concurrent edit that changed the document has roughly a 1-in-16.7M chance of slipping past the precondition, which is tiny but is a weaker guarantee than people normally assume an ETag carries, and once frozen it costs a major version to widen.

If the length is worth keeping short for the map's readability, it might be worth saying so explicitly in the doc comment and README ("24 bits; adequate for optimistic concurrency, not a content identifier") so consumers don't treat it as collision-free. Alternatively, freezing the derivation but not the width (e.g. documenting that a longer prefix of the same digest may be accepted in future, and having comparison use startsWith) would leave room to grow without breaking stored tokens.

Comment thread src/model.ts Outdated
export const versionOf = (document: string | Uint8Array): string =>
(typeof document === "string"
? createHash("sha256").update(document, "utf8")
: createHash("sha256").update(document)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Optional: the ternary isn't needed. Node's Hash#update defaults a string to "utf8", and the shim's update has the same default, so both branches collapse to:

export const versionOf = (document: string | Uint8Array): string =>
  createHash("sha256").update(document).digest("hex").slice(0, 6);

If the explicit "utf8" is there to make the encoding visible at the call site, that's a fair reason to keep it; just noting the two calls do the same thing.

coddingtonbot and others added 2 commits October 5, 2026 19:54
The shim's comment said bytes were copied so a caller reusing its buffer
could not change what gets hashed, but it copied with data.slice(), and
on a Node Buffer slice() is subarray(): a view of the same memory.
new Uint8Array(data) copies for both a Uint8Array and a Buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node's Hash#update already treats a string as UTF-8 and hashes a
Uint8Array as-is, and the playground's shim has the same defaults, so
branching on the input type to pass "utf8" explicitly bought nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coddingtonbear
coddingtonbear merged commit 8114218 into main Oct 6, 2026
1 check passed
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