Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
12 changed files with 231 additions and 29 deletions
Showing only changes of commit c0fd69344b - Show all commits
@@ -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 P1P3 avant P4P6.
- **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.
@@ -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.
@@ -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) :
+5
View File
@@ -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,
});
+6 -1
View File
@@ -20,13 +20,18 @@ async function loadRuntimeConfig(): Promise<void> {
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<string, unknown>;
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.
}
+3
View File
@@ -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.
@@ -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é
+13 -9
View File
@@ -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
+56
View File
@@ -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<string, number>;
/** Total objects received (summed across sets), per shape label. */
objects: Map<string, number>;
}
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();
}
+11 -1
View File
@@ -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<T = UnionSubject>(
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;
+43
View File
@@ -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<string, unknown>;
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);
});
+56
View File
@@ -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;
}