Skip to content

docs: lead with the case a lock cannot cover - #29

Merged
cardmagic merged 7 commits into
mainfrom
docs/lead-with-ticket-sale
Aug 25, 2026
Merged

docs: lead with the case a lock cannot cover#29
cardmagic merged 7 commits into
mainfrom
docs/lead-with-ticket-sale

Conversation

@cardmagic

@cardmagic cardmagic commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Framing fix to the first screen and the fit sections. No API documentation was
rewritten, and no runtime code changed.

The lead example

A cart append needs one thing from one value. The ticket sale needs three:

  • it must never go below zero;
  • it must give the seat back if the buyer does not pay within ten minutes; and
  • it must show the current count to everyone watching the page.

The first is one UPDATE statement. The second is an expiresAt column plus a
sweeper. The third is a push on every path that changes the number. The
combination is the argument, and the example now shows all three in one class:
the guard, this.schedule({ at, key }), and broadcastValue(this.remaining).

Answering the objection where it is asked

## Why not just use a transaction and a row lock? now sits in the first
screen. It concedes first, in the first sentence, because a concession is what
buys the right to be believed in the paragraph after it. Then it argues scope
rather than discipline:

A lock is scoped to one transaction, on one connection, in one process. The
ticket sale above leaves that scope on one line: the hold expires in ten
minutes, and no transaction stays open for ten minutes. A setTimeout does
not cover it either, because it dies with the process.

Any column named expiresAt, scheduledAt, or nextRunAt is evidence that
the critical section already outlived the lock that was supposed to cover it.
What follows such a column is a sweeper that looks for due rows, and then a
race between that sweeper and the next writer of the same row.

Arguing "you might forget the lock" would lose that exchange, because the reader
answers "so remember" and they are right.

Also

  • ## Is it worth installing here? moves into the first screen, and says who
    should not use this, specifically.
  • The existing "Not a replacement for SQL transactions" callout asserted the
    limit without showing where it bites. It now names the cases and links to the
    section that argues them.
  • The Comparison table already carried a SQL transaction or row lock row, so
    it is unchanged.
  • The realtime section concedes writing and then sending on your own socket
    before explaining the two gaps: two sends can arrive out of order, and a send
    is not part of the write.

Validation

The lead example was executed against this branch as a temporary test, not read
for plausibility: three concurrent reserves serialize to remaining 97, a
repeat reserve for the same buyer returns false, and expire returns the seat
to 98. The scratch test was removed before committing.

pnpm run check clean, pnpm test 343 passed and 13 skipped.

The Ruby counterpart is cardmagic/solid-objects-ruby#54. Verifying its example
turned up a bug in the shipped homepage copy: it stores Time.current in actor
state, which Ruby state serialization rejects, so that reserve never commits.
The JS homepage example stores Date.now() and is unaffected.

Two comments on r/rails, both negative, and the first one was right: a
counter is the worst possible lead example, because one statement of SQL
does it. The README opened with a shopping cart that pushed a string onto
an array, which reads the same way. The first thing a skeptical reader
saw was the argument against installing anything.

The lead is now the ticket sale from the homepage: 100 seats, a hold, a
ten-minute expiry that frees the seat, and a published count. It is the
smallest example that needs three things from one number, and the three
things are the actual argument. A cart append needs one.

The objection now gets answered where it is asked. A new section concedes
the transaction and the row lock first, including navigator.locks in the
browser, then makes the case on scope rather than discipline: a lock is
scoped to one transaction, on one connection, in one process, and any
expiresAt or scheduledAt column is evidence the critical section already
outlived it. What follows that column is a sweeper, and then a race
between the sweeper and the next writer. Arguing that someone might
forget the lock would lose, because the reader answers "so remember".

The transactions callout already existed and asserted the limit without
showing where it bites, so it now points at that section. A short
worth-it and not-worth-it section moves into the first screen, and the
realtime section concedes send-on-your-own-socket before explaining
where push-after-write drops an update.
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reframes the README around coordination that outlives a transaction and synchronizes the package version for the documentation release.

  • Replaces the introductory cart example with a scheduled, realtime ticket-sale example.
  • Moves transaction-scope and adoption guidance into the opening sections.
  • Clarifies that realtime publication is recorded atomically but delivered separately with at-least-once semantics.
  • Updates the changelog and version metadata to 0.14.3.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
