feat(data): auto-seed opt-in (FESTIPOD_AUTO_SEED) + logs data lisibles; diag bug participantCount

Seed: l'auto-seed sur wallet vide est désormais OPT-IN, OFF par défaut — ne se
déclenche que si FESTIPOD_AUTO_SEED=1 (livré en dev via /festipod-config.json +
define build.ts, comme le shared-wallet). Le seed répété bloatait le wallet
(lenteurs de lecture). Seed explicite (loadTestData, tests @data) inchangé.

Logs: chaque useShapeQuery logge à la réception du set le nombre d'objets + le
type + des compteurs globaux cumulés :
  [FestipodData] set reçu: 9 objets Event (public) en 1234ms
  [FestipodData] totaux — Event: 9, Participation: 3, UserProfile: 10 (5 sets)
(polyfill docs.ts: "N rows" -> "N triple-rows" pour clarifier que ce sont des
triplets RDF, pas des objets métier.)

Diagnostic bug participantCount (NON corrigé, design-sensible): le propriétaire
d'un événement reste à participantCount=0 quand un inscrit d'un AUTRE verifier
dépose. Cause: le owner-materializer n'est re-déclenché que par ownedKey, jamais
par un push d'inbox — doc_subscribe ne délivre aucun Patch cross-session. Le
bloat de wallet MASQUAIT le bug (faux-vert). La théorie "StorageError" était
fausse. Scénario réactif @wip = test ROUGE qui documente le bug.

Doctrine: knowledge_context-internals (caveat BUG ACTIF + auto-seed opt-in),
brief_2026-07-06 (claim D.2 "prouvé vert" REFUTÉ), build-pipeline (nouvelle var).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-13 14:38:38 +02:00
parent 39b67feea0
commit c0fd69344b
12 changed files with 231 additions and 29 deletions
+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;
}