Three reads still got a flush wrong:
- A write re-sent after its reply was lost, found already applied, left
the claim without touching a warm cached base, so balance reads stayed
short of what the store held. The claim and the base are now dropped
together, and the next read seeds a fresh base from the store.
- A read with no cached base took the buffer, then the store; a flush
that wrote a batch and left its claim in between had it counted twice.
- An exact read added another deployment's claim but not the usage that
arrived behind it, which that read can't flush, so a charge crossing
the allowance went entirely to the allowance.
Reads that go to the store now take everything buffered (delta and
claim), read the store consistently, and read the buffer again, retrying
when it shrank in between. Single and array reads and exact reads all
use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`readExact` read the store and the outstanding claim side by side. When
a flush wrote a batch and took it off its claim between the two, the
store read had the counter from before the write and the claim read had
nothing, so the batch was missed: 10 units recorded, 0 read.
The claim is now read first. An amount gone from it by then was written
before the store read, which is consistent, so the read has it. When the
claim changes during the store read, the batch may be in both, so the
read is retried, up to three times.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The running total `incr` returns and `readExact` also missed usage a
flush had claimed but not yet written. Both feed how a charge is split
between the allowance and purchased credit, so an account could be
charged to its allowance for usage that had already used it up. Both
now add the counter's outstanding claim.
A read or seed with no cached base asked the store with an ordinary
read, which could miss a write a settle had just made; it now reads
consistently. A write-through after a cache failure drops the cached
base, which doesn't have the write, so the next read reseeds it.
The dirty-set reconcile test no longer depends on how many flush cycles
earlier tests ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A flush takes a counter's buffered amounts aside before writing them
onward, and reads only looked at the live delta and the cached base.
From the claim until the settle replaced the base, those amounts were
missing from every read: one store write normally, up to the orphan
sweep if the write failed. Balance checks, credit reservations among
them, could see an account as having more than it does.
Reads now add the counter's outstanding claim. A batch written onward
leaves the claim and joins the cached base in one step, so it stays
counted until the settle replaces the base with the store's view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser test now counts the chunks the transport refused while the
socket read as closing, and requires at least one, so it can't pass
without exercising the gap. The socket stays closing until the
connection drops instead of going back to open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A conversation still reads as connected for a moment after its socket
starts closing. A microphone chunk sent in that moment got
`session_closed`, and the capture handler treated that like a real
media failure: it stopped the tracks and the AudioContext, so the
replacement session after the reconnect had no microphone.
The built-in microphone now drops a chunk the transport refuses with
`session_closed` or `session_not_ready`, as it already did for
`queue_full`, and keeps capturing. A reconnect resumes sending; a final
close still stops capture through the conversation, and processor or
other errors still release it. Dropped chunks aren't replayed, and
public sends keep reporting the transport error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server event over the 4 MiB limit closes the socket with 4009, which
ends a connected conversation. During a reconnect attempt the same close
was reported as `session_closed`, which is retried, so the conversation
tried ten more times and finally closed with the original drop code.
A replacement session opened after a restart notice was reopened again
and again for the same reason.
A 4009 close now ends the conversation during a reconnect too, reporting
4009, and a replacement session that hits it isn't reopened. Tests cover
both, a multibyte event of exactly 4 MiB, and a whole retrieved item
through the relay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The conversation transport refused any server event over 64 KiB with a
terminal 4009 close, the limit meant for what the SDK sends. The relay
forwards provider events up to 4 MiB, and a session.updated echo or a
whole audio item easily passes 64 KiB, so valid conversations ended.
Incoming events now have their own 4 MiB limit, matching the relay,
counted in UTF-8 bytes (a string short enough that it can't exceed the
limit isn't encoded to check). An event over it still closes with 4009
and doesn't reconnect. Uploads keep their 64 KiB cap, and malformed
events are still ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every release bumped the balance version, so an account's own short-lived
holds (each realtime session probes one and releases it at once) could
keep its other reservations reading a stale version until they were
refused. The usage a hold stands for already bumps the version when it's
recorded, before the hold is released, and a release without usage
leaves the balance as it was, so releases no longer bump it.
The admission read also uses the cached add-ons record when it shows no
purchased credit left, which can only understate the balance; it reads
consistently only while credit remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reservation that found the balance changed three times in a row used
to skip the version check on its fourth attempt, which brought back the
race the check closes; an account could provoke that itself. It now
waits a short randomized time between attempts and refuses after five.
A missing version key now starts from a random value, so a version read
before the key expired can't match its successor. Credit grants,
allowance changes, manual usage corrections and a usage write that
fails partway also bump the version, and the store refuses an amount
it can't hold instead of storing a negative hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`reserveAiCredits` checked the balance and then took an unconditional
hold, so concurrent requests could all pass the check before any hold
was visible; two 16.2M µ¢ holds were admitted against a 20M µ¢ balance.
The hold store now takes a hold only if it fits beside the live holds,
in one Lua script on the actor's hold key. A per-user version, bumped
when usage is recorded and when a hold is released, lets the script
refuse a balance read that a settlement has since overtaken; the
service reads the balance again and retries, and skips the version
check on its last attempt rather than refuse an account whose balance
keeps moving. Both keys share the actor's hash tag, so the script runs
on Redis Cluster. The balance read for this check reads purchased
credit consistently.
`reserveCreditsIfAffordable` carries this through the service, and the
AI facade calls it once with the cost factor applied. `reserveCredits`
keeps its unconditional semantics. When the cache is unreachable, the
balance alone decides and nothing is held, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plan or funds refusal was classified by a plan lookup made after the
decision. A fallback plan is cached for only a few seconds, so when it
ran out in between, the refusal was judged against the recovered plan
and still ended the session with a terminal 1008. The plan is now also
checked just before each decision, and a refusal is retryable if either
check found a fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reconnect test made the next credential lookup fail once, but the
SDK's own socket.io connection could take that failure before the
relay's reconnect attempt did, so the relay never saw a failed lookup
and the test failed in CI. Lookups now fail until the relay has refused
an attempt on one, then recover.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the billing lookup failed, metering answered with a provisional
fallback plan, so the relay refused the session with
`subscription_required` or, against the fallback's allowance,
`insufficient_funds`, both terminal 1008 closes, and the SDK showed the
upgrade prompt to a paying account. A plan or funds refusal decided on a
provisional plan now sends `internal_error` and closes with 1011, at
connection and mid-session, so the client retries once the lookup
recovers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A credential lookup that threw (a database or cache outage) was treated
as no actor, so the session got `invalid_api_key` and a 1008 close. That
is terminal for the SDK, which then ended an established conversation
that only needed to reconnect. A failed lookup now gets `internal_error`
and closes with 1011, which the SDK retries; missing, invalid and
revoked credentials keep `invalid_api_key` and 1008. Nothing is cached
for the failed lookup, and the cause is logged server-side only.
A session whose usage records pile up behind metering now closes with
1013 instead of 1008, still with `rate_limit_exceeded`. Queued usage is
still recorded and its holds released. An unexpected setup failure now
sends `internal_error` before its 1011 close.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session refused while the server drains returned before its client
error and close listeners were registered, so a frame that errored the
socket during the close handshake (an oversized one, for example)
emitted an unhandled `error` and ended the process. The listeners now
go on as soon as the session exists, ahead of every refusal.
The 404 answer to an upgrade for another host also ends a raw socket
that has no error listener once Node hands it over; it now has one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(events): schema for user-scope handlers
Add event_user_handlers and event_subscriptions.handler_scope in all three
dialects. event_handlers is unchanged.
* feat(events): scope the handler and durable subscription stores
Handler store methods take a scope (app, or a user's scope of an app) and read
the matching table. App-scope behavior, SQL and cache keys are unchanged; user
scopes get their own cache keys, a 20-handler cap, and the inline @<sha256>
name. Adds user-scope listing, counting, deletion and unbound-handler reads.
Durable subscriptions store and return handlerScope, and listByHandler
filters by scope and holder.
* fix(events): hold the user-scope handler cap under concurrency and drop all scope caches on app delete
A create that lands past the cap removes itself and is refused. deleteForApp
drops each holder's cached set in batches, here and on peers. Batch scans over
user scopes and unbound handlers take limit and cursor.
* feat(events): set a CPU limit on events worker deploys
* test(events): cover redeploy and resend of an evicted events script
* test(events): name the deploy-on-miss test for what it checks
* fix(events): keep a deploy-on-miss call inside the delivery lease
* feat(workers): cap rented units per plan and refuse only new ones
* feat(events): publish handlers into a user scope, with inline create
* test(events): cover user-scope publish, inline create and listing
* test(workers): name the credentials gate without a vendor
* feat(events): run user-scope handlers as the holder
A user scope deploys one script per handler under the holder's own
identity, throttled per scope, with an eager deploy after publish that
skips an upload another holder already made. Delivery and schedule runs
address the holder's handler, carry the holder on the invoke headers,
batch calls, the miss handler and the rehydrate route, and stay owed
while the flag is off. Suspensions of a user scope notify the holder,
teardown (app delete, consent revoke, account delete) drops the scope,
and a job deletes unbound handlers after a day.
* test(events): cover user-scope deploy, address, notices, teardown and the sweep
Runs an inline user-scope handler through the local worker runtime as its
holder, and covers the holder on the wire, the flag-off path, revoke and
account-delete teardown, and the unbound-handler sweep.
* test(events): a lagging replica read does not suspend a user-scope schedule run
* feat(puter-js): inline user-scope handlers and scope option for handlers and workers
* docs(events): document inline and user-scope handlers, with playground examples
Cover inline handlers on onSchedule and onPersistent, handlerScope, the
scope option on handlers and workers, the user-scope limits and rented
units, and the new error codes. Add six playground examples and pin the
new limit constants in limits.test.ts.
* feat(schedules): carry the handler scope on the schedule view
* docs(events): describe user-handler cleanup, schedule handlerScope and suspension notices
* fix(events): gate the rented-unit cap on user handlers and let an account session destroy its own user scope
* fix(events): keep user-scope rows off app handlers across mixed versions and bound their deploys and reads
Cached durable rows carry an explicit scope marker; an unmarked entry with a
handler is resolved from the table and rewritten. User-scope rows are parked
before credit checks, billing, client delivery and suspension while the flag
is off. Delivery-time handler checks and addressing share the replica cache
entry. Concurrent eager deploys of one user-scope script upload once.
* fix(events): keep a user scope's age across handler churn for rent
event_user_handlers.scope_since records when the scope first held a handler.
A new row takes the scope's existing minimum, so replacing code does not make
the scope look newer to the monthly sweep. The count uses it instead of the
oldest remaining handler's created_at.
* fix(events): drop cached entries no row backs; document account-session scope destroy
* test(jobs): list the user-handler sweep among the system jobs
* fix(events): let the user-handlers migration apply on its own
* fix(events): fail a cached-row read whose scope lookup fails instead of returning app-scope rows
* fix(events): touch a reused user-scope handler before binding so the sweep cannot take it
* fix(events): bound the deploy-on-miss wait by the call's deadline
stream.pipeline adds seven close listeners to the response; with the ones
metering, compression and tracing add, every streamed /drivers/call passed
Node's ten-listener limit and logged MaxListenersExceededWarning. pipe plus
explicit error and close handling adds two, and keeps the same behavior: a
source failing mid-body aborts the response and logs once, and a client
hanging up stops the source.
* feat(gui): type a path into the dashboard Files path bar
Clicking the path bar anywhere but on a crumb, or typing /, swaps the
breadcrumbs for a text field. Enter opens the folder typed (or a file's
folder with the file selected); ~ is home and relative paths start from
the folder on screen. Escape or clicking away puts the crumbs back, and
a path to nothing shows an alert and keeps the field open.
* fix(gui): closing the path alert with Escape gives the path field back
UIAlert only settles when a button is clicked, so an alert closed with
Escape left the path field waiting forever: it no longer closed on blur
and never got the focus back. Wait for the alert window to close
instead.
* fix(gui): look a typed path up once, however often Enter is pressed
A second Enter (or a held key) during the lookup started another one,
and for a path to nothing stacked a second alert on the first.
* fix(gui): leave Enter and Escape to an IME composing in the path field
Picking a candidate with Enter while typing a name through an input
method went to the typed path, and Escape to cancel the composition
closed the field.
* fix(gui): arrow keys go on from rows the Files tab selects for you
Selecting a typed file, or the items just uploaded or pasted, left the
keyboard's starting row on whatever was selected before, so Down jumped
next to that row instead of the new selection.
* fix(gui): opening the path field no longer moves a narrow Files header
On phones and in narrow lists the field was shorter than the crumb row
it replaced and dropped its divider, so the buttons and the listing
jumped up as it opened. It now takes a crumb's height and keeps the
divider.
* fix(gui): space the path field from the Files toolbar buttons
Its border ran up against the sort button, a few pixels from the icon,
where the toolbar's other items keep 10px apart.
* fix(gui): phone keyboards leave a typed path as typed
iOS autocorrect rewrote folder names in the path field as they were
typed. Turn it off, as the GUI's other name fields do, and label the
keyboard's Enter key Go.
* fix(gui): a typed / or .. reaches the root folder
The root can't be stat'ed, so the path field reported "/" as not found,
even though Up goes there. Like Shared, it is now opened without a
lookup.
* fix(gui): Escape calls off a path lookup still running
Escape closed the path field, but the lookup behind it carried on: the
tab still went to the path once it answered, or alerted that it wasn't
found, and a path typed into the reopened field was ignored until then.
* fix(puter-js): a tap without a URL opens the site the push came from
A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.
* fix(puter-js): push taps go to top-level windows only
push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.
* fix(puter-js): deliver a push tap to the page once
push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.
* fix(puter-js): clear stored push taps that nobody reads
push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.
* fix(puter-js): a late page already at the tap's URL still gets the tap
When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.
* fix(puter-js): a push tap runs at most once when IndexedDB fails
A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.
* fix(puter-js): take a tap back and keep it for the next page in one step
When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.
* test(puter-js): shorten the fake IndexedDB comment
* fix(puter-js): sign-out and disable() remove only Puter's push subscription
Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.
* fix(puter-js): sign-out and a user change end this browser's pushes
Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.
A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.
A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.
* fix(puter-js): record a push opt-in before other tabs can check it
enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.
* fix(puter-js): push cleanup runs without the lock where Web Locks refuse
In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.
* fix(puter-js): send the sign-out removal again when keepalive is refused
A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.
* fix(puter-js): a user change finishes its push cleanup before enable()
enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.
* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again
The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.
* fix(puter-js): background push syncs never prompt for sign-in
The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.
* fix(puter-js): turning push off in one tab stops other tabs' syncs
Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.
* fix(puter-js): wait for a push confirmation only once the lock is held
A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.
* test(puter-js): reach the push sync in the boot sign-in e2e
The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.
* test(puter-js): run the push boot test on full Chromium and skip it without push support
The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.
* test(puter-js): keep the push boot test comment to one line
* fix(ui): honor the default font in showFontPicker on the desktop
String arguments are normalized to { defaultFont } in the IPC handler,
matching showColorPicker, instead of throwing on property assignment.
UIWindowFontPicker reads defaultValue/defaultFont/default case-
insensitively, and the SDK fallback and docs accept the same aliases.
* fix(ui): document the showFontPicker cancel result and alias order
The JSDoc gains overloads for the string and options call forms and
states that closing the picker resolves false in an app and null on a
website; the docs page and examples match. e2e coverage adds a
scroll-into-view check for the preselected font and a close-without-
selecting case.
* feat(db): add nowMsExpr, the database clock in epoch milliseconds, and share the retry backoff
* feat(config): type the jobs config as an augmentable IJobsConfig and resolve its namespace in one place
* feat(jobs): add JobScheduler for fleet- and region-wide system jobs
* refactor(jobs): run core's periodic sweeps as system jobs, and publish the generation bumps of reaped subscriptions
* feat(events): seams for schedules run by an extension, with events_disabled where none is installed
* feat(notifications): add notification.send_key, the deferred mailbox write and notify({ sendAt }) through the schedules contract
* feat(puter.js): schedules, notify({ sendAt }) and notifications.cancel(), and treat a 503 events_disabled as final
* docs(events): document schedules, sendAt and cancel(), with recipes
* fix(events): stop resuming a holder's later pages once the credit sweep's signal aborts, and keep its checkpoint
* fix(events): pre-release fixes for jobs, schedule seams and delayed sends
- The delayed-send write is a field on the notifications driver, not a
method, so /drivers/call can't reach it.
- A delayed send checks for its own earlier write by uid, so it takes no
gap lock that every other notification insert waits on.
- The notification send_key index builds in place without locking writes.
- Reaps re-check expiry and suspension on the primary, so a row resumed
since the replica read is kept.
- Revocation settles skip schedule rows they can't settle.
- An empty handler set read from a replica is re-read on the primary.
- One ended notice per settle, not one per page.
- Sweeps that run once per fleet take more batches per run.
- The global usage rate check keeps its 14-minute minimum window.
- shutdown() waits for prepare hooks a drain already started.
* fix(puter.js): quota refusals are final, and schedule error codes match the server
Schedule, subscription and handler quota 429s no longer go through the
generic retry. ScheduleErrorCode lists what the schedule methods throw,
and the notify({ sendAt }) overload has type tests.
* docs(events): schedule and delayed-send accuracy and style pass
Spread and send-time wording match the runner, the per-user runs-at-once
and rate-limited stored notification limits are published, and the new
pages follow the docs writing rules.
* docs(events): add the Change Data at a Set Time recipe
A one-time schedule that changes data when it runs, cancelling and moving
it, and a repeat-a-few-times variant with maxRuns.
* fix(events): delayed sends recheck the app's access when they fire, and a handler set missing the run's handler is re-read on the primary
Also stops job polling at drain start and marks replayable notification sends.
* feat(events): batch handler invocations to the events dispatcher
Invocations that come due within a few milliseconds go out as one
/invoke-batch request (up to events.invokeBatchSize, default 60). The
dispatcher runs a few at a time and streams a started line and an answer line
per call, so a slow handler delays no other call's answer. A call's timeout
runs from its started line, so time queued behind other calls is never the
handler's; queue time comes out of the connection-wait share instead, and a
call still queued past it is never started and fails as Puter's.
A lone call still uses /invoke, and a dispatcher without the batch route gets
single calls for five minutes before the batch route is tried again.
* docs: complex fs recipe (PUT-1978)
A drop folder that reacts to files landing in it, using `puter.events` with an
`fs:` subject instead of polling `readdir`.
One use case per the recipe guidance: watch, ignore your own writes, recover
from a gap, stop watching. The gap section works out what is already done from
the folder itself rather than a counter, so a dropped delivery costs a listing
and never a file.
* docs: rework fs recipe into a CSV importer (PUT-1978)
Renames the recipe to "Import Files From a Folder" and reframes it around
importing CSV files with puter.fs and onLocal(). Watches the app's own
inbox folder, imports files moved in from another folder, and catches up
on open and on gaps. Drops the event.self check, which skipped every file
the user added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refine 2
---------
Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs: refresh KV sharing, Events and Notifications docs and recipes
Bring the KV, Events and Notifications reference pages and the recipes that
use them in line with current behavior, and tighten them.
- KV: share levels, re-share and recipient rules, handle scoping, list
limits, the 399 KB value limit, and full error tables per method.
- Events: kv:<handle> subjects, what each way of losing access does to a
subscription, fetch() notification types, handler ownership, and accurate
anchor-deletion and deleted-app behavior.
- Notifications: where a push goes, desktop and website behavior, sensitive
taps, share-notify limits, and enable()/status() errors.
- Recipes: fix broken code paths (undefined helpers, single-page share
listings, read-modify-write races, oversized batch writes, missing value
fallbacks), use events.update(), onlyIfAbsent and batch share recipients
where they fit, and cut repetition.
- New recipes: Add a Notification Settings Switch, Clean Up Background Work.
- Rename push-when-closed to reach-a-user-when-your-app-is-closed, drop
playground examples that need an app the reader owns, and redirect both.
* fix(workers): send CORS on router 404s and readable 500 bodies
Unmatched paths and methods now carry Access-Control-Allow-Origin like
every other router response, so a browser sees the 404 instead of a CORS
failure. A handler response with immutable headers is copied rather than
turned into a 500, and a thrown puter.js error object is sent as JSON.
* fix(kv): one error code for oversized values, and SDK types that match the store
The store now rejects a value over 399 KB with value_too_large, as the
SDK's own check and a growing path write already did. get() declares its
batch form, and the JSDoc gives the 399 KB limit and says watch shares
can list keys.
* docs(notifications): notifications sent within 250 ms reach live listeners as one
* test(events): give the per-handle subscription cap test room on a loaded runner
It fills the whole cap of 1,000 session subscriptions one add at a time,
which went past vitest's 5 s default on a busy CI shard.
"carries on from its record when one run runs out of time" seeds and
retires a full batch of share handles. It takes under a second alone but
went past vitest's 5 s default on a busy CI shard. The timed-out body kept
running with Date.now moved ahead, and "runs once when two runners race
for it" then failed with it.
* fix(events): queue every row's single delivery before any handler runs
A dispatch queued each matched single row's event and then waited on that
row's hand-over, which can be a whole handler run, before queueing the next
row's. An event matching several rows queued the later ones only after the
earlier handlers finished, and lost them if the process stopped in between.
A batched filesystem write did the same across its entries.
The event is now queued in line and the hand-over runs detached; anything it
doesn't finish is retried by the pending sweep. A hand-over that throws no
longer raises the 'could not be queued and is lost' alarm, since the event
is still queued.
* perf(events): address an app's events worker from a cached handler set
Every handler invocation read the app's handler set (names and hashes)
from the primary database to work out which script to call. The delivery
path now reads it through a Redis cache. Publishing, updating or removing a
handler, and deleting the app's handlers, drop the entry on this node and
every peer, so only a read that raced one of those writes can address the
superseded script, for at most 30 seconds. An empty set isn't cached.
Deploys and the events-worker listing still read the primary.
* perf(events): skip leased queues without a database read, and sweep in parallel
The pending sweep read each queue's subscription row from the primary
database before finding out whether anything in the queue could be claimed.
A queue whose head is in flight or waiting out a retry now goes to the back
of the line on a Redis check alone; its row is read once the lease lapses.
The backstop re-check of a queued row's access waits for that lapse too, and
nothing is handed out in the meantime.
The pass also worked through its subscriptions one at a time, so one slow
handler run held up every queue behind it. It now works on up to ten at
once.
* fix(events): don't count a wait for a dispatcher connection as a handler timeout
Invocations share one pool of connections to the events dispatcher per
process, and the invoke timeout started when a call was made, not when it
got a connection. With the pool busy, a call could spend its timeout
waiting, be reported as a handler that ran past it, and count toward
suspending a subscription whose handler never ran.
A call now waits for a free connection before its timeout starts. A call
that waits more than half the timeout for one fails as Puter's
(unavailable): retried on the usual schedule and not counted, as the
documented failure rules already say. Wait and run together stay inside the
worker lease.
* fix(events): end a background row whose consent a delivery finds withdrawn
Withdrawing an app's events:background consent is settled by a listener on
the node that handled the revoke. If that settle never ran or failed, the
row stayed in service: every delivery found the consent gone, minted no
token and was deferred, so the backlog was retried forever and the row was
never suspended or reported.
When the delivery path's consent check answers no, it now runs the same
revocation settle (permission_revoked, backlog purged, holder told, worker
session revoked), one at a time per holder and app. A consent check that
throws still only defers the delivery.
* perf(auth): record no session or user activity for events handler calls
Every API call an events handler made authenticated through its worker
session and touched the session and the user, so background handler runs
wrote session and user activity rows on the primary database (each at most
once a minute), and replaced the session's last IP and user agent with the
worker runtime's.
Calls made with an events handler token no longer touch either. A handler
run isn't the user being active, and the handler session expires only when
it is revoked, so nothing reads its activity to keep it alive. The handler
session's last activity in the session list now reads as when it was
created.
* fix(events): settle withdrawn consent only on a fresh answer
The delivery path's consent check reads through the permission scan cache
and the cached user-to-app grants, which can trail a grant, for example on
a node that cached the app's grants before the consent was given. A stale
no then ended the subscription for good (permission_revoked, backlog
purged, holder told, worker session revoked), where before it only
deferred the delivery until the caches caught up.
Before settling, the no is now asked again with a new `primary` scan
option, which skips the scan cache and reads user-to-app grants from the
primary database. Only a fresh no settles. A fresh yes, or a check that
throws, leaves the delivery deferred.
* test(events): assert no forwarded delivery, not no traffic at all
The region's own connect announces presence to its peers on the forward
queue's next flush. Now that a dispatch returns before the hand-over, the
wait for the socket send can outlast that flush, and the test saw the
presence announce as forwarding.
* fix(events): budget the settles deliveries start for withdrawn consent
A settle a delivery starts when it finds background consent withdrawn ends
rows for good. If the permission check itself answered no wrongly, every
row a delivery reached would be suspended, its backlog purged and its
holder told, with nothing to bring it back.
Each process now settles at most 20 such pairs a minute. Past that, the
rest stay deferred, as a cached no always left them, and a warning alarm
says so. A spent budget skips the fresh read too. A pair a fresh read just
found consented isn't read again for a minute, so a stale cached no costs
one primary read rather than one per delivery. Each settle is logged.