From 05ee576d7dff44c077d5ac6c4ff002cead888d3b Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 28 Jul 2026 17:17:47 +0200 Subject: [PATCH] =?UTF-8?q?refactor(comments):=20retirer=20les=20raisonnem?= =?UTF-8?q?ents=20sur=20l'=C3=A9tat=20de=20NextGraph=20du=20code=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application de la règle qu'on vient de durcir : l'app IGNORE entièrement l'état d'implémentation de NextGraph. Le CODE était légitime — `inbox.readSynced` est une surface SDK exportée ; ce sont les JUSTIFICATIONS qui fautaient, en expliquant les choix par des internes du cœur. Réécrit en termes de CONTRAT : - registration.ts / FestipodDataContext : « barrier-gated read, le repo d'inbox n'est pas encore ouvert dans le verifier, un read ancré renverrait 0 » devient « `read` rend ce qui est connu localement maintenant, `readSynced` rend une fois les dépôts synchronisés visibles ; ce site a besoin du second parce qu'il lit depuis une session froide ». - ngBootstrap : « le verifier sérialise les créations » devient « `docCreate` est un aller-retour qui ne recouvre pas le suivant, donc le coût du seed croît LINÉAIREMENT avec le nombre de documents ». Le ~2s mesuré est conservé, mais explicitement comme une observation, pas comme un contrat. - entityWrites : description de lecture périmée (ORM fan-out, ngSet couplé au scope) remplacée par la vue réactive. La distinction read/readSynced vit désormais là où elle est légitime : knowledge_sdk-surface, avec le critère de choix (`read` dans une session qui observe déjà l'inbox, `readSynced` dès que la justesse dépend d'une session froide voyant le dépôt d'une autre identité). knowledge_context-internals cesse d'expliquer le fix par `ensureRepoOpen`/premier `State` et pointe le contrat. Laissé tel quel : `src/shared/support/hooks.ts` et les steps e2e — le harness de test connaît légitimement la plomberie ; la règle vise l'app. Et le « no cross-account fan-out » de FestipodDataContext, qui décrit le périmètre de l'app et non NextGraph. tsc : 0 erreur. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg --- .../data-layer/knowledge_context-internals.md | 4 ++-- .../data-layer/knowledge_sdk-surface.md | 2 +- src/shared/context/FestipodDataContext.tsx | 17 ++++++++--------- src/shared/data/entityWrites.ts | 7 +++---- src/shared/data/registration.ts | 15 +++++++-------- src/shared/utils/ngBootstrap.ts | 10 +++++----- 6 files changed, 26 insertions(+), 29 deletions(-) diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index caae611..f875f9a 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -1,6 +1,6 @@ --- type: knowledge -summary: Internal pitfalls of FestipodDataContext — currentUserId = a stable principal derived from the identifier, TWO id spaces joined through the normalized identifier (resolveParticipantUser / USER_PRINCIPAL_PREFIX), OPT-IN auto-seed (FESTIPOD_AUTO_SEED, OFF by default), Option-B derived participantCount (reliable at the owner's connection through a barrier-gated inbox read; single source = event.participantCount), session reset on identity change (overlay + caps), useShapeQuery instrumentation (spinner + timing) + identity-first logs, mutations that are no-ops in local mode despite the toast +summary: Internal pitfalls of FestipodDataContext — currentUserId = a stable principal derived from the identifier, TWO id spaces joined through the normalized identifier (resolveParticipantUser / USER_PRINCIPAL_PREFIX), OPT-IN auto-seed (FESTIPOD_AUTO_SEED, OFF by default), Option-B derived participantCount (reliable at the owner's connection because it reads under the synced-view contract; single source = event.participantCount), session reset on identity change (overlay + caps), useShapeQuery instrumentation (spinner + timing) + identity-first logs, mutations that are no-ops in local mode despite the toast last_checked: 2026-07-27 --- @@ -52,7 +52,7 @@ When it is enabled, the auto-seed fires if events AND users are both empty — * ## `participantCount` — derived and owned by the owner (Option B) -> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) a **barrier-gated inbox read** — `inbox.readSynced` (= `ensureRepoOpen(doc)` waits for the first `State`, THEN `read`, like `discovery.readIndex`) instead of `inbox.read`, so an already-synced deposit IS seen on connection; (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]). +> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) read under the **synced-view contract** — `inbox.readSynced` instead of `inbox.read`, so a deposit already synced by another identity IS seen from a cold session (the two differ by contract, see [[knowledge_sdk-surface]]); (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]). **Since Option B (2026-07-07)**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization: - `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc (that would be an isolation violation — the participant writing someone else's doc; NextGraph writes are membership-bound, with no append). The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`). diff --git a/.project/concepts/data-layer/knowledge_sdk-surface.md b/.project/concepts/data-layer/knowledge_sdk-surface.md index 3cbdf47..edf54e3 100644 --- a/.project/concepts/data-layer/knowledge_sdk-surface.md +++ b/.project/concepts/data-layer/knowledge_sdk-surface.md @@ -47,7 +47,7 @@ May assume: the SDK owns NURI construction and placement. May not assume: that t ## Inbox — delivery to an identity -**`inbox.post(targetInbox, { payload, from?, ts? })`** deposits into a document's inbox. `from` omitted defaults to the current identity; **`from: null` is an explicit anonymous deposit**, and naming another identity is rejected as a spoof. **`inbox.read(targetInbox)`** returns every `Deposit` (`{ from, payload, ts }`) sorted by ascending `ts`. **`inbox.watch(targetInbox, onDeposits)`** fires once on the initial state and again on every change; it returns an unsubscribe. **`inbox.readSynced`** is the read that waits for the document to be current. `inbox.materialize` is an alias of `read`. +**`inbox.post(targetInbox, { payload, from?, ts? })`** deposits into a document's inbox. `from` omitted defaults to the current identity; **`from: null` is an explicit anonymous deposit**, and naming another identity is rejected as a spoof. **`inbox.read(targetInbox)`** returns every `Deposit` (`{ from, payload, ts }`) sorted by ascending `ts`. **`inbox.watch(targetInbox, onDeposits)`** fires once on the initial state and again on every change; it returns an unsubscribe. **`inbox.readSynced`** is the same read under a stronger contract: it returns once the deposits synced to that inbox are visible, where `read` returns what is known locally right now. **Choose by need, not by habit**: `read` inside a session already watching the inbox, `readSynced` whenever correctness depends on a cold session seeing another identity's deposit. `inbox.materialize` is an alias of `read`. May assume: diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index ab8096f..bd7f41a 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -599,10 +599,9 @@ function useNgData(): FestipodDataContextValue { ` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`, ); // (1) COUNT — derive the distinct active-registration set for this event - // and write it on MY OWN event doc (only when it changed). The read inside - // `materializeAttendance` is BARRIER-GATED (`inbox.readSynced`): at the - // owner's connection it waits for the inbox sync barrier before reading, so - // a registrant's already-synced deposit IS seen (no premature 0). + // and write it on MY OWN event doc (only when it changed). `materializeAttendance` + // reads through the synced-view contract (`inbox.readSynced`), so a + // 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); @@ -663,11 +662,11 @@ function useNgData(): FestipodDataContextValue { } }; - // (A) RELIABLE-AT-CONNECTION: run one barrier-gated 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 (the read - // waits for the inbox sync barrier, so a registrant's synced deposit is seen). - // It does NOT depend on a cross-session inbox push arriving. + // (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'); // (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on diff --git a/src/shared/data/entityWrites.ts b/src/shared/data/entityWrites.ts index 1556e79..14dd648 100644 --- a/src/shared/data/entityWrites.ts +++ b/src/shared/data/entityWrites.ts @@ -16,10 +16,9 @@ * `docs.sparqlUpdate` primitive (the real injected `ng`) — the same direct-write * path `insertNotification` already uses. The document was just created and is * openable, so the write lands immediately. The READ stays reactive: the document - * NURI is registered into the scope's `useShape({ graphs })`, and the ORM reads - * the entity back. Write (direct, per-document) and read (reactive fan-out) are - * decoupled — the model (one document per entity, per-document isolation) is - * unchanged; only the write mechanism moves off the scope-coupled ngSet. + * NURI joins the scope's reactive view, which reads the entity back. Write + * (direct, per-document) and read (reactive) are decoupled — the model (one + * document per entity, per-document isolation) is unchanged. * * TYPED TERMS. The ORM reads back via the SHEX shapes (festipodShapes.shex), so * each field must be written with the RIGHT RDF term: xsd:integer/float/boolean diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index a833dc1..f0f05a7 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -250,14 +250,13 @@ export async function materializeAttendance( targetInbox: string, eventId: string, ): Promise { - // BARRIER-GATED read (`inbox.readSynced`, not `inbox.read`): the owner - // materializes at its NEXT CONNECTION, and on a fresh session the event inbox - // repo is not yet open in the verifier — a plain anchored read would silently - // return 0 deposits even for a registrant's deposit already synced to the broker - // (the premature-0 that made the owner stick at count 0). `readSynced` awaits the - // inbox's first `State` (deterministic sync barrier) before reading, so the - // synced deposits ARE visible. No polling (rule_no-broker-polling): one barrier - // wait, then one read. + // `inbox.readSynced`, not `inbox.read` — the two differ by CONTRACT, and this + // call site needs the stronger one. `read` returns what is known locally right + // now; `readSynced` returns once the deposits synced to this inbox are visible. + // The owner materializes at its NEXT CONNECTION, i.e. from a cold session where + // "known locally right now" is not yet the truth — so the synced view is the + // only correct one here. Single wait, single read: no polling + // (see `rule_no-broker-polling`). const deposits = await inbox.readSynced(targetInbox); // Match deposits to this event on the CANONICAL id-form (base repo id, stripping // any `:v:` suffix). On the current tree the forms already agree, but diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index 9889e7e..fcdc0d5 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -38,8 +38,7 @@ export interface BootstrapResult { * model's doc set for the union READ. * * `walletHasData` tells the seed whether the wallet already carries entities (a - * returning user → skip). The caller computes it from the union read (no ORM set - * needed — the read side is now the one-shot union query, not a reactive fan-out). + * returning user → skip). The caller computes it from what it has already read. */ export async function bootstrapWallet( walletHasData: boolean, @@ -70,9 +69,10 @@ export async function bootstrapWallet( // OWNER is shared). Falls back to the fixture username when no login is present. const seedOwner = owner ?? (seedUsers[0] ? normalizeIdentifier(seedUsers[0].username) : 'seed'); - // SEED FOOTPRINT (perf). Each entity is its OWN document, and each `docCreate` - // is a SERIAL ~2s broker round-trip (the verifier serializes creations — they - // do NOT parallelize), so the seed cost is ~2s × (#docs). Seeding the full + // SEED FOOTPRINT (perf). Each entity is its OWN document, and `docCreate` is a + // round-trip that does not overlap with the next one, so the seed cost grows + // LINEARLY with the number of documents (measured around 2s each on our test + // setup — an observation, not a contract). Seeding the full // fixture (14 users + 5 events + 5 participations = 24 docs) blows past the test // step budget. So the CONNECTED seed writes only what the app/@data needs to be // exercised: ALL events (looked up by title), a FEW user profiles ("wallet has