13 Commits

Author SHA1 Message Date
Sylvain Duchesne 4b4dfa6cad Put every persisted entity on Refine, and the test bridge on the app's view
Profiles and participations now read through useList and write through
useCreate/useUpdate/useDelete, so nothing in the data context talks to the ORM
any more. The seed receives injected writers instead of ORM sets, and decides on
the count the caller has already read -- taken before any await, which is what
the previous version got wrong in the other direction.

The test bridge had to follow. It exposed the raw ORM sets, which no longer hold
what the app holds: a step observing one while the app decides on the other
races itself, and an explicit load seeded a second time because the set still
read empty. The bridge now reads the app's own views through a ref, because a
captured snapshot goes stale after a seed -- the sibling branch had met this and
said so. Its mutations delegate to the data context too; a bridge mutating an
ORM set was testing the bridge.

Those mutations are awaitable now, which is right on its own terms but did NOT
fix what I expected: the counts stay at their seeded values with no error
anywhere, identical before and after. The app's own caveat says the count needs
one connection more than the write, and main only passed because it mutated the
set in place, locally. Those three assertions encode an immediacy this path does
not offer, and whether a test waits for the push is not mine to decide.

STILL FAILING, deliberately left: the three counter assertions above, an
unexplained double seed, and the human wallet-import flow that fails on main
too. setLike.delete is a no-op copied from a branch where nothing needs deleting
before the seed -- true there, false here.
2026-10-01 15:45:55 +02:00
Sylvain Duchesne 974d251079 Rotate the bloated test profile before opening the wallet
The @data suite writes into one wallet that nothing empties, so every run
leaves more behind. Past some volume the broker stops answering in time and
scenarios die on their step timeout, SILENTLY -- nothing in the console. That
failure looks exactly like a bug in whatever was last edited and gets diagnosed
as one: it happened twice in this session before anybody measured the profile,
which had reached 94MB.

Ported from the sibling branch's validate.ts, which already had the remedy and
the threshold. Placed just before ensureAuth rather than at the top of
BeforeAll: that is the branch which actually opens the profile, and the mock
fallback would pay a broker sign-up for nothing.

Two things the comment records rather than hides. Here the rotation resets the
DATA, not just browser state -- nothing serves a shared wallet file in the @data
path, so the wallet is the broker-side one reached through this profile; the
sibling branch documents the opposite, correctly for itself. And it is not free:
a new wallet comes from driving the real broker's sign-up, so each rotation
abandons one there. FESTIPOD_PROFILE_BLOAT_MB=0 opts out.

MEASURED AND UNRESOLVED: one run leaves 54MB, so a 50MB threshold fires every
time. Either it rises or systematic rotation is accepted.
2026-10-01 15:45:41 +02:00
Sylvain Duchesne fd02570b8b Widen the backend contract from one entity to the set
The contract carried a single event binding, which only worked while Events was
the one vertical on Refine. It now carries one binding per persisted entity,
and each binding says which resource name it lives under and who must be able
to read it.

Visibility is DECLARED, not yet enforced: Festipod's confidentiality is
per-entity while Refine has no notion of it, and the NextGraph provider reads
across every graph it can see. Naming it marks where each backend has to close
that gap rather than pretending it is closed.

