diff --git a/build.ts b/build.ts index 7d7bb12..74b68e8 100644 --- a/build.ts +++ b/build.ts @@ -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, }); diff --git a/scripts/overlay-local-checkout.ts b/scripts/overlay-local-checkout.ts new file mode 100644 index 0000000..ae279ad --- /dev/null +++ b/scripts/overlay-local-checkout.ts @@ -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/ 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/ (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 = { + 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); + } +}); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index b44d0c3..becd215 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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, diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index eda48b3..bdbef73 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -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 = { diff --git a/src/shared/utils/autoSeed.test.ts b/src/shared/utils/autoSeed.test.ts new file mode 100644 index 0000000..35b596a --- /dev/null +++ b/src/shared/utils/autoSeed.test.ts @@ -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; + +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); +}); diff --git a/src/shared/utils/autoSeed.ts b/src/shared/utils/autoSeed.ts new file mode 100644 index 0000000..3e03548 --- /dev/null +++ b/src/shared/utils/autoSeed.ts @@ -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; +} diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index 12ab0a1..55fa464 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -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; ngParticipations: DeepSignalSet; @@ -58,12 +66,13 @@ async function flushAndWait(ms = 100): Promise { } export async function bootstrapWallet( - { eventCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps, + { eventCount, userCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps, ): Promise { - // 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() }; } diff --git a/src/shared/utils/ngSession.ts b/src/shared/utils/ngSession.ts index 0e1f6c9..c5e1010 100644 --- a/src/shared/utils/ngSession.ts +++ b/src/shared/utils/ngSession.ts @@ -70,7 +70,9 @@ export async function logoutNg(): Promise { } 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;