Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
6 changed files with 26 additions and 29 deletions
Showing only changes of commit 05ee576d7d - Show all commits
@@ -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`).
@@ -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:
+8 -9
View File
@@ -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
+3 -4
View File
@@ -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
+7 -8
View File
@@ -250,14 +250,13 @@ export async function materializeAttendance(
targetInbox: string,
eventId: string,
): Promise<ActiveRegistration[]> {
// 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:<overlay>` suffix). On the current tree the forms already agree, but
+5 -5
View File
@@ -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