meetingPoint is optional for now because its SHEX shape does not exist on this
branch; adding it means regenerating the ORM bindings, which drops the Fp prefix
and is its own step. Friendship is absent on purpose — it has no shape and no
persistence anywhere in this project.
2026-10-01 12:29:30 +02:00
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 7726 additions and 1698 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 },
);
});
+253 -129
View File
@@ -14,11 +14,14 @@ import {
seedMeetingPoints,
seedFriendships,
} from '../data/seedData';
import { useList, useCreate, useUpdate, useDelete } 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
@@ -66,9 +71,15 @@ interface FestipodDataContextValue {
selectedUser: FpUserData | undefined;
createEvent(event: Omit<FpEventData, 'id'>): Promise<FpEventData>;
updateEvent(id: string, updates: Partial<FpEventData>): void;
joinEvent(eventId: string, userId?: string): void;
leaveEvent(eventId: string, userId?: string): void;
/**
* AWAITABLE, and that matters: a caller that needs the write to have landed —
* a test driving the app's own path, a screen confirming — has to be able to
* wait for it. Returning `void` made the write fire-and-forget, so an assertion
* right after it read the value from before.
*/
updateEvent(id: string, updates: Partial<FpEventData>): Promise<void>;
joinEvent(eventId: string, userId?: string): Promise<void>;
leaveEvent(eventId: string, userId?: string): Promise<void>;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
updateProfile(updates: Partial<FpUserData>): void;
@@ -93,19 +104,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"],
@@ -204,13 +209,13 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
console.log('[FestipodData] createEvent (local, no-op):', event.title);
return { ...event, id: nextId('event') };
}, []);
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
const updateEvent = useCallback(async (_id: string, _updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (local, no-op)');
}, []);
const joinEvent = useCallback((_eventId: string) => {
const joinEvent = useCallback(async (_eventId: string) => {
console.log('[FestipodData] joinEvent (local, no-op)');
}, []);
const leaveEvent = useCallback((_eventId: string) => {
const leaveEvent = useCallback(async (_eventId: string) => {
console.log('[FestipodData] leaveEvent (local, no-op)');
}, []);
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
@@ -245,13 +250,19 @@ 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 bindings = useBackend().bindings;
const eventBinding = bindings.event;
const profileBinding = bindings.profile;
const participationBinding = bindings.participation;
// 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 +271,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,27 +289,130 @@ 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;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
// --- 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;
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
// --- Profiles and participations: read through REFINE too -----------------
// Nothing in this provider talks to the ORM any more. What that costs, stated
// rather than hidden: these two used to read from the PROTECTED scope, and this
// data provider reads across every graph it can see. The narrowing that still
// happens is `applyIsolation` below, at the app level — the backend's
// `visibility` declaration is where closing it properly belongs.
const { result: profilesResult, query: profilesQuery } = useList<FpUserProfile>({
resource: profileBinding.resource,
pagination: { mode: 'off' },
});
const { result: participationsResult } = useList<FpParticipation>({
resource: participationBinding.resource,
pagination: { mode: 'off' },
});
const profileRows = profilesResult?.data ?? [];
const participationRows = participationsResult?.data ?? [];
const events = eventsShape.items;
const users = usersShape.items;
const participations = participationsShape.items;
const events = eventRows.map(eventBinding.toDomain);
const users = profileRows.map(profileBinding.toDomain);
const participations = participationRows.map(participationBinding.toDomain);
// --- Writes: every entity through REFINE ----------------------------------
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
const { mutateAsync: updateEventRow } = useUpdate<FpEvent>();
const { mutateAsync: createProfileRow } = useCreate<FpUserProfile>();
const { mutateAsync: updateProfileRow } = useUpdate<FpUserProfile>();
const { mutateAsync: createParticipationRow } = useCreate<FpParticipation>();
const { mutateAsync: deleteParticipationRow } = useDelete<FpParticipation>();
/** Sign one user up to one event — a Participation record of its own. */
const createParticipation = useCallback(
async (eventId: string, userId: string): Promise<void> => {
await createParticipationRow({
resource: participationBinding.resource,
values: participationBinding.toValues({ eventId, userId, isConfirmed: true }),
});
},
[createParticipationRow, participationBinding],
);
/** Create ONE profile and return the stored record — used by the seed. */
const createProfileDoc = useCallback(
async (profile: Omit<FpUserData, 'id'>): Promise<FpUserProfile | undefined> => {
const created = await createProfileRow({
resource: profileBinding.resource,
values: profileBinding.toValues(profile),
});
return created?.data;
},
[createProfileRow, profileBinding],
);
/**
* 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[]>([]);
@@ -315,30 +430,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.
// Readiness is now the Refine query's, not an ORM subscription's: an empty list
// means nothing until that query has actually succeeded.
const seedWritable = profilesQuery.isSuccess;
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 || profileRows.length > 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({
walletHasData,
seedEvent: createEventDoc,
seedProfile: createProfileDoc,
}).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, profileRows.length, createEventDoc, createProfileDoc]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -372,80 +504,59 @@ 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] },
);
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) {
await createParticipation(eventIri, currentUserId);
setSelectedEventId(eventIri);
}
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) {
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
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()}` };
}, [createEventDoc, createParticipation, 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]);
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (Refine):', id, updates);
await writeEvent(id, updates);
}, [writeEvent]);
const joinEvent = useCallback((eventId: string, userId?: string) => {
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
if (participations.some(p => p.eventId === eventId && p.userId === uid)) {
console.log('[FestipodData] Already participating, skipping');
return;
}
participationsShape.ngSet.add({
"@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;
await createParticipation(eventId, uid);
// 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) {
await writeEvent(eventId, { participantCount: current.participantCount + 1 });
}
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [participations, createParticipation, eventRows, eventBinding, writeEvent, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', 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);
console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid);
// Addressed by the RECORD key, not the domain id — the binding answers both,
// and `deleteOne` only accepts the first.
const row = participationRows.find(r => {
const p = participationBinding.toDomain(r);
return p.eventId === eventId && p.userId === uid;
});
if (row) {
await deleteParticipationRow({
resource: participationBinding.resource,
id: participationBinding.recordKey(row),
});
const current = eventRows.find(r => eventBinding.domainId(r) === eventId);
if (current) {
await writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
}
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [participationRows, participationBinding, deleteParticipationRow, eventRows, eventBinding, writeEvent, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -462,26 +573,39 @@ function useNgData(): FestipodDataContextValue {
}, [currentUserId]);
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
console.log('[FestipodData] updateProfile (NG):', updates);
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|| [...usersShape.ngSet][0];
if (ngUser) {
if (updates.name !== undefined) ngUser.name = updates.name;
if (updates.initials !== undefined) ngUser.initials = updates.initials;
if (updates.username !== undefined) ngUser.username = updates.username;
if (updates.role !== undefined) ngUser.role = updates.role;
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
console.log('[FestipodData] updateProfile (Refine):', updates);
// "My profile" is the one the current user resolves to, with the historical
// fallback kept: the account layer may not have resolved yet.
const row =
profileRows.find(r => profileBinding.domainId(r) === currentUserId)
?? profileRows.find(r => profileBinding.toDomain(r).username === '@mariedupont')
?? profileRows[0];
if (!row) {
console.warn('[FestipodData] updateProfile: no profile to write');
return;
}
}, [usersShape.ngSet]);
void updateProfileRow({
resource: profileBinding.resource,
id: profileBinding.recordKey(row),
values: profileBinding.updateReplaces
? profileBinding.toValues({ ...profileBinding.toDomain(row), ...updates })
: profileBinding.toPatch(updates),
});
}, [profileRows, profileBinding, updateProfileRow, currentUserId]);
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]);
console.log('[FestipodData] loadTestData (Refine)');
// The emptiness answer comes from what this render has ALREADY read. The
// previous version awaited an ORM subscription, which re-synced in the
// meantime and reported back the very profiles a caller had just deleted — so
// the seed declined and never ran again. There is no subscription left to
// await: the Refine queries have already resolved by the time this is called.
return bootstrapWallet({
walletHasData: eventRows.length > 0 || profileRows.length > 0,
seedEvent: createEventDoc,
seedProfile: createProfileDoc,
});
}, [eventRows.length, profileRows.length, createEventDoc, createProfileDoc]);
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, resourceNames, 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={resourceNames(backend).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,
};
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 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, RecordBindings, RefineBackend } from './types';
export { resourceNames } 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;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* 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, FpParticipationData, FpUserData } from '../../data/types';
import type { FpEvent, FpParticipation, FpUserProfile } from '../../shapes/orm/festipodShapes.typings';
import shapeTypes, {
RESOURCE_EVENTS,
RESOURCE_PARTICIPATIONS,
RESOURCE_PROFILES,
} from '../shapeTypes';
import { createNgAuthProvider } from '../ngAuthProvider';
import type { RecordBinding, RefineBackend } from './types';
/**
* The RDF classes Festipod persists. These IRIs ARE the stored data — renaming
* one renames what is on disk, so they are never adjusted for convenience.
*/
const TYPE = {
event: 'http://festipod.org/Event',
profile: 'http://festipod.org/UserProfile',
participation: 'http://festipod.org/Participation',
} as const;
/**
* Shared by every binding here: this provider addresses a record by the DOCUMENT
* NURI, while the app's entities reference each other by the SUBJECT IRI. One
* document per record keeps them 1-1, but they are different strings and `update`
* only accepts the first.
*/
const recordKey = (record: unknown): BaseKey =>
(record as { '@graph': string })['@graph'] as BaseKey;
const domainId = (record: unknown): string => (record as { '@id': string })['@id'];
/** This provider's `update` deletes the old object and adds one built from values. */
const replaces = {
updateReplaces: true as const,
toPatch: (): never => {
throw new Error('nextgraph: update replaces the record — send the whole of it, not a patch');
},
};
const eventBinding: RecordBinding<FpEventData> = {
resource: RESOURCE_EVENTS,
// Events are what other users must find — see concept `functional-domain`.
visibility: 'public',
recordKey,
domainId,
...replaces,
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': TYPE.event,
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,
}),
};
const profileBinding: RecordBinding<FpUserData> = {
resource: RESOURCE_PROFILES,
// A profile is readable by one's network, not by everyone.
visibility: 'protected',
recordKey,
domainId,
...replaces,
toDomain: (record) => {
const u = record as FpUserProfile;
return {
id: u['@id'],
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
};
},
toValues: (data) => ({
'@type': TYPE.profile,
name: data.name,
initials: data.initials,
username: data.username,
role: data.role,
isPublic: data.isPublic,
}),
};
const participationBinding: RecordBinding<FpParticipationData> = {
resource: RESOURCE_PARTICIPATIONS,
visibility: 'protected',
recordKey,
domainId,
...replaces,
// `fp:event` and `fp:user` hold SUBJECT IRIs, which is why `domainId` above is
// the subject and not the document: these references resolve against it.
toDomain: (record) => {
const p = record as FpParticipation;
return {
id: p['@id'],
eventId: p.event,
userId: p.user,
isConfirmed: p.isConfirmed,
};
},
toValues: (data) => ({
'@type': TYPE.participation,
event: data.eventId,
user: data.userId,
isConfirmed: data.isConfirmed,
}),
};
export const nextgraphBackend: RefineBackend = {
name: 'nextgraph',
bindings: {
event: eventBinding,
profile: profileBinding,
participation: participationBinding,
// meetingPoint: pending its SHEX shape — see types.ts.
},
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 }),
};
},
};
+117
View File
@@ -0,0 +1,117 @@
/**
* 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,
FpMeetingPointData,
FpParticipationData,
FpUserData,
} 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;
/**
* Which Refine resource these records live under. The NAME is the backend's,
* not the app's: `@ng-org` keys `shapeTypes` by it, `@activitypods` keys
* `resources` by it, and nothing says the two must agree on a spelling.
*/
readonly resource: string;
/**
* WHO MUST BE ABLE TO READ these records. Festipod's confidentiality model is
* per-entity (concept `functional-domain`), and each backend enforces it its own
* way — NextGraph by placing a document in a store, ActivityPods by shape trees
* and ACLs. Refine itself has NO notion of it, so the intent is declared here
* and the backend is what honours it.
*
* Naming it does not make it enforced: the NextGraph data provider currently
* reads across every graph it can see, so this records the INTENT and marks
* where a backend has to close the gap.
*/
readonly visibility: 'public' | 'protected' | 'private';
}
/**
* The entities the app binds. One key per domain type it persists.
*
* `meetingPoint` is OPTIONAL only because its SHEX shape does not exist on this
* branch yet — adding it means regenerating the ORM bindings, which is its own
* step with its own failure mode (the generator drops the `Fp` prefix, since the
* emitted name follows the shape IRI). Once that lands it becomes required like
* the others. `Friendship` is deliberately absent: it has no shape and no
* persistence anywhere in this project.
*/
export interface RecordBindings {
event: RecordBinding<FpEventData>;
profile: RecordBinding<FpUserData>;
participation: RecordBinding<FpParticipationData>;
meetingPoint?: RecordBinding<FpMeetingPointData>;
}
/** 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;
};
/** One binding per entity the app persists. */
readonly bindings: RecordBindings;
}
/** Every resource name a backend serves, derived from its bindings. */
export function resourceNames(backend: RefineBackend): string[] {
return [...new Set(Object.values(backend.bindings).map((b) => b.resource))];
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 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 — no error, just nothing. Every `useList` / `useOne` / `useCreate` /
* `useUpdate` call site must therefore name a resource declared below.
*
* These names are the NextGraph backend's, which is why this file lives beside it
* rather than in the app: another backend keys its own configuration by its own
* names, and nothing says the two must agree on a spelling.
*/
import type { ShapeTypes } from '@ng-org/refine-providers';
import {
FpEventShapeType,
FpParticipationShapeType,
FpUserProfileShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
/** Resource names. Import them — never retype the strings. */
export const RESOURCE_EVENTS = 'events';
export const RESOURCE_PROFILES = 'profiles';
export const RESOURCE_PARTICIPATIONS = 'participations';
const shapeTypes: ShapeTypes = {
[RESOURCE_EVENTS]: FpEventShapeType,
[RESOURCE_PROFILES]: FpUserProfileShapeType,
[RESOURCE_PARTICIPATIONS]: FpParticipationShapeType,
};
export default shapeTypes;
+99 -3
View File
@@ -1,6 +1,6 @@
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
import { chromium, type Browser, type BrowserContext, type Page, type Frame } from 'playwright';
import { execSync, spawn, type ChildProcess } from 'child_process';
import { execSync, spawn, spawnSync, type ChildProcess } from 'child_process';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
@@ -37,6 +37,84 @@ const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
// Persistent Chromium profile for NG wallet (not the user's daily browser)
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
/**
* Rotate the profile once it exceeds this many MB. Override with
* FESTIPOD_PROFILE_BLOAT_MB; 0 disables the rotation entirely.
*/
const BLOAT_THRESHOLD_MB = Number(process.env.FESTIPOD_PROFILE_BLOAT_MB ?? 50);
function dirSizeMB(dir: string): number {
if (!fs.existsSync(dir)) return 0;
try {
const result = spawnSync('du', ['-sm', dir], { encoding: 'utf-8' });
const line = result.stdout.trim().split('\n')[0] ?? '';
return parseInt(line.split('\t')[0] ?? '0', 10);
} catch {
return 0;
}
}
/**
* Remove any stale Chromium singleton files from the profile. Chromium refuses to
* launch (ProcessSingleton error) if SingletonLock, SingletonCookie or
* SingletonSocket are left over from a previous crashed run. Idempotent.
*/
function cleanSingletons(profilePath: string): void {
if (!fs.existsSync(profilePath)) return;
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
const f = path.join(profilePath, name);
if (!fs.existsSync(f)) continue;
try {
fs.rmSync(f, { force: true });
console.log(`[rotate] removed stale ${name}.`);
} catch {
// Non-fatal: if it cannot be removed, the launch fails with a clear error.
}
}
}
/**
* ROTATE THE TEST PROFILE when it has grown past the threshold.
*
* WHY THIS RUNS UNATTENDED. The @data suite writes into one wallet that nothing
* empties, so every run leaves more behind. Past some volume the broker stops
* answering in time and scenarios die on their step timeout — SILENTLY, nothing
* in the console. That failure looks exactly like a bug in whatever was last
* edited, and gets diagnosed as one: it happened twice in a single session before
* anybody measured the profile. A guard nobody has to remember is the point.
*
* On THIS branch the rotation resets the DATA, not just the browser state:
* nothing here serves a shared wallet file (`ngSession` configures none, and the
* `.ngw` is used only by the @e2e staging build), so the @data wallet is the
* broker-side one reached through this profile's storage. Removing the profile
* takes the `.wallet-ready` marker with it and `ensureAuth` creates a fresh
* wallet. The sibling branch documents the opposite, correctly for itself —
* there the data lives in the served file and survives this.
*
* NOT free, and not quiet about it: the new wallet is created by driving the real
* broker's sign-up, so each rotation leaves an abandoned wallet behind on it.
* That is the price of a measurable suite; `FESTIPOD_PROFILE_BLOAT_MB=0` opts out.
*/
function rotateProfileIfBloated(): void {
if (BLOAT_THRESHOLD_MB <= 0) {
console.log('[rotate] disabled (FESTIPOD_PROFILE_BLOAT_MB=0)');
return;
}
const sizeMB = dirSizeMB(PLAYWRIGHT_PROFILE);
if (sizeMB > BLOAT_THRESHOLD_MB) {
console.log(`[rotate] profile ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating (a NEW broker wallet will be created)`);
try {
fs.rmSync(PLAYWRIGHT_PROFILE, { recursive: true, force: true });
console.log('[rotate] profile removed — it will be recreated, with empty data.');
} catch (e) {
console.warn(`[rotate] failed to remove the profile: ${e}`);
}
return;
}
cleanSingletons(PLAYWRIGHT_PROFILE);
console.log(`[rotate] profile ${sizeMB}MB — below threshold, keeping it.`);
}
let harnessServer: http.Server | null = null;
let harnessPort = 0;
let useRealBroker = false;
@@ -325,15 +403,33 @@ 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)`);
// BEFORE touching the wallet: rotate the profile if it has bloated past the
// point where the broker stops answering in time. Here and not earlier in
// BeforeAll, because this is the branch that actually uses the profile — the
// mock fallback below never opens it, and rotating it would cost a broker
// sign-up for nothing.
rotateProfileIfBloated();
// Ensure wallet exists in persistent profile (opens browser if needed)
await ensureAuth();
useRealBroker = true;
+102 -62
View File
@@ -7,11 +7,13 @@
*
* Exposes window.__testData for Playwright-driven Cucumber steps.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, 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 type { FpEventData, FpParticipationData, FpUserData } from '../data/types';
import { useShapeSet, useShapeSubscription } from '../hooks/useShapeSet';
import { ng } from '@ng-org/web';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
@@ -21,7 +23,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 +32,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 +68,15 @@ 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);
// The bridge getters must read the LIVE appData. Captured once, a snapshot goes
// stale after a seed or a reactive re-render: the step reads 0 while the screens
// already show the data. A ref updated every render is what keeps them honest.
const appDataRef = useRef(appData);
appDataRef.current = appData;
const [bridgeReady, setBridgeReady] = useState(false);
// Stopgap multi-store validation: a doc created on demand via doc_create,
@@ -79,21 +90,48 @@ 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'] || '';
// Always read the LIVE appData through the ref — see appDataRef above.
const AD = () => appDataRef.current;
const eventAdapter = () =>
AD().events.map((e: FpEventData) => ({ '@id': e.id, title: e.title, participantCount: e.participantCount }));
const userAdapter = () =>
AD().users.map((u: FpUserData) => ({ '@id': u.id, username: u.username, name: u.name }));
const partAdapter = () =>
AD().participations.map((p: FpParticipationData) => ({
'@id': p.id, event: p.eventId, user: p.userId, isConfirmed: p.isConfirmed,
}));
/**
* A read-only Set-like over an app-backed array snapshot: supports
* `[...x]`, `x.size`, and a no-op `delete`. The "empty wallet" scenario runs
* before any seed, so there is nothing for it to delete — and a seeded
* record is not this bridge's to remove.
*/
const setLike = <T,>(snapshot: () => T[]) => ({
get size() { return snapshot().length; },
[Symbol.iterator]() { return snapshot()[Symbol.iterator](); },
delete(_item: T) { /* app-backed: seeded records aren't deletable here */ },
});
// Expose the test bridge
(window as any).__testData = {
ready: true,
// --- Raw DeepSignalSets (backward compatible with existing tests) ---
events,
users,
participations,
// --- App-backed entity views (the path the screens actually use) -----
// These used to be the raw ORM sets. They cannot be, now that the app
// reads every entity through Refine: the set and the app's list no longer
// hold the same thing, and a step that observes one while the app decides
// on the other races itself — an explicit "load test data" seeded a second
// time because the set still read empty. The step contract
// (`[...td.events]` with `@id`/`title`/`participantCount`, `.size`,
// `p.user`/`p.event`) is preserved by mapping the app types to that shape.
get events() { return setLike(eventAdapter); },
get users() { return setLike(userAdapter); },
get participations() { return setLike(partAdapter); },
currentUserId,
session,
@@ -101,56 +139,46 @@ function ConnectedHarness() {
appData,
ngStatus: ngCtx.status,
// --- Query helpers ---
// --- Query helpers (app-backed) ---
getEvent(id: string) {
return [...events].find(e => e['@id'] === id);
return eventAdapter().find((e) => e['@id'] === id);
},
getEventByTitle(title: string) {
return [...events].find(e => e.title === title);
return eventAdapter().find((e) => e.title === title);
},
isParticipating(eventId: string, userId: string) {
return [...participations].some(p => p.event === eventId && p.user === userId);
return AD().isParticipating(eventId, userId);
},
getEventParticipants(eventId: string) {
return [...participations].filter(p => p.event === eventId);
return partAdapter().filter((p) => p.event === eventId);
},
// --- Mutations (direct ngSet access) ---
// --- Mutations (the APP path, not a bridge shortcut) -----------------
// Delegated to the data context so a @data scenario faces the same writes
// the screens drive. A bridge that mutated an ORM set directly would be
// testing the bridge.
async joinEvent(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
if (already) return;
const graph = await ensureGraphNuri(events as any, users as any, participations as any);
participations.add({
'@graph': graph,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: eventId,
user: userId,
isConfirmed: true,
} as FpParticipation);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = ev.participantCount + 1;
await AD().joinEvent(eventId, userId);
},
leaveEvent(eventId: string, userId: string) {
const part = [...participations].find(p => p.event === eventId && p.user === userId);
if (!part) return;
participations.delete(part);
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
async leaveEvent(eventId: string, userId: string) {
await AD().leaveEvent(eventId, userId);
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
for (const [key, value] of Object.entries(updates)) {
if (key !== '@id' && key !== '@graph' && key !== '@type') {
(ev as any)[key] = value;
}
}
async updateEvent(eventId: string, updates: Record<string, any>) {
await AD().updateEvent(eventId, updates);
},
/** 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 +254,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,13 +281,25 @@ function FanoutProbe({ graphs }: { graphs: string[] }) {
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
// The SET and its READINESS. This probe WRITES, and since @ng-org/orm alpha.21
// the set does not exist until the subscription resolves — so `ready` is not
// decoration: `add()` before it refuses, loudly, rather than dropping the write.
// `__smoke.ready` is what the step waits on, so it must mean "writable", not
// merely "mounted".
const { set, ready, promise } = useShapeSubscription(FpParticipationShapeType, { graphs: [docNuri] });
// Read through a ref, for the same reason the bridge does: the effect's closure
// would otherwise hand the step the write-refusing placeholder captured on the
// first render, and `count()` would keep reporting that empty set.
const subRef = useRef({ set, promise });
subRef.current = { set, promise };
useEffect(() => {
(window as any).__smoke = {
ready: true,
ready,
docNuri,
add() {
set.add({
/** Awaits the subscription, then writes — the step may call it either side of `ready`. */
async add() {
await subRef.current.promise;
subRef.current.set.add({
'@graph': docNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
@@ -268,12 +308,12 @@ function SmokeProbe({ docNuri }: { docNuri: string }) {
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
count() { return subRef.current.set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
return [...subRef.current.set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, docNuri]);
}, [docNuri, ready]);
return null;
}
+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;
}
+62 -76
View File
@@ -3,16 +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 { ensureGraphNuri } from './ngGraph';
import {
seedEvents,
seedUsers,
seedParticipations,
} from '../data/seedData';
import type { FpEvent, FpUserProfile } from '../shapes/orm/festipodShapes.typings';
import type { FpEventData, FpUserData } from '../data/types';
import { seedEvents, seedUsers } from '../data/seedData';
export interface BootstrapResult {
seeded: boolean;
@@ -20,96 +24,78 @@ export interface BootstrapResult {
eventIdMap: Map<string, string>;
}
/** Writes ONE entity and returns the stored record (or nothing). */
export type Seeder<T, R> = (entity: T) => Promise<R | undefined>;
export interface BootstrapDeps {
/**
* Whether the wallet already carries entities — a returning user, nothing to
* seed. The CALLER computes it, from what it has already read: this module
* cannot, now that nothing here holds a live set. It must also be computed
* BEFORE any await, or a subscription that re-syncs in the meantime reports
* data the caller has just deleted and the seed never runs again.
*/
walletHasData: boolean;
seedEvent: Seeder<Omit<FpEventData, 'id'>, FpEvent>;
seedProfile: Seeder<Omit<FpUserData, 'id'>, FpUserProfile>;
}
/**
* Flush ORM microtask batch and give the broker time to process.
* Flush and give the broker time to settle between two document creations.
*
* The ORM batches signal mutations into microtasks. A `Promise.resolve()`
* flushes the pending batch to the NG engine. The short delay lets the
* broker create the new repo/document before the next add.
* The seed is LINEAR IN DOCUMENTS — one document per entity, each a serial round
* trip — so it writes only what is needed, and the delay between writes is not
* padding: each creation opens a repo.
*/
async function flushAndWait(ms = 100): Promise<void> {
await Promise.resolve(); // flush ORM microtask batch
await new Promise(r => setTimeout(r, ms)); // let broker process
await Promise.resolve();
await new Promise(r => setTimeout(r, ms));
}
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
{ walletHasData, seedEvent, seedProfile }: 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);
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}
console.log('[Bootstrap] First time for this wallet — seeding default data...');
console.log('[Bootstrap] First time for this wallet — seeding per-entity records...');
// 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);
console.log('[Bootstrap] Using graph NURI:', graph);
// Seed users — one at a time with ORM flush between each
// ONLY A FEW PROFILES, not all fourteen. 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".
// Three are kept rather than one so a participants list has somebody to show,
// and `@mariedupont` is among them because the data context 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) {
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
"@id": "",
name: u.name,
initials: u.initials,
username: u.username,
role: u.role,
isPublic: u.isPublic,
} as FpUserProfile);
for (const u of seedUsers.slice(0, SEEDED_PROFILES)) {
const stored = await seedProfile(u);
await flushAndWait();
const added = [...ngUsers].find(nu => nu.username === u.username);
if (added) userIdMap.set(u.id, added["@id"]);
const iri = stored?.['@id'];
if (iri) userIdMap.set(u.id, iri);
}
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
console.log('[Bootstrap] Seeded', userIdMap.size, 'profiles');
// Seed events — one at a time with ORM flush between each
// All events — the scenarios look them up by title.
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 {
console.warn('[Bootstrap] Event seeded without a subject IRI:', 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 and read back from
// it, never counted from Participation records.
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/*"]
},