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
+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 ?? "",
),
// The app may bootstrap demo data into an empty connected wallet only when
// FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts). Absent → OFF.
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
process.env.FESTIPOD_AUTO_SEED ?? "",
),
},
...cliConfig,
});
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* overlay-local-checkout.ts — Reactive local overlay of a data-layer PROVIDER's checkout
* into node_modules. One script, one provider per run; only the paths differ between them.
*
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
* polyfill → node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
* indexing → node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
*
* WHY a copy-overlay and not a symlink (identical for every provider):
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod → ONE @ng-org
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
* checkout would put the checkout's copies in the resolution path → a SECOND @ng-org
* (and, for `indexing`, a second @ng-eventually/polyfill) → broken SDK, two verifiers.
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
* shared packages still resolve up to Festipod, single instance preserved.
*
* WHAT IT DOES:
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
* holding the local checkout's package.json + src (NO node_modules).
* 2. Asserts the single-instance invariant — every package this provider SHARES with
* Festipod must resolve to the same realpath from Festipod and from the overlay —
* and aborts if it would break.
* 3. Watches the local checkout's src and copies each change into the overlay.
*
* ⚠️ RESTART `bun run dev` AFTER THIS SCRIPT WRITES — a rebuild is NOT a substitute.
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
* The server's resolution of that import is pinned at process start and a rebuild does not
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you — a stale
* server looks exactly like a current one. See
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
* an hour to learn. Watching copies the files; it does not make anything reload them.
*
* USAGE (reactive dev):
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
*
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
* Return to the committed git-installed dependencies: pnpm install
*/
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
import { watch } from "node:fs";
import { join, dirname } from "node:path";
interface Provider {
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
readonly packageName: string;
/** Local checkout used when the env override is unset. */
readonly defaultLocal: string;
/** Env var overriding the local checkout path. */
readonly envOverride: string;
/**
* Packages this provider SHARES with Festipod and that must stay single-instance.
* Each is resolved from Festipod and from the overlay; the realpaths must match.
*/
readonly singletons: readonly string[];
}
const PROVIDERS: Record<string, Provider> = {
polyfill: {
packageName: "@ng-eventually/polyfill",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
envOverride: "NG_EVENTUALLY_LOCAL",
singletons: ["@ng-org/web"],
},
indexing: {
packageName: "@ng-helpers/indexing",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
envOverride: "NG_HELPERS_LOCAL",
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
},
};
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
const args = process.argv.slice(2);
const ONCE = args.includes("--once");
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
function fail(msg: string): never {
console.error(`✖ overlay:${KEY} — ${msg}`);
process.exit(1);
}
const provider = PROVIDERS[KEY];
if (!provider) {
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
}
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
const SRC_LOCAL = join(LOCAL, "src");
const SRC_TARGET = join(TARGET, "src");
if (!existsSync(join(LOCAL, "package.json"))) {
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
}
if (!existsSync(SRC_LOCAL)) {
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
}
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
console.log(`→ overlaying local ${provider.packageName}: ${LOCAL}`);
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
rmSync(TARGET, { recursive: true, force: true });
}
mkdirSync(TARGET, { recursive: true });
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
const from = join(LOCAL, meta);
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
}
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single instances).
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
// 2. Assert the single-instance invariant for every package shared with Festipod.
const overlayReal = realpathSync(TARGET);
for (const spec of provider.singletons) {
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
console.log(` ${spec} (Festipod): ${fromFestipod}`);
console.log(` ${spec} (overlay) : ${fromOverlay}`);
if (fromFestipod !== fromOverlay) {
fail(
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
" The overlay must not contain its own node_modules. Aborting.",
);
}
}
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
if (ONCE) {
console.log("✓ overlay ready (--once, not watching)");
process.exit(0);
}
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
// dev server notice — not even across a rebuild (see the header). Restart it.
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
if (!filename) return;
const from = join(SRC_LOCAL, filename);
const to = join(SRC_TARGET, filename);
try {
if (existsSync(from)) {
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
console.log(` ↻ ${filename}`);
} else if (existsSync(to)) {
rmSync(to, { force: true });
console.log(` ✗ ${filename} (removed)`);
}
} catch (err) {
console.error(` ! failed to sync ${filename}:`, err);
}
});
+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;