Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e780c5246c | |||
| 0d925c7cb9 |
@@ -34,7 +34,7 @@ Each module may contain:
|
||||
| `data/` | User stories, `features.ts` (auto-generated), `seedData.ts`, `types.ts` |
|
||||
| `hooks/` | empty — the reactive read binding lives in `data/useShapeQuery.ts` (concept `data-layer`) |
|
||||
| `shapes/` | SHEX + ORM bindings (see concept `data-layer`) |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts`, `storeRegistry.ts`, `connections.ts`, `identifier.ts` |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts`, `storeRegistry.ts`, `connections.ts`, `identifier.ts`, `resolveOnce.ts` (single-flight resolution per key, unit-tested), `serialTask.ts` (a task that never runs concurrently with itself, unit-tested) |
|
||||
| `steps/`, `support/` | Shared Cucumber step definitions and hooks (concept `bdd-testing`) |
|
||||
| `lib/` | Helpers (`cn`, etc.) |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ A **probe** is a one-off Playwright script, outside Cucumber — no World, no ho
|
||||
|
||||
## When to reach for one
|
||||
|
||||
Before believing a flow works. The create-and-participate flow had been declared *correct by construction* on typecheck, build and reading; the first probe ever run against it found **three defects** none of those could see — one still open ([[bug_participant-count-stays-at-zero]] in `data-layer`) and two shipped as fixes.
|
||||
Before believing a flow works. The create-and-participate flow had been declared *correct by construction* on typecheck, build and reading; the first probe ever run against it found **three defects** none of those could see — all three now fixed, though one left a residual one-connection display lag whose cause sits outside the app ([[caveat_participant-count-one-connection-lag]] in `data-layer`).
|
||||
|
||||
Reach for it when the suite cannot answer the question: the `@data` run dies silently from around its sixth scenario ([[caveat_wallet-bloat-hang]]), its scenarios have no fixtures ([[caveat_data-suite-has-no-fixtures]]), and entry paths are covered by nothing ([[caveat_first-time-entry-untested]], `app-architecture` → [[caveat_boot-unverified-outside-broker]]).
|
||||
|
||||
|
||||
@@ -27,13 +27,10 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
|
||||
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; access is granted per document, so this is what makes isolation possible
|
||||
- [[rule_app-uses-sdk-surface-only]] — the pulled contract is the only reference; a gap in it is raised with the provider, never worked around here
|
||||
|
||||
## Open defects — the sign-up flow does not deliver an honest count
|
||||
|
||||
- [[bug_participant-count-stays-at-zero]] — the count stays at 0 through the session that signs up and through the first reconnect, only catching up on the second
|
||||
|
||||
## Pitfalls (read before touching deletions / event fields)
|
||||
## Pitfalls (read before touching deletions / event fields / the participant count)
|
||||
|
||||
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
|
||||
- [[caveat_participant-count-one-connection-lag]] — `participantCount` lags one connection behind the write that produced it; cause is outside the app, no app-side compensation
|
||||
|
||||
> Confidentiality (scope isolation, trusting the SDK): concept `app-security`. Product scopes per entity + discovery: concept `functional-domain`.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
type: bug
|
||||
severity: major
|
||||
opened: 2026-08-16
|
||||
last_checked: 2026-08-16
|
||||
summary: After a sign-up the count stays at 0 for the rest of the session and through the first reconnect — it only reaches the true value on the SECOND reconnect, one connection later than expected. Withdrawal converges in a single connection, for a reason not yet established.
|
||||
---
|
||||
|
||||
# The participant count takes one connection more than expected to converge
|
||||
|
||||
## What happens, VERIFIED
|
||||
|
||||
In the create-and-participate flow — declare an event, sign up to it — the event's `participantCount` **stays at 0 for the rest of the session** while the button reads « ✓ Je participe ». VERIFIED 2 runs out of 2, the count still 0 **120 s** and **75 s** after the sign-up.
|
||||
|
||||
The count starting at 0 on creation is correct and is not the defect ([[knowledge_context-internals]] §participantCount: no host baseline). The defect is that it does not move within the session — and, now measured, not on the reconnect that immediately follows it either.
|
||||
|
||||
**The "owner offline" explanation does not apply.** In this flow the signer **is** the event's owner, so the counter's only writer is present, connected, and watching the inbox it deposited into. Eventual delivery to an absent owner explains nothing here.
|
||||
|
||||
## The convergence, now measured, VERIFIED
|
||||
|
||||
The count does converge, but **one connection later than expected**: the first reconnect after the sign-up still reads 0; the count only reaches the true value on the **second** reconnect.
|
||||
|
||||
INFERRED: the first reconnect is the one that drains the deposit and writes the counter, and the write's own value shows up only on the load that follows it, not on the load that triggered it.
|
||||
|
||||
**Withdrawal, by contrast, converges in a single connection — VERIFIED.** Why it converges one connection sooner than sign-up does is NOT established: `leaveEvent` deposits its own marker (`depositLeave`) into the same inbox, processed by the same owner materializer as a join ([[knowledge_context-internals]] §participantCount), so the asymmetry is not explained by withdrawal skipping the deposit — that explanation does not survive a look at how `leaveEvent` is wired. Record the one-connection gap as open rather than reach for a mechanism.
|
||||
|
||||
## What is still open
|
||||
|
||||
Which side is at fault for the extra connection is still open — never written, or written and not re-read. The pair of measurement points laid down for exactly this question (the owner's materializer logging `participantCount` before → after its write, and the display read logging the value as exposed to the render — [[knowledge_context-internals]] §logging) is where a diagnosis starts.
|
||||
|
||||
## Reproduce
|
||||
|
||||
1. Connect, declare an event (the count shows 0 — correct).
|
||||
2. Sign up to it; the button reaches « ✓ Je participe ».
|
||||
3. Stay on the page and watch the count for a couple of minutes — it stays at 0.
|
||||
4. Reconnect once — still 0. Reconnect a second time — now correct.
|
||||
|
||||
Method: `bdd-testing` → [[cookbook_live-probe]]. Watching *after* the confirmation, over a real interval, and across two reconnects, is what makes this visible at all — every individual step reports success.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: After a sign-up (or a withdrawal) the participantCount a bystander sees needs one connection more than the write itself — written on the first reconnect, displayed on the second. Cause is outside the app, in the layer not notifying you of your own actions; no app-side compensation, deliberately.
|
||||
last_checked: 2026-08-17
|
||||
---
|
||||
|
||||
# Caveat: the participant count lags one connection behind the write that produced it
|
||||
|
||||
In the create-and-participate flow — declare an event, sign up to it — the event's `participantCount` **stays at 0 for the rest of the session**, VERIFIED over two-minute intervals, while the button already reads « ✓ Je participe ». The count starting at 0 on creation is correct and is not this caveat ([[knowledge_context-internals]] §participantCount: no host baseline).
|
||||
|
||||
## The convergence, VERIFIED
|
||||
|
||||
The count does converge, but **one connection later than the write**: the first reconnect after the sign-up still reads 0; the count only reaches the true value on the **second** reconnect. The same one-connection lag applies to a withdrawal — earlier it looked like withdrawal converged immediately while sign-up never did, but that asymmetry was the multi-inbox race below, not a separate mechanism: with one inbox per document, both paths share this same lag.
|
||||
|
||||
## Two measured causes, both about the layer not notifying you of your own actions
|
||||
|
||||
- A deposit you make into an inbox **you watch** produces no push — so the owner's own materializer, sitting on its own inbox, is not woken by its own sign-up.
|
||||
- A write to **your own document** is not re-read by `watchShape` in the writing session — so the materializer's own count write does not come back on the load that made it, only on the one after.
|
||||
|
||||
Both are gaps in [[contract_polyfill-surface]], raised with the provider ([[rule_app-uses-sdk-surface-only]] in this concept) — not something to work around in the app.
|
||||
|
||||
## Why nothing is done about it here
|
||||
|
||||
Any retry or short-interval poll to paper over the gap is exactly what `bdd-testing` → [[rule_no-broker-polling]] forbids. The count is not lost — the materializer fires directly on connection, not only on a push, so it always catches up on the second reconnect — so there is nothing to compensate for beyond the one connection of delay.
|
||||
|
||||
## What this is not
|
||||
|
||||
Not data loss, not a race: [[knowledge_context-internals]] §participantCount describes the concurrency-safety the flow now has (one inbox per document, one materialize cycle at a time, a monotonic guard against a stale write). This caveat is the residual display delay that mechanism does not close, because its cause sits below it.
|
||||
|
||||
## Reproduce
|
||||
|
||||
1. Connect, declare an event (the count shows 0 — correct).
|
||||
2. Sign up to it; the button reaches « ✓ Je participe ».
|
||||
3. Stay on the page and watch the count for a couple of minutes — it stays at 0.
|
||||
4. Reconnect once — still 0. Reconnect a second time — now correct.
|
||||
|
||||
Method: `bdd-testing` → [[cookbook_live-probe]]. Watching *after* the confirmation, over a real interval, and across two reconnects, is what makes this visible at all.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Internal pitfalls of FestipodDataContext — the signed-in identity and the profile are two unrelated things, "my profile" is the profile document I own, mutations reject instead of succeeding silently, participantCount is derived by the event's owner, and local mode is a no-op
|
||||
last_checked: 2026-08-16
|
||||
last_checked: 2026-08-17
|
||||
---
|
||||
|
||||
# Internals & pitfalls of `FestipodDataContext`
|
||||
@@ -70,12 +70,14 @@ The counter is **not** incremented by whoever joins: only a document's owner wri
|
||||
|
||||
- A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`).
|
||||
- The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`.
|
||||
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Being a pure function of the inbox, a replay is *designed* to converge: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change.
|
||||
- **One inbox per document, whatever the concurrency.** `openDocumentInbox` (`storeRegistry.ts`) resolves at most once per document per session (`resolveOncePerKey`, `src/shared/utils/`, unit-tested): several callers racing for the same event's inbox — create, materializer, watch wiring, watch callback — all await the same in-flight resolution instead of minting a second address. A rejection is not memoized (unknown, not absent), so a later call genuinely retries.
|
||||
- **One materialize cycle at a time.** The owner's connection trigger and its inbox-push trigger both call into a `createSerialTask` (`src/shared/utils/`, `serialTask.ts`, unit-tested): a cycle in flight absorbs every request that arrives during it into a single follow-up, so two read-derive-write passes never race on the same document. Each cycle carries a monotonic sequence number, and a write only lands if no fresher cycle has already written — a stale cycle can no longer clobber a newer value.
|
||||
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Being a pure function of the inbox, a replay is *designed* to converge: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change, and lands in **one** SPARQL statement (`updateEntityField`: `DELETE … INSERT … WHERE`), closing a window where a reader could see the field briefly absent and read zero.
|
||||
- **Owner offline = eventual.** While the owner is disconnected the count does not move for anyone else; nothing is lost. The materializer fires directly on connection, not only on a push, and it never locks in a premature 0.
|
||||
|
||||
> ⚠️ **This section describes the design, and the design is not what a live run does.** Driven end to end with the owner present and connected — the signer *being* the owner — the count stayed at **0** for the rest of the session after a sign-up. Read the convergence properties above as intent to be re-established, not as observed behaviour: [[bug_participant-count-stays-at-zero]].
|
||||
- The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back.
|
||||
|
||||
> A live run still shows the count **one connection later** than this design implies — not a race, not data loss, a layer that does not notify you of your own actions: [[caveat_participant-count-one-connection-lag]].
|
||||
|
||||
Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file.
|
||||
|
||||
## Logging convention — identity-first, and the counter before→after
|
||||
|
||||
@@ -27,4 +27,4 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
|
||||
|
||||
> Mutations are **genuinely persisted** in connected mode: `joinEvent` writes a Participation into its own document and **deposits** into the event's inbox (the deposit is the delivery — no notification is written for the host), `leaveEvent` deletes authoritatively (see [[caveat_participation-deletion]]). Both **reject** rather than returning quietly when they cannot write, and the screen's confirmation follows the write. In local/demo mode they are **no-ops that still show a success toast** — see [[knowledge_context-internals]].
|
||||
>
|
||||
> **Per-call honesty is not flow-level honesty.** Every one of those calls tells the truth about itself, and the sign-up flow driven end to end still does not show the user what happened: the count stays at 0 through the session and the first reconnect, only catching up on the second — [[bug_participant-count-stays-at-zero]]. Do not read the paragraph above as "signing up works".
|
||||
> **Per-call honesty is not flow-level honesty.** Every one of those calls tells the truth about itself; the sign-up flow driven end to end still shows a bystander a stale `participantCount` for one connection longer than the write itself — not a lie, a layer that neither pushes you your own deposit nor re-reads your own write in the same session, see [[caveat_participant-count-one-connection-lag]]. Do not read the paragraph above as "the count updates instantly".
|
||||
|
||||
@@ -42,7 +42,7 @@ So: **write = direct SPARQL into the entity's document** (immediate, per-documen
|
||||
|
||||
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the shape read queries that same anchored default graph. This is the **canonical** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`. It is a choice of **simplicity and uniformity**, not a round-trip necessity: an explicit `GRAPH` wrapper anchored to the same document does round-trip, so a "0 entities" symptom is never evidence of a graph mismatch — look at the test wallet first (`bdd-testing/caveat_wallet-bloat-hang`).
|
||||
|
||||
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
|
||||
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: one `DELETE … INSERT … WHERE` update, not a DELETE followed by a separate INSERT — the latter left a window where a reader could see the field briefly absent) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
|
||||
|
||||
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the NURI of **the profile document this session OWNS** — never the identity it signed in as, which is opaque and never written into an entity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before that document resolves must **reject** rather than write, which is what `joinEvent` and `leaveEvent` do — they throw, and the screen's confirmation follows the write. See [[knowledge_context-internals]].
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ summary: What is implemented today (event + meeting point lifecycle, profiles, c
|
||||
- User profile, profile update, profile sharing
|
||||
- Friends list (connections), another user's profile
|
||||
|
||||
> **Signing up is wired step by step, and the flow as a whole does not yet deliver an honest count.** Each step is honest: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]); neither succeeds in silence, and the confirmation the user sees follows the write. Driven end to end in a real browser, the sign-up nonetheless **announces a success ahead of what it shows**: the user is told they participate, and the count they see stays at 0 through the session and the first reconnect, only catching up on the second ([[bug_participant-count-stays-at-zero]]). Honest steps do not add up to an honest flow, and nothing short of exercising the whole thing shows it (concept `bdd-testing`, [[cookbook_live-probe]]). Treat the bullet above as *screens reachable*, not as a working journey. Public discovery — a user seeing another user's public event — works too.
|
||||
> **Signing up is wired step by step, and the count a bystander sees lags the flow by one connection.** Each step is honest: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]); neither succeeds in silence, and the confirmation the user sees follows the write. Driven end to end in a real browser, the signer's own confirmation is instant and correct, but the `participantCount` a bystander sees stays at 0 through the session and the first reconnect, only catching up on the second — a known layer limitation, not data loss (concept `data-layer`, [[caveat_participant-count-one-connection-lag]]). Nothing short of exercising the whole thing end to end shows this kind of gap (concept `bdd-testing`, [[cookbook_live-probe]]). Treat the bullet above as *screens reachable*, not as an instantly-consistent journey. Public discovery — a user seeing another user's public event — works too.
|
||||
|
||||
> **Updating an event is reserved to its declarer**, and the interface says so rather than discovering it late: the edit route is decided by ownership, and the confirmation follows the write instead of preceding it (concept `app-architecture`, [[knowledge_screen-pattern]]). Owner-only is not a policy choice here — it is the only reading the data model allows ([[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
openDocumentInbox,
|
||||
} from '../utils/storeRegistry';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import { createSerialTask } from '../utils/serialTask';
|
||||
import { useShapeQuery } from '../data/useShapeQuery';
|
||||
import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
|
||||
// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken
|
||||
@@ -918,36 +919,77 @@ function useNgData(): FestipodDataContextValue {
|
||||
// failed, or this event arrived after the last one. Never 'not-mine' here.
|
||||
return 'unknown';
|
||||
}, [ownedCanonicalIds, ruledOutEventIds]);
|
||||
// Last count written per owned event, so we only persist a genuine change.
|
||||
const materializedCountRef = useRef<Map<string, number>>(new Map());
|
||||
// Last count WRITTEN per owned event (keyed by canonical id), stamped with the
|
||||
// cycle that wrote it. The stamp is what makes a STALE cycle harmless — see the
|
||||
// guard below.
|
||||
const materializedCountRef = useRef<Map<string, { count: number; seq: number }>>(new Map());
|
||||
// Monotonic cycle number, shared by every materialize cycle of this session. It
|
||||
// has to outlive the effect: when the owned set changes the effect re-runs, and
|
||||
// a cycle started by the PREVIOUS run can still be in flight.
|
||||
const cycleSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const owned = ownedEvents;
|
||||
if (owned.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const materialize = async (trigger: string) => {
|
||||
// ONE inbox address per owned event, resolved ONCE here and shared by the two
|
||||
// things that must not disagree: the cycle that READS the inbox and the watch
|
||||
// that SUBSCRIBES to it. Resolving them apart is how the owner ends up watching
|
||||
// one address while a deposit lands in another — the sign-up is then never seen
|
||||
// in the session that made it. `openDocumentInbox` is itself single-flight per
|
||||
// document (utils/storeRegistry), so the app can no longer open a second inbox
|
||||
// at all; this map is the narrower promise that the reader and the watcher hold
|
||||
// the very same value.
|
||||
//
|
||||
// Resolved on first need and kept — but a FAILED resolution is released, not
|
||||
// kept: it means the address is UNKNOWN, so the next trigger must really ask
|
||||
// again instead of inheriting a permanent "no inbox".
|
||||
let resolvingInboxes: Promise<Map<Nuri, Nuri>> | null = null;
|
||||
const inboxesForOwnedEvents = (): Promise<Map<Nuri, Nuri>> => {
|
||||
if (resolvingInboxes) return resolvingInboxes;
|
||||
const attempt = (async () => {
|
||||
const byEvent = new Map<Nuri, Nuri>();
|
||||
for (const evId of owned) byEvent.set(evId, await openDocumentInbox(evId));
|
||||
return byEvent;
|
||||
})().catch(err => {
|
||||
if (resolvingInboxes === attempt) resolvingInboxes = null;
|
||||
throw err;
|
||||
});
|
||||
resolvingInboxes = attempt;
|
||||
return attempt;
|
||||
};
|
||||
|
||||
const runCycle = async (trigger: string) => {
|
||||
if (cancelled) return;
|
||||
const seq = ++cycleSeqRef.current;
|
||||
try {
|
||||
const inboxes = await inboxesForOwnedEvents();
|
||||
console.log(
|
||||
`${logPrefix} owner participation materialize START (trigger=${trigger}) — ${owned.length} owned ` +
|
||||
`event(s)`,
|
||||
`${logPrefix} owner participation materialize START (trigger=${trigger}, cycle=${seq}) — ` +
|
||||
`${owned.length} owned event(s)`,
|
||||
);
|
||||
const notifs: FpNotificationData[] = [];
|
||||
for (const evId of owned) {
|
||||
// Each event has its OWN inbox, and only its OWNER can open it. This
|
||||
// call returns the address the owner reads and watches; a depositor
|
||||
// never sees it (they name the document instead).
|
||||
const targetInbox = await openDocumentInbox(evId);
|
||||
// Each event has its OWN inbox, and only its OWNER can open it. The address
|
||||
// comes from the shared map above — the same one the watch subscribed; a
|
||||
// depositor never sees it (they name the document instead).
|
||||
for (const [evId, targetInbox] of inboxes) {
|
||||
const canonId = canonicalEventId(evId);
|
||||
// BEFORE — the event's readable detail (short id + title) and the
|
||||
// participantCount value as currently READ/exposed (the app-side `events`
|
||||
// state), captured before this cycle's derive+write. Comparing this to the
|
||||
// AFTER log below tells whether the counter is a DATA problem (never
|
||||
// incremented) or a DISPLAY/read problem (incremented but not re-read).
|
||||
const knownEvent = events.find(e => e.id === evId);
|
||||
//
|
||||
// Read through the REF, never the closure: this effect only re-runs on
|
||||
// [ready, ownedKey], so the `events` it captured is the snapshot from the
|
||||
// render that wired the watch — which is why this line printed `(unknown)`
|
||||
// for the whole session and told the last investigation nothing. Matched
|
||||
// on the canonical id-form, like every other event-id comparison here.
|
||||
const knownEvent = eventsRef.current.find(e => canonicalEventId(e.id) === canonId);
|
||||
const knownCount = knownEvent?.participantCount;
|
||||
console.log(
|
||||
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}` +
|
||||
`${logPrefix} participation materialize — event=${canonId}` +
|
||||
(knownEvent?.title ? ` "${knownEvent.title}"` : '') +
|
||||
` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`,
|
||||
);
|
||||
@@ -957,18 +999,35 @@ function useNgData(): FestipodDataContextValue {
|
||||
// registrant's deposit is visible even on a cold session.
|
||||
const active = await materializeAttendance(targetInbox, evId);
|
||||
const nextCount = active.length; // no host baseline (creator not auto-in)
|
||||
const prevCount = materializedCountRef.current.get(evId);
|
||||
// Write ONLY when the derived value actually changes (anti-loop). This
|
||||
// memo does NOT lock in a premature 0: the barrier-gated read above makes
|
||||
// the first post-connection materialize see the real deposits, so once the
|
||||
// set becomes non-empty `nextCount !== prevCount` and the correct count is
|
||||
// written. A transient write failure reverts the memo so the next trigger
|
||||
// retries. The guard's sole job is to avoid re-writing an UNCHANGED value.
|
||||
if (prevCount !== nextCount) {
|
||||
materializedCountRef.current.set(evId, nextCount);
|
||||
const written = materializedCountRef.current.get(canonId);
|
||||
// THE VALUE A CYCLE CARRIES IS ONLY AS FRESH AS THE READ IT CAME FROM.
|
||||
// Cycles of this effect can no longer interleave (they are serialized
|
||||
// below), but the effect re-runs whenever the owned set changes, and a
|
||||
// cycle from the previous run can still be in flight — holding a count it
|
||||
// derived BEFORE the fresher one's. Writing it would put the stale value
|
||||
// back on the document, which is a count that goes backwards for no
|
||||
// visible reason. So a cycle may only overwrite what an OLDER cycle wrote.
|
||||
if (written && written.seq > seq) {
|
||||
console.log(
|
||||
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`participantCount ${prevCount ?? '(none)'} → ${nextCount} (writing own doc)`,
|
||||
`${logPrefix} owner participation materialize — event=${canonId}: cycle ${seq} is STALE ` +
|
||||
`(cycle ${written.seq} already wrote ${written.count}) — not writing ${nextCount}`,
|
||||
);
|
||||
} else if (written?.count === nextCount) {
|
||||
// Write ONLY when the derived value actually changes (anti-loop). This
|
||||
// memo does NOT lock in a premature 0: the barrier-gated read above makes
|
||||
// the first post-connection materialize see the real deposits, so once the
|
||||
// set becomes non-empty the value differs and the correct count is
|
||||
// written. A transient write failure reverts the memo so the next trigger
|
||||
// retries. The guard's sole job is to avoid re-writing an UNCHANGED value.
|
||||
console.log(
|
||||
`${logPrefix} owner participation materialize — event=${canonId}: ` +
|
||||
`participantCount unchanged (${nextCount}) — no write`,
|
||||
);
|
||||
} else if (!cancelled) {
|
||||
materializedCountRef.current.set(canonId, { count: nextCount, seq });
|
||||
console.log(
|
||||
`${logPrefix} owner participation materialize — event=${canonId}: ` +
|
||||
`participantCount ${written?.count ?? '(none)'} → ${nextCount} (writing own doc, cycle=${seq})`,
|
||||
);
|
||||
// The write lands on the owned event doc, which `watchShape('public')`
|
||||
// already subscribes → the reactive read re-renders the new count on
|
||||
@@ -976,9 +1035,11 @@ function useNgData(): FestipodDataContextValue {
|
||||
let writeOk = true;
|
||||
await updateEntityField(evId, evId, 'participantCount', int(nextCount))
|
||||
.catch(err => {
|
||||
// Revert the memo so a transient write failure retries next trigger.
|
||||
// Revert the memo so a transient write failure retries next trigger
|
||||
// — but only if it is still OURS. A fresher cycle's value stands.
|
||||
writeOk = false;
|
||||
materializedCountRef.current.delete(evId);
|
||||
const current = materializedCountRef.current.get(canonId);
|
||||
if (current && current.seq === seq) materializedCountRef.current.delete(canonId);
|
||||
console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err);
|
||||
});
|
||||
if (writeOk) {
|
||||
@@ -988,15 +1049,10 @@ function useNgData(): FestipodDataContextValue {
|
||||
// file) still showing the old N after this fires means the counter data
|
||||
// is fine and it is the read side that lags.
|
||||
console.log(
|
||||
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`${logPrefix} participation materialize — event=${canonId}: ` +
|
||||
`participantCount AFTER write = ${knownCount ?? '(unknown)'} → ${nextCount}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`participantCount unchanged (${nextCount}) — no write`,
|
||||
);
|
||||
}
|
||||
// (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c).
|
||||
const evNotifs = await readRegistrationNotifications(targetInbox, evId);
|
||||
@@ -1015,12 +1071,22 @@ function useNgData(): FestipodDataContextValue {
|
||||
}
|
||||
};
|
||||
|
||||
// A CYCLE IS A READ-DERIVE-WRITE, AND TWO OF THEM MUST NOT OVERLAP. The two
|
||||
// triggers below fire within the same instant on the connection that creates an
|
||||
// event, and run concurrently they both read the inbox before either writes —
|
||||
// so the one that finishes last puts its own, older reading back on the
|
||||
// document. Serialized, a request arriving mid-cycle is served by ONE follow-up
|
||||
// cycle once the current one has finished (a cycle re-derives everything from
|
||||
// the inbox, so one follow-up covers however many requests it coalesces).
|
||||
const materialize = createSerialTask(runCycle);
|
||||
|
||||
// (A) RELIABLE-AT-CONNECTION: run one materialization directly on this trigger
|
||||
// ([ready, ownedKey]). This is the spec's core — the owner, at its NEXT
|
||||
// CONNECTION, deterministically processes its owned events' inbox, reading
|
||||
// through the synced-view contract. It does NOT depend on a cross-session
|
||||
// inbox push arriving.
|
||||
void materialize('connection');
|
||||
void materialize('connection').catch(err =>
|
||||
console.error(`${logPrefix} owner participation materialize cycle rejected:`, err));
|
||||
|
||||
// (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on
|
||||
// every later deposit visible to THIS verifier (a local deposit, or a remote one
|
||||
@@ -1028,16 +1094,18 @@ function useNgData(): FestipodDataContextValue {
|
||||
// stays live when a deposit does push. Cross-session convergence does NOT rely on
|
||||
// this (it relies on (A) at the owner's next connection); this only sharpens the
|
||||
// same-session/live case. One watch PER owned event — each event has its OWN
|
||||
// inbox document — resolved async, so wire them inside an IIFE and stash the
|
||||
// unsubscribes for cleanup.
|
||||
// inbox document — and the address watched is the one taken from the SHARED map
|
||||
// above, so what is watched is exactly what the cycle reads.
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
(async () => {
|
||||
for (const evId of owned) {
|
||||
const targetInbox = await openDocumentInbox(evId);
|
||||
if (cancelled) return;
|
||||
unsubscribes.push(inbox.watch(targetInbox, () => void materialize('inbox-push')));
|
||||
void (async () => {
|
||||
const inboxes = await inboxesForOwnedEvents();
|
||||
if (cancelled) return;
|
||||
for (const targetInbox of inboxes.values()) {
|
||||
unsubscribes.push(inbox.watch(targetInbox, () =>
|
||||
void materialize('inbox-push').catch(err =>
|
||||
console.error(`${logPrefix} owner participation materialize cycle rejected:`, err))));
|
||||
}
|
||||
})();
|
||||
})().catch(err => console.error(`${logPrefix} owner inbox watch wiring failed:`, err));
|
||||
return () => { cancelled = true; for (const stop of unsubscribes) stop(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, ownedKey]);
|
||||
|
||||
@@ -102,12 +102,30 @@ export async function updateEntityField(
|
||||
// write shape (same as writeEntity / registration.ts); SDK graph details live in
|
||||
// `@ng-eventually/polyfill`, not here. `docs.sparqlUpdate` validates the anchor at
|
||||
// its own door — `subject` only needs escaping, as it lands in an IRI position.
|
||||
const del = `DELETE WHERE { <${s}> <${pred}> ?o }`;
|
||||
await docs.sparqlUpdate(sid, del, graphNuri);
|
||||
if (obj !== null) {
|
||||
const ins = `INSERT DATA { <${s}> <${pred}> ${obj} }`;
|
||||
await docs.sparqlUpdate(sid, ins, graphNuri);
|
||||
if (obj === null) {
|
||||
// Clearing the field: there is nothing to put back, so the removal stands alone.
|
||||
await docs.sparqlUpdate(sid, `DELETE WHERE { <${s}> <${pred}> ?o }`, graphNuri);
|
||||
return;
|
||||
}
|
||||
// ONE update, not a DELETE followed by an INSERT. Sent as two, the field is
|
||||
// ABSENT between them, and a read landing in that window does not see "the old
|
||||
// value" — it sees NO value, which the read side turns into the field's empty
|
||||
// reading (0 for `participantCount`). The window is small and the reads are
|
||||
// pushed, so it shows up as a count that flickers to 0 for no reason anyone can
|
||||
// reproduce on demand.
|
||||
//
|
||||
// `DELETE … INSERT … WHERE` is ONE SPARQL modify operation — the surface takes
|
||||
// an update string and this is a single one, so nothing here invents a
|
||||
// transaction the SDK does not offer. The `OPTIONAL` is what makes it work on a
|
||||
// field that is not there yet: the WHERE still yields one solution (with `?o`
|
||||
// unbound, so the DELETE template drops out) and the INSERT applies. When the
|
||||
// field is present, every one of its triples is removed and the new one written
|
||||
// in the same operation.
|
||||
const update = `
|
||||
DELETE { <${s}> <${pred}> ?o }
|
||||
INSERT { <${s}> <${pred}> ${obj} }
|
||||
WHERE { OPTIONAL { <${s}> <${pred}> ?o } }`;
|
||||
await docs.sparqlUpdate(sid, update, graphNuri);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ import { inbox, docs } from '@ng-eventually/polyfill';
|
||||
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { listMyEntityDocs } from '../utils/storeRegistry';
|
||||
import { canonicalDocumentId } from '../utils/documentNuri';
|
||||
import { escapeLiteral, escapeIri } from './sparqlEscape';
|
||||
import type { FpNotificationData } from './types';
|
||||
|
||||
@@ -105,13 +106,12 @@ function mintDepositUid(): string {
|
||||
* target).
|
||||
*
|
||||
* A NURI with no `:v:` overlay (or a non-`did:ng:o:` id) passes through unchanged.
|
||||
*
|
||||
* The canonicalization itself is `canonicalDocumentId` (utils/documentNuri): the
|
||||
* SAME invariant also keys the app's one-inbox-per-document resolution, and one
|
||||
* document must not have two canonical forms depending on who is asking.
|
||||
*/
|
||||
export function canonicalEventId(id: string): string {
|
||||
// did:ng:o:<repo>:v:<overlay> → did:ng:o:<repo>. The overlay segment is the
|
||||
// LAST `:v:`-introduced part; a base id (`did:ng:o:<repo>`) has no `:v:`.
|
||||
const i = id.indexOf(':v:');
|
||||
return i === -1 ? id : id.slice(0, i);
|
||||
}
|
||||
export const canonicalEventId = canonicalDocumentId;
|
||||
|
||||
/**
|
||||
* Build the host-facing notification from a registration deposit. The recipient
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The CANONICAL form of a document reference — the one form every comparison and
|
||||
* every per-document memo in the app keys on.
|
||||
*
|
||||
* A document reference is a `did:ng:o:<repo>[:v:<overlay>]`. The SAME document can
|
||||
* legitimately be named with or without its `:v:<overlay>` suffix depending on
|
||||
* which boundary handed it over (a create, a listing, a read subject). Two forms
|
||||
* of one document must never become two entries anywhere: two owned events, two
|
||||
* counts — or, the defect this file was extracted for, two INBOXES for one
|
||||
* document, one of which nobody reads.
|
||||
*
|
||||
* A reference with no `:v:` overlay (or one that is not a `did:ng:o:` id at all)
|
||||
* passes through unchanged.
|
||||
*
|
||||
* `canonicalEventId` (data/registration) is this function under the name the event
|
||||
* call sites use; it is re-exported there, not reimplemented.
|
||||
*/
|
||||
export function canonicalDocumentId(id: string): string {
|
||||
// did:ng:o:<repo>:v:<overlay> → did:ng:o:<repo>. The overlay segment is the
|
||||
// LAST `:v:`-introduced part; a base id (`did:ng:o:<repo>`) has no `:v:`.
|
||||
const i = id.indexOf(':v:');
|
||||
return i === -1 ? id : id.slice(0, i);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { resolveOncePerKey } from './resolveOnce';
|
||||
|
||||
/** A resolution the test controls: it settles when the test says so. */
|
||||
function controllable() {
|
||||
let calls = 0;
|
||||
const gates: Array<{ resolve(v: string): void; reject(e: unknown): void }> = [];
|
||||
const resolve = (arg: string) => {
|
||||
calls++;
|
||||
return new Promise<string>((res, rej) => {
|
||||
gates.push({ resolve: res, reject: rej });
|
||||
}).then(v => `${v}:${arg}`);
|
||||
};
|
||||
return { get calls() { return calls; }, gates, resolve };
|
||||
}
|
||||
|
||||
test('simultaneous callers for one key start ONE resolution and share its value', async () => {
|
||||
const c = controllable();
|
||||
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
|
||||
|
||||
const a = resolveOnce('doc');
|
||||
const b = resolveOnce('doc');
|
||||
const d = resolveOnce('doc');
|
||||
expect(c.calls).toBe(1); // the second and third joined the one in flight
|
||||
|
||||
c.gates[0]!.resolve('inbox-1');
|
||||
expect(await a).toBe('inbox-1:doc');
|
||||
expect(await b).toBe('inbox-1:doc');
|
||||
expect(await d).toBe('inbox-1:doc');
|
||||
expect(c.calls).toBe(1);
|
||||
});
|
||||
|
||||
test('the answer is kept — a later caller never starts a second resolution', async () => {
|
||||
const c = controllable();
|
||||
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
|
||||
|
||||
const first = resolveOnce('doc');
|
||||
c.gates[0]!.resolve('inbox-1');
|
||||
await first;
|
||||
|
||||
expect(await resolveOnce('doc')).toBe('inbox-1:doc');
|
||||
expect(c.calls).toBe(1);
|
||||
});
|
||||
|
||||
test('distinct keys resolve independently', async () => {
|
||||
const c = controllable();
|
||||
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
|
||||
|
||||
const a = resolveOnce('doc-a');
|
||||
const b = resolveOnce('doc-b');
|
||||
expect(c.calls).toBe(2);
|
||||
c.gates[0]!.resolve('inbox-a');
|
||||
c.gates[1]!.resolve('inbox-b');
|
||||
expect(await a).toBe('inbox-a:doc-a');
|
||||
expect(await b).toBe('inbox-b:doc-b');
|
||||
});
|
||||
|
||||
test('two spellings of one key are ONE resolution (the overlay case)', async () => {
|
||||
const c = controllable();
|
||||
const resolveOnce = resolveOncePerKey<string, string>(
|
||||
arg => arg.split(':v:')[0]!,
|
||||
c.resolve,
|
||||
);
|
||||
|
||||
const bare = resolveOnce('did:ng:o:repo');
|
||||
const overlaid = resolveOnce('did:ng:o:repo:v:overlay');
|
||||
expect(c.calls).toBe(1);
|
||||
c.gates[0]!.resolve('inbox-1');
|
||||
expect(await bare).toBe('inbox-1:did:ng:o:repo');
|
||||
expect(await overlaid).toBe('inbox-1:did:ng:o:repo');
|
||||
});
|
||||
|
||||
test('a rejection reaches every waiting caller and is NOT memoized', async () => {
|
||||
const c = controllable();
|
||||
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
|
||||
|
||||
const a = resolveOnce('doc');
|
||||
const b = resolveOnce('doc');
|
||||
c.gates[0]!.reject(new Error('unknown'));
|
||||
await expect(a).rejects.toThrow('unknown');
|
||||
await expect(b).rejects.toThrow('unknown');
|
||||
|
||||
// UNKNOWN is not "there is none": the next caller really retries.
|
||||
const retry = resolveOnce('doc');
|
||||
expect(c.calls).toBe(2);
|
||||
c.gates[1]!.resolve('inbox-1');
|
||||
expect(await retry).toBe('inbox-1:doc');
|
||||
});
|
||||
|
||||
test('the hooks report a join and the one resolution', async () => {
|
||||
const c = controllable();
|
||||
const joined: string[] = [];
|
||||
const resolved: Array<[string, string]> = [];
|
||||
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve, {
|
||||
onJoined: key => joined.push(key),
|
||||
onResolved: (key, value) => resolved.push([key, value]),
|
||||
});
|
||||
|
||||
const a = resolveOnce('doc');
|
||||
resolveOnce('doc');
|
||||
c.gates[0]!.resolve('inbox-1');
|
||||
await a;
|
||||
|
||||
expect(joined).toEqual(['doc']);
|
||||
expect(resolved).toEqual([['doc', 'inbox-1:doc']]);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* ONE resolution per key, whatever the concurrency — a single-flight memo.
|
||||
*
|
||||
* WHY THIS EXISTS. Some resolutions are not idempotent from the outside: asking
|
||||
* twice does not hand back the same thing twice, it CREATES a second thing. A
|
||||
* document's inbox is exactly that — "open the inbox of this document" answers
|
||||
* with an address, and two callers racing each other end up with two addresses
|
||||
* for one document, so the side that watches one never sees what was deposited
|
||||
* in the other. Nothing about the calling code looks wrong: four independent
|
||||
* call sites, each perfectly reasonable on its own, all firing within the same
|
||||
* few hundred milliseconds.
|
||||
*
|
||||
* So the guarantee is not "we call it less often" (a cache) but "the application
|
||||
* can never be the reason a second one exists": while a resolution is in flight,
|
||||
* every other caller for the same key AWAITS THAT SAME PROMISE instead of
|
||||
* starting its own, and once it has settled they all read the one value.
|
||||
*
|
||||
* A REJECTION IS NOT MEMOIZED. It means UNKNOWN, never "there is none": every
|
||||
* caller waiting on it sees the failure, and the key is released so a later
|
||||
* caller genuinely retries rather than inheriting a permanent "no".
|
||||
*
|
||||
* The memo lives as long as the returned function does. For a per-session
|
||||
* resolution (a browser context is one identity for its whole life) that is the
|
||||
* intended lifetime: keep the function at module scope and the answer is settled
|
||||
* once for the session.
|
||||
*/
|
||||
|
||||
export interface ResolveOnceHooks<Value> {
|
||||
/** A caller joined a resolution already in flight — nothing new was started. */
|
||||
onJoined?(key: string): void;
|
||||
/** A resolution completed and became the key's one answer. */
|
||||
onResolved?(key: string, value: Value): void;
|
||||
}
|
||||
|
||||
export function resolveOncePerKey<Arg, Value>(
|
||||
keyOf: (arg: Arg) => string,
|
||||
resolve: (arg: Arg) => Promise<Value>,
|
||||
hooks: ResolveOnceHooks<Value> = {},
|
||||
): (arg: Arg) => Promise<Value> {
|
||||
const byKey = new Map<string, Promise<Value>>();
|
||||
|
||||
return (arg: Arg): Promise<Value> => {
|
||||
const key = keyOf(arg);
|
||||
const known = byKey.get(key);
|
||||
if (known) {
|
||||
hooks.onJoined?.(key);
|
||||
return known;
|
||||
}
|
||||
const resolving = resolve(arg).then(
|
||||
value => {
|
||||
hooks.onResolved?.(key, value);
|
||||
return value;
|
||||
},
|
||||
err => {
|
||||
// UNKNOWN, not "none" — release the key so a later caller can retry.
|
||||
byKey.delete(key);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
byKey.set(key, resolving);
|
||||
return resolving;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { createSerialTask } from './serialTask';
|
||||
|
||||
/** A run the test releases by hand, recording overlap as it goes. */
|
||||
function controllable() {
|
||||
const started: string[] = [];
|
||||
const releases: Array<() => void> = [];
|
||||
const rejects: Array<(err: unknown) => void> = [];
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const run = async (reason: string) => {
|
||||
started.push(reason);
|
||||
inFlight++;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
try {
|
||||
await new Promise<void>((res, rej) => {
|
||||
releases.push(res);
|
||||
rejects.push(rej);
|
||||
});
|
||||
} finally {
|
||||
inFlight--;
|
||||
}
|
||||
};
|
||||
return { started, releases, rejects, run, get maxInFlight() { return maxInFlight; } };
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0));
|
||||
|
||||
test('a request made while a run is in flight does not start a second run', async () => {
|
||||
const c = controllable();
|
||||
const task = createSerialTask(c.run);
|
||||
|
||||
void task('connection').catch(() => {});
|
||||
void task('inbox-push').catch(() => {});
|
||||
await tick();
|
||||
|
||||
expect(c.started).toEqual(['connection']);
|
||||
expect(c.maxInFlight).toBe(1);
|
||||
});
|
||||
|
||||
test('the follow-up runs once the first has finished, and only once for N requests', async () => {
|
||||
const c = controllable();
|
||||
const task = createSerialTask(c.run);
|
||||
|
||||
void task('connection').catch(() => {});
|
||||
void task('inbox-push').catch(() => {});
|
||||
void task('inbox-push').catch(() => {});
|
||||
void task('inbox-push').catch(() => {});
|
||||
|
||||
c.releases[0]!(); // first run completes
|
||||
await tick();
|
||||
expect(c.started).toEqual(['connection', 'inbox-push']); // ONE follow-up
|
||||
expect(c.maxInFlight).toBe(1);
|
||||
|
||||
c.releases[1]!();
|
||||
await tick();
|
||||
expect(c.started).toEqual(['connection', 'inbox-push']);
|
||||
});
|
||||
|
||||
test('every request coalesced into one follow-up settles when that run does', async () => {
|
||||
const c = controllable();
|
||||
const task = createSerialTask(c.run);
|
||||
|
||||
const first = task('connection');
|
||||
const joinA = task('inbox-push');
|
||||
const joinB = task('inbox-push');
|
||||
|
||||
c.releases[0]!();
|
||||
await first;
|
||||
c.releases[1]!();
|
||||
await joinA;
|
||||
await joinB; // same run — both are served
|
||||
expect(c.started.length).toBe(2);
|
||||
});
|
||||
|
||||
test('requests made when idle each get their own run, in order', async () => {
|
||||
const c = controllable();
|
||||
const task = createSerialTask(c.run);
|
||||
|
||||
const a = task('connection');
|
||||
c.releases[0]!();
|
||||
await a;
|
||||
const b = task('inbox-push');
|
||||
c.releases[1]!();
|
||||
await b;
|
||||
|
||||
expect(c.started).toEqual(['connection', 'inbox-push']);
|
||||
expect(c.maxInFlight).toBe(1);
|
||||
});
|
||||
|
||||
test('a failed run rejects its requesters and does not wedge the task', async () => {
|
||||
const c = controllable();
|
||||
const task = createSerialTask(c.run);
|
||||
|
||||
const failing = task('connection');
|
||||
const queued = task('inbox-push');
|
||||
c.rejects[0]!(new Error('materialize failed'));
|
||||
await expect(failing).rejects.toThrow('materialize failed');
|
||||
|
||||
// The follow-up still ran, and a later request is still served.
|
||||
await tick();
|
||||
expect(c.started).toEqual(['connection', 'inbox-push']);
|
||||
c.releases[1]!();
|
||||
await queued;
|
||||
const later = task('connection');
|
||||
c.releases[2]!();
|
||||
await later;
|
||||
expect(c.started).toEqual(['connection', 'inbox-push', 'connection']);
|
||||
expect(c.maxInFlight).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* A task that NEVER runs concurrently with itself.
|
||||
*
|
||||
* WHY THIS EXISTS. A read-derive-write cycle (read an inbox, derive a value,
|
||||
* write it) is only correct if nothing else is doing the same thing at the same
|
||||
* time on the same target: two cycles started a few milliseconds apart both read
|
||||
* before either writes, and the one that finishes last puts ITS (older) reading
|
||||
* back on the document. The owner's participation materializer had two
|
||||
* independent triggers — the connection and the inbox push — and nothing between
|
||||
* them.
|
||||
*
|
||||
* COALESCING. A request made while a run is in flight does not queue behind an
|
||||
* unbounded chain: at most ONE follow-up run is scheduled, and every request
|
||||
* made during the current run shares it. That is sound precisely because a run
|
||||
* re-derives everything from the current state — one follow-up observes whatever
|
||||
* the N requests were about. What a run must NOT be is incremental (a `+1`); the
|
||||
* caller keeps that property, this primitive assumes it.
|
||||
*
|
||||
* WHAT A CALLER GETS BACK. The promise of the run that will serve its request —
|
||||
* the running one when it started idle, the coalesced follow-up otherwise. It
|
||||
* settles with that run's outcome, so a failure is never swallowed here; a
|
||||
* caller that does not await must attach its own rejection handler.
|
||||
*/
|
||||
|
||||
interface Settle {
|
||||
promise: Promise<void>;
|
||||
resolve(): void;
|
||||
reject(err: unknown): void;
|
||||
}
|
||||
|
||||
function settleLater(): Settle {
|
||||
let resolve!: () => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export function createSerialTask<Reason>(
|
||||
run: (reason: Reason) => Promise<void>,
|
||||
): (reason: Reason) => Promise<void> {
|
||||
let busy = false;
|
||||
let queued: { reason: Reason; settle: Settle } | null = null;
|
||||
|
||||
const pump = async (reason: Reason, settle: Settle): Promise<void> => {
|
||||
busy = true;
|
||||
try {
|
||||
await run(reason);
|
||||
settle.resolve();
|
||||
} catch (err) {
|
||||
settle.reject(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
const next = queued;
|
||||
queued = null;
|
||||
// A failed run must not wedge the task: the follow-up starts either way.
|
||||
if (next) void pump(next.reason, next.settle);
|
||||
}
|
||||
};
|
||||
|
||||
return (reason: Reason): Promise<void> => {
|
||||
if (!busy) {
|
||||
const settle = settleLater();
|
||||
void pump(reason, settle);
|
||||
return settle.promise;
|
||||
}
|
||||
// Already running: one follow-up is enough for every request made meanwhile.
|
||||
// The FIRST such request names it — the ones that join it are, by definition,
|
||||
// asking for the same thing.
|
||||
if (!queued) queued = { reason, settle: settleLater() };
|
||||
return queued.settle.promise;
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
// a fact of the module graph rather than a convention.
|
||||
import './ngSession';
|
||||
import { storeRegistry as sdkStoreRegistry } from '@ng-eventually/polyfill';
|
||||
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
|
||||
import { canonicalDocumentId } from './documentNuri';
|
||||
import { resolveOncePerKey } from './resolveOnce';
|
||||
|
||||
export type Scope = 'public' | 'protected' | 'private';
|
||||
|
||||
@@ -42,12 +45,42 @@ export const {
|
||||
// SDK-shaped scope resolvers — the app asks by scope, the SDK resolves
|
||||
// placement (no store id ever crosses the boundary).
|
||||
resolveScopeGraph,
|
||||
// A document only HAS an inbox if its owner opened one. The app opens one on
|
||||
// the documents meant to RECEIVE deposits (its events), and the address this
|
||||
// returns is what the owner reads and watches.
|
||||
openDocumentInbox,
|
||||
// Per-entity document creation. The SDK itself files the creator's key on
|
||||
// create, so the app declares NO access policy here: reading is possession,
|
||||
// and the creator holds what it created.
|
||||
createEntityDoc,
|
||||
} = sdkStoreRegistry;
|
||||
|
||||
// --- A document's inbox: opened ONCE, by this session, whatever the concurrency -
|
||||
// A document only HAS an inbox if its owner opened one. The app opens one on the
|
||||
// documents meant to RECEIVE deposits (its events), and the address this returns
|
||||
// is what the owner reads and watches.
|
||||
//
|
||||
// ASKING TWICE IS NOT FREE. "Open the inbox of this document" answers with an
|
||||
// address; two callers racing each other get two, and then the owner watches one
|
||||
// while a deposit lands in the other — the sign-up is never seen. Nothing looked
|
||||
// wrong at any single call site: creating an event opens its inbox, the
|
||||
// materializer opens it to read, the watch opens it to subscribe, and the watch
|
||||
// callback re-enters the materializer — four calls within a fraction of a second,
|
||||
// none of them aware of the others.
|
||||
//
|
||||
// So the app resolves it exactly ONCE PER DOCUMENT for the whole session, and
|
||||
// simultaneous callers AWAIT THAT SAME RESOLUTION instead of starting their own
|
||||
// (`resolveOncePerKey`). This wrapper is the ONLY place the SDK call is made — the
|
||||
// raw entry is not re-exported, so no call site can bypass it. The key is the
|
||||
// document's CANONICAL form, so the same document named with and without its
|
||||
// overlay suffix is one document here too.
|
||||
//
|
||||
// A session is one identity for its whole life, so a session-long memo can never
|
||||
// hand one person another's address. A REJECTION is not memoized: it means the
|
||||
// answer is UNKNOWN, so the next caller genuinely retries.
|
||||
export const openDocumentInbox: (doc: NuriLike) => Promise<Nuri> = resolveOncePerKey(
|
||||
(doc: NuriLike) => canonicalDocumentId(doc),
|
||||
(doc: NuriLike) => sdkStoreRegistry.openDocumentInbox(doc),
|
||||
{
|
||||
onResolved: (doc, address) =>
|
||||
console.log(`[app][inbox] doc=${doc} → inbox=${address} (this session's only one)`),
|
||||
onJoined: doc =>
|
||||
console.log(`[app][inbox] doc=${doc} — joined the resolution already in flight (no second inbox)`),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user