clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix(effects): review remediations — durable anonymous-unit identity, notice-queue boot merge, a11y

Dami committed 48a2820
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.
commit
48a28208ddb67ed1401f119722deb17074d1946a
parent
623227574a0aa06619886f15e2c5a3c54db55d8b
author
Dami <dami.etoile@gmail.com>
8 files changed, +283 -106
Mdocs/effects.md9+-
Mpackage/preact/Notices.tsx16+++-
Mpackage/runtime/notice-queue.ts43++++++++-
Mpackage/runtime/notice-queue_test.ts16+++
Mpackage/runtime/write-ledger.ts36+++++--
Mpackage/schema/effect-library.ts142++++++++++++++++--------------
Mpackage/schema/effect_library_test.ts43+++++++++
Mpackage/schema/effects.ts84+++++++++++++++---
M docs/effects.md +6 -3
M package/preact/Notices.tsx +12 -4
M package/runtime/notice-queue.ts +37 -6
@@ -99,12 +99,28 @@ export class NoticeQueue {
9999 this.#onCountChange = onCountChange;100100 }101101 102- /** Loads the persisted document once, dropping already-expired entries. */102+ /**103+ * Loads the persisted document once, dropping already-expired entries.104+ * Entries enqueued in the async load window — an effect firing at a boot105+ * re-arm before load resolves — are merged in rather than clobbered: the106+ * persisted set is the base, and any in-memory entry (fresher) wins on id.107+ */103108 async load(): Promise<void> {104109 if (this.#loaded) return;105- this.#document = parse(await this.#storage.load());110+ const persisted = parse(await this.#storage.load());111+ const pending = this.#document.entries;112+ const byId = new Map<string, NoticeEntry>();113+ for (const entry of persisted.entries) byId.set(entry.id, entry);114+ // In-memory entries were enqueued this session and are authoritative for115+ // their id; they overwrite any stale persisted copy.116+ for (const entry of pending) byId.set(entry.id, entry);117+ this.#document = { version: 1, entries: [...byId.values()] };106118 this.#loaded = true;107119 this.#sweepExpired();120+ // A load that absorbed in-flight entries must re-persist the merged set121+ // and publish it, so the boot-enqueued notice is not silently deferred.122+ if (pending.length > 0) this.#persist();123+ this.#emit();108124 }109125 110126 /**
@@ -142,10 +158,23 @@ export class NoticeQueue {
142158 this.#emit();143159 }144160 145- /** The live notices: enqueued, not dismissed, not past their TTL. */161+ /**162+ * The live notices: enqueued, not dismissed, not past their TTL. A pure163+ * read — it computes the live view without mutating stored state, so a164+ * render never silently prunes the queue (which would drift the persisted165+ * set and the `activeNotices` count from what a later `sweep()` sees).166+ * Actual retirement of expired entries happens in {@link sweep}, which167+ * persists and notifies. A fresh array each call, so callers may hold it.168+ */146169 list(): readonly NoticeEntry[] {147- this.#sweepExpired();148- return this.#document.entries;170+ return this.#liveEntries();171+ }172+173+ #liveEntries(): NoticeEntry[] {174+ const now = this.#now();175+ return this.#document.entries.filter((entry) =>176+ entry.expiresAt === null || entry.expiresAt > now177+ );149178 }150179 151180 /** Subscribes to queue changes; returns an unsubscribe function. */
@@ -182,7 +211,9 @@ export class NoticeQueue {
182211 }183212 184213 #emit(): void {185- this.#onCountChange?.(this.#document.entries.length);214+ // The count and every subscriber see the live view, so `activeNotices`215+ // never overcounts entries that have expired but not yet been swept.216+ this.#onCountChange?.(this.#liveEntries().length);186217 for (const listener of this.#listeners) listener();187218 }188219 }
M package/runtime/notice-queue_test.ts +16 -0
M package/runtime/write-ledger.ts +25 -11
@@ -910,6 +910,7 @@ export function armWriteLedger(): Promise<void> {
910910 }911911 912912 let defaultNotices: NoticeQueue | null = null;913+let noticeSweepTimer: ReturnType<typeof setInterval> | null = null;913914 914915 /**915916 * The package-wide durable notice queue behind `s.notice`, created lazily and
@@ -919,7 +920,7 @@ let defaultNotices: NoticeQueue | null = null;
919920 */920921 export function getNoticeQueue(): NoticeQueue {921922 if (!defaultNotices) {922- defaultNotices = new NoticeQueue(923+ const queue = new NoticeQueue(923924 createDefaultNoticeStorage(appId),924925 Date.now,925926 (count) =>
@@ -927,15 +928,17 @@ export function getNoticeQueue(): NoticeQueue {
927928 diagnostics.activeNotices = count;928929 }),929930 );930- // Load persisted entries and publish the initial count; a storage-less or931- // prerender context simply starts empty.932- void defaultNotices.load()933- .then(() =>934- updateRuntimeDiagnostics((diagnostics) => {935- diagnostics.activeNotices = defaultNotices?.list().length ?? 0;936- })937- )938- .catch(() => undefined);931+ defaultNotices = queue;932+ // Load persisted entries; the merged initial count publishes through the933+ // queue's own onCountChange. A storage-less or prerender context simply934+ // starts empty.935+ void queue.load().catch(() => undefined);936+ // A TTL-only expiry has no queue mutation to repaint it, so a modest937+ // periodic sweep retires expired entries and republishes the count.938+ if (typeof setInterval === "function") {939+ noticeSweepTimer = setInterval(() => queue.sweep(), 30_000);940+ (noticeSweepTimer as { unref?: () => void })?.unref?.();941+ }939942 }940943 return defaultNotices;941944 }
@@ -1003,7 +1006,14 @@ setMutationRuntime({
10031006 ttlMs: input.ttlMs,10041007 }),10051008 applyMark: async (table, rowId, patch) => {1006- await getWriteLedger().perform({ kind: "update", table, id: rowId, patch }).saved;1009+ try {1010+ await getWriteLedger().perform({ kind: "update", table, id: rowId, patch }).saved;1011+ } catch {1012+ // A mark is best-effort: the status patch may find no row (a row removed1013+ // elsewhere) or be denied by policy. Swallow so the mark obligation does1014+ // not re-arm and quarantine over a patch that will never land; the1015+ // fate-as-data guarantee for must-survive signals belongs to s.notice.1016+ }10071017 },10081018 unitRegistered: (name) => defaultLedger?.retryObligationsFor(name),10091019 });
@@ -1012,4 +1022,8 @@ import.meta.hot?.dispose(() => {
10121022 defaultLedger?.dispose();10131023 defaultLedger = null;10141024 defaultNotices = null;1025+ if (noticeSweepTimer !== null) {1026+ clearInterval(noticeSweepTimer);1027+ noticeSweepTimer = null;1028+ }10151029 });
M package/schema/effect-library.ts +75 -67
@@ -26,33 +26,17 @@
2626 import type { TableProxy } from "jazz-tools";2727 import type { WriteHandle } from "../runtime/write-handle.ts";2828 import {29- anonymousEffectName,30- effect,29+ anonymousUnit,30+ cachedBuiltin,3131 type EffectContext,32+ type EffectHandlers,3233 type EffectRow,3334 type EffectUnit,34- type EffectUnitOptions,3535 type NoticeInput,3636 PermanentEffectError,3737 requireMutationRuntime,3838 } from "./effects.ts";3939 40-// A tableless built-in binds no columns; the core `effect` uses its table41-// argument only for row typing, so this placeholder documents the intent in42-// one place instead of scattering casts.43-const noTable = undefined as unknown as TableProxy<{ id: string }, unknown>;44-45-function tablelessEffect(46- name: string,47- handlers: {48- onSynced?: (row: EffectRow<{ id: string }>, context: EffectContext) => void | Promise<void>;49- onRejected?: (row: EffectRow<{ id: string }>, context: EffectContext) => void | Promise<void>;50- },51- options?: EffectUnitOptions,52-): EffectUnit<{ id: string }> {53- return effect(name, noTable, handlers, options);54-}55-5640 /**5741 * Observation unit: a span from the write's journaling to its settled fate,5842 * recorded in runtime diagnostics as an OpenTelemetry-shaped event with the
@@ -71,10 +55,12 @@ function tablelessEffect(
7155 export function trace(label?: string): EffectUnit<{ id: string }> {7256 if (label !== undefined && !label.trim()) throw new Error("trace label must not be empty");7357 const name = label ? `trace:${label}` : "trace";74- const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {75- requireMutationRuntime().recordTrace?.(label ?? null, context);76- };77- return tablelessEffect(name, { onSynced: record, onRejected: record });58+ return cachedBuiltin(name, () => {59+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {60+ requireMutationRuntime().recordTrace?.(label ?? null, context);61+ };62+ return { onSynced: record, onRejected: record };63+ });7864 }7965 8066 /**
@@ -91,12 +77,14 @@ export function trace(label?: string): EffectUnit<{ id: string }> {
9177 * ```9278 */9379 export function debug(): EffectUnit<{ id: string }> {94- const production = !import.meta.env.DEV;95- const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {96- if (production) return;97- requireMutationRuntime().recordDebug?.(context.fate, context);98- };99- return tablelessEffect("debug", { onSynced: record, onRejected: record });80+ return cachedBuiltin("debug", () => {81+ const production = !import.meta.env.DEV;82+ const record = (_row: EffectRow<{ id: string }>, context: EffectContext) => {83+ if (production) return;84+ requireMutationRuntime().recordDebug?.(context.fate, context);85+ };86+ return { onSynced: record, onRejected: record };87+ });10088 }10189 10290 /** A notice message resolved from the settled row, or a static string. */
@@ -159,10 +147,10 @@ export function notice<Row extends { id: string } = { id: string }>(
159147 context,160148 );161149 };162- return tablelessEffect(anonymousEffectName("notice"), {150+ return anonymousUnit<{ id: string }>("notice", {163151 onSynced: enqueue(config.synced, "success"),164152 onRejected: enqueue(config.rejected, "error"),165- }) as EffectUnit<Row>;153+ }) as unknown as EffectUnit<Row>;166154 }167155 168156 /** Per-fate row patches for {@link mark}. */
@@ -184,6 +172,13 @@ export type MarkConfig<Init> = {
184172 * rejected patch is skipped for inserts; on updates and removes the row185173 * survives the rollback and the patch records the failure.186174 *175+ * The mark is best-effort: its patch is an ordinary update that the store may176+ * itself deny (a policy that governs the status column), or that finds no row177+ * (an update whose row was concurrently removed elsewhere). In those cases the178+ * mark simply does not land — it is a convenience over the manual status179+ * column, not a delivery guarantee. For a fate signal that must survive, pair180+ * it with {@link notice} (durable queue) or a `webhook`.181+ *187182 * @example188183 * ```ts189184 * export const submit = s.mutation("submit", s.update(app.claims), {
@@ -217,7 +212,7 @@ export function mark<T extends { id: string }, Init>(
217212 patch as Record<string, unknown>,218213 );219214 };220- return effect(anonymousEffectName("mark"), table, {215+ return anonymousUnit<T>("mark", {221216 onSynced: (row, context) => apply(config.synced, row, false, context),222217 onRejected: (row, context) => apply(config.rejected, row, true, context),223218 });
@@ -245,13 +240,13 @@ export function chain<Row extends { id: string }, NextInput>(
245240 next: (input: NextInput) => WriteHandle<unknown>,246241 toInput: (row: EffectRow<Row>) => NextInput,247242 ): EffectUnit<Row> {248- return tablelessEffect(anonymousEffectName("chain"), {243+ return anonymousUnit<{ id: string }>("chain", {249244 onSynced: async (row) => {250245 // Await local durability of the follow-up: only then is this link safe251246 // to mark delivered, so a crash re-issues rather than dropping it.252247 await next(toInput(row as EffectRow<Row>)).saved;253248 },254- }) as EffectUnit<Row>;249+ }) as unknown as EffectUnit<Row>;255250 }256251 257252 /** Options for the {@link webhook} unit. */
@@ -298,40 +293,53 @@ const defaultWebhookExpiry = 24 * 60 * 60 * 1000;
298293 export function webhook(url: string, options: WebhookOptions = {}): EffectUnit<{ id: string }> {299294 if (!url.trim()) throw new Error("webhook url must not be empty");300295 const fates = new Set(options.on ?? ["synced", "rejected"]);301- const post = async (row: EffectRow<{ id: string }>, context: EffectContext) => {302- if (!fates.has(context.fate)) return;303- let response: Response;304- try {305- response = await fetch(url, {306- method: "POST",307- headers: {308- "content-type": "application/json",309- "idempotency-key": context.journalId,310- ...options.headers,311- },312- body: JSON.stringify({313- journalId: context.journalId,314- verb: context.verb,315- table: context.table,316- op: context.op,317- fate: context.fate,318- code: context.code,319- reason: context.reason,320- row,321- }),322- });323- } catch (error) {324- // A network-level failure is transient by nature: rethrow so the ledger325- // retries with backoff inside the delivery window.326- throw new Error(`webhook ${url} unreachable: ${(error as Error).message}`);327- }328- if (response.ok) return;329- if (response.status >= 400 && response.status < 500 && response.status !== 429) {330- throw new PermanentEffectError(`webhook ${url} refused with ${response.status}`);331- }332- throw new Error(`webhook ${url} transient failure ${response.status}`);296+ const build = (): EffectHandlers<{ id: string }> => {297+ const post = async (row: EffectRow<{ id: string }>, context: EffectContext) => {298+ if (!fates.has(context.fate)) return;299+ let response: Response;300+ try {301+ response = await fetch(url, {302+ method: "POST",303+ headers: {304+ "content-type": "application/json",305+ "idempotency-key": context.journalId,306+ ...options.headers,307+ },308+ body: JSON.stringify({309+ journalId: context.journalId,310+ verb: context.verb,311+ table: context.table,312+ op: context.op,313+ fate: context.fate,314+ code: context.code,315+ reason: context.reason,316+ row,317+ }),318+ });319+ } catch (error) {320+ // A network-level failure is transient by nature: rethrow so the321+ // ledger retries with backoff inside the delivery window.322+ throw new Error(`webhook ${url} unreachable: ${(error as Error).message}`);323+ }324+ if (response.ok) return;325+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {326+ throw new PermanentEffectError(`webhook ${url} refused with ${response.status}`);327+ }328+ throw new Error(`webhook ${url} transient failure ${response.status}`);329+ };330+ return { onSynced: post, onRejected: post };333331 };334- return tablelessEffect("webhook:" + url, { onSynced: post, onRejected: post }, {332+ // Keyed by url + options so two verbs posting the same configured webhook333+ // share one unit (no name collision), while a different config is distinct.334+ const name = `webhook:${url}:${335+ JSON.stringify({336+ on: options.on ?? null,337+ expiresAfterMs: options.expiresAfterMs ?? null,338+ maxAttempts: options.maxAttempts ?? null,339+ headers: options.headers ?? null,340+ })341+ }`;342+ return cachedBuiltin(name, build, {335343 expiresAfterMs: options.expiresAfterMs === undefined336344 ? defaultWebhookExpiry337345 : options.expiresAfterMs ?? undefined,
M package/schema/effect_library_test.ts +43 -0
@@ -6,8 +6,11 @@ import {
66 clearEffectDeclarations,77 effect,88 type EffectContext,9+ insert,10+ mutation,911 type MutationRuntime,1012 type NoticeInput,13+ resolveEffectUnit,1114 setMutationRuntime,1215 } from "./effects.ts";1316
@@ -63,6 +66,30 @@ function context(overrides: Partial<EffectContext> = {}): EffectContext {
6366 };6467 }6568 69+// The identity guarantee: an anonymous built-in takes its durable name from70+// the verb it is attached to and its position in that verb's effects, NOT from71+// a global declaration counter. This is what makes a journaled notice/mark/72+// chain obligation re-arm against the same logical unit regardless of which73+// module evaluated first on the re-arming boot.74+Deno.test("anonymous units are named <verb>#<index>, stable and verb-scoped", () => {75+ clearEffectDeclarations();76+ install();77+ const orders = schema.defineApp({ orders: schema.table({ item: schema.string() }) }).orders;78+ mutation("placeOrder", insert(orders), {79+ effects: [notice({ synced: "Placed." }), mark(orders, { synced: { item: "x" } })],80+ });81+ mutation("reorder", insert(orders), {82+ effects: [notice({ synced: "Reordered." })],83+ });84+ // Position within the verb, not a global counter: reorder's notice is #0,85+ // not #2, so adding placeOrder above it cannot shift reorder's identity.86+ assert(resolveEffectUnit("placeOrder#0") !== null, "the first unit is <verb>#0");87+ assert(resolveEffectUnit("placeOrder#1") !== null, "the second unit is <verb>#1");88+ assert(resolveEffectUnit("reorder#0") !== null, "a second verb restarts the index at 0");89+ assert(resolveEffectUnit("reorder#1") === null, "reorder declares only one anonymous unit");90+ clearEffectDeclarations();91+});92+6693 Deno.test("trace records a span with the saved-to-fate latency on either fate", () => {6794 clearEffectDeclarations();6895 const recorder = install();
@@ -74,6 +101,22 @@ Deno.test("trace records a span with the saved-to-fate latency on either fate",
74101 clearEffectDeclarations();75102 });76103 104+// The content-named built-ins share one unit per identity instead of throwing105+// a duplicate-name error when reused across verbs.106+Deno.test("trace and webhook share one unit per content across verbs", () => {107+ clearEffectDeclarations();108+ install();109+ assert(trace("checkout") === trace("checkout"), "one label shares one trace unit");110+ assert(trace() === trace(), "the unlabeled trace shares one unit");111+ const url = "https://hooks.example.com/x";112+ assert(webhook(url) === webhook(url), "one url+config shares one webhook unit");113+ assert(114+ webhook(url) !== webhook(url, { maxAttempts: 9 }),115+ "a different config is a distinct unit",116+ );117+ clearEffectDeclarations();118+});119+77120 Deno.test("notice enqueues a message keyed by the obligation journal id", () => {78121 clearEffectDeclarations();79122 const recorder = install();
M package/schema/effects.ts +69 -15
@@ -162,6 +162,15 @@ export type EffectUnit<Row = { id: string }> = {
162162 readonly expiresAfterMs?: number | null;163163 /** Failing attempts before quarantine retires the obligation. */164164 readonly maxAttempts?: number;165+ /**166+ * Set on a built-in unit the author did not name (`s.notice`, `s.mark`,167+ * `s.chain`). Such a unit is registered lazily, when a {@link mutation}168+ * includes it: its durable name becomes `<verb>#<position>` — stable across169+ * reloads because it derives from the author-chosen verb name and the unit's170+ * fixed position in that verb's `effects`, not from module-evaluation order.171+ * Absent on named units, which register at declaration.172+ */173+ readonly anonymousPrefix?: string;165174 };166175 167176 /** Which operation a {@link MutationOp} performs. */
@@ -294,8 +303,8 @@ type EffectsSlot = {
294303 effects: Map<string, EffectUnit<{ id: string }>>;295304 verbs: Map<string, MutationDescriptor>;296305 logs: Map<string, EffectUnit<{ id: string }>>;297- /** Monotonic counter naming anonymous built-in units in declaration order. */298- anonSequence: number;306+ /** Cache for content-named built-ins (trace, debug, webhook) shared by name. */307+ cache: Map<string, EffectUnit<{ id: string }>>;299308 };300309 301310 const slotName = "__LOFI_EFFECT_DECLARATIONS__";
@@ -307,7 +316,7 @@ function slot(): EffectsSlot {
307316 effects: new Map(),308317 verbs: new Map(),309318 logs: new Map(),310- anonSequence: 0,319+ cache: new Map(),311320 };312321 return effectsGlobal[slotName];313322 }
@@ -332,7 +341,7 @@ export function clearEffectDeclarations(): void {
332341 state.effects.clear();333342 state.verbs.clear();334343 state.logs.clear();335- state.anonSequence = 0;344+ state.cache.clear();336345 }337346 338347 // During dev hot replacement author modules re-evaluate routinely; the newest
@@ -453,17 +462,52 @@ export function requireMutationRuntime(): MutationRuntime {
453462 }454463 455464 /**456- * A stable-per-declaration name for an anonymous built-in unit (a notice,457- * mark, or chain the author did not name). The journal re-arms by name, and458- * these carry no author name, so their durable identity is declaration order459- * within one module-evaluation — deterministic across reloads for an unchanged460- * bundle. Reordering the verbs that declare them orphans in-flight obligations461- * the same way renaming a named unit does.465+ * Builds an anonymous built-in unit (a notice, mark, or chain the author did466+ * not name) without registering it. Registration is deferred to467+ * {@link mutation}, which names it `<verb>#<position>` from the verb it is468+ * attached to and its position in that verb's `effects`. That identity is469+ * stable across reloads regardless of which module evaluates first, so a470+ * journaled obligation always re-arms against the same logical unit; the only471+ * way to orphan one is to reorder the effects within its own verb.462472 */463-export function anonymousEffectName(prefix: string): string {473+export function anonymousUnit<Row extends { id: string }>(474+ prefix: string,475+ handlers: EffectHandlers<Row>,476+ options: EffectUnitOptions = {},477+): EffectUnit<Row> {478+ return {479+ effectName: "",480+ handlers,481+ anonymousPrefix: prefix,482+ expiresAfterMs: options.expiresAfterMs ?? null,483+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),484+ };485+}486+487+/**488+ * Returns the shared built-in unit registered under `name`, building and489+ * registering it on first use. For content-named observation and external490+ * units (`s.trace`, `s.debug`, `s.webhook`) whose whole identity is their491+ * name, so reusing one across verbs shares a single unit instead of colliding492+ * — the same discipline {@link log} follows.493+ */494+export function cachedBuiltin<Row extends { id: string }>(495+ name: string,496+ build: () => EffectHandlers<Row>,497+ options: EffectUnitOptions = {},498+): EffectUnit<Row> {464499 const state = slot();465- state.anonSequence = (state.anonSequence ?? 0) + 1;466- return `${prefix}#${state.anonSequence}`;500+ const existing = state.cache.get(name);501+ if (existing) return existing as unknown as EffectUnit<Row>;502+ const unit: EffectUnit<Row> = {503+ effectName: name,504+ handlers: build(),505+ expiresAfterMs: options.expiresAfterMs ?? null,506+ ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),507+ };508+ state.cache.set(name, unit as unknown as EffectUnit<{ id: string }>);509+ registerUnit(unit as unknown as EffectUnit<{ id: string }>);510+ return unit;467511 }468512 469513 /**
@@ -510,12 +554,22 @@ export function mutation<T extends { id: string }, Init, Kind extends MutationOp
510554 }511555 const units: EffectUnit<{ id: string }>[] = [];512556 const seen = new Set<string>();513- for (const unit of options.effects ?? []) {557+ const declared = options.effects ?? [];558+ for (let index = 0; index < declared.length; index += 1) {559+ let unit = declared[index] as EffectUnit<{ id: string }>;560+ if (unit.anonymousPrefix !== undefined) {561+ // An anonymous built-in (s.notice/s.mark/s.chain) is named and562+ // registered here, from the verb it is attached to and its position in563+ // this verb's effects — a durable identity independent of module load564+ // order. Register a finalized copy so the ledger resolves it at re-arm.565+ unit = { ...unit, effectName: `${name}#${index}`, anonymousPrefix: undefined };566+ registerUnit(unit);567+ }514568 if (seen.has(unit.effectName)) {515569 throw new Error(`mutation "${name}" declares effect "${unit.effectName}" twice`);516570 }517571 seen.add(unit.effectName);518- units.push(unit as EffectUnit<{ id: string }>);572+ units.push(unit);519573 }520574 if (options.onSynced || options.onRejected) {521575 if (seen.has(name)) {