README.md Reworks the introductory narrative and correctly resolves the prior ambiguity between durable publication recording and subsequent at-least-once delivery.
CHANGELOG.md Documents the 0.14.3 README rewrite and corrected realtime-delivery language.
package.json Updates the package version from 0.14.2 to 0.14.3.
src/version.ts Keeps the exported runtime version synchronized at 0.14.3.

Reviews (2): Last reviewed commit: "release: use 0.14.3, not 0.15.0" | Re-trigger Greptile

Comment thread README.md Outdated
The realtime paragraph claimed the value is published once per change
from the saving turn. The saving turn records the publication in the
same transaction as the state change, which is the part that answers
push-after-write. Delivery is a separate worker and is at least once,
so "published once" promised something the runtime does not.

The claim now matches docs/correctness.md: rows are claimed in actor
revision order, subscribers reject a duplicate or stale revision, and
the guarantee is that a subscriber cannot end up on an older value.

That distinction is what makes the argument against push-after-write
work. The point was never that a socket send is unreliable. It is that
the send is not part of the write and its ordering comes from arrival
rather than revision.
The framing pass added the argument the README was missing, but it added
it to a page that already repeated itself. The single-row-transaction
point appeared in five places. Three sections answered "is this for me"
with overlapping tables. Length is not thoroughness: a reader who bounces
at screen three never reaches the reference material that justified it.

Removed what docs/ owns outright. Measured behavior restated
docs/benchmarks.md number for number, so it keeps the four rows that
change a decision and links the rest. How it works, Delivery boundaries,
Requirements, and Operations restated correctness, support, and
operations docs, so each keeps the claims a reader needs before clicking
and drops the rest. The Comparison table restated docs/comparisons.md,
so the celld paragraph stays, because it is the only place that says
what to choose and why, and the table goes.

Removed what the README said three times. "Good and poor fits" is gone,
with its unique rows folded into "Is it worth installing here?": per
document reminders, realtime projections, edge placement, and the
identity-splitting rule. The pattern table survives because its "one
identity per" column exists nowhere else.

Fixed a claim that was not true. Design provenance said the API was
redesigned around Web Components. There is no customElements or
HTMLElement anywhere in src; the browser surface is a framework-neutral
component registry.
The standard-readme spec requires a table of contents above 100 lines.
This file is 537 with 17 sections and never had one, so a reader landing
from the npm page had no map of what the page covers.

An audit against that spec, the Prana et al. content categories, and the
popularity correlation study found nothing else missing here: this
README already carries contribution and security links, uses lists, and
links out to docs. The Ruby sibling had the larger gaps.
The section answering the lock objection was titled after the mechanism,
"a transaction and a row lock", which is longer than the question a
reader actually asks. The heading now matches the Ruby sibling and says
transactions. The body still names SELECT ... FOR UPDATE and
navigator.locks, because those are what a reader reaches for.

"What an actor replaces" was ambiguous in a document that also discusses
Durable Objects, Rivet actors, and celld. It now says a Solid Objects
actor.

The not-worth-it list ended at hot identities and global counters
without saying where such a reader should go. Solid Objects Pro is the
commercial performance layer for the family, so the list now names it
and what it adds. It says the Rails gem ships today and the Node build
is in development, which is what the product page states, so a reader
here does not expect something they cannot buy yet.
The README is what npmjs.com renders, and docs/ ships inside the
tarball, so this work does not reach a reader until a release goes out.
Published 0.14.2 still leads with the cart example and answers the lock
objection nowhere.

Minor rather than patch: the page a reader lands on is different, not
corrected, and 0.15.0 puts the package back in step with the gem after
the two drifted apart at 0.14.1 and 0.14.2.
Nothing in this release changes behavior. The runtime is untouched and
every change is prose, so semver calls this a patch. The earlier bump
reached for minor to resync the version numbers with the gem, which is
not a reason to spend a minor: the parity ledger tracks capability, not
the number.
@cardmagic
cardmagic merged commit 26c998f into main Aug 25, 2026
19 checks 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.

1 participant