9 Commits

Author SHA1 Message Date
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
20 changed files with 7054 additions and 1555 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>
@@ -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 },
);
});
+179 -76
View File
@@ -14,11 +14,13 @@ import {
seedMeetingPoints,
seedFriendships,
} from '../data/seedData';
import { useList, useCreate, useUpdate } from '@refinedev/core';
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph, createEntityDoc, listEntityDocs } from '../utils/storeRegistry';
import { ensureAccount, resolveReadGraphs, resolveWriteGraph } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import { RESOURCE_EVENTS } from '../refine/shapeTypes';
// Multi-document mode (storeRegistry): one document per (account × scope),
// mirroring the target per-user stores. Default OFF — the validated mono-store
@@ -28,13 +30,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
@@ -107,6 +111,23 @@ const mapEvent = (e: FpEvent): FpEventData => ({
hostInitials: e.hostInitials,
});
/**
* Every persisted field of an event, in the form the Refine data provider takes.
* `@type` is carried explicitly — see `createEventDoc` for why it must be.
*/
const eventFields = (e: Omit<FpEventData, 'id'>) => ({
"@type": "http://festipod.org/Event",
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,
});
const mapUser = (u: FpUserProfile): FpUserData => ({
id: u["@id"],
name: u.name,
@@ -251,7 +272,7 @@ function useNgData(): FestipodDataContextValue {
// 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 +281,16 @@ function useNgData(): FestipodDataContextValue {
(async () => {
try {
await ensureAccount(username); // create this account's index docs on first sight
// Public = per-entity documents (events/PdR) listed by the public index.
// Protected = grouped in each account's protected index document.
const [pub, prot, wProt] = await Promise.all([
listEntityDocs('public'),
// NO public fan-out any more: events are a Refine resource, and the data
// provider addresses them by shape across every graph it can see — it
// never takes a graph list, so the public index has no reader left.
const [prot, wProt] = await Promise.all([
resolveReadGraphs('protected'),
resolveWriteGraph(username, 'protected'),
]);
if (cancelled) return;
setReadGraphs({ public: pub, protected: prot });
setReadGraphs({ protected: prot });
setWriteGraphs({ protected: wProt });
} catch (err) {
console.error('[FestipodData] storeRegistry init failed:', err);
@@ -277,28 +299,92 @@ function useNgData(): FestipodDataContextValue {
return () => { cancelled = true; };
}, [privateNuri, username]);
// Scope per entity: events live in the PUBLIC docs, profiles + participations
// in the PROTECTED docs. Mono-store mode collapses all to the private store.
const publicScope: ShapeScope = MULTISTORE
? (readGraphs.public.length ? { graphs: readGraphs.public } : undefined)
: privateNuri;
// Scope for profiles + participations, which still read through the ORM.
// Mono-store mode collapses them to the private store.
const protectedScope: ShapeScope = MULTISTORE
? (readGraphs.protected.length ? { graphs: readGraphs.protected } : undefined)
: privateNuri;
// --- Events: read through REFINE -----------------------------------------
// `useList` goes to `@ng-org/refine-providers`, which resolves the resource
// name to its SHEX shape (see refine/shapeTypes.ts) and reads every graph the
// verifier holds. Pagination is off: the screens show the whole list and the
// provider does not paginate at the source anyway.
const { result: eventsResult, query: eventsQuery } = useList<FpEvent>({
resource: RESOURCE_EVENTS,
pagination: { mode: 'off' },
});
// The rows carry BOTH ids: Refine's `id` (the DOCUMENT NURI, which is what its
// `update` addresses) and `@id` (the SUBJECT IRI, which is what participations
// reference). `FpEventData.id` stays the subject IRI so `isParticipating` and
// `getEventParticipants` keep matching; the document NURI is looked up below.
const eventRows = eventsResult?.data ?? [];
// "No events" and "not read yet" are different answers, and the auto-seed below
// must not confuse them — an empty list while the query is still in flight is
// not an empty wallet.
const eventsRead = eventsQuery.isSuccess;
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
const emptyEvents: FpEventData[] = [];
const emptyUsers: FpUserData[] = [];
const emptyParticipations: FpParticipationData[] = [];
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
const events = eventsShape.items;
const events = eventRows.map(mapEvent);
const users = usersShape.items;
const participations = participationsShape.items;
// --- Events: write through REFINE ----------------------------------------
const { mutateAsync: createEventRow } = useCreate<FpEvent>();
const { mutateAsync: updateEventRow } = useUpdate<FpEvent>();
/**
* Create ONE event as its own document. Returns the stored row, whose `@id`
* is the subject IRI the participations reference.
*
* `@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 rather than a Note. Drop it and the shape stops matching on
* read, and the event disappears.
*/
const createEventDoc = useCallback(
async (event: Omit<FpEventData, 'id'>): Promise<FpEvent | undefined> => {
const created = await createEventRow({
resource: RESOURCE_EVENTS,
values: eventFields(event),
});
return created?.data;
},
[createEventRow],
);
/**
* Overwrite an event, addressed by its app id (the subject IRI).
*
* The provider's `update` REPLACES the record — it deletes the old object and
* adds a new one built from `values` — so every persisted field has to be sent.
* A partial write here would silently drop whatever it left out, which is why
* the current row is read back and merged first.
*/
const writeEvent = useCallback(
async (id: string, updates: Partial<FpEventData>): Promise<void> => {
const row = eventRows.find(r => r['@id'] === id);
if (!row) {
console.warn('[FestipodData] updateEvent: no event known under', id);
return;
}
await updateEventRow({
resource: RESOURCE_EVENTS,
// Refine addresses the record by its DOCUMENT NURI, not its subject IRI.
id: row['@graph'],
values: eventFields({ ...mapEvent(row), ...updates }),
});
},
[eventRows, updateEventRow],
);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
const [friendships, setFriendships] = useState<FpFriendshipData[]>([]);
@@ -315,30 +401,47 @@ function useNgData(): FestipodDataContextValue {
}, [events.length, selectedEventId]);
// Dev auto-seed: if the wallet is still empty 3s after the session is ready,
// bootstrap with seed data. `bootstrapWallet()` self-checks (ngSet.size > 0
// bootstrap with seed data. `bootstrapWallet()` self-checks (existing data
// → skip), so this is safe even if shapes finish hydrating after the timer.
// Gated on NODE_ENV so production users see their own (possibly empty) wallet.
//
// TWO readiness guards, and both are load-bearing. `eventsRead` says the Refine
// list has actually resolved — an empty list means nothing while the query is in
// flight, and seeding on it would write the fixtures a second time. `seedWritable`
// says the ORM subscriptions the seed WRITES INTO exist: since @ng-org/orm
// alpha.21 the set is `undefined` until the subscription resolves, so an add
// issued before then has nowhere to land and throws.
const seedWritable = usersShape.ready && participationsShape.ready;
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
// OFF unless FESTIPOD_AUTO_SEED says otherwise. Firing unconditionally in dev
// is what bloats a persistent wallet over repeated runs — see autoSeed.ts.
if (!autoSeedEnabled()) return;
if (MULTISTORE) return; // seed targets the private store; multi-doc seeding is a separate concern
if (hasTriedAutoSeed.current) return;
if (!privateNuri) return;
const t = setTimeout(() => {
if (!eventsRead || !seedWritable) {
console.log('[FestipodData] Dev auto-seed: subscriptions not ready yet — skip');
return;
}
hasTriedAutoSeed.current = true;
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
const walletHasData = eventRows.length > 0 || usersShape.ngSet.size > 0;
if (shouldAutoSeed(walletHasData)) {
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
bootstrapWallet({
eventCount: eventRows.length,
userCount: usersShape.ngSet.size,
seedEvent: createEventDoc,
ngUsers: usersShape.ngSet as any,
ngParticipations: participationsShape.ngSet as any,
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
} else {
console.log('[FestipodData] Dev auto-seed: wallet already has data — skip');
}
}, 3000);
return () => clearTimeout(t);
}, [privateNuri, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
}, [privateNuri, eventsRead, seedWritable, eventRows.length, createEventDoc, usersShape.ngSet, participationsShape.ngSet]);
// --- Derived ---
// Resolve current user from the chosen account username (the perceived login);
@@ -372,52 +475,29 @@ function useNgData(): FestipodDataContextValue {
const protectedGraph = (MULTISTORE ? writeGraphs.protected : undefined) || privateNuri || '';
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
console.log('[FestipodData] createEvent (NG):', event.title);
// Per-entity: in multistore each event is its OWN document. Mono-store: the
// private store. (Multistore create reactivity is best-effort — the new doc
// is appended to the read fan-out so it shows after re-subscribe.)
const eventGraph = MULTISTORE
? await createEntityDoc(username || '', 'public')
: (privateNuri || '');
if (MULTISTORE && eventGraph) {
setReadGraphs(prev =>
prev.public.includes(eventGraph) ? prev : { ...prev, public: [...prev.public, eventGraph] },
);
}
eventsShape.ngSet.add({
"@graph": eventGraph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
console.log('[FestipodData] createEvent (Refine):', event.title);
// The data provider creates the document itself, one per record — so there is
// no graph to resolve here any more, and nothing to append to a read fan-out.
const stored = await createEventDoc({ ...event, participantCount: event.participantCount || 1 });
const eventIri = stored?.["@id"];
if (eventIri && currentUserId) {
participationsShape.ngSet.add({
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
event: eventIri, user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
setSelectedEventId(eventIri);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [protectedGraph, eventsShape.ngSet, participationsShape.ngSet, currentUserId, privateNuri, username]);
return { ...event, id: eventIri || `ng-pending-${Date.now()}` };
}, [protectedGraph, createEventDoc, participationsShape.ngSet, currentUserId]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
if (ngEvent) {
if (updates.title !== undefined) ngEvent.title = updates.title;
if (updates.description !== undefined) ngEvent.description = updates.description;
if (updates.date !== undefined) ngEvent.date = updates.date;
if (updates.location !== undefined) ngEvent.location = updates.location;
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
}
}, [eventsShape.ngSet]);
console.log('[FestipodData] updateEvent (Refine):', id, updates);
void writeEvent(id, updates);
}, [writeEvent]);
const joinEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
console.log('[FestipodData] joinEvent (Refine):', eventId, 'user:', uid);
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (existing) {
console.log('[FestipodData] Already participating, skipping');
@@ -427,25 +507,27 @@ function useNgData(): FestipodDataContextValue {
"@graph": protectedGraph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
// The count lives on the event, which is now a Refine record: bumping it is a
// full record write, not the in-place ORM mutation it used to be.
const current = eventRows.find(r => r["@id"] === eventId);
if (current) {
void writeEvent(eventId, { participantCount: current.participantCount + 1 });
}
}, [protectedGraph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [protectedGraph, participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
console.log('[FestipodData] leaveEvent (Refine):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (ngPart) {
console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]);
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
const current = eventRows.find(r => r["@id"] === eventId);
if (current) {
void writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) });
}
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
}, [participationsShape.ngSet, eventRows, writeEvent, currentUserId]);
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
@@ -476,12 +558,33 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log('[FestipodData] loadTestData (NG)');
return bootstrapWallet(
eventsShape.ngSet as any,
usersShape.ngSet as any,
participationsShape.ngSet as any,
);
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
// The seed WRITES into the ORM subscriptions, which since @ng-org/orm
// alpha.21 do not exist until they have loaded. Await the ORM's own
// readiness signal first — a `bootstrapWallet` started too early throws on
// its very first `add`, and callers see a wallet that seeded nothing.
//
// The RESOLVED VALUE is the set. Re-reading `usersShape.ngSet` after the
// await would hand over the placeholder captured by this closure, since the
// subscription resolving does not retroactively change the render that built
// this callback. `?? ngSet` covers the no-scope case, where there is no
// promise and the placeholder is the honest answer.
// Snapshot the "is the wallet empty?" answer BEFORE awaiting. The await lets
// the subscription re-sync, so afterwards the set holds whatever the broker
// still has — including profiles a caller has just deleted. Deciding on the
// post-await view means never seeding again.
const userCount = usersShape.ngSet.size;
const [loadedUsers, loadedParticipations] = await Promise.all([
usersShape.loaded,
participationsShape.loaded,
]);
return bootstrapWallet({
eventCount: eventRows.length,
userCount,
seedEvent: createEventDoc,
ngUsers: (loadedUsers ?? usersShape.ngSet) as any,
ngParticipations: (loadedParticipations ?? participationsShape.ngSet) as any,
});
}, [eventRows.length, createEventDoc, usersShape, participationsShape]);
return {
currentUserId, currentUser,
+88
View File
@@ -0,0 +1,88 @@
/**
* useShapeSet — `useShape` narrowed back down to "just the set", plus the one
* fact the new contract forces callers to know: whether it has loaded.
*
* WHY THIS EXISTS. `@ng-org/orm` changed `useShape`'s contract between alpha.18
* and alpha.21: it used to RETURN the `DeepSignalSet`, and now returns a result
* object that carries it under `data`, alongside `isLoading`, `promise`,
* `subscription` and the paging controls. Every Festipod call site wants the set,
* so the whole adaptation lives HERE, in one place, rather than being spelled out
* at each `useShape(...)`.
*
* AND THE BUMP IS NOT OPTIONAL. `@ng-org/refine-providers` imports
* `RdfOrmSubscription` from `@ng-org/orm`; alpha.18 published that class under
* the name `OrmSubscription` and only alpha.21 exports `RdfOrmSubscription`. So
* using the providers requires alpha.21, which requires absorbing this break.
*
* WHAT CHANGES FOR WRITERS. Under alpha.18 a defined scope handed back a WRITABLE
* set straight away, so a caller could `add()` the moment it had the reference.
* Under alpha.21 `data` is `undefined` until the subscription resolves — there is
* genuinely no set to write to yet. So a writer must WAIT, and `ready` is what
* lets it: `useShapeWithDefaults` surfaces it, and the seeding path gates on it.
* (Only a scope of `undefined` never becomes ready — that is the "no scope"
* case, which alpha.18 also refused writes on.)
*
* The placeholder returned meanwhile refuses writes rather than accepting them
* into a detached `Set`, because a silently dropped seed is far harder to notice
* than a thrown one — it looks exactly like a wallet that persisted nothing.
*/
import { useMemo } from 'react';
import { useShape } from '@ng-org/orm/react';
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
* `useShape` accepts both natively.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
export interface ShapeSubscription<NgT extends BaseType> {
/** The live set, or a write-refusing placeholder until `ready`. */
set: DeepSignalSet<NgT>;
/** `true` once the subscription has produced its set — a write may land. */
ready: boolean;
/**
* Resolves when the subscription has loaded — the ORM's own readiness signal,
* so a writer AWAITS it instead of re-reading in a loop (`rule_no-broker-polling`).
* `undefined` when the scope is `undefined`: there is nothing to wait for.
*/
promise: Promise<unknown> | undefined;
}
/** An empty set that names the problem instead of swallowing a write. */
function notLoadedSet<T>(): DeepSignalSet<T> {
const refuse = (): never => {
throw new Error(
'useShapeSet: the shape subscription has not loaded yet — await `ready` before writing',
);
};
return Object.assign(new Set<T>(), { add: refuse, delete: refuse }) as unknown as DeepSignalSet<T>;
}
/** The set plus its readiness — for callers that WRITE. */
export function useShapeSubscription<NgT extends BaseType>(
shapeType: ShapeType<NgT>,
scope: ShapeScope,
): ShapeSubscription<NgT> {
// Memoised so the placeholder keeps ONE identity for this hook instance: it
// lands in `useCallback` dependency arrays, and a fresh set on every render
// would rebuild every mutation callback for as long as the load takes.
const placeholder = useMemo(() => notLoadedSet<NgT>(), []);
const result = useShape(shapeType as never, scope as never) as unknown as {
data?: DeepSignalSet<NgT>;
promise?: Promise<unknown>;
};
const data = result?.data;
return { set: data ?? placeholder, ready: data !== undefined, promise: result?.promise };
}
/** The set alone — for callers that only READ. */
export function useShapeSet<NgT extends BaseType>(
shapeType: ShapeType<NgT>,
scope: ShapeScope,
): DeepSignalSet<NgT> {
return useShapeSubscription(shapeType, scope).set;
}
+16 -9
View File
@@ -8,22 +8,28 @@
* Must only be called when NG is connected (inside NgDataProvider).
*/
import { useShape } from '@ng-org/orm/react';
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import { useShapeSubscription, type ShapeScope } from './useShapeSet';
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
/** Mapped items from NG store */
items: AppT[];
/** Raw NG signal set for mutations */
ngSet: DeepSignalSet<NgT>;
/**
* `true` once the subscription holds a real set. A WRITER must check this:
* since @ng-org/orm alpha.21 the set does not exist until the subscription
* resolves, so an `ngSet.add(…)` issued before then has nowhere to land.
*/
ready: boolean;
/** Resolves once the subscription has loaded — await it before writing. */
loaded: Promise<unknown> | undefined;
}
/**
* `scope` is either a single store/document NURI (mono-store mode) or a
* `{ graphs }` set of document NURIs (multi-document mode — storeRegistry).
* `useShape` accepts both natively.
*/
export type ShapeScope = string | { graphs: string[] } | undefined;
// `ShapeScope` now belongs to useShapeSet (the module that talks to the ORM);
// it is re-exported here so the existing importers stay unchanged.
export type { ShapeScope };
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
shapeType: ShapeType<NgT>,
@@ -34,9 +40,10 @@ export function useShapeWithDefaults<NgT extends BaseType, AppT>(
): ShapeWithDefaults<NgT, AppT> {
// Mono-store: a single store NURI opens the repo in the verifier (enables
// writes). Multi-document: a { graphs } scope subscribes to several docs.
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
// `useShapeSubscription` is what unwraps the ORM's result object — see that module.
const { set: ngSet, ready, promise: loaded } = useShapeSubscription(shapeType, storeNuri);
const usingDefaults = !shapesReady;
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
return { items, ngSet };
return { items, ngSet, ready, loaded };
}
+49
View File
@@ -0,0 +1,49 @@
/**
* RefineProvider — mounts Refine over the NextGraph providers.
*
* This is the app's new data entry point for the Events vertical: screens keep
* calling `useFestipodData()`, and `FestipodDataContext` now reads and writes
* events through Refine's hooks, which land on `@ng-org/refine-providers`.
*
* 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.
*
* The providers are built ONCE at module scope, as the package's README does.
* Rebuilding the data provider on a render would hand Refine a new provider
* identity and re-run every query behind it.
*/
import type { ReactNode } from 'react';
import { Refine } from '@refinedev/core';
import { dataProvider, liveProvider } from '@ng-org/refine-providers';
import shapeTypes, { RESOURCE_EVENTS } from './shapeTypes';
import { createNgAuthProvider } from './ngAuthProvider';
const ngAuthProvider = createNgAuthProvider();
const ngDataProvider = dataProvider({ shapeTypes, authProvider: ngAuthProvider });
const ngLiveProvider = liveProvider({ shapeTypes });
export function RefineProvider({ children }: { children: ReactNode }) {
return (
<Refine
dataProvider={ngDataProvider}
liveProvider={ngLiveProvider}
authProvider={ngAuthProvider}
resources={[{ name: RESOURCE_EVENTS }]}
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>
);
}
+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,
};
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The Refine RESOURCE MAP — the one place that says which Refine resource name
* stands for which SHEX shape.
*
* `@ng-org/refine-providers` identifies data by shape type, not by endpoint: its
* data provider looks `shapeTypes[resource]` up on every call, so a resource name
* that is absent here silently resolves to `undefined` and the read comes back
* empty. Every `useList` / `useOne` / `useCreate` / `useUpdate` call site must
* therefore name a resource declared below.
*
* Only `events` is wired: the Events vertical is what goes through Refine —
* profiles and participations still read through `useShapeWithDefaults`.
*/
import type { ShapeTypes } from '@ng-org/refine-providers';
import { FpEventShapeType } from '../shapes/orm/festipodShapes.shapeTypes';
/** Refine resource name for Festipod events. Import it — never retype the string. */
export const RESOURCE_EVENTS = 'events';
const shapeTypes: ShapeTypes = {
[RESOURCE_EVENTS]: FpEventShapeType,
};
export default shapeTypes;
+13 -2
View File
@@ -325,13 +325,24 @@ async function ensureAuth(): Promise<void> {
BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
console.log('Starting Festipod BDD tests...');
// `--format=iife` IS LOad-BEARING, not tidiness. Both bundles are loaded as
// CLASSIC scripts (`<script src="/harness.js">` for the NG harness,
// `addScriptTag({path})` for the mock one), so every top-level declaration in
// an unwrapped bundle becomes a GLOBAL binding. `@tanstack/query-core` — pulled
// in by Refine — exports a class named `MutationObserver`, which then shadows
// the DOM's for anything that runs afterwards in that page. Playwright's own
// injected script does `new MutationObserver(...)`, so it got TanStack's and
// every `waitForFunction` died with "defaultMutationOptions is not a function".
// The IIFE wrapper keeps the bundle's declarations to itself; `window.__testData`
// is still published explicitly, so the bridge is unaffected.
// Build the mock harness (always needed for non-@data or fallback)
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle`, { stdio: 'pipe' });
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle --format=iife`, { stdio: 'pipe' });
console.log(`[Harness] Built mock (${(fs.statSync(HARNESS_OUT).size / 1024).toFixed(0)} KB)`);
// Try to build the real broker harness
try {
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle --format=iife`, { stdio: 'pipe' });
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
// Ensure wallet exists in persistent profile (opens browser if needed)
+29 -18
View File
@@ -10,8 +10,9 @@
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { RefineProvider } from '../refine/RefineProvider';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
import { useShape } from '@ng-org/orm/react';
import { useShapeSet } from '../hooks/useShapeSet';
import { ng } from '@ng-org/web';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import {
@@ -21,7 +22,6 @@ import {
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { seedEvents, seedUsers, seedParticipations } from '../data/seedData';
import { bootstrapWallet } from '../utils/ngBootstrap';
import { ensureGraphNuri } from '../utils/ngGraph';
// ============================================================================
@@ -31,9 +31,13 @@ import { ensureGraphNuri } from '../utils/ngGraph';
function DataHarnessNG() {
return (
<NextGraphProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
{/* Same order as the real app (app/App.tsx): the data context reads events
through Refine's hooks, so it cannot mount outside <Refine>. */}
<RefineProvider>
<FestipodDataProvider>
<HarnessRouter />
</FestipodDataProvider>
</RefineProvider>
</NextGraphProvider>
);
}
@@ -63,9 +67,9 @@ function ConnectedHarness() {
// Use private store NURI as scope (opens the repo for reads AND writes)
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
const events = useShape(FpEventShapeType, privateNuri) as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, privateNuri) as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
const events = useShapeSet(FpEventShapeType, privateNuri);
const users = useShapeSet(FpUserProfileShapeType, privateNuri);
const participations = useShapeSet(FpParticipationShapeType, privateNuri);
const [bridgeReady, setBridgeReady] = useState(false);
// Stopgap multi-store validation: a doc created on demand via doc_create,
@@ -79,12 +83,10 @@ function ConnectedHarness() {
const timer = setTimeout(() => {
const session = ngCtx.session!;
// Get current user ID
let currentUserId = '';
const existingUsers = [...users];
if (existingUsers.length > 0) {
currentUserId = existingUsers[0]['@id'];
}
// Who the tests act as. The APP's own answer comes first — the bridge
// should not invent an identity the screens would not use — and the first
// profile in the set is the fallback while the context is still resolving.
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
// Expose the test bridge
(window as any).__testData = {
@@ -148,9 +150,18 @@ function ConnectedHarness() {
}
},
/** Load the app's default seed data into the wallet */
/**
* Load the app's default seed data into the wallet.
*
* Delegates to the data context instead of calling `bootstrapWallet`
* directly: seeding events now goes through Refine's `create` (one
* document per event), and the callback that does it lives in the
* context. Calling the bootstrap from here with raw ORM sets would seed
* by a DIFFERENT path than the app's — which is the divergence this
* harness exists to avoid.
*/
loadTestData() {
return bootstrapWallet(events as any, users as any, participations as any);
return appData.loadTestData();
},
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
@@ -226,7 +237,7 @@ function ConnectedHarness() {
// ============================================================================
function FanoutProbe({ graphs }: { graphs: string[] }) {
const set = useShape(FpEventShapeType, { graphs } as any) as DeepSignalSet<FpEvent>;
const set = useShapeSet(FpEventShapeType, { graphs });
useEffect(() => {
(window as any).__fanout = {
ready: true,
@@ -253,7 +264,7 @@ function FanoutProbe({ graphs }: { graphs: string[] }) {
// ============================================================================
function SmokeProbe({ docNuri }: { docNuri: string }) {
const set = useShape(FpParticipationShapeType, { graphs: [docNuri] } as any) as DeepSignalSet<FpParticipation>;
const set = useShapeSet(FpParticipationShapeType, { graphs: [docNuri] });
useEffect(() => {
(window as any).__smoke = {
ready: true,
+43
View File
@@ -0,0 +1,43 @@
import { expect, test, afterEach } from 'bun:test';
import { autoSeedEnabled, shouldAutoSeed } from './autoSeed';
// The gate is driven by the build/runtime-injected global `__FESTIPOD_AUTO_SEED__`
// (see autoSeed.ts / build.ts / frontend.tsx). Drive it directly here.
const g = globalThis as Record<string, unknown>;
afterEach(() => {
delete g['__FESTIPOD_AUTO_SEED__'];
});
test('seed-gate DEFAULT (env var absent) → auto-seed disabled', () => {
delete g['__FESTIPOD_AUTO_SEED__'];
expect(autoSeedEnabled()).toBe(false);
// Even on a genuinely EMPTY wallet, the gate must NOT trigger a seed.
expect(shouldAutoSeed(false)).toBe(false);
});
test('seed-gate empty string (env unset → "") → disabled', () => {
g['__FESTIPOD_AUTO_SEED__'] = '';
expect(autoSeedEnabled()).toBe(false);
expect(shouldAutoSeed(false)).toBe(false);
});
test('seed-gate ENABLED ("1") on empty wallet → seed triggered', () => {
g['__FESTIPOD_AUTO_SEED__'] = '1';
expect(autoSeedEnabled()).toBe(true);
// Empty wallet + gate on → seed.
expect(shouldAutoSeed(false)).toBe(true);
});
test('seed-gate accepts "true" and boolean true', () => {
g['__FESTIPOD_AUTO_SEED__'] = 'true';
expect(autoSeedEnabled()).toBe(true);
g['__FESTIPOD_AUTO_SEED__'] = true;
expect(autoSeedEnabled()).toBe(true);
});
test('seed-gate ENABLED but wallet already has data → NO seed', () => {
g['__FESTIPOD_AUTO_SEED__'] = '1';
// Non-empty wallet must never be re-seeded, even with the gate on.
expect(shouldAutoSeed(true)).toBe(false);
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Auto-seed gate.
*
* On a genuinely-empty wallet the app CAN bootstrap demo data (see
* ngBootstrap.bootstrapWallet, driven by the auto-seed effect in
* FestipodDataContext). This used to fire unconditionally in dev, which bloats a
* persistent wallet over repeated runs. It is now OFF BY DEFAULT and only runs
* when explicitly enabled via the env var `FESTIPOD_AUTO_SEED` (= "1").
*
* Same compile-time plumbing as the shared-wallet password (sharedWallet.ts):
* `build.ts` `define`s the global from the env var. Absent env → global
* undefined → seed OFF, which is the whole point: OFF needs no plumbing at all.
*
* RECOVERED FROM THE ng-eventually BRANCH, with two differences that branch does
* not have.
*
* TURNING IT ON ONLY REACHES A BUILT BUNDLE. `bun run dev` and `bun run start`
* both serve from `src/` (`src/index.ts` imports `index.html`), so the `define`
* in `build.ts` never reaches them — only `dist-staging/`, which the @e2e harness
* builds, is affected. ng-eventually closes that gap with a
* `/festipod-config.json` route read by `frontend.tsx` before the app loads;
* recovering it here means making the app bootstrap async, which is not needed
* for the gate's purpose (stopping the dev seed from bloating a persistent
* wallet) and is therefore left out until someone actually wants to opt in.
*
* `fixtureSeedEnabled()` BELOW IS DELIBERATELY NOT ENFORCED in `bootstrapWallet`
* here, and it is not a decision left pending: the intent behind it — stated
* 2026-09-21 — was to stop the dev app from creating fixtures BY ITSELF, and
* using fixtures in TESTS was never a problem. `autoSeedEnabled()` above is
* exactly that intent. The master switch overshoots it, because it also silences
* an explicit `loadTestData()`; on ng-eventually that cost the @data suite its
* fixtures (a caveat still open there). So the gate that governs the automatic
* seed is the one wired, and an explicit seed still writes.
*/
/**
* MASTER SWITCH — may a fixture seed be written into the connected wallet AT ALL?
*
* Currently OFF, a product decision: no fixture reaches the connected wallet by
* any route — neither the opt-in auto-seed (`FESTIPOD_AUTO_SEED`, whose gate
* below is subordinate to this one) nor an explicit `loadTestData()`.
*
* OFF, not deleted. The fixtures (`data/seedData.ts`) and the seeding code stay,
* because two paths still need them and NEITHER writes to a wallet: the
* demo/disconnected provider (`LocalDataProvider`) reads the fixtures straight
* into React state, and the rendering-layer tests read them directly too. Only
* the connected wallet is closed off.
*
* Enforced at the single place fixtures enter a wallet — `bootstrapWallet`
* (`utils/ngBootstrap.ts`) — so no caller can bypass it by construction; the
* call sites consult it too, only so they neither log nor await work that will
* not happen.
*/
export const fixtureSeedEnabled = (): boolean => false;
// Build-injected global (not `process.env`, absent in the browser); any path that
// doesn't inject it reads `undefined` → false safely (no ReferenceError).
declare global {
// eslint-disable-next-line no-var
var __FESTIPOD_AUTO_SEED__: string | boolean | undefined;
}
/** Truthy value of the auto-seed flag ("1"/"true"/true → enabled). */
function readFlag(): boolean {
const v = globalThis.__FESTIPOD_AUTO_SEED__;
if (v === true) return true;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === '1' || s === 'true';
}
return false;
}
/**
* Whether the app's AUTOMATIC empty-wallet seed is enabled. Read at call time so
* the runtime config set by frontend.tsx (dev/start) is picked up even though it
* lands after this module first evaluates.
*/
export const autoSeedEnabled = (): boolean => readFlag();
/**
* The seed-gate decision: should the app AUTO-SEED demo data right now? True ONLY
* when the gate is enabled (`FESTIPOD_AUTO_SEED`) AND the wallet is genuinely empty
* (no data to preserve). `walletHasData` is the caller's "synced and non-empty"
* signal. Extracted as a pure predicate so the gate is unit-testable in isolation
* from the React effect that consumes it.
*/
export function shouldAutoSeed(walletHasData: boolean): boolean {
if (!autoSeedEnabled()) return false;
return !walletHasData;
}
+74 -46
View File
@@ -3,10 +3,20 @@
*
* Called once after NG connection + shapes ready. If the wallet already
* has events/users, it's a returning user — skip seeding.
*
* EVENTS ARE SEEDED THROUGH REFINE, not through an ORM set like users and
* participations. They have to be: the Refine data provider derives a record's
* `id` from its `@graph`, and it creates a fresh document on every `create`.
* Seeding events the old way — all of them into one shared graph — would give
* every seeded event the SAME Refine id, so the list would collide on itself and
* an update would address the whole document instead of one event. The seeder is
* injected rather than imported because `useCreate` is a React hook and this
* module runs outside the component tree.
*/
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import type { FpEventData } from '../data/types';
import { ensureGraphNuri } from './ngGraph';
import {
seedEvents,
@@ -20,6 +30,29 @@ export interface BootstrapResult {
eventIdMap: Map<string, string>;
}
/** Writes ONE event as its own document and returns the stored row (or nothing). */
export type EventSeeder = (event: Omit<FpEventData, 'id'>) => Promise<FpEvent | undefined>;
export interface BootstrapDeps {
/**
* How many events the Refine-backed read already sees. This is the
* "returning user" check that `ngEvents.size` used to answer — the caller owns
* it now, because the events no longer come from a set this module can count.
*/
eventCount: number;
/**
* How many profiles the caller saw WHEN IT DECIDED TO SEED — not when the
* write finally starts. The two differ now that seeding awaits the ORM
* subscription: a caller that just emptied the wallet hands over 0, while the
* set it awaits has meanwhile re-synced whatever the broker still holds.
* Reading `ngUsers.size` here would consult the second and never seed again.
*/
userCount: number;
seedEvent: EventSeeder;
ngUsers: DeepSignalSet<FpUserProfile>;
ngParticipations: DeepSignalSet<FpParticipation>;
}
/**
* Flush ORM microtask batch and give the broker time to process.
*
@@ -33,27 +66,37 @@ async function flushAndWait(ms = 100): Promise<void> {
}
export async function bootstrapWallet(
ngEvents: DeepSignalSet<FpEvent>,
ngUsers: DeepSignalSet<FpUserProfile>,
ngParticipations: DeepSignalSet<FpParticipation>,
{ eventCount, userCount, seedEvent, ngUsers, ngParticipations }: BootstrapDeps,
): Promise<BootstrapResult> {
// Already has data → returning user, nothing to seed
if (ngEvents.size > 0 || ngUsers.size > 0) {
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
'users:', ngUsers.size, 'participations:', ngParticipations.size);
// Already has data → returning user, nothing to seed. Both counts come from
// the CALLER's view at decision time — see `userCount`.
if (eventCount > 0 || userCount > 0) {
console.log('[Bootstrap] Wallet already has data — events:', eventCount,
'users:', userCount, 'participations:', ngParticipations.size);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
}
console.log('[Bootstrap] First time for this wallet — seeding default data...');
// Create (or get) a document in the private store for our data.
// The ORM requires a real document NURI as @graph, not the store ID.
const graph = await ensureGraphNuri(ngEvents, ngUsers, ngParticipations);
// Create (or get) a document in the private store for users + participations.
// The ORM requires a real document NURI as @graph, not the store ID. Events
// are NOT placed here: each one gets its own document, created by `seedEvent`.
const graph = await ensureGraphNuri(ngUsers, ngParticipations);
console.log('[Bootstrap] Using graph NURI:', graph);
// Seed users — one at a time with ORM flush between each
// Seed users — one at a time with ORM flush between each.
//
// ONLY A FEW, not all fourteen. The connected seed is LINEAR in the number of
// documents (one document per entity, each a serial round trip), so it writes
// only what is needed. Nothing in the @data layer looks a seeded profile up by
// name; what it needs is that SOME profile exists — one to answer
// `currentUserId`, and a non-empty set for "the wallet contains users". The
// first three are kept rather than one, so a participants list has somebody to
// show, and `@mariedupont` is among them because `FestipodDataContext` falls
// back to her when the account layer has not resolved.
const SEEDED_PROFILES = 3;
const userIdMap = new Map<string, string>();
for (const u of seedUsers) {
for (const u of seedUsers.slice(0, SEEDED_PROFILES)) {
ngUsers.add({
"@graph": graph,
"@type": "http://festipod.org/UserProfile",
@@ -70,46 +113,31 @@ export async function bootstrapWallet(
}
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
// Seed events — one at a time with ORM flush between each
// Seed events — one at a time, each into its OWN document via Refine.
// The map is keyed on the seed id and holds the SUBJECT IRI, because that is
// what the participations below reference (`fp:event`).
const eventIdMap = new Map<string, string>();
for (const e of seedEvents) {
ngEvents.add({
"@graph": graph,
"@type": "http://festipod.org/Event",
"@id": "",
title: e.title,
description: e.description,
date: e.date,
location: e.location,
distance: e.distance,
participantCount: e.participantCount,
coverImage: e.coverImage,
hostName: e.hostName,
hostInitials: e.hostInitials,
} as FpEvent);
const stored = await seedEvent(e);
await flushAndWait();
const added = [...ngEvents].find(ne => ne.title === e.title);
if (added) eventIdMap.set(e.id, added["@id"]);
const iri = stored?.["@id"];
if (iri) {
eventIdMap.set(e.id, iri);
} else {
// Loud, not silent: without a subject IRI the participations that point at
// this event would be written against the SEED id, which nothing resolves.
console.warn('[Bootstrap] Event seeded without a subject IRI — participations '
+ 'referencing it will not resolve:', e.title);
}
}
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
// Seed participations with mapped IDs — one at a time
let partCount = 0;
for (const p of seedParticipations) {
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
const userIri = userIdMap.get(p.userId) || p.userId;
ngParticipations.add({
"@graph": graph,
"@type": "http://festipod.org/Participation",
"@id": "",
event: eventIri,
user: userIri,
isConfirmed: p.isConfirmed,
} as FpParticipation);
await flushAndWait();
partCount++;
}
console.log('[Bootstrap] Seeded', partCount, 'participations');
// NO PARTICIPATIONS. The sign-up scenarios create theirs live, which is the
// behaviour they exist to exercise, and nothing asserts on a seeded one: a
// scenario's starting participant count is SET on the event
// (`updateEvent({ participantCount })`) and read back from it, never counted
// from Participation documents. Writing five here would cost five more serial
// round trips to furnish something no test looks at.
return { seeded: true, userIdMap, eventIdMap };
}
+3 -1
View File
@@ -70,7 +70,9 @@ export async function logoutNg(): Promise<void> {
}
export interface NextGraphSession {
ng: typeof NG;
// `NG` IS the type of the SDK object, not a value whose type we could take.
// `typeof NG` was a type-level error that the broken typecheck gate hid.
ng: NG;
session_id: string;
protected_store_id: string;
private_store_id: string;
+4 -1
View File
@@ -21,7 +21,10 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
// No `baseUrl`: deprecated in TypeScript 6, where it is reported as an ERROR
// that aborts the whole compilation — `tsc --noEmit` then exits 0 having
// checked nothing, so the typecheck gate silently passes everything. `paths`
// resolves relative to this file since TS 4.4, so dropping it changes nothing.
"paths": {
"@/*": ["./src/*"]
},