diff --git a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md index dd9a989..9df38f8 100644 --- a/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md +++ b/.project/concepts/data-layer/brief_2026-07-06_reactive-reads-and-attendance.md @@ -178,6 +178,9 @@ But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incr > **Hooks réactifs du SDK** (précision) : l'adaptateur React de NextGraph expose `useShape` (shapes RDF réactives) et `useDiscrete` (docs CRDT discrets) — pas de `useQuery`. La lib ré-expose `useShape`. Pour la lecture UNION de N docs (le cas de Festipod), `useShape`/l'ORM en fan-out *hangue* ; le chemin réactif app passe donc par `subscribeDocs` (par-doc) + re-`readUnion`, éventuellement enveloppé en un hook de lecture réactive côté lib (à décider en P3). Autres points à trancher : + +> ⚠️ **RÉFUTÉ (2026-07-13)** — l'affirmation ci-dessous « **Prouvé par l'e2e D.2 … 12 steps verts … A voit `participantCount === 2` sans reload** » est **FAUSSE**. Sur profil FRAIS, ce scénario réactif est `@wip` et ROUGE : le propriétaire (A) reste à `participantCount = 0` quand un inscrit (B) d'un autre verifier dépose. Le « vert » observé était un **artefact de wallet bloaté** (le backfill `listMyEntityDocs` faisait varier `ownedKey` et re-déclenchait le materializer par accident). Cause racine vérifiée : `inbox.watch`/`doc_subscribe` ne délivre **aucun Patch cross-session**, donc le materializer du propriétaire n'est jamais re-déclenché par un dépôt distant (il l'est seulement par `ownedKey`). Voir [[knowledge_context-internals]] §participantCount (caveat en tête). La convergence réactive de l'attendance reste **NON prouvée** ; ce brief ne doit PAS graduer tant que le push cross-session (côté lib) n'est pas corrigé + le scénario vert sur profil frais. + - **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6. - **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte. - **Migration inbox natif** : aujourd'hui l'inbox est émulée sur le wallet partagé (`inbox.ts` post/read RDF). À la migration vers l'inbox broker natif (`inbox_post`/`inbox_pop_for_user`, scellé), le flux Option B **reste valide** (dépôt non-membre autorisé, lecture réservée aux *readers* = propriétaire), mais le wrapper `subscribeDoc` sur l'inbox devra viser le mécanisme natif de notification de dépôt. À vérifier au moment de la migration. diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index 86c05ef..fb2cd2a 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: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed dev-only supprimé par loadTestData (seed possédé par l'identité courante), participantCount muté en place (cache), mutations no-op en mode local malgré le toast +summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B avec BUG ACTIF de convergence cross-session (propriétaire reste à 0), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast last_checked: 2026-07-07 --- @@ -32,8 +32,9 @@ jamais de poll ([[rule_no-broker-polling]]). Vidé au changement d'identité. ## Auto-seed de dev -Un auto-seed se déclenche **uniquement hors production** (`NODE_ENV !== 'production'`), -si events ET users sont vides — **gardé sur `isSuccess`** (la readiness de `watchShape`), +**Depuis 2026-07-13, l'auto-seed est OPT-IN et OFF par défaut** : il ne se déclenche que si la variable d'env `FESTIPOD_AUTO_SEED` est définie (`=1`), plus sur `NODE_ENV`. Variable absente → **aucun seed automatique**, même en dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts` ; livrée en dev via la route runtime `/festipod-config.json` + `define` compile-time dans `build.ts`, même mécanisme que le shared-wallet — cf. `tech-stack/knowledge_build-pipeline`). Le seed **explicite** (`loadTestData()`, tests @data) est inchangé. Motivation : le seed auto répété bloatait le wallet (lenteurs de lecture, cf. [[caveat_wallet-bloat-hang]]). + +Quand il est activé, l'auto-seed se déclenche si events ET users sont vides — **gardé sur `isSuccess`** (la readiness de `watchShape`), PLUS sur un `setTimeout` de 3s : on ne décide « wallet vide » qu'une fois la sync **confirmée** (`isSuccess`), sinon la lecture pas-encore-finie était prise pour un wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants : @@ -46,6 +47,8 @@ wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants : ## `participantCount` — dérivé et possédé par le propriétaire (Option B) +> ⚠️ **BUG ACTIF (vérifié 2026-07-13) — la convergence réactive décrite ci-dessous NE MARCHE PAS cross-session.** Symptôme : user2 crée un événement, user1 s'inscrit et voit le participant, mais user2 (le propriétaire) reste à `participantCount = 0`. **Cause racine** : le owner-materializer n'est re-déclenché que quand sa dépendance React `ownedKey` change (backfill `listMyEntityDocs`) — **JAMAIS par un push d'inbox**. Le dépôt de l'inscrit ARRIVE bien dans le verifier du propriétaire (une lecture directe le PULL), mais `inbox.watch`/`doc_subscribe` ne délivre **aucun Patch pour un dépôt cross-session** (autre verifier). Donc sur wallet propre (un seul event possédé, `ownedKey` stable), le propriétaire matérialise UNE fois avant le dépôt → lit `active=0` → écrit 0 → **mémoïse 0** (`materializedCountRef`) → ne recalcule plus. Le **bloat de wallet MASQUAIT** le bug : le backfill faisait varier `ownedKey` en boucle → re-déclenchait par accident (« faux-vert »). Régression documentée ROUGE par le scénario réactif `@wip` de `event/e2e-multibrowser.feature`. Fix côté lib (push d'inbox cross-session fiable sans re-poll broker) + lever la mémoïsation prématurée du `0` — non appliqué (design-sensible). Le reste de cette section décrit le design VISÉ, correct pour le cas same-session. + **Depuis Option B (2026-07-07)** : `participantCount` n'est plus muté en place par l'inscrit. Le flux est dépôt-inbox → matérialisation-propriétaire : - `joinEvent`/`leaveEvent` n'écrivent **plus** `participantCount` sur le doc de l'événement (ce serait une violation d'isolation — l'inscrit écrirait le doc d'un autre ; le write NextGraph est membership-bound, pas d'append). L'inscrit écrit seulement son **propre** doc de participation (protected) puis **dépose** un marqueur dans l'inbox de l'événement (`depositRegistration` sur join, `depositLeave` sur leave, `src/shared/data/registration.ts`). - La session du **propriétaire** de l'événement matérialise : elle est abonnée (`inbox.watch`, `doc_subscribe`, sans polling) à l'inbox de ses events possédés (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + les events fraîchement créés), et sur chaque dépôt **recalcule** `participantCount` sur **son propre** doc d'événement (`updateEntityField` sur son doc). C'est le seul écrivain du compteur. diff --git a/.project/concepts/tech-stack/knowledge_build-pipeline.md b/.project/concepts/tech-stack/knowledge_build-pipeline.md index 24d3f28..ade9e0e 100644 --- a/.project/concepts/tech-stack/knowledge_build-pipeline.md +++ b/.project/concepts/tech-stack/knowledge_build-pipeline.md @@ -18,7 +18,7 @@ Le serveur sert `src/index.html`, qui charge `src/app/frontend.tsx` (voir `app-a ## Globals de build vs config runtime (piège du wallet partagé) -`build.ts` injecte des **globals à la compilation** via `define` (p. ex. `__FESTIPOD_SHARED_WALLET_PASSWORD__` depuis `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`). **Piège** : le serveur `src/index.ts` (utilisé par `bun run dev` ET `bun run start`) bundle `index.html` via l'import HTML de Bun, qui **n'applique aucun `define`** — ni `bun --define` ni `process.env` ne s'y propagent (vérifié). Donc une variable d'env passée à `bun run dev` n'atteint pas le bundle frontend par ce chemin. +`build.ts` injecte des **globals à la compilation** via `define` (p. ex. `__FESTIPOD_SHARED_WALLET_PASSWORD__` depuis `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, et `__FESTIPOD_AUTO_SEED__` depuis `FESTIPOD_AUTO_SEED` — l'auto-seed de dev, OFF si absent). **Piège** : le serveur `src/index.ts` (utilisé par `bun run dev` ET `bun run start`) bundle `index.html` via l'import HTML de Bun, qui **n'applique aucun `define`** — ni `bun --define` ni `process.env` ne s'y propagent (vérifié). Donc une variable d'env passée à `bun run dev` n'atteint pas le bundle frontend par ce chemin. Pour ces chemins servis depuis `src/`, la config passe donc au **runtime** : `src/index.ts` expose `/festipod-config.json` (lu depuis l'env), et l'entrée `src/app/frontend.tsx` la **fetch d'abord**, pose le global, **puis importe l'app dynamiquement** (`await import('./App')`) — ainsi `sharedWallet.ts` lit la valeur à son évaluation. Dans un bundle `build.ts` la valeur est déjà inline par `define`, donc le fetch est court-circuité (`NODE_ENV === 'production'`). Conséquence pratique : pour exercer le flux « portefeuille partagé » en dev **de bout en bout** (téléchargement + import qui fonctionne), passer le VRAI mot de passe du wallet e2e **et** le fichier — le mot de passe affiché à l'écran doit correspondre au `.ngw` importé, sinon l'import échoue (une valeur factice comme `1` fait juste apparaître l'écran) : diff --git a/build.ts b/build.ts index 7d7bb12..b43fa84 100644 --- a/build.ts +++ b/build.ts @@ -142,6 +142,11 @@ const result = await Bun.build({ "globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify( process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "", ), + // Auto-seed gate (OFF by default): only seed a genuinely-empty wallet with + // demo data when FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts). + "globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify( + process.env.FESTIPOD_AUTO_SEED ?? "", + ), }, ...cliConfig, }); diff --git a/src/app/frontend.tsx b/src/app/frontend.tsx index 29a466e..615b195 100644 --- a/src/app/frontend.tsx +++ b/src/app/frontend.tsx @@ -20,13 +20,18 @@ async function loadRuntimeConfig(): Promise { try { const res = await fetch("/festipod-config.json"); if (!res.ok) return; - const cfg = (await res.json()) as { sharedWalletPassword?: string }; + const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string }; // Bracket access so build.ts's `define` (which matches the dotted global) // never rewrites this assignment. Only set when the env actually carries one. const g = globalThis as Record; if (cfg.sharedWalletPassword && g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] == null) { g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] = cfg.sharedWalletPassword; } + // Auto-seed gate (see src/shared/utils/autoSeed.ts): only set the global when + // the env var carries a truthy value; absent → stays undefined → seed OFF. + if (cfg.autoSeed && g["__FESTIPOD_AUTO_SEED__"] == null) { + g["__FESTIPOD_AUTO_SEED__"] = cfg.autoSeed; + } } catch { // No runtime config endpoint (static build) → rely on the compile-time define. } diff --git a/src/index.ts b/src/index.ts index e58c516..a23716b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,9 @@ const server = serve({ "/festipod-config.json": () => Response.json({ sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "", + // Auto-seed gate (OFF by default): only set when the env var is present, so + // the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts). + autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "", }), // The shared wallet file (download target of the access barrier), when configured. diff --git a/src/modules/event/features/e2e-multibrowser.feature b/src/modules/event/features/e2e-multibrowser.feature index 41264aa..e8717d0 100644 --- a/src/modules/event/features/e2e-multibrowser.feature +++ b/src/modules/event/features/e2e-multibrowser.feature @@ -57,22 +57,36 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) # A (poussé par doc_subscribe sur le doc public de l'événement) montre # participantCount === 1 et un participant "inconnu". - # @wip — LIMITE STRUCTURELLE wallet partagé (pas un bug produit) + # @wip — BUG PRODUIT RÉEL : le compteur ne converge pas chez le PROPRIÉTAIRE + # (asymétrie hôte/inscrit). Reproduit sur profil FRAIS + événement neuf, 1er run + # (bloat écarté). VÉRIFIÉ par sonde instrumentée (log des firings du + # owner-materializer + inbox.read/materializeAttendance des deux côtés). # - # CAUSE RACINE : en harness @shared-wallet, A et B partagent UNE SEULE identité - # NextGraph. `listMyEntityDocs(username, 'public')` retourne les mêmes docs dans - # les deux contextes Playwright. Le owner-materializer de B détecte donc les - # événements créés par A comme "possédés" et tente d'écrire `participantCount` sur - # le doc de A. Le verifier de B n'a pas ouvert ce repo (il a été créé dans la - # session de A) → le broker retourne `StorageError` sur `sparql_update`. La - # matérialisation échoue côté B, et le compteur ne converge pas dans le délai du - # test. En production, A et B sont des identités distinctes (wallets séparés) et B - # ne possède jamais les événements de A — ce conflit n'existe pas. + # CAUSE RACINE (vérifiée) : le owner-materializer de A + # (FestipodDataContext, effet `[ready, ownedKey]`) n'est RE-DÉCLENCHÉ que quand + # `ownedKey` change (backfill `listMyEntityDocs`) — JAMAIS par un push d'inbox. + # `inbox.watch` (→ `subscribeDoc`/`doc_subscribe` sur le doc-inbox partagé, lib + # `@ng-eventually/client`) délivre bien son `State` initial à A, mais AUCUN Patch + # quand B dépose depuis un AUTRE verifier : le dépôt cross-session ne remonte pas + # en PUSH. Conséquence : sur un wallet propre (un seul event possédé, `ownedKey` + # stable), le materializer de A tourne UNE fois — AVANT que le dépôt de B soit + # synchronisé dans le verifier de A — lit `active=0`, écrit `participantCount=0`, + # mémoïse 0, et ne recalcule plus jamais → A (hôte) reste à 0. B (inscrit) voit sa + # participation via son propre état, d'où l'asymétrie. (Le dépôt ARRIVE bien dans + # le verifier de A : un `inbox.read` direct — cold anchored read qui PULL — le + # voit ; seul le PUSH d'abonnement manque.) + # NB : sur un wallet BLOATÉ, le backfill `listMyEntityDocs` fait varier `ownedKey` + # en boucle et re-déclenche le materializer par accident → le compteur converge + # "par chance". C'est ce qui masquait le bug lors des re-runs. + # RÉFUTÉ : l'ancienne hypothèse "B écrit le doc de A → StorageError". Mesuré : le + # write `participantCount` réussit (`writeResult: OK`) quand on l'appelle ; le + # défaut est le NON-DÉCLENCHEMENT du recalcul chez A, pas un échec d'écriture. # - # FIX ATTENDU : wallet isolation réelle (distinct-wallets, T02.d/T02.g) — chaque - # navigateur a sa propre identité NG ; `listMyEntityDocs` ne retourne que les docs - # de cette identité ; le owner-materializer est naturellement borné à son propre - # wallet. Jusqu'à cette migration, ce scénario reste @wip documenté. + # FIX RECOMMANDÉ (design-sensible, non appliqué) : rendre le push d'inbox + # cross-session fiable dans la lib (`inbox.watch` doit re-lire au vrai push d'un + # dépôt distant sans re-poll broker — cf. rule_no-broker-polling), OU un signal + # réactif équivalent qui re-déclenche le materializer du propriétaire à l'arrivée + # d'un dépôt distant. Reste @wip tant que non corrigé. @wip Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload Étant donné un navigateur "A" avec le wallet partagé diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 79762be..c7f9aef 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -45,6 +45,7 @@ import { } from '../shapes/orm/festipodShapes.shapeTypes'; import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites'; import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap'; +import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed'; // ============================================================================ // Context interface @@ -409,28 +410,31 @@ function useNgData(): FestipodDataContextValue { } }, [events.length, selectedEventId]); - // Dev auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the + // Auto-seed: bootstrap seed data into a genuinely EMPTY wallet. Gated on the // SDK's `isSuccess` (readReady) — the sync barrier is reached for every scope — // so an empty set means "synced and truly empty", NOT "still syncing". This // replaces the old 3s chronometer heuristic (which guessed a sync delay and mis- // fired a re-seed on the 3rd connect). `isPending` → wait; `isSuccess` + empty - // data → seed. Gated on NODE_ENV so production users see their own (possibly - // empty) wallet. `hasTriedAutoSeed` keeps it single-shot (also suppressed by an - // explicit `loadTestData`). + // data → seed. Gated behind the `FESTIPOD_AUTO_SEED` env var (see utils/autoSeed): + // OFF BY DEFAULT so a fresh/persistent wallet is never silently bloated with demo + // data — the seed only runs when explicitly opted in. Explicit `loadTestData` + // (the test-data action + every @data test) is UNAFFECTED (it seeds directly). + // `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit + // `loadTestData`). const hasTriedAutoSeed = useRef(false); useEffect(() => { - if (process.env.NODE_ENV === 'production') return; + if (!autoSeedEnabled()) return; if (hasTriedAutoSeed.current) return; if (!ready) return; if (!readReady) return; // still syncing — do NOT mistake pending for empty const walletHasData = events.length > 0 || users.length > 0; - if (walletHasData) { - console.log('[FestipodData] Dev auto-seed: wallet already has data — skip'); + if (!shouldAutoSeed(walletHasData)) { + console.log('[FestipodData] Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip'); return; } - // Synced AND empty → a real empty wallet. Seed once. + // Enabled AND synced-empty → a real empty wallet. Seed once. hasTriedAutoSeed.current = true; - console.log('[FestipodData] Dev auto-seed: wallet empty (synced), bootstrapping…'); + console.log('[FestipodData] Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…'); bootstrapWallet(false, createEntityDoc, identifier || undefined) .catch(err => console.error('[FestipodData] Auto-seed failed:', err)); // The reactive `watchShape` reads pick the seeded per-entity docs up on their diff --git a/src/shared/data/dataStats.ts b/src/shared/data/dataStats.ts new file mode 100644 index 0000000..38efa23 --- /dev/null +++ b/src/shared/data/dataStats.ts @@ -0,0 +1,56 @@ +/** + * dataStats — a tiny module-level tally of the reactive data the app has received, + * so the raw broker logs ("readDoc → 9 rows") gain a human-readable, app-level + * companion: how many objects of WHICH shape have landed across every query cycle. + * + * It is fed by `useShapeQuery` on each first-result transition (isPending → + * success) — one call per received set — and prints a concise recap so the + * developer sees "how many events / participations / profiles in total" at a + * glance, without decoding the low-level access log. + * + * Plain module singleton (like pendingQueries.ts): no React coupling, safe to call + * from anywhere. Counters are CUMULATIVE across the session (they only grow), which + * is intentional — it answers "how much data flowed", not "current set size". + */ + +/** A short, readable shape name from a shape URI ("…/Event" → "Event"). */ +export function shapeLabel(shapeKey: string): string { + const m = /[/#]([^/#]+)$/.exec(shapeKey); + return m?.[1] ?? shapeKey; +} + +interface Stats { + /** Number of sets (query first-results) received, per shape label. */ + sets: Map; + /** Total objects received (summed across sets), per shape label. */ + objects: Map; +} + +const stats: Stats = { sets: new Map(), objects: new Map() }; + +/** Record one received set of `count` objects of shape `label`. */ +export function recordSet(label: string, count: number): void { + stats.sets.set(label, (stats.sets.get(label) ?? 0) + 1); + stats.objects.set(label, (stats.objects.get(label) ?? 0) + count); +} + +/** A concise "Label: N" recap of cumulative objects received, sorted by label. */ +export function totalsSummary(): string { + const parts = [...stats.objects.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([label, n]) => `${label}: ${n}`); + return parts.join(', ') || '(aucun)'; +} + +/** Total number of sets received across all shapes (for the recap line). */ +export function totalSets(): number { + let n = 0; + for (const c of stats.sets.values()) n += c; + return n; +} + +/** Reset the tally (test hook — never called by the app). */ +export function resetDataStats(): void { + stats.sets.clear(); + stats.objects.clear(); +} diff --git a/src/shared/data/useShapeQuery.ts b/src/shared/data/useShapeQuery.ts index 1fafa08..ec8b28f 100644 --- a/src/shared/data/useShapeQuery.ts +++ b/src/shared/data/useShapeQuery.ts @@ -27,6 +27,7 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { watchShape, type ShapeQuery, type ShapeObservable, type UnionSubject } from '@ng-eventually/client'; import { beginQuery, resolveQuery } from './pendingQueries'; +import { recordSet, shapeLabel, totalsSummary, totalSets } from './dataStats'; type Scope = 'public' | 'protected' | 'private'; @@ -102,8 +103,17 @@ export function useShapeQuery( const elapsed = startedAt == null ? 0 : Math.round(performance.now() - startedAt); resolveQuery(cycleId); const n = Array.isArray(query.data) ? query.data.length : 0; + // A readable set-reception log: HOW MANY objects of WHICH shape, in which + // scope, and how long the first result took. `shapeKey` is the shape URI + // (…/Event); `shapeLabel` trims it to the readable shape name (Event). + const label = shapeLabel(shapeKey); + // Cumulative tally across the session (per shape), so the raw broker + // "readDoc → N rows" logs gain an app-level running total. + recordSet(label, n); // eslint-disable-next-line no-console - console.log(`[FestipodData] ${shapeKey}/${scope} premier résultat en ${elapsed}ms (n=${n})`); + console.log(`[FestipodData] set reçu: ${n} objets ${label} (${scope}) en ${elapsed}ms`); + // eslint-disable-next-line no-console + console.log(`[FestipodData] totaux — ${totalsSummary()} (${totalSets()} sets reçus)`); }, [query, cycleId, shapeKey, scope]); return query; diff --git a/src/shared/utils/autoSeed.test.ts b/src/shared/utils/autoSeed.test.ts new file mode 100644 index 0000000..35b596a --- /dev/null +++ b/src/shared/utils/autoSeed.test.ts @@ -0,0 +1,43 @@ +import { expect, test, afterEach } from 'bun:test'; +import { autoSeedEnabled, shouldAutoSeed } from './autoSeed'; + +// The gate is driven by the build/runtime-injected global `__FESTIPOD_AUTO_SEED__` +// (see autoSeed.ts / build.ts / frontend.tsx). Drive it directly here. +const g = globalThis as Record; + +afterEach(() => { + delete g['__FESTIPOD_AUTO_SEED__']; +}); + +test('seed-gate DEFAULT (env var absent) → auto-seed disabled', () => { + delete g['__FESTIPOD_AUTO_SEED__']; + expect(autoSeedEnabled()).toBe(false); + // Even on a genuinely EMPTY wallet, the gate must NOT trigger a seed. + expect(shouldAutoSeed(false)).toBe(false); +}); + +test('seed-gate empty string (env unset → "") → disabled', () => { + g['__FESTIPOD_AUTO_SEED__'] = ''; + expect(autoSeedEnabled()).toBe(false); + expect(shouldAutoSeed(false)).toBe(false); +}); + +test('seed-gate ENABLED ("1") on empty wallet → seed triggered', () => { + g['__FESTIPOD_AUTO_SEED__'] = '1'; + expect(autoSeedEnabled()).toBe(true); + // Empty wallet + gate on → seed. + expect(shouldAutoSeed(false)).toBe(true); +}); + +test('seed-gate accepts "true" and boolean true', () => { + g['__FESTIPOD_AUTO_SEED__'] = 'true'; + expect(autoSeedEnabled()).toBe(true); + g['__FESTIPOD_AUTO_SEED__'] = true; + expect(autoSeedEnabled()).toBe(true); +}); + +test('seed-gate ENABLED but wallet already has data → NO seed', () => { + g['__FESTIPOD_AUTO_SEED__'] = '1'; + // Non-empty wallet must never be re-seeded, even with the gate on. + expect(shouldAutoSeed(true)).toBe(false); +}); diff --git a/src/shared/utils/autoSeed.ts b/src/shared/utils/autoSeed.ts new file mode 100644 index 0000000..d4a985f --- /dev/null +++ b/src/shared/utils/autoSeed.ts @@ -0,0 +1,56 @@ +/** + * Auto-seed gate. + * + * On a genuinely-empty wallet the app CAN bootstrap demo data (see + * ngBootstrap.bootstrapWallet, driven by the auto-seed effect in + * FestipodDataContext). This used to fire unconditionally in dev, which bloats a + * persistent wallet over repeated runs. It is now OFF BY DEFAULT and only runs + * when explicitly enabled via the env var `FESTIPOD_AUTO_SEED` (= "1"). + * + * Same runtime/compile-time plumbing as the shared-wallet password + * (sharedWallet.ts): `build.ts` `define`s the global from the env var for a + * static bundle, and the dev server / `bun run start` expose it at runtime via + * `/festipod-config.json` (read by frontend.tsx, which sets the global before the + * app tree loads). Absent env → global undefined → seed OFF. + * + * NOTE: this gate only affects the app's AUTOMATIC seed. Explicit seeding via + * `loadTestData()` (the "Load test data" action + every @data test through the + * harness bridge) is UNAFFECTED — it calls bootstrapWallet directly. + */ + +// Build-injected global (not `process.env`, absent in the browser); any path that +// doesn't inject it reads `undefined` → false safely (no ReferenceError). +declare global { + // eslint-disable-next-line no-var + var __FESTIPOD_AUTO_SEED__: string | boolean | undefined; +} + +/** Truthy value of the auto-seed flag ("1"/"true"/true → enabled). */ +function readFlag(): boolean { + const v = globalThis.__FESTIPOD_AUTO_SEED__; + if (v === true) return true; + if (typeof v === 'string') { + const s = v.trim().toLowerCase(); + return s === '1' || s === 'true'; + } + return false; +} + +/** + * Whether the app's AUTOMATIC empty-wallet seed is enabled. Read at call time so + * the runtime config set by frontend.tsx (dev/start) is picked up even though it + * lands after this module first evaluates. + */ +export const autoSeedEnabled = (): boolean => readFlag(); + +/** + * The seed-gate decision: should the app AUTO-SEED demo data right now? True ONLY + * when the gate is enabled (`FESTIPOD_AUTO_SEED`) AND the wallet is genuinely empty + * (no data to preserve). `walletHasData` is the caller's "synced and non-empty" + * signal. Extracted as a pure predicate so the gate is unit-testable in isolation + * from the React effect that consumes it. + */ +export function shouldAutoSeed(walletHasData: boolean): boolean { + if (!autoSeedEnabled()) return false; + return !walletHasData; +}