Decide the seed on the caller's view, and take the sibling branch's answers

bootstrapWallet consulted ngUsers.size, which the await for the subscription
lets the broker re-populate — a caller that has just emptied the wallet then
never seeds again. The count now comes from the caller, as the event count
already did: deciding whether to seed reads what the caller sees, writing
needs the resolved set.

Recovered from ng-eventually, which had answers this session went looking for:
autoSeed.ts, whose gate keeps the dev seed from bloating a persistent wallet
over repeated runs (its fixtureSeedEnabled master switch is deliberately NOT
enforced here — adopting it stands down the seeding scenarios and is a product
decision); overlay-local-checkout.ts, which holds the single-@ng-org invariant;
ng: NG in place of typeof NG, and the harness reading the app's own
currentUserId with optional chaining, which together take typecheck from 3
errors to 1.
This commit is contained in:
Sylvain Duchesne
2026-09-21 14:08:58 +02:00
parent db483726af
commit 35df3e9110
8 changed files with 321 additions and 13 deletions
+13 -2
View File
@@ -38,6 +38,7 @@ import {
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
// ============================================================================
// Context interface
@@ -413,7 +414,9 @@ function useNgData(): FestipodDataContextValue {
const seedWritable = usersShape.ready && participationsShape.ready;
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
// OFF unless FESTIPOD_AUTO_SEED says otherwise. Firing unconditionally in dev
// is what bloats a persistent wallet over repeated runs — see autoSeed.ts.
if (!autoSeedEnabled()) return;
if (MULTISTORE) return; // seed targets the private store; multi-doc seeding is a separate concern
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
@@ -423,10 +426,12 @@ function useNgData(): FestipodDataContextValue {
return;
}
hasTriedAutoSeed.current = true;
if (eventRows.length === 0 && usersShape.ngSet.size === 0) {
const walletHasData = eventRows.length > 0 || usersShape.ngSet.size > 0;
if (shouldAutoSeed(walletHasData)) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet({
eventCount: eventRows.length,
userCount: usersShape.ngSet.size,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
@@ -563,12 +568,18 @@ function useNgData(): FestipodDataContextValue {
// subscription resolving does not retroactively change the render that built
// this callback. `?? ngSet` covers the no-scope case, where there is no
// promise and the placeholder is the honest answer.
// Snapshot the "is the wallet empty?" answer BEFORE awaiting. The await lets
// the subscription re-sync, so afterwards the set holds whatever the broker
// still has — including profiles a caller has just deleted. Deciding on the
// post-await view means never seeding again.
const userCount = usersShape.ngSet.size;
const [loadedUsers, loadedParticipations] = await Promise.all([
usersShape.loaded,
participationsShape.loaded,
]);
return bootstrapWallet({
eventCount: eventRows.length,
userCount,
seedEvent: createEventDoc,
ngUsers: (loadedUsers ?? usersShape.ngSet) as any,
ngParticipations: (loadedParticipations ?? participationsShape.ngSet) as any,
+4 -6
View File
@@ -83,12 +83,10 @@ function ConnectedHarness() {
const timer = setTimeout(() => {
const session = ngCtx.session!;
// Get current user ID
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
currentUserId = existingUsers[0]['@id'];
}
// Who the tests act as. The APP's own answer comes first — the bridge
// should not invent an identity the screens would not use — and the first
// profile in the set is the fallback while the context is still resolving.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// Expose the test bridge
(window as any).__testData = {
+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);
});
+80
View File
@@ -0,0 +1,80 @@
/**
* 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 compile-time plumbing as the shared-wallet password (sharedWallet.ts):
* `build.ts` `define`s the global from the env var for a static bundle. Absent
* env → global undefined → seed OFF.
*
* RECOVERED FROM THE ng-eventually BRANCH, with two differences that branch does
* not have:
* - there is no `/festipod-config.json` here, so the flag arrives at BUILD time
* only; a dev server picks it up from the environment it was built with;
* - `fixtureSeedEnabled()` below is NOT enforced in `bootstrapWallet` on this
* branch. On ng-eventually it switches every route off, which stands down the
* @data scenarios that seed; adopting that here is a product decision, not a
* side effect of recovering this file. Until it is taken, only the AUTOMATIC
* seed is gated — an explicit `loadTestData()` still writes.
*/
/**
* MASTER SWITCH — may a fixture seed be written into the connected wallet AT ALL?
*
* Currently OFF, a product decision: no fixture reaches the connected wallet by
* any route — neither the opt-in auto-seed (`FESTIPOD_AUTO_SEED`, whose gate
* below is subordinate to this one) nor an explicit `loadTestData()`.
*
* OFF, not deleted. The fixtures (`data/seedData.ts`) and the seeding code stay,
* because two paths still need them and NEITHER writes to a wallet: the
* demo/disconnected provider (`LocalDataProvider`) reads the fixtures straight
* into React state, and the rendering-layer tests read them directly too. Only
* the connected wallet is closed off.
*
* Enforced at the single place fixtures enter a wallet — `bootstrapWallet`
* (`utils/ngBootstrap.ts`) — so no caller can bypass it by construction; the
* call sites consult it too, only so they neither log nor await work that will
* not happen.
*/
export const fixtureSeedEnabled = (): boolean => false;
// 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;
}
+13 -4
View File
@@ -40,6 +40,14 @@ export interface BootstrapDeps {
* it now, because the events no longer come from a set this module can count.
*/
eventCount: number;
/**
* How many profiles the caller saw WHEN IT DECIDED TO SEED — not when the
* write finally starts. The two differ now that seeding awaits the ORM
* subscription: a caller that just emptied the wallet hands over 0, while the
* set it awaits has meanwhile re-synced whatever the broker still holds.
* Reading `ngUsers.size` here would consult the second and never seed again.
*/
userCount: number;
seedEvent: EventSeeder;
ngUsers: DeepSignalSet<FpUserProfile>;
ngParticipations: DeepSignalSet<FpParticipation>;
@@ -58,12 +66,13 @@ async function flushAndWait(ms = 100): Promise<void> {
}
export async function bootstrapWallet(
{ eventCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
{ eventCount, userCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
): Promise<BootstrapResult> {
// Already has data → returning user, nothing to seed
if (eventCount > 0 || ngUsers.size > 0) {
// Already has data → returning user, nothing to seed. Both counts come from
// the CALLER's view at decision time — see `userCount`.
if (eventCount > 0 || userCount > 0) {
console.log('[Bootstrap] Wallet already has data — events:', eventCount,
'users:', ngUsers.size, 'participations:', ngParticipations.size);
'users:', userCount, 'participations:', ngParticipations.size);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}
+3 -1
View File
@@ -70,7 +70,9 @@ export async function logoutNg(): Promise<void> {
}
export interface NextGraphSession {
ng: typeof NG;
// `NG` IS the type of the SDK object, not a value whose type we could take.
// `typeof NG` was a type-level error that the broken typecheck gate hid.
ng: NG;
session_id: string;
protected_store_id: string;
private_store_id: string;