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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user