Compare commits
13 Commits
main
...
4b4dfa6cad
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4dfa6cad | |||
| 974d251079 | |||
| fd02570b8b | |||
| efdfcac4c1 | |||
| 750538bd73 | |||
| 20b3ef8712 | |||
| ff285455d2 | |||
| 35df3e9110 | |||
| db483726af | |||
| 46abbf2e64 | |||
| 753282c7b1 | |||
| d237753916 | |||
| 33d54c6e3e |
@@ -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,
|
||||
});
|
||||
|
||||
+16
-8
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+6165
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/*"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user