10 Commits

Author SHA1 Message Date
Sylvain Duchesne efdfcac4c1 Make the Refine backend a choice rather than a wiring
A backend now answers a contract, and each of its four points is there because
the Events vertical ran into it: how its providers are configured (SHEX shape
types here, resources+jsonContext for ActivityPods), what its records look like,
which key Refine addresses a record by versus which id the app references, and
whether its update replaces or patches. Nothing in it is anticipated.

Switching happens at runtime, so nothing resolves at module scope: the current
backend is React state held by RefineProvider, read through useBackend(), and
<Refine> carries its name as key so switching remounts it and drops the cache
of the backend being left. Leaving one loses access to its data, accepted.

Not done, and not guessed: no activitypods backend. Its configuration is
published, its record shape and update semantics are not, and it declares
resources in ActivityStreams while Festipod persists fp: shapes -- as:Event
exists, a meeting point does not. And AuthGate still gates on the NextGraph
session, so a second backend needs its own barrier to be reachable at all.

The @data budgets in connexion.steps.ts came from ng-eventually, which had
already met this: the timeout was in waitForFunction's SECOND slot, where it is
taken as the predicate's argument, so the wait silently ran on the 30s default.
2026-09-28 16:08:13 +02:00
Sylvain Duchesne 750538bd73 Take the sibling branch's answer for the seed step, instead of a rewrite
ng-eventually lives with one document per entity already, so it had met this
cost and settled it in two lines: await the seed, and give the wait a budget
that matches what opening five documents takes. The 10 s was calibrated when
seeding meant adding rows to an already-open graph.

This replaces a rewrite of the step, begun before looking there, that would
have had each scenario create its own event. @data is now 14 passed / 1 failed,
the same as main, and the remaining failure is main's too.
2026-09-21 16:47:37 +02:00
Sylvain Duchesne 20b3ef8712 Seed only what the tests need, and record that it did not speed anything up
Doctrine prescribes a connected seed that writes only what is needed, because
it is linear in documents. Verified safe before applying: no step asserts on a
seeded participation -- a scenario SETS its starting participant count on the
event and reads it back -- and no profile is looked up by name. So three
profiles instead of fourteen, and no participations.

The speed argument for it was REFUTED by measurement: 24 documents took 13.3s,
eight took 20.2s. Main writes 24 in 1.4s on the same accumulated wallet, so
neither document count nor accumulation is what dominates. Main adds into one
already-open graph; this branch opens a document and a fresh subscription per
event, which is what the Refine provider's create does. The cost is in opening
documents, not in writing triples.

Kept anyway, on the justification that survives: it writes less into a wallet
nothing ever empties.
2026-09-21 15:52:31 +02:00
Sylvain Duchesne ff285455d2 Say where the auto-seed flag can actually arrive
The recovered comment claimed a dev server picks the flag up from the
environment it was built with. It does not: dev and start both serve from src/,
so build.ts's define only reaches the staging bundle the @e2e harness builds.
OFF, the default and the reason the gate exists, needs no plumbing; opting IN
is what is out of reach, and closing that gap means making the app bootstrap
async, which nothing here needs yet.
2026-09-21 14:12:03 +02:00
Sylvain Duchesne 35df3e9110 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.
2026-09-21 14:08:58 +02:00
Sylvain Duchesne db483726af Install with pnpm, as the sibling branch does
bun install does not rewrite what is already in node_modules when a version
goes DOWN, which silently left @ng-org/orm and @ng-org/web on versions that
disagree about orm_start_graph's arity — the callback then lands in the config
slot and the SDK rejects it as unserializable. Every dependency measurement
taken this way was meaningless.

Carried over from ng-eventually with it: cucumber invoked through the package
entry rather than the .bin shim, which node --import tsx/esm cannot load under
pnpm; the build:orm paths, which still pointed at a src/shapes that moved to
src/shared/shapes; and the tsconfig fix that DROPS baseUrl instead of silencing
its deprecation — reported as a config error, it made tsc exit without checking
a single file.
2026-09-21 13:55:32 +02:00
Sylvain Duchesne 46abbf2e64 Wait for the ORM subscription before seeding into it
The placeholder useShapeSet hands back while a subscription loads refuses
writes, and bootstrapWallet writes its users straight into that set — so the
seed threw on its very first add and the wallet reported nothing persisted.
The refusal is right; issuing the write that early was not.

The previous orm version handed back a writable set immediately for a defined
scope, so a caller never had to wait. Since alpha.21 the set does not exist
until the subscription resolves, so loadTestData now awaits the ORM's own
promise and seeds into the set it resolves with — re-reading ngSet after the
await would hand over the placeholder this closure captured. The dev auto-seed
gates on the same readiness.
2026-09-21 13:53:19 +02:00
Sylvain Duchesne 753282c7b1 Absorb the useShape contract change the provider versions require
@ng-org/orm stopped returning the DeepSignalSet from useShape and now returns
a result object carrying it under data. The bump is forced: the providers
import RdfOrmSubscription, which alpha.18 published as OrmSubscription.

useShapeSet unwraps it in one place and hands back a set, so the call sites
keep the contract they were written against. While the subscription is
loading it yields an empty set that refuses writes rather than dropping
them, matching what the previous version documented.
2026-09-21 12:17:40 +02:00
Sylvain Duchesne d237753916 Mount the @data harness inside Refine, and stop the bundle leaking globals
The harness builds its bundles with a classic script tag, so an unwrapped
bundle publishes every top-level declaration as a global. Bundling Refine
brought in @tanstack/query-core, whose exported MutationObserver class then
shadowed the DOM one for Playwright's injected script, and every
waitForFunction died on defaultMutationOptions. --format=iife keeps the
bundle's declarations to itself.

The harness also mounted the data context outside <Refine> and called the
previous bootstrapWallet signature; it now mirrors the app's provider order
and seeds through the context, so tests and app write events by one path.
2026-09-21 12:13:12 +02:00
Sylvain Duchesne 33d54c6e3e WIP: events read and written through Refine
Mounts Refine over @ng-org/refine-providers and routes the Events vertical
through it. Known incomplete: the @data harness still mounts the data context
outside <Refine> and calls the old bootstrapWallet signature.
2026-09-21 11:58:32 +02:00
24 changed files with 7369 additions and 1583 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,
});
-1380
View File
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -8,26 +8,28 @@
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts",
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
"test:auth-setup": "bun scripts/setup-test-auth.ts",
"cucumber:report": "bun scripts/parse-test-results.ts",
"features:parse": "bun scripts/parse-features.ts",
"steps:extract": "bun scripts/extract-step-definitions.ts",
"build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm",
"build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
"build:ng": "bash scripts/build-ng-packages.sh",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
"@ng-org/orm": "0.1.2-alpha.18",
"@ng-org/shex-orm": "0.1.2-alpha.8",
"@ng-org/web": "0.1.2-alpha.13",
"@ng-org/alien-deepsignals": "0.1.2-alpha.13",
"@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/refine-providers": "0.1.2-alpha.1",
"@ng-org/shex-orm": "0.1.2-alpha.9",
"@ng-org/web": "0.1.2-alpha.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@refinedev/core": "^5.0.12",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -56,5 +58,11 @@
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-onboarding": "^10.3.5"
}
},
"pnpm": {
"onlyBuiltDependencies": [
"bun"
]
},
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
}
+6165
View File
File diff suppressed because it is too large Load Diff
+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);
}
});
+15 -10
View File
@@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { AccountProvider } from '../shared/context/AccountContext';
import { RefineProvider } from '../shared/refine/RefineProvider';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
@@ -58,16 +59,20 @@ export function App() {
<ThemeProvider>
<NextGraphProvider>
<AccountProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
{/* Refine wraps the data context: FestipodDataContext reads and writes
events through Refine's hooks, so it must sit inside <Refine>. */}
<RefineProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</RefineProvider>
</AccountProvider>
</NextGraphProvider>
</ThemeProvider>
+33 -15
View File
@@ -40,10 +40,13 @@ Given('le portefeuille contient déjà des événements', async function (this:
const td = (window as any).__testData;
td.loadTestData();
});
// Wait for data to propagate
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
// timeout goes in the THIRD slot; passed second it is silently taken as the
// predicate's argument and the wait runs on the 30s default instead.
await this.appFrame!.waitForFunction(
() => (window as any).__testData.events.size > 0,
{ timeout: 10000 },
undefined,
{ timeout: 75000 },
);
}
});
@@ -58,22 +61,37 @@ When('je charge les données de test', async function (this: FestipodWorld) {
});
(this as any)._eventCountBefore = countBefore;
await this.appFrame!.evaluate(() => {
// AWAIT the seed's own promise (loadTestData returns a BootstrapResult promise)
// and record whether it actually seeded — so the propagation wait below can tell
// a genuinely-populated wallet (nothing to appear) from an empty one that must
// seed. Fire-and-forget here would let the assertions race the async seed.
const seededResult = await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
td.loadTestData();
const r = await td.loadTestData();
return { seeded: r?.seeded ?? false };
});
(this as any)._loadSeeded = seededResult.seeded;
// Wait for data to propagate (if wallet was empty, data should appear)
await this.appFrame!.waitForFunction(
() => {
const td = (window as any).__testData;
// Either data was already there, or it should appear after loading
return td.events.size > 0 || td._loadResult?.seeded === false;
},
{ timeout: 10000 },
).catch(() => {
// Timeout is OK if wallet was already populated (idempotent case)
});
// Wait for data to propagate, and for BOTH events and users — not just events.
// They arrive by two independent paths here: events through the Refine read,
// the seeded profiles through the ORM subscription on the protected scope. If
// only events are awaited, `contient des utilisateurs` asserts before the
// profile read lands and flakes to users:0. On a wallet that already had data
// (seeded === false) there is nothing to wait for. The assertions still verify
// the real counts; this only synchronizes.
if (seededResult.seeded) {
await this.appFrame!.waitForFunction(
() => {
const td = (window as any).__testData;
return td.events.size > 0 && td.users.size > 0;
},
undefined,
{ timeout: 75000 },
).catch(() => {
// Timeout tolerated — the assertions below surface the real failure with a
// clearer message than a raw waitForFunction timeout.
});
}
});
// --- Assertions ---
@@ -8,10 +8,13 @@ import type { FestipodWorld } from '../../../../shared/support/world';
// --- Setup ---
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
// Ensure wallet has data (seed if empty)
await this.appFrame!.evaluate(() => {
// Ensure wallet has data (seed if empty). AWAITED, and given a budget that
// matches what seeding costs once every entity is its own document: the seed
// opens five of them, and opening a document takes seconds. The previous 10 s
// was calibrated when seeding meant adding rows to an already-open graph.
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
if (td.events.size === 0) td.loadTestData();
if (td.events.size === 0) await td.loadTestData();
});
// Wait for event to appear
await this.appFrame!.waitForFunction(
@@ -20,7 +23,7 @@ Given('un événement {string} existe', async function (this: FestipodWorld, eve
return [...td.events].some((e: any) => e.title === title);
},
eventTitle,
{ timeout: 10000 },
{ timeout: 75000 },
);
});
+174 -89
View File
@@ -14,11 +14,14 @@ import {
seedMeetingPoints,
seedFriendships,
} from '../data/seedData';
import { useList, useCreate, useUpdate } from '@refinedev/core';
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { RESOURCE_EVENTS } from '../refine/shapeTypes';
import { useBackend } from '../refine/RefineProvider';
// Multi-document mode (storeRegistry): one document per (account × scope),
// mirroring the target per-user stores. Default OFF — the validated mono-store
@@ -28,13 +31,15 @@ import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefa
// custom vars, so a bare `process.env.FESTIPOD_MULTISTORE` throws
// "process is not defined" in the browser harness. Guard it.
const MULTISTORE = typeof process !== 'undefined' && process?.env?.FESTIPOD_MULTISTORE === '1';
// `FpEventShapeType` is NOT imported here any more: events go through Refine, and
// the resource → shape binding lives in refine/shapeTypes.ts.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
} 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
@@ -93,19 +98,13 @@ function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) =
return undefined;
}
// NG shape → app type mappers
const mapEvent = (e: FpEvent): FpEventData => ({
id: e["@id"],
title: e.title,
description: e.description || '',
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
});
// EVENTS are read and written through the selected Refine BACKEND, so the record
// shape, the vocabulary and the two identifiers are its business, not this file's
// — see refine/providers/types.ts. The binding is read from `useBackend()` inside
// the provider below, NOT captured here: a module-level constant would pin the app
// to whichever backend was current at import time, which is exactly what makes a
// backend unswitchable. Everything else in this file still speaks `fp:` directly,
// because profiles and participations have not moved to Refine yet.
const mapUser = (u: FpUserProfile): FpUserData => ({
id: u["@id"],
@@ -245,13 +244,16 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
function useNgData(): FestipodDataContextValue {
const { session } = useNextGraph();
const { username } = useAccount();
// The selected backend's binding for events — read here, so a switch re-renders
// with the new one instead of keeping whichever was current at import time.
const eventBinding = useBackend().event;
// Mono-store fallback scope: the shared wallet's private store NURI. Opens the
// repo in the verifier (enables reads + writes), per the data-layer rule.
const privateNuri = session ? `did:ng:${session.private_store_id}` : undefined;
// Multi-document state (storeRegistry): read fan-out (all accounts' docs per
// scope) and the current account's write docs. Populated by the effect below.
const [readGraphs, setReadGraphs] = useState<{ public: string[]; protected: string[] }>({ public: [], protected: [] });
const [readGraphs, setReadGraphs] = useState<{ protected: string[] }>({ protected: [] });
const [writeGraphs, setWriteGraphs] = useState<{ protected?: string }>({});
useEffect(() => {
@@ -260,15 +262,16 @@ function useNgData(): FestipodDataContextValue {
(async () => {
try {
await ensureAccount(username); // create this account's index docs on first sight
// Public = per-entity documents (events/PdR) listed by the public index.
// Protected = grouped in each account's protected index document.
const [pub, prot, wProt] = await Promise.all([
listEntityDocs('public'),
// NO public fan-out any more: events are a Refine resource, and the data
// provider addresses them by shape across every graph it can see — it
// never takes a graph list, so the public index has no reader left.
const [prot, wProt] = await Promise.all([
resolveReadGraphs('protected'),
resolveWriteGraph(username, 'protected'),
]);
if (cancelled) return;
setReadGraphs({ public: pub, protected: prot });
setReadGraphs({ protected: prot });
setWriteGraphs({ protected: wProt });
} catch (err) {
console.error('[FestipodData] storeRegistry init failed:', err);
@@ -277,28 +280,93 @@ function useNgData(): FestipodDataContextValue {
return () => { cancelled = true; };
}, [privateNuri, username]);
// Scope per entity: events live in the PUBLIC docs, profiles + participations
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
const publicScope: ShapeScope = MULTISTORE
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
: privateNuri;
// Scope for profiles + participations, which still read through the ORM.
// Mono-store mode collapses them to the private store.
const protectedScope: ShapeScope = MULTISTORE
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
: privateNuri;
// --- Events: read through REFINE -----------------------------------------
// `useList` goes to `@ng-org/refine-providers`, which resolves the resource
// name to its SHEX shape (see refine/shapeTypes.ts) and reads every graph the
// verifier holds. Pagination is off: the screens show the whole list and the
// provider does not paginate at the source anyway.
const { result: eventsResult, query: eventsQuery } = useList<FpEvent>({
resource: RESOURCE_EVENTS,
pagination: { mode: 'off' },
});
// The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its
// `update` addresses) and `@id` (the SUBJECT IRI, which is what participations
// reference). `FpEventData.id` stays the subject IRI so `isParticipating` and
// `getEventParticipants` keep matching; the document NURI is looked up below.
const eventRows = eventsResult?.data ?? [];
// "No events" and "not read yet" are different answers, and the auto-seed below
// must not confuse them — an empty list while the query is still in flight is
// not an empty wallet.
const eventsRead = eventsQuery.isSuccess;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
const events = eventsShape.items;
const events = eventRows.map(eventBinding.toDomain);
const users = usersShape.items;
const participations = participationsShape.items;
// --- Events: write through REFINE ----------------------------------------
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
const { mutateAsync: updateEventRow } = useUpdate<FpEvent>();
/**
* Create ONE event and return the stored record. The backend's binding supplies
* the values, including whatever type marker that backend needs — see its
* `toValues`, and `providers/types.ts` for why that is not this file's business.
*/
const createEventDoc = useCallback(
async (event: Omit<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
const created = await createEventRow({
resource: RESOURCE_EVENTS,
values: eventBinding.toValues(event),
});
return created?.data;
},
[createEventRow, eventBinding],
);
/**
* Update an event, addressed by its APP id — which is not necessarily the key
* Refine addresses the record by, so the binding answers both.
*
* When the backend's `update` REPLACES the record, every persisted field has to
* be sent or whatever is omitted is silently dropped; that is why the current
* record is read back and merged first. A backend that patches does not need
* the merge, so the binding says which it is rather than this file assuming.
*/
const writeEvent = useCallback(
async (id: string, updates: Partial<FpEventData>): Promise<void> => {
const row = eventRows.find(r => eventBinding.domainId(r) === id);
if (!row) {
console.warn('[FestipodData] updateEvent: no event known under', id);
return;
}
// Replacing → send the merged WHOLE record. Patching → send only what
// changed, so a concurrent edit to another field is not overwritten by a
// value this render happened to be holding.
const values = eventBinding.updateReplaces
? eventBinding.toValues({ ...eventBinding.toDomain(row), ...updates })
: eventBinding.toPatch(updates);
await updateEventRow({
resource: RESOURCE_EVENTS,
id: eventBinding.recordKey(row),
values,
});
},
[eventRows, updateEventRow, eventBinding],
);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
@@ -315,30 +383,47 @@ function useNgData(): FestipodDataContextValue {
}, [events.length, selectedEventId]);
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
// bootstrap with seed data. `bootstrapWallet()` self-checks (existing data
// → skip), so this is safe even if shapes finish hydrating after the timer.
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
//
// TWO readiness guards, and both are load-bearing. `eventsRead` says the Refine
// list has actually resolved — an empty list means nothing while the query is in
// flight, and seeding on it would write the fixtures a second time. `seedWritable`
// says the ORM subscriptions the seed WRITES INTO exist: since @ng-org/orm
// alpha.21 the set is `undefined` until the subscription resolves, so an add
// issued before then has nowhere to land and throws.
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;
const t = setTimeout(() => {
if (!eventsRead || !seedWritable) {
console.log('[FestipodData] Dev auto-seed: subscriptions not ready yet — skip');
return;
}
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 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(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
bootstrapWallet({
eventCount: eventRows.length,
userCount: usersShape.ngSet.size,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
}, 3000);
return () => clearTimeout(t);
}, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
}, [privateNuri, eventsRead, seedWritable, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -372,52 +457,29 @@ function useNgData(): FestipodDataContextValue {
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Per-entity: in multistore each event is its OWN document. Mono-store: the
// private store. (Multistore create reactivity is best-effort — the new doc
// is appended to the read fan-out so it shows after re-subscribe.)
const eventGraph = MULTISTORE
? await createEntityDoc(username || '', 'public')
: (privateNuri || '');
if (MULTISTORE && eventGraph) {
setReadGraphs(prev =>
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
);
}
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
console.log('[FestipodData] createEvent (Refine):', event.title);
// The data provider creates the document itself, one per record — so there is
// no graph to resolve here any more, and nothing to append to a read fan-out.
const stored = await createEventDoc({ ...event, participantCount: event.participantCount || 1 });
const eventIri = stored?.["@id"];
if (eventIri && currentUserId) {
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
event: eventIri, user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
setSelectedEventId(eventIri);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
return { ...event, id: eventIri || `ng-pending-${Date.now()}` };
}, [protectedGraph, createEventDoc, participationsShape.ngSet, currentUserId]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
if (updates.date !== undefined) ngEvent.date = updates.date;
if (updates.location !== undefined) ngEvent.location = updates.location;
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
}
}, [eventsShape.ngSet]);
console.log('[FestipodData] updateEvent (Refine):', id, updates);
void writeEvent(id, updates);
}, [writeEvent]);
const joinEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] Already participating, skipping');
@@ -427,25 +489,27 @@ function useNgData(): FestipodDataContextValue {
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
// The count lives on the event, which is now a Refine record: bumping it is a
// full record write, not the in-place ORM mutation it used to be.
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
void writeEvent(eventId, { participantCount: current.participantCount + 1 });
}
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [protectedGraph, participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (ngPart) {
console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]);
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
}
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -476,12 +540,33 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
return bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
);
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
// The seed WRITES into the ORM subscriptions, which since @ng-org/orm
// alpha.21 do not exist until they have loaded. Await the ORM's own
// readiness signal first — a `bootstrapWallet` started too early throws on
// its very first `add`, and callers see a wallet that seeded nothing.
//
// The RESOLVED VALUE is the set. Re-reading `usersShape.ngSet` after the
// await would hand over the placeholder captured by this closure, since the
// 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,
});
}, [eventRows.length, createEventDoc, usersShape, participationsShape]);
return {
currentUserId, currentUser,
+88
View File
@@ -0,0 +1,88 @@
/**
* useShapeSet — `useShape` narrowed back down to "just the set", plus the one
* fact the new contract forces callers to know: whether it has loaded.
*
* WHY THIS EXISTS. `@ng-org/orm` changed `useShape`'s contract between alpha.18
* and alpha.21: it used to RETURN the `DeepSignalSet`, and now returns a result
* object that carries it under `data`, alongside `isLoading`, `promise`,
* `subscription` and the paging controls. Every Festipod call site wants the set,
* so the whole adaptation lives HERE, in one place, rather than being spelled out
* at each `useShape(...)`.
*
* AND THE BUMP IS NOT OPTIONAL. `@ng-org/refine-providers` imports
* `RdfOrmSubscription` from `@ng-org/orm`; alpha.18 published that class under
* the name `OrmSubscription` and only alpha.21 exports `RdfOrmSubscription`. So
* using the providers requires alpha.21, which requires absorbing this break.
*
* WHAT CHANGES FOR WRITERS. Under alpha.18 a defined scope handed back a WRITABLE
* set straight away, so a caller could `add()` the moment it had the reference.
* Under alpha.21 `data` is `undefined` until the subscription resolves — there is
* genuinely no set to write to yet. So a writer must WAIT, and `ready` is what
* lets it: `useShapeWithDefaults` surfaces it, and the seeding path gates on it.
* (Only a scope of `undefined` never becomes ready — that is the "no scope"
* case, which alpha.18 also refused writes on.)
*
* The placeholder returned meanwhile refuses writes rather than accepting them
* into a detached `Set`, because a silently dropped seed is far harder to notice
* than a thrown one — it looks exactly like a wallet that persisted nothing.
*/
import { useMemo } from 'react';
import { useShape } from '@ng-org/orm/react';
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
* `useShape` accepts both natively.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export interface ShapeSubscription<NgT extends BaseType> {
/** The live set, or a write-refusing placeholder until `ready`. */
set: DeepSignalSet<NgT>;
/** `true` once the subscription has produced its set — a write may land. */
ready: boolean;
/**
* Resolves when the subscription has loaded — the ORM's own readiness signal,
* so a writer AWAITS it instead of re-reading in a loop (`rule_no-broker-polling`).
* `undefined` when the scope is `undefined`: there is nothing to wait for.
*/
promise: Promise<unknown> | undefined;
}
/** An empty set that names the problem instead of swallowing a write. */
function notLoadedSet<T>(): DeepSignalSet<T> {
const refuse = (): never => {
throw new Error(
'useShapeSet: the shape subscription has not loaded yet — await `ready` before writing',
);
};
return Object.assign(new Set<T>(), { add: refuse, delete: refuse }) as unknown as DeepSignalSet<T>;
}
/** The set plus its readiness — for callers that WRITE. */
export function useShapeSubscription<NgT extends BaseType>(
shapeType: ShapeType<NgT>,
scope: ShapeScope,
): ShapeSubscription<NgT> {
// Memoised so the placeholder keeps ONE identity for this hook instance: it
// lands in `useCallback` dependency arrays, and a fresh set on every render
// would rebuild every mutation callback for as long as the load takes.
const placeholder = useMemo(() => notLoadedSet<NgT>(), []);
const result = useShape(shapeType as never, scope as never) as unknown as {
data?: DeepSignalSet<NgT>;
promise?: Promise<unknown>;
};
const data = result?.data;
return { set: data ?? placeholder, ready: data !== undefined, promise: result?.promise };
}
/** The set alone — for callers that only READ. */
export function useShapeSet<NgT extends BaseType>(
shapeType: ShapeType<NgT>,
scope: ShapeScope,
): DeepSignalSet<NgT> {
return useShapeSubscription(shapeType, scope).set;
}
+16 -9
View File
@@ -8,22 +8,28 @@
* Must only be called when NG is connected (inside NgDataProvider).
*/
import { useShape } from '@ng-org/orm/react';
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import { useShapeSubscription, type ShapeScope } from './useShapeSet';
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
/** Mapped items from NG store */
items: AppT[];
/** Raw NG signal set for mutations */
ngSet: DeepSignalSet<NgT>;
/**
* `true` once the subscription holds a real set. A WRITER must check this:
* since @ng-org/orm alpha.21 the set does not exist until the subscription
* resolves, so an `ngSet.add(…)` issued before then has nowhere to land.
*/
ready: boolean;
/** Resolves once the subscription has loaded — await it before writing. */
loaded: Promise<unknown> | undefined;
}
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
* `useShape` accepts both natively.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
// `ShapeScope` now belongs to useShapeSet (the module that talks to the ORM);
// it is re-exported here so the existing importers stay unchanged.
export type { ShapeScope };
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
@@ -34,9 +40,10 @@ export function useShapeWithDefaults<NgT extends BaseType, AppT>(
): ShapeWithDefaults<NgT, AppT> {
// Mono-store: a single store NURI opens the repo in the verifier (enables
// writes). Multi-document: a { graphs } scope subscribes to several docs.
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
// `useShapeSubscription` is what unwraps the ORM's result object — see that module.
const { set: ngSet, ready, promise: loaded } = useShapeSubscription(shapeType, storeNuri);
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
return { items, ngSet, ready, loaded };
}
+100
View File
@@ -0,0 +1,100 @@
/**
* RefineProvider — mounts Refine over the SELECTED backend, and owns the selection.
*
* This is the app's data entry point for the Events vertical: screens keep calling
* `useFestipodData()`, and `FestipodDataContext` reads and writes events through
* Refine's hooks, which land on whichever backend is current.
*
* SWITCHING AT RUNTIME is what shapes this file. The selection is React state, and
* `<Refine>` carries the backend name as its `key`, so switching REMOUNTS it:
* Refine builds its own query client inside, and remounting is what discards the
* cache of the backend being left. Without that key the new backend would serve
* its first reads out of the old one's cached rows.
*
* Leaving a backend loses access to its data — accepted, and nothing is migrated.
* What is NOT solved here is the sign-in: `AuthGate` still gates on the NextGraph
* session, so a second backend needs its own barrier before it can actually be
* reached. That is the next piece, not this one.
*
* NO ROUTER PROVIDER, deliberately. Festipod has its own path-based router
* (`app/router.tsx`); handing Refine a second one would give the app two actors
* navigating the same history. Refine works without it — only its navigation
* helpers (which this app does not call) go dormant.
*/
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { Refine } from '@refinedev/core';
import { getBackend, initialBackendName, type RefineBackend } from './providers';
interface BackendSelection {
backend: RefineBackend;
/** Switch backends. The data of the one being left becomes unreachable. */
switchTo(name: string): void;
}
const BackendContext = createContext<BackendSelection | null>(null);
/**
* The current backend. Read it rather than importing one: a module-level import
* of a specific backend is what makes it unswitchable.
*/
export function useBackend(): RefineBackend {
const selection = useContext(BackendContext);
if (!selection) {
throw new Error('useBackend must be used within a RefineProvider');
}
return selection.backend;
}
/** The selection itself, for whatever eventually offers the choice. */
export function useBackendSelection(): BackendSelection {
const selection = useContext(BackendContext);
if (!selection) {
throw new Error('useBackendSelection must be used within a RefineProvider');
}
return selection;
}
export function RefineProvider({ children }: { children: ReactNode }) {
const [name, setName] = useState(initialBackendName);
const backend = useMemo(() => getBackend(name), [name]);
// Built once PER BACKEND, not per render: a fresh provider identity makes Refine
// re-run every query behind it.
const providers = useMemo(() => {
console.log(`[refine] backend: ${backend.name}`);
return backend.build();
}, [backend]);
const switchTo = useCallback((next: string) => {
// Resolved here so an unknown name fails at the call, not on the next render.
getBackend(next);
setName(next);
}, []);
const selection = useMemo<BackendSelection>(() => ({ backend, switchTo }), [backend, switchTo]);
return (
<BackendContext.Provider value={selection}>
<Refine
key={backend.name}
dataProvider={providers.dataProvider}
liveProvider={providers.liveProvider}
authProvider={providers.authProvider}
resources={backend.resources.map((resource) => ({ name: resource }))}
options={{
// `auto` is what makes the live provider actually subscribe behind every
// query — without it the ORM pushes changes and no hook is listening.
liveMode: 'auto',
// Nothing about this app's usage is sent anywhere.
disableTelemetry: true,
// The app has no router, so Refine's unsaved-changes prompt (which hooks
// navigation) has nothing to hook and would only warn.
warnWhenUnsavedChanges: false,
}}
>
{children}
</Refine>
</BackendContext.Provider>
);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* ngAuthProvider — the `AuthProvider` that `@ng-org/refine-providers` asks to be
* handed, backed by Festipod's own NextGraph session.
*
* WHY NOT THE PACKAGE'S OWN `authProvider()`. Its factory calls `init(…)` from
* `@ng-org/web` the moment it runs, and then `initNg(ng, session)` — which is
* exactly, line for line, what `utils/ngSession.init()` already does. Using both
* would register TWO init callbacks against one WASM session: outside the broker
* iframe `init` REDIRECTS the page, so the second registration is a second
* redirect, and the two callbacks race to be the one that owns `session`.
*
* `DataProviderConfig` declares `authProvider` as an INJECTION POINT — the data
* provider only ever calls `getSession()` on it, to read a `session_id` for
* `doc_create`. So supplying Festipod's session here is using the published
* surface as it is meant to be used, not working around it: the session stays
* single, and `ngSession` stays the only module that calls `init`.
*
* The remaining `AuthProvider` members are Refine's auth contract. They are wired
* to what Festipod actually has — the technical NextGraph login/logout — rather
* than stubbed: `check` reports whether the wallet session is open, and nothing
* here invents an identity the app does not hold.
*/
import type { AuthProvider } from '@ng-org/refine-providers';
import type { Session } from '@ng-org/web';
import { session, login as ngLogin, logoutNg } from '../utils/ngSession';
export function createNgAuthProvider(): AuthProvider {
return {
/**
* The real technical login: opening the wallet page. `ngSession.login()`
* navigates away, so the resolved value is only reached when the broker
* round-trip did not happen (already logged in).
*/
login: async () => {
await ngLogin();
return { success: true };
},
logout: async () => {
await logoutNg();
return { success: true };
},
/**
* Authenticated = the NextGraph session is open. `AuthGate` is what actually
* gates the screens (it reads `NextGraphContext`), so no `redirectTo` is
* returned here: Refine has no router in this app and must not try to route.
*/
check: async () => ({ authenticated: session !== undefined }),
onError: async (error) => ({ error }),
/**
* The NextGraph session carries the wallet user id under `user` when one is
* open. Festipod's PERCEIVED login (the chosen username) lives in
* `AccountContext`, which is React state and not reachable from here — so
* this reports the technical identity only, and `undefined` when there is none.
*/
getIdentity: async () => {
const user = session?.user;
return typeof user === 'string' && user ? { id: user } : undefined;
},
/**
* The handle the data provider actually consumes. Festipod's
* `NextGraphSession` is the same object `@ng-org/web` hands its `init`
* callback; the cast only reconciles the two local declarations of it.
*/
getSession: () => session as Session | undefined,
};
}
+73
View File
@@ -0,0 +1,73 @@
/**
* The backend registry — which Refine backends exist, and which one starts.
*
* SWITCHING IS MEANT TO HAPPEN AT RUNTIME, so nothing here is resolved at module
* scope: a module-level `const backend = …` is precisely what makes a backend
* unswitchable. The current one lives in React state, held by `RefineProvider`,
* and consumers read it through `useBackend()`. The build-time global below only
* decides which one is selected FIRST.
*
* Leaving a backend loses access to its data, and that is accepted: each holds its
* own behind its own sign-in, and nothing is migrated between them.
*
* There is no selector screen yet — that is interface work, not seam work.
*
* ADDING A BACKEND is one file implementing `RefineBackend`, plus one line here.
* What that file must answer is not negotiable and cannot be guessed — see
* `types.ts`: how its providers are configured, what its records look like, which
* key addresses a record versus which id the app references, and whether its
* `update` replaces or patches.
*
* `@activitypods/refine-providers` is the next candidate, and two of those four
* are already known from its published configuration: providers take
* `resources: { events: { shapeTreeUri } }` plus a `jsonContext`, and auth is
* Solid-OIDC with a `clientId`. The other two — its record shape and whether
* `update` patches — are not, and neither is the question that gates the whole
* thing: it declares resources in ActivityStreams, while Festipod persists `fp:`
* shapes. `as:Event` exists; a meeting point, the product's unit of value, has no
* obvious ActivityStreams equivalent. That has to be settled before an adapter
* can be written rather than invented.
*/
import { nextgraphBackend } from './nextgraph';
import type { RefineBackend } from './types';
export type { RecordBinding, RefineBackend } from './types';
const BACKENDS: Record<string, RefineBackend> = {
[nextgraphBackend.name]: nextgraphBackend,
};
// Build-injected, same plumbing as the other build-time globals (build.ts
// `define`s it). Absent → the default below, so nothing has to be configured.
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_BACKEND__: string | undefined;
}
const DEFAULT_BACKEND = nextgraphBackend.name;
/** Every registered backend — what a selector would offer. */
export function listBackends(): RefineBackend[] {
return Object.values(BACKENDS);
}
/**
* Look a backend up by name. An unknown name throws rather than falling back:
* running against a different store than the one asked for is worse than
* failing, because nothing downstream would look wrong.
*/
export function getBackend(name: string): RefineBackend {
const backend = BACKENDS[name];
if (!backend) {
throw new Error(
`Unknown Refine backend "${name}" — known: ${Object.keys(BACKENDS).join(', ')}`,
);
}
return backend;
}
/** Which backend is selected when the app starts. */
export function initialBackendName(): string {
return globalThis.__FESTIPOD_BACKEND__ || DEFAULT_BACKEND;
}
+89
View File
@@ -0,0 +1,89 @@
/**
* The NextGraph backend — `@ng-org/refine-providers` over the wallet this
* deployment already opens.
*
* Everything backend-specific that used to sit inline in `FestipodDataContext`
* lives here: the `fp:` vocabulary, the two identifiers, and the fact that this
* provider's `update` replaces a record rather than patching it.
*/
import { dataProvider, liveProvider } from '@ng-org/refine-providers';
import type { BaseKey } from '@refinedev/core';
import type { FpEventData } from '../../data/types';
import type { FpEvent } from '../../shapes/orm/festipodShapes.typings';
import shapeTypes, { RESOURCE_EVENTS } from '../shapeTypes';
import { createNgAuthProvider } from '../ngAuthProvider';
import type { RecordBinding, RefineBackend } from './types';
/** The RDF class Festipod events are persisted as. */
const EVENT_TYPE = 'http://festipod.org/Event';
const eventBinding: RecordBinding<FpEventData> = {
// Refine addresses a record by its DOCUMENT NURI. `create` makes one document
// per record, so this is 1-1 with the subject IRI below — but it is a different
// string, and `update` only accepts this one.
recordKey: (record) => (record as FpEvent)['@graph'] as BaseKey,
// What the rest of the app references: a participation's `fp:event` holds the
// SUBJECT IRI, so that is the id `FpEventData` must carry.
domainId: (record) => (record as FpEvent)['@id'],
toDomain: (record) => {
const e = record as FpEvent;
return {
id: e['@id'],
title: e.title,
description: e.description || '',
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
};
},
// `@type` IS NOT OPTIONAL. The data provider writes a hardcoded ActivityStreams
// `Note` type — the type of the example app it was written against — and spreads
// the caller's values AFTER it, so passing `@type` is what makes the record a
// Festipod Event. Drop it and the shape stops matching on read.
toValues: (data) => ({
'@type': EVENT_TYPE,
title: data.title,
description: data.description,
date: data.date,
location: data.location,
distance: data.distance,
participantCount: data.participantCount,
coverImage: data.coverImage,
hostName: data.hostName,
hostInitials: data.hostInitials,
}),
// Measured: `update` deletes the old object and adds one built from `values`.
updateReplaces: true,
// Never reached while `updateReplaces` is true. It exists so a patching backend
// has somewhere to put its answer, and it fails loudly rather than sending a
// partial record to a provider that would drop everything else.
toPatch: () => {
throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch');
},
};
export const nextgraphBackend: RefineBackend = {
name: 'nextgraph',
resources: [RESOURCE_EVENTS],
event: eventBinding,
build: () => {
// Festipod's own AuthProvider, not the package's — see ngAuthProvider for why
// a second `init()` on one WASM session is a collision, not a convenience.
const authProvider = createNgAuthProvider();
return {
authProvider,
dataProvider: dataProvider({ shapeTypes, authProvider }),
liveProvider: liveProvider({ shapeTypes }),
};
},
};
+74
View File
@@ -0,0 +1,74 @@
/**
* The contract a Refine BACKEND must satisfy to serve Festipod.
*
* Refine's own `DataProvider` / `AuthProvider` / `LiveProvider` already abstract
* the transport, so they are not what this file adds. What they do NOT abstract
* is everything around them, and each item below exists because the Events
* vertical ran into it against `@ng-org/refine-providers` — none of it is
* anticipated:
*
* - **Configuration.** `@ng-org` takes SHEX shape types; `@activitypods` takes
* `resources: { events: { shapeTreeUri } }` plus a `jsonContext`. There is no
* common shape, so building the providers belongs to the backend.
* - **Record shape.** A record's fields are the backend's vocabulary. `@ng-org`
* yields `fp:` predicates with `@id` / `@graph` / `@type`; ActivityPods yields
* ActivityStreams JSON-LD. So domain mapping belongs to the backend too.
* - **Two identifiers.** Refine addresses a record by one id, and the app's
* entities reference each other by another. Under `@ng-org` those differ: the
* address is the document NURI (`@graph`), while a participation points at the
* subject IRI (`@id`). Collapsing them silently breaks the references.
* - **Update semantics.** `@ng-org`'s `update` REPLACES the record, so a partial
* write drops every field it omits. A backend that patches would not need the
* read-merge the app does today, so the app has to be told which it is.
*
* A backend that cannot answer one of these cannot be added by guessing: the
* answer is a fact about that backend, to be read from its contract or measured.
*/
import type { AuthProvider, BaseKey, DataProvider, LiveProvider } from '@refinedev/core';
import type { FpEventData } from '../../data/types';
/** How one entity's records translate to and from the app's domain type. */
export interface RecordBinding<T extends { id: string }> {
/**
* The key Refine's `update` / `getOne` address this record by. NOT necessarily
* the domain id — see "Two identifiers" above.
*/
recordKey(record: unknown): BaseKey;
/** The id the APP and its other entities use to reference this record. */
domainId(record: unknown): string;
/** Read a backend record into the app's domain type. */
toDomain(record: unknown): T;
/**
* The values to send on create, and on update when the backend REPLACES: the
* complete field set, because anything omitted is dropped.
*/
toValues(data: Omit<T, 'id'>): Record<string, unknown>;
/**
* The values to send when the backend PATCHES — only the fields that changed,
* so a concurrent edit elsewhere in the record survives. Never called when
* `updateReplaces` is true.
*/
toPatch(updates: Partial<T>): Record<string, unknown>;
/** True when `update` replaces the record rather than patching it. */
readonly updateReplaces: boolean;
}
/** Everything Festipod needs from one Refine backend. */
export interface RefineBackend {
/** Short, meaning-bearing name — used in logs and, later, in a selector. */
readonly name: string;
/**
* Build this backend's Refine providers, with its own configuration. Called
* ONCE: a new provider identity makes Refine re-run every query behind it.
*/
build(): {
dataProvider: DataProvider;
authProvider?: AuthProvider;
liveProvider?: LiveProvider;
};
/** Resource names this backend serves, for `<Refine resources>`. */
readonly resources: readonly string[];
/** The Events vertical is the only one wired so far. */
readonly event: RecordBinding<FpEventData>;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The Refine RESOURCE MAP — the one place that says which Refine resource name
* stands for which SHEX shape.
*
* `@ng-org/refine-providers` identifies data by shape type, not by endpoint: its
* data provider looks `shapeTypes[resource]` up on every call, so a resource name
* that is absent here silently resolves to `undefined` and the read comes back
* empty. Every `useList` / `useOne` / `useCreate` / `useUpdate` call site must
* therefore name a resource declared below.
*
* Only `events` is wired: the Events vertical is what goes through Refine —
* profiles and participations still read through `useShapeWithDefaults`.
*/
import type { ShapeTypes } from '@ng-org/refine-providers';
import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes';
/** Refine resource name for Festipod events. Import it — never retype the string. */
export const RESOURCE_EVENTS = 'events';
const shapeTypes: ShapeTypes = {
[RESOURCE_EVENTS]: FpEventShapeType,
};
export default shapeTypes;
+13 -2
View File
@@ -325,13 +325,24 @@ async function ensureAuth(): Promise<void> {
BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
console.log('Starting Festipod BDD tests...');
// `--format=iife` IS LOad-BEARING, not tidiness. Both bundles are loaded as
// CLASSIC scripts (`<script src="/harness.js">` for the NG harness,
// `addScriptTag({path})` for the mock one), so every top-level declaration in
// an unwrapped bundle becomes a GLOBAL binding. `@tanstack/query-core` — pulled
// in by Refine — exports a class named `MutationObserver`, which then shadows
// the DOM's for anything that runs afterwards in that page. Playwright's own
// injected script does `new MutationObserver(...)`, so it got TanStack's and
// every `waitForFunction` died with "defaultMutationOptions is not a function".
// The IIFE wrapper keeps the bundle's declarations to itself; `window.__testData`
// is still published explicitly, so the bridge is unaffected.
// Build the mock harness (always needed for non-@data or fallback)
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle`, { stdio: 'pipe' });
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle --format=iife`, { stdio: 'pipe' });
console.log(`[Harness] Built mock (${(fs.statSync(HARNESS_OUT).size / 1024).toFixed(0)} KB)`);
// Try to build the real broker harness
try {
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle --format=iife`, { stdio: 'pipe' });
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
// Ensure wallet exists in persistent profile (opens browser if needed)
+29 -18
View File
@@ -10,8 +10,9 @@
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { RefineProvider } from '../refine/RefineProvider';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShape } from '@ng-org/orm/react';
import { useShapeSet } from '../hooks/useShapeSet';
import { ng } from '@ng-org/web';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
@@ -21,7 +22,6 @@ import {
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
import { bootstrapWallet } from '../utils/ngBootstrap';
import { ensureGraphNuri } from '../utils/ngGraph';
// ============================================================================
@@ -31,9 +31,13 @@ import { ensureGraphNuri } from '../utils/ngGraph';
function DataHarnessNG() {
return (
<NextGraphProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
{/* Same order as the real app (app/App.tsx): the data context reads events
through Refine's hooks, so it cannot mount outside <Refine>. */}
<RefineProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</RefineProvider>
</NextGraphProvider>
);
}
@@ -63,9 +67,9 @@ function ConnectedHarness() {
// Use private store NURI as scope (opens the repo for reads AND writes)
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
const events = useShape(FpEventShapeType, privateNuri) as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, privateNuri) as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
const events = useShapeSet(FpEventShapeType, privateNuri);
const users = useShapeSet(FpUserProfileShapeType, privateNuri);
const participations = useShapeSet(FpParticipationShapeType, privateNuri);
const [bridgeReady, setBridgeReady] = useState(false);
// Stopgap multi-store validation: a doc created on demand via doc_create,
@@ -79,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 = {
@@ -148,9 +150,18 @@ function ConnectedHarness() {
}
},
/** Load the app's default seed data into the wallet */
/**
* Load the app's default seed data into the wallet.
*
* Delegates to the data context instead of calling `bootstrapWallet`
* directly: seeding events now goes through Refine's `create` (one
* document per event), and the callback that does it lives in the
* context. Calling the bootstrap from here with raw ORM sets would seed
* by a DIFFERENT path than the app's — which is the divergence this
* harness exists to avoid.
*/
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
return appData.loadTestData();
},
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
@@ -226,7 +237,7 @@ function ConnectedHarness() {
// ============================================================================
function FanoutProbe({ graphs }: { graphs: string[] }) {
const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet<FpEvent>;
const set = useShapeSet(FpEventShapeType, { graphs });
useEffect(() => {
(window as any).__fanout = {
ready: true,
@@ -253,7 +264,7 @@ function FanoutProbe({ graphs }: { graphs: string[] }) {
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
const set = useShapeSet(FpParticipationShapeType, { graphs: [docNuri] });
useEffect(() => {
(window as any).__smoke = {
ready: true,
+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);
});
+91
View File
@@ -0,0 +1,91 @@
/**
* 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. Absent env → global
* undefined → seed OFF, which is the whole point: OFF needs no plumbing at all.
*
* RECOVERED FROM THE ng-eventually BRANCH, with two differences that branch does
* not have.
*
* TURNING IT ON ONLY REACHES A BUILT BUNDLE. `bun run dev` and `bun run start`
* both serve from `src/` (`src/index.ts` imports `index.html`), so the `define`
* in `build.ts` never reaches them — only `dist-staging/`, which the @e2e harness
* builds, is affected. ng-eventually closes that gap with a
* `/festipod-config.json` route read by `frontend.tsx` before the app loads;
* recovering it here means making the app bootstrap async, which is not needed
* for the gate's purpose (stopping the dev seed from bloating a persistent
* wallet) and is therefore left out until someone actually wants to opt in.
*
* `fixtureSeedEnabled()` BELOW IS DELIBERATELY NOT ENFORCED in `bootstrapWallet`
* here, and it is not a decision left pending: the intent behind it — stated
* 2026-09-21 — was to stop the dev app from creating fixtures BY ITSELF, and
* using fixtures in TESTS was never a problem. `autoSeedEnabled()` above is
* exactly that intent. The master switch overshoots it, because it also silences
* an explicit `loadTestData()`; on ng-eventually that cost the @data suite its
* fixtures (a caveat still open there). So the gate that governs the automatic
* seed is the one wired, and an explicit seed 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;
}
+74 -46
View File
@@ -3,10 +3,20 @@
*
* Called once after NG connection + shapes ready. If the wallet already
* has events/users, it's a returning user — skip seeding.
*
* EVENTS ARE SEEDED THROUGH REFINE, not through an ORM set like users and
* participations. They have to be: the Refine data provider derives a record's
* `id` from its `@graph`, and it creates a fresh document on every `create`.
* Seeding events the old way — all of them into one shared graph — would give
* every seeded event the SAME Refine id, so the list would collide on itself and
* an update would address the whole document instead of one event. The seeder is
* injected rather than imported because `useCreate` is a React hook and this
* module runs outside the component tree.
*/
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import type { FpEventData } from '../data/types';
import { ensureGraphNuri } from './ngGraph';
import {
seedEvents,
@@ -20,6 +30,29 @@ export interface BootstrapResult {
eventIdMap: Map<string, string>;
}
/** Writes ONE event as its own document and returns the stored row (or nothing). */
export type EventSeeder = (event: Omit<FpEventData, 'id'>) => Promise<FpEvent | undefined>;
export interface BootstrapDeps {
/**
* How many events the Refine-backed read already sees. This is the
* "returning user" check that `ngEvents.size` used to answer — the caller owns
* 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>;
}
/**
* Flush ORM microtask batch and give the broker time to process.
*
@@ -33,27 +66,37 @@ async function flushAndWait(ms = 100): Promise<void> {
}
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
{ eventCount, userCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
): Promise<BootstrapResult> {
// Already has data → returning user, nothing to seed
if (ngEvents.size > 0 || ngUsers.size > 0) {
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
'users:', ngUsers.size, 'participations:', ngParticipations.size);
// 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:', userCount, 'participations:', ngParticipations.size);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}
console.log('[Bootstrap] First time for this wallet — seeding default data...');
// Create (or get) a document in the private store for our data.
// The ORM requires a real document NURI as @graph, not the store ID.
const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations);
// Create (or get) a document in the private store for users + participations.
// The ORM requires a real document NURI as @graph, not the store ID. Events
// are NOT placed here: each one gets its own document, created by `seedEvent`.
const graph = await ensureGraphNuri(ngUsers, ngParticipations);
console.log('[Bootstrap] Using graph NURI:', graph);
// Seed users — one at a time with ORM flush between each
// Seed users — one at a time with ORM flush between each.
//
// ONLY A FEW, not all fourteen. The connected seed is LINEAR in the number of
// documents (one document per entity, each a serial round trip), so it writes
// only what is needed. Nothing in the @data layer looks a seeded profile up by
// name; what it needs is that SOME profile exists — one to answer
// `currentUserId`, and a non-empty set for "the wallet contains users". The
// first three are kept rather than one, so a participants list has somebody to
// show, and `@mariedupont` is among them because `FestipodDataContext` falls
// back to her when the account layer has not resolved.
const SEEDED_PROFILES = 3;
const userIdMap = new Map<string, string>();
for (const u of seedUsers) {
for (const u of seedUsers.slice(0, SEEDED_PROFILES)) {
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
@@ -70,46 +113,31 @@ export async function bootstrapWallet(
}
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
// Seed events — one at a time with ORM flush between each
// Seed events — one at a time, each into its OWN document via Refine.
// The map is keyed on the seed id and holds the SUBJECT IRI, because that is
// what the participations below reference (`fp:event`).
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
ngEvents.add({
"@graph": graph,
"@type": "http://festipod.org/Event",
"@id": "",
title: e.title,
description: e.description,
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
} as FpEvent);
const stored = await seedEvent(e);
await flushAndWait();
const added = [...ngEvents].find(ne => ne.title === e.title);
if (added) eventIdMap.set(e.id, added["@id"]);
const iri = stored?.["@id"];
if (iri) {
eventIdMap.set(e.id, iri);
} else {
// Loud, not silent: without a subject IRI the participations that point at
// this event would be written against the SEED id, which nothing resolves.
console.warn('[Bootstrap] Event seeded without a subject IRI — participations '
+ 'referencing it will not resolve:', e.title);
}
}
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
// Seed participations with mapped IDs — one at a time
let partCount = 0;
for (const p of seedParticipations) {
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
const userIri = userIdMap.get(p.userId) || p.userId;
ngParticipations.add({
"@graph": graph,
"@type": "http://festipod.org/Participation",
"@id": "",
event: eventIri,
user: userIri,
isConfirmed: p.isConfirmed,
} as FpParticipation);
await flushAndWait();
partCount++;
}
console.log('[Bootstrap] Seeded', partCount, 'participations');
// NO PARTICIPATIONS. The sign-up scenarios create theirs live, which is the
// behaviour they exist to exercise, and nothing asserts on a seeded one: a
// scenario's starting participant count is SET on the event
// (`updateEvent({ participantCount })`) and read back from it, never counted
// from Participation documents. Writing five here would cost five more serial
// round trips to furnish something no test looks at.
return { seeded: true, userIdMap, eventIdMap };
}
+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;
+4 -1
View File
@@ -21,7 +21,10 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
// No `baseUrl`: deprecated in TypeScript 6, where it is reported as an ERROR
// that aborts the whole compilation — `tsc --noEmit` then exits 0 having
// checked nothing, so the typecheck gate silently passes everything. `paths`
// resolves relative to this file since TS 4.4, so dropping it changes nothing.
"paths": {
"@/*": ["./src/*"]
},