feat(effects): built-in effect library and the custom-unit authoring contract - #184
Conversation
…ns, typed enrollment errors, account-gate freshness The template half of #175, following the runtime half in #180. DeviceStatus names why nothing is syncing, not merely that it is not: the one-line Data sync verdict distinguishes owner mismatch, store refusals (no schema, rejected ticket), an unopenable sink record, and the absence of any sync location, with owner-mismatch copy naming the owning account when known. The no-sync hint now leads with ticket enrollment; the compiled-in env-var path is the stated alternative. TicketEnrollForm relays typed refusals verbatim — SyncEnrollmentError, SyncOwnerError, and DataSinkError carry their own remediation, and the generic paste-again line cannot fix an unprovisioned store or a foreign sync owner. AccountGate re-reads the session when the runtime is recreated, gains the owner-mismatch branch offering exactly the two remediations (stop sync releases the election, restore adopts the owner), and surfaces a definite store problem in the backed-up state. Starter mirror and snapshot regenerated.
…contract Closes #141. Grows the #139 effect machinery into a tiered standard library and formalizes the contract for authoring custom units. Contract (effects.ts): - EffectContext gains `op` and `writeCreatedAt` so units can see the operation and measure saved→fate latency. - PermanentEffectError / isPermanentEffectError: a handler throws it to retire an obligation immediately (a hopeless failure) instead of re-arming until maxAttempts. The ledger honors the severity. - requireMutationRuntime + anonymousEffectName expose the seams the library is built on, keeping every built-in on the public authoring surface. Built-in units (schema/effect-library.ts), tiered by risk: - Observation — s.trace (saved→fate span in diagnostics, OTLP-shaped, no vendor coupling), s.debug (dev-only timeline, stripped in PROD). s.log stays in the core beside the runtime recorder. - Data-internal — s.notice (durable, UI-agnostic message queue, idempotent by journal id; closes "a rejected write flashed success"), s.mark (patch the row on fate, convergent, skips a rolled-back insert), s.chain (issue a follow-up verb on synced, no saga API). - External — s.webhook (POST row+fate with journalId as Idempotency-Key; 4xx → permanent, 5xx/429/network → retryable; default 24h delivery window). Runtime: - notice-queue.ts: durable queue (OPFS→localStorage→memory), idempotent enqueue, TTL + dismissal, subscribe; activeNotices diagnostic. - write-ledger.ts installs recordTrace/recordDebug/enqueueNotice/applyMark and owns the queue singleton; public listNotices/subscribeNotices/dismissNotice. - diagnostics.ts: effectTraces, effectDebugTimeline, activeNotices. Preact: useNotices hook + Notices component render the durable queue; a toast stack is a userland wrapper over the same hook. Docs: docs/effects.md (library reference + authoring contract + worked custom unit), teaching-path link from nouns-and-verbs, README reference entries. Tests: per-unit happy/rejection/idempotency, notice-queue idempotence/TTL/ reload, ledger permanent-failure retirement, and a custom unit built purely on the public contract. Reference app adds s.trace to addTask (dogfood); starter mirror + snapshot regenerated. Manifest updated for the three new modules. Deferred, noted for a follow-up: migrating the reference app's hand-rolled task-notice to s.notice (a focused island refactor).
…notice-queue boot merge, a11y Addresses findings from the multi-agent + Codex review of #184. Anonymous-unit identity (HIGH — silent mis-binding across reloads): - notice/mark/chain no longer take their durable name from a global declaration counter (module-load-order dependent, so a re-armed obligation could bind to the WRONG unit — for chain, the wrong follow-up verb). They are now named `<verb>#<position>` at mutation() time, from the author-chosen verb and their slot in its effects — an identity independent of which module loads first. - trace/debug/webhook are content-named and now share one unit per identity (cachedBuiltin), so reusing one across verbs aggregates instead of throwing a duplicate-name error. webhook keys on url+config. Notice queue: - Boot-window clobber (HIGH): load() now MERGES persisted entries with any enqueued during the async load window instead of overwriting, so a notice an effect enqueues at a boot re-arm is not silently dropped. - TTL drift (MEDIUM): list() is now a pure read; retirement happens in sweep(), wired to a periodic timer in the queue owner; the activeNotices count is computed from the live view so it never overcounts expired entries. mark best-effort (MEDIUM): applyMark swallows a denied/vanished-row patch instead of quarantine-spamming; documented that mark is a convenience over the status column, not a delivery guarantee (use notice/webhook for that). Notices a11y (MEDIUM): the aria-live region stays mounted (hidden) when empty so the first notice is announced; custom children are keyed. Tests: anonymous-unit verb-scoped naming, content-name sharing across verbs, notice boot-merge. All green; build refreshed.
Review pass (multi-agent + Codex) — findings addressedRan a three-agent correctness review plus a Codex release-readiness read. The converging headline was the anonymous-unit durable identity, which Codex flagged as the release blocker. Fixes landed in 48a2820:
Verified sound by the reviewers (no change): webhook 4xx/5xx/429 classification, New tests cover verb-scoped anonymous naming, content-name sharing across verbs, and the notice boot-merge. Codex release-readiness note (for whoever cuts 0.9.0)Verdict was NO-GO immediately, GO after a short stabilization gate. The blocking item (anonymous identity) is now resolved. Remaining pre-tag recommendations, not gated on this PR: a consumer-style import test over every new public symbol; a durable-format registry documenting the effect-journal and notice-queue on-disk formats (version + corruption/forward-version behavior); and a changelog with a "Persistence" section. Tracking these separately. |
Use proof-of-possession connect URLs for store-status checks during enrollment and boot. Migrate the demo overlay to durable notices and improve recovery/passkey diagnostics.
Closes #141. Grows the #139 effect machinery (
s.effect, per-obligation journaling, at-least-once delivery) into a tiered standard library and formalizes the contract for authoring custom units. Single PR covering all six issue sections; the one deferred item is called out below.1. The authoring contract (
package/schema/effects.ts)EffectContextgainsopandwriteCreatedAt, so a unit can see the operation and measure the saved→fate latency.PermanentEffectError/isPermanentEffectError: a handler throws it to retire the obligation immediately (a call the receiver will keep refusing) instead of re-arming untilmaxAttempts. An ordinary throw stays retryable. The ledger honors the distinction and both are counted in diagnostics.anonymousEffectName), documented with the same orphaning caveat as a rename.requireMutationRuntimeexposes the runtime seam so every built-in is on the public authoring surface — nothing private.s.logwas reviewed against this and already qualified.2. Built-in units (
package/schema/effect-library.ts), tiered by riskObservation —
s.trace(label?)(saved→fate span in diagnostics, OpenTelemetry-shaped, no vendor coupling),s.debug()(dev-only timeline, records nothing in aPRODbundle).s.logstays in the core beside its runtime recorder.Data-internal —
s.notice(config)(durable, UI-agnostic message queue, idempotent by journal id — closes the "a rejected write flashed success" failure mode),s.mark(table, config)(patch the row when its fate resolves; absolute/convergent patch; skips a rolled-back insert),s.chain(verb, toInput)(issue a follow-up verb on synced, no saga API).External —
s.webhook(url, opts?)(POST row + fate with the journal id asIdempotency-Key;4xx→ permanent,5xx/429/network → retryable; default 24-hour delivery window since receiver keys expire,nullopts out).3. Runtime
notice-queue.ts— durable queue (OPFS → localStorage → memory), idempotent enqueue, TTL + dismissal, subscribe; drops entries expired-while-away on load.write-ledger.tsinstallsrecordTrace/recordDebug/enqueueNotice/applyMark, owns the queue singleton, and exposes publiclistNotices/subscribeNotices/dismissNotice/dismissAllNotices.diagnostics.ts—effectTraces,effectDebugTimeline,activeNotices.4. Preact
useNotices()hook +<Notices />component render the durable queue as an ARIA live region; a toast stack is a userland wrapper over the same hook.5. Docs
docs/effects.md— the library reference, the authoring contract, and a worked custom unit modeled on a built-in. Teaching-path link added tonouns-and-verbs.md(declare → observe → make fate data → compose → reach outside), plus README reference entries.6. Tests
notice-queue: idempotent enqueue, TTL retirement, persisted reload dropping expired entries.PermanentEffectErrorretires on the first failure without exhausting attempts (alongside the existing quarantine test).Verification
deno task checkgreen end-to-end (fmt, lint, doc, doc:closure, links, all suites — 21 JSR entrypoints).deno task buildgreen; manifest updated foreffect-library.ts,notice-queue.ts,Notices.tsx,use-notices.ts.s.trace("task-added")toaddTask(dogfood); starter mirror + snapshot regenerated.Deferred (follow-up)
The reference app's hand-rolled task-notice (
publishNotice/useTaskNotice) is exactly the patterns.noticereplaces, but migrating it is a focused island refactor with its own snapshot churn — better as a separate PR than mixed into the library landing.