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