feat(data): union read model — list via anchorless sparql_query, hang eliminated
Replace the reactive-ORM per-entity fan-out read (which HUNG 75s: orm_start_graph opened every scope graph and RepoNotFound on any fresh/unsynced doc aborted the subscription) with the read model: - readEntities.ts → lib readUnion: resolve the by-need doc set (my own scope docs via listMyEntityDocs + public events via the discovery index — NOT all-accounts fan-out), then ONE anchorless union sparql_query (GRAPH ?g, VALUES-pinned). Map to app types. Re-query on a change signal (no reactive union query). - countUserParticipations no longer fans out over all accounts (own docs only). - await loadTestData in the seed step; deleted orphaned useShapeWithDefaults; removed the old multistore-stopgap fan-out scenarios; added the read-model-probe. - Doctrine: rule_document-per-entity read half + _overview rewritten to the union model (write half unchanged). Result: the 75s ORM hang is ELIMINATED (0 hangs; build/tsc/lib-93-tests green; boundary clean). @data is NOT yet fully green: remaining failures are 90s step timeouts in the test-harness broker data ops (clearWallet / runUnionProbe / seed) this run — a harness/broker-op issue, not the read path. To finish separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,9 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
// Ensure wallet has data (seed if empty)
|
||||
await this.appFrame!.evaluate(() => {
|
||||
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(
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# language: fr
|
||||
@WORKSHOP @priority-1
|
||||
Fonctionnalité: Stopgap multi-store — primitives de données
|
||||
En tant que développeur
|
||||
Je veux valider, contre le vrai broker NextGraph, les primitives du stopgap
|
||||
wallet partagé (création de documents, ORM sur un document créé, aller-retour
|
||||
du sharedWalletShim) avant d'activer le mode multi-document.
|
||||
|
||||
# --- Data (broker réel) ---
|
||||
|
||||
@data
|
||||
Scénario: L'ORM lit et écrit dans un document créé par doc_create
|
||||
Étant donné un nouveau document de graphe est créé dans le wallet partagé
|
||||
Quand j'écris une participation dans ce document via l'ORM
|
||||
Alors la participation est lisible dans ce document
|
||||
|
||||
@data
|
||||
Scénario: Le sharedWalletShim fait l'aller-retour par le wallet
|
||||
Étant donné un compte "@smoketest" est enregistré dans le shim
|
||||
Alors le compte "@smoketest" est retrouvé après rechargement du shim
|
||||
Et le compte "@smoketest" possède trois documents de périmètre distincts
|
||||
|
||||
@data
|
||||
Scénario: Lecture fan-out sur plusieurs documents d'entité (1 doc par entité)
|
||||
Étant donné deux comptes ayant chacun un document d'événement indexé
|
||||
Quand j'écris un événement dans chacun de ces deux documents
|
||||
Alors un abonnement multi-graphes lit les deux événements ensemble
|
||||
Et l'index public liste les deux documents
|
||||
@@ -0,0 +1,16 @@
|
||||
# language: fr
|
||||
# THROWAWAY probe (T03.k) — pins the read-model union premise on the REAL broker.
|
||||
# Remove after the read-model refactor lands.
|
||||
@data @probe
|
||||
Fonctionnalité: Probe du modèle de lecture (union locale sparql_query)
|
||||
|
||||
# VERIFIED on the real broker (T03.k): a GRAPH ?g { } body sans anchor voit
|
||||
# l'UNION LOCALE de tous les graphes synchronisés — c'est la prémisse du modèle
|
||||
# de lecture (listing = open/sync + une seule requête union sans anchor). Un
|
||||
# corps GRAPH ?g explicite itère sur TOUS les graphes nommés indépendamment du
|
||||
# graphe par défaut : l'anchor ne restreint donc PAS un tel motif (il ne borne
|
||||
# que le graphe par défaut). Le modèle n'a besoin que de l'union sans anchor.
|
||||
Scénario: sparql_query sans anchor renvoie l'union locale des graphes synchronisés
|
||||
Étant donné deux documents A et B contenant chacun un triplet distinct
|
||||
Quand j'interroge l'union locale sans anchor
|
||||
Alors la requête sans anchor voit A et B
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Data-layer validation of the shared-wallet multi-store stopgap, against the
|
||||
// REAL broker. Exercises the exact app mechanisms: doc_create, a real
|
||||
// useShape({graphs}) on the created doc (window.__smoke, via <SmokeProbe>), and
|
||||
// the sharedWalletShim round-trip (window.__testData.validateShim).
|
||||
// See brief_2026-06-15_shared-wallet-shim.
|
||||
|
||||
// --- Scenario 1: ORM on a doc_create'd document ---
|
||||
|
||||
Given('un nouveau document de graphe est créé dans le wallet partagé', async function (this: FestipodWorld) {
|
||||
const nuri = await this.appFrame!.evaluate(async () => {
|
||||
return await (window as any).__testData.createSmokeDoc();
|
||||
});
|
||||
expect(nuri, 'doc_create should return a NURI').to.be.a('string');
|
||||
expect((nuri as string).length, 'doc_create NURI should be non-empty').to.be.greaterThan(0);
|
||||
// Wait for <SmokeProbe> to mount the useShape({graphs}) and expose __smoke.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__smoke?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('j\'écris une participation dans ce document via l\'ORM', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.evaluate(() => (window as any).__smoke.add());
|
||||
});
|
||||
|
||||
Then('la participation est lisible dans ce document', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__smoke.count() >= 1,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const items = await this.appFrame!.evaluate(() => (window as any).__smoke.items());
|
||||
expect(items.length, 'participation should be readable via ORM on the created doc').to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
// --- Scenario 2: sharedWalletShim round-trip ---
|
||||
|
||||
Given('un compte {string} est enregistré dans le shim', async function (this: FestipodWorld, username: string) {
|
||||
const res = await this.appFrame!.evaluate(
|
||||
async (u) => await (window as any).__testData.validateShim(u),
|
||||
username,
|
||||
);
|
||||
(this as any).shimResult = res;
|
||||
expect(res?.created, 'ensureAccount should return a record').to.exist;
|
||||
});
|
||||
|
||||
Then('le compte {string} est retrouvé après rechargement du shim', function (this: FestipodWorld, username: string) {
|
||||
const res = (this as any).shimResult;
|
||||
expect(res?.reloaded, `account ${username} should reload from the wallet shim`).to.exist;
|
||||
expect(res.reloaded.username).to.equal(username);
|
||||
});
|
||||
|
||||
Then('le compte {string} possède trois documents de périmètre distincts', function (this: FestipodWorld, _username: string) {
|
||||
const r = (this as any).shimResult?.reloaded;
|
||||
expect(r, 'reloaded account should exist').to.exist;
|
||||
const docs = [r.docPublic, r.docProtected, r.docPrivate];
|
||||
for (const d of docs) {
|
||||
expect(d, 'each scope doc should be a string').to.be.a('string');
|
||||
expect((d as string).length, 'each scope doc NURI should be non-empty').to.be.greaterThan(0);
|
||||
}
|
||||
expect(new Set(docs).size, 'the three scope docs must be distinct').to.equal(3);
|
||||
});
|
||||
|
||||
// --- Scenario 3: per-entity granularity + multi-graph fan-out ---
|
||||
|
||||
Given('deux comptes ayant chacun un document d\'événement indexé', async function (this: FestipodWorld) {
|
||||
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.setupFanout());
|
||||
(this as any).fanout = res;
|
||||
expect(res.docA, 'event doc A').to.be.a('string');
|
||||
expect(res.docB, 'event doc B').to.be.a('string');
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__fanout?.ready === true,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
});
|
||||
|
||||
When('j\'écris un événement dans chacun de ces deux documents', async function (this: FestipodWorld) {
|
||||
const { docA, docB } = (this as any).fanout;
|
||||
await this.appFrame!.evaluate(
|
||||
([a, b]: [string, string]) => {
|
||||
(window as any).__fanout.addEventTo(a, 'FanA');
|
||||
(window as any).__fanout.addEventTo(b, 'FanB');
|
||||
},
|
||||
[docA, docB] as [string, string],
|
||||
);
|
||||
});
|
||||
|
||||
Then('un abonnement multi-graphes lit les deux événements ensemble', async function (this: FestipodWorld) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__fanout.count() >= 2,
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
|
||||
expect(titles, 'fan-out should read event from doc A').to.include('FanA');
|
||||
expect(titles, 'fan-out should read event from doc B').to.include('FanB');
|
||||
});
|
||||
|
||||
Then('l\'index public liste les deux documents', function (this: FestipodWorld) {
|
||||
const { docA, docB, listed } = (this as any).fanout;
|
||||
expect(listed, 'public index should list doc A').to.include(docA);
|
||||
expect(listed, 'public index should list doc B').to.include(docB);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// THROWAWAY probe steps (T03.k) — assert the read-model union premise against the
|
||||
// REAL broker via window.__testData.runUnionProbe (harness-ng). Remove with the
|
||||
// feature after the read-model refactor lands.
|
||||
|
||||
Given('deux documents A et B contenant chacun un triplet distinct', async function (this: FestipodWorld) {
|
||||
const res = await this.appFrame!.evaluate(async () => await (window as any).__testData.runUnionProbe());
|
||||
(this as any).unionProbe = res;
|
||||
expect(res?.docA, 'doc A NURI').to.be.a('string');
|
||||
expect(res?.docB, 'doc B NURI').to.be.a('string');
|
||||
});
|
||||
|
||||
When("j'interroge l'union locale sans anchor", function (this: FestipodWorld) {
|
||||
// The probe ran the query inside runUnionProbe; nothing more to do here.
|
||||
expect((this as any).unionProbe, 'probe result').to.exist;
|
||||
});
|
||||
|
||||
Then('la requête sans anchor voit A et B', function (this: FestipodWorld) {
|
||||
const r = (this as any).unionProbe;
|
||||
expect(r.unionHasA, `union must see A (objs=${JSON.stringify(r.unionObjs)})`).to.equal(true);
|
||||
expect(r.unionHasB, `union must see B (objs=${JSON.stringify(r.unionObjs)})`).to.equal(true);
|
||||
});
|
||||
@@ -27,16 +27,10 @@ import {
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { useAccount, normalizeUsername } from './AccountContext';
|
||||
import { declareConnections } from '@ng-eventually/client/polyfill';
|
||||
import { listEntityDocs, createEntityDoc } from '../utils/storeRegistry';
|
||||
import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry';
|
||||
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
|
||||
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
|
||||
import { readEntities } from '../data/readEntities';
|
||||
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
|
||||
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';
|
||||
|
||||
// ============================================================================
|
||||
@@ -76,7 +70,7 @@ interface FestipodDataContextValue {
|
||||
leaveEvent(eventId: string, userId?: string): Promise<void> | void;
|
||||
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
|
||||
addFriend(friendId: string): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
|
||||
loadTestData(): Promise<BootstrapResult>;
|
||||
}
|
||||
|
||||
@@ -91,42 +85,7 @@ function nextId(prefix: string): string {
|
||||
return `${prefix}-${++idCounter}`;
|
||||
}
|
||||
|
||||
function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
|
||||
for (const item of set) {
|
||||
if (predicate(item)) return item;
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
const mapUser = (u: FpUserProfile): FpUserData => ({
|
||||
id: u["@id"],
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
});
|
||||
|
||||
const mapParticipation = (p: FpParticipation): FpParticipationData => ({
|
||||
id: p["@id"],
|
||||
eventId: p.event,
|
||||
userId: p.user,
|
||||
isConfirmed: p.isConfirmed,
|
||||
});
|
||||
// NG shape → app type mapping now lives in `../data/readEntities` (union read).
|
||||
|
||||
// ============================================================================
|
||||
// Shared queries builder — same logic for both local and NG modes
|
||||
@@ -253,110 +212,110 @@ function useNgData(): FestipodDataContextValue {
|
||||
const { username } = useAccount();
|
||||
// The app speaks ONLY in logical scopes — it holds no store id and builds no
|
||||
// `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope
|
||||
// (`createEntityDoc(scope)`, the SDK create) and reads a scope by subscribing
|
||||
// to the set of its per-entity documents (`listEntityDocs(scope)`). The SDK
|
||||
// owns the physical placement AND the per-document isolation — the app carries
|
||||
// no access logic (see rule_document-per-entity, knowledge_trust-model).
|
||||
// (`createEntityDoc(scope)`, the SDK create). It READS by NEED: it asks the SDK
|
||||
// for the document NURIs it may read (its own scope docs via `listEntityDocs`,
|
||||
// the discovery index via `readDiscoveredEvents`) and hands them to the SDK's
|
||||
// UNION READ (`readEntities` → `readModel.readUnion`) — the SDK opens/syncs the
|
||||
// docs and runs ONE anchorless union `sparql_query`. There is NO reactive union
|
||||
// query, so reactivity = RE-QUERY on a change signal (see `bumpRead`). This
|
||||
// replaces the OLD reactive-ORM fan-out (`useShape({ graphs })`), which HUNG
|
||||
// ~75s on a per-entity fan-out (see readEntities.ts, SDK docs/read-model.md).
|
||||
// `ready` gates the effects on the session.
|
||||
const ready = !!session;
|
||||
|
||||
// Per-entity document sets, by scope (the SDK create appends here immediately
|
||||
// so a freshly-created entity is visible without waiting for a re-list). Events
|
||||
// → public; profiles + participations → protected. Seeded from listEntityDocs.
|
||||
// The by-need document set to READ (union), by scope. Events → public (my own +
|
||||
// the index-discovered ones); profiles + participations → protected (my own).
|
||||
// A freshly-created entity's doc is registered here immediately (reactivity).
|
||||
const [publicDocs, setPublicDocs] = useState<string[]>([]);
|
||||
const [protectedDocs, setProtectedDocs] = useState<string[]>([]);
|
||||
// Re-query signal: bumped after every mutation / doc registration so the union
|
||||
// read re-runs and picks up the change (there is no reactive union query).
|
||||
const [readTick, setReadTick] = useState(0);
|
||||
const bumpRead = useCallback(() => setReadTick(t => t + 1), []);
|
||||
|
||||
/** Add a freshly-created entity document to its scope's live subscription set
|
||||
* (reactivity: the new doc joins the useShape graphs immediately). */
|
||||
/** Add a freshly-created entity document to its scope's read set AND trigger a
|
||||
* re-query (reactivity: the new doc joins the union read immediately). */
|
||||
const registerDoc = useCallback((scope: 'public' | 'protected', nuri: string) => {
|
||||
const setter = scope === 'public' ? setPublicDocs : setProtectedDocs;
|
||||
setter(prev => (prev.includes(nuri) ? prev : [...prev, nuri]));
|
||||
setReadTick(t => t + 1);
|
||||
}, []);
|
||||
|
||||
// Resolve the by-need doc NURIs — READ BY NEED, never an all-accounts fan-out
|
||||
// (the OLD `listEntityDocs('public'|'protected')` enumerated EVERY account and
|
||||
// tried to open/sync other accounts' unsynced docs → HANG ~75s; see
|
||||
// read-model.md). Two bounded sources:
|
||||
// • PUBLIC events (all) → the GLOBAL DISCOVERY INDEX only (`readDiscoveredEvents`,
|
||||
// the ONE sanctioned enumeration): it yields the public event-doc NURIs to
|
||||
// open/sync. No account fan-out for events.
|
||||
// • MY OWN entities (my profile, my participations) → MY OWN account's scope
|
||||
// docs only (`listMyEntityDocs(username, scope)`, bounded to the current
|
||||
// account — NO cross-account enumeration). Freshly-created docs are already
|
||||
// tracked locally via `registerDoc`, so this only backfills on (re)login.
|
||||
// The app never fans out an ORM subscription; it collects NURIs to hand to the
|
||||
// union read. Union with locally-registered docs so a just-created doc isn't
|
||||
// dropped before the re-list catches up.
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [pub, prot] = await Promise.all([
|
||||
listEntityDocs('public'),
|
||||
listEntityDocs('protected'),
|
||||
// Owner key = the account username (what `createEntityDoc`/`setCurrentUser`
|
||||
// key on). No login (dev/demo) → no "my" docs to backfill; the discovery
|
||||
// index still yields public events.
|
||||
const owner = username;
|
||||
const [myProtected, discovered] = await Promise.all([
|
||||
owner ? listMyEntityDocs(owner, 'protected') : Promise.resolve<string[]>([]),
|
||||
readDiscoveredEvents(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
// Union with any docs already registered locally (don't drop a doc the
|
||||
// user just created before the re-list caught up).
|
||||
setPublicDocs(prev => [...new Set([...prev, ...pub])]);
|
||||
setProtectedDocs(prev => [...new Set([...prev, ...prot])]);
|
||||
const discDocs = discovered.map(r => r.doc).filter(Boolean) as string[];
|
||||
// My own public event docs (bounded to my account) so a host reads back
|
||||
// their own events even before the discovery index materializes.
|
||||
const myPublic = owner ? await listMyEntityDocs(owner, 'public') : [];
|
||||
if (cancelled) return;
|
||||
setPublicDocs(prev => [...new Set([...prev, ...myPublic, ...discDocs])]);
|
||||
setProtectedDocs(prev => [...new Set([...prev, ...myProtected])]);
|
||||
setReadTick(t => t + 1);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] entity-doc listing failed:', err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, username]);
|
||||
|
||||
// --- Public discovery (T03.c): read the GLOBAL INDEX ----------------------
|
||||
// Discovery is "read the global index" (the SDK read). The app asks the SDK
|
||||
// for the discovered public event references and subscribes to the documents
|
||||
// they point at — a user sees other accounts' public events *without a
|
||||
// connection* (Alice sees Bob's public event even if they're not friends).
|
||||
// The SDK owns the index entirely (how it's stored, who hosts it, how a
|
||||
// submission is materialized); the app holds NO index document NURI / store id
|
||||
// and never fans out over accounts. Making an event discoverable is the
|
||||
// symmetric SDK act on createEvent (`submitEventToIndex`).
|
||||
//
|
||||
// Additive & non-regressive: runs in BOTH modes but only contributes when the
|
||||
// index has entries. In the default path the index is empty (nothing was ever
|
||||
// submitted → []), so the discovery shape stays empty and the base `events`
|
||||
// read is untouched. When events HAVE been submitted, discovery unions them in.
|
||||
const [discoveryGraphs, setDiscoveryGraphs] = useState<string[]>([]);
|
||||
// --- The UNION READ (replaces the reactive ORM fan-out) -------------------
|
||||
// Open/sync the by-need docs and run ONE anchorless union query via the SDK,
|
||||
// mapped to app types. Re-runs whenever the doc set or the re-query tick
|
||||
// changes. `readReady` flips true after the first read so the empty state
|
||||
// isn't mistaken for "wallet empty" by the auto-seed.
|
||||
const [events, setEvents] = useState<FpEventData[]>([]);
|
||||
const [users, setUsers] = useState<FpUserData[]>([]);
|
||||
const [participations, setParticipations] = useState<FpParticipationData[]>([]);
|
||||
const [readReady, setReadReady] = useState(false);
|
||||
const allReadDocs = React.useMemo(
|
||||
() => [...new Set([...publicDocs, ...protectedDocs])],
|
||||
[publicDocs, protectedDocs],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const refs = await readDiscoveredEvents(); // reads the SDK global index
|
||||
const docs = [...new Set(refs.map(r => r.doc).filter(Boolean))];
|
||||
if (!cancelled) setDiscoveryGraphs(docs);
|
||||
const { events: ev, users: us, participations: pa } = await readEntities(allReadDocs);
|
||||
if (cancelled) return;
|
||||
setEvents(ev);
|
||||
setUsers(us);
|
||||
setParticipations(pa);
|
||||
setReadReady(true);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] index-based discovery failed:', err);
|
||||
console.error('[FestipodData] union read failed:', err);
|
||||
if (!cancelled) setReadReady(true);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [ready, username]);
|
||||
const discoveryScope: ShapeScope = discoveryGraphs.length ? { graphs: discoveryGraphs } : undefined;
|
||||
|
||||
// Scope per entity: events read the PUBLIC scope, profiles + participations the
|
||||
// PROTECTED scope. Each scope subscribes to the SET of its per-entity documents
|
||||
// (opaque SDK NURIs — the app never sees a store id). The SDK's per-document
|
||||
// ReadCap filter returns only the documents the current identity may read.
|
||||
const publicScope: ShapeScope = publicDocs.length ? { graphs: publicDocs } : undefined;
|
||||
const protectedScope: ShapeScope = protectedDocs.length ? { graphs: protectedDocs } : undefined;
|
||||
|
||||
// useShapeWithDefaults: show EMPTY data until NG populates (no seed defaults)
|
||||
const emptyEvents: FpEventData[] = [];
|
||||
const emptyUsers: FpUserData[] = [];
|
||||
const emptyParticipations: FpParticipationData[] = [];
|
||||
|
||||
const eventsShape = useShapeWithDefaults(FpEventShapeType, publicScope, emptyEvents, mapEvent, true);
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, protectedScope, emptyUsers, mapUser, true);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, protectedScope, emptyParticipations, mapParticipation, true);
|
||||
|
||||
// Cross-account public discovery: read the discovered documents as events.
|
||||
const discoveryShape = useShapeWithDefaults(FpEventShapeType, discoveryScope, emptyEvents, mapEvent, true);
|
||||
|
||||
// Union the current-scope events with the cross-account discovered ones,
|
||||
// de-duplicated by id (an event already read via publicScope must not appear
|
||||
// twice). Discovery is purely additive — it never hides an existing event.
|
||||
const events = React.useMemo(() => {
|
||||
const seen = new Set(eventsShape.items.map(e => e.id));
|
||||
const merged = [...eventsShape.items];
|
||||
for (const e of discoveryShape.items) {
|
||||
if (e.id && !seen.has(e.id)) { seen.add(e.id); merged.push(e); }
|
||||
}
|
||||
return merged;
|
||||
}, [eventsShape.items, discoveryShape.items]);
|
||||
const users = usersShape.items;
|
||||
const participations = participationsShape.items;
|
||||
}, [ready, allReadDocs, readTick]);
|
||||
|
||||
// Not in SHEX shapes yet
|
||||
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
|
||||
@@ -377,9 +336,10 @@ 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
|
||||
// → 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.
|
||||
// bootstrap with seed data. Guarded on the UNION READ result (events/users
|
||||
// empty AND the first read has completed), so a slow first read isn't mistaken
|
||||
// for an empty wallet. Gated on NODE_ENV so production users see their own
|
||||
// (possibly empty) wallet.
|
||||
const hasTriedAutoSeed = useRef(false);
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
@@ -387,24 +347,22 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (!ready) return;
|
||||
const t = setTimeout(() => {
|
||||
hasTriedAutoSeed.current = true;
|
||||
if (eventsShape.ngSet.size === 0 && usersShape.ngSet.size === 0) {
|
||||
const walletHasData = events.length > 0 || users.length > 0;
|
||||
if (!walletHasData) {
|
||||
console.log('[FestipodData] Dev auto-seed: wallet empty, bootstrapping…');
|
||||
bootstrapWallet(
|
||||
eventsShape.ngSet as any,
|
||||
usersShape.ngSet as any,
|
||||
participationsShape.ngSet as any,
|
||||
createEntityDoc,
|
||||
).then(({ createdDocs }) => {
|
||||
// Register the seeded per-entity docs into the live subscription sets.
|
||||
createdDocs.public.forEach(d => registerDoc('public', d));
|
||||
createdDocs.protected.forEach(d => registerDoc('protected', d));
|
||||
}).catch(err => console.error('[FestipodData] Auto-seed failed:', err));
|
||||
bootstrapWallet(walletHasData, createEntityDoc)
|
||||
.then(({ createdDocs }) => {
|
||||
// Register the seeded per-entity docs into the read set (+ re-query).
|
||||
createdDocs.public.forEach(d => registerDoc('public', d));
|
||||
createdDocs.protected.forEach(d => registerDoc('protected', d));
|
||||
})
|
||||
.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);
|
||||
}, [ready, eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet]);
|
||||
}, [ready, events.length, users.length]);
|
||||
|
||||
// --- Derived ---
|
||||
// Resolve current user from the chosen account username (the perceived login);
|
||||
@@ -543,7 +501,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
registerDoc('protected', partGraph);
|
||||
setSelectedEventId(eventId);
|
||||
}
|
||||
const addedEvent = { "@id": eventId, title: event.title } as FpEvent;
|
||||
const addedEvent = { "@id": eventId, title: event.title };
|
||||
// Make the PUBLIC event discoverable: submit its reference to the SDK global
|
||||
// discovery index (an SDK act — the app holds no index/store id). The SDK
|
||||
// enforces public-only: passing the event's own document lets it refuse a
|
||||
@@ -560,30 +518,27 @@ function useNgData(): FestipodDataContextValue {
|
||||
).catch(err => console.error('[FestipodData] submit event to index failed:', err));
|
||||
}
|
||||
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
|
||||
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId, username, registerDoc]);
|
||||
}, [currentUserId, username, registerDoc]);
|
||||
|
||||
const updateEvent = useCallback(async (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);
|
||||
// The event's `@id` is its own document NURI (one entity = one document); use
|
||||
// it as both the write graph and the subject.
|
||||
const graph = ngEvent?.["@graph"] || 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;
|
||||
// The event's `@id` IS its own document NURI (one entity = one document), so
|
||||
// it is both the write graph and the subject. Persist each provided mutable
|
||||
// field DIRECTLY via SPARQL (the durable write) then re-query so the union
|
||||
// read reflects it — there is no reactive set to mutate in place anymore.
|
||||
const graph = id;
|
||||
const persists: Promise<void>[] = [];
|
||||
if (updates.participantCount !== undefined) {
|
||||
persists.push(updateEntityField(graph, id, 'participantCount', int(updates.participantCount)));
|
||||
}
|
||||
// Persist `participantCount` DURABLY (a mutable field). An in-place ORM
|
||||
// mutation is local only — a later reactive re-sync from the broker reverts it
|
||||
// to the stored value; the SPARQL update makes it stick and the re-read match.
|
||||
if (updates.participantCount !== undefined && graph) {
|
||||
await updateEntityField(graph, id, 'participantCount', int(updates.participantCount))
|
||||
.catch(err => console.error('[FestipodData] persist participantCount failed:', err));
|
||||
}
|
||||
}, [eventsShape.ngSet]);
|
||||
if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title)));
|
||||
if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description)));
|
||||
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
|
||||
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
|
||||
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
|
||||
await Promise.all(persists).catch(err => console.error('[FestipodData] persist event update failed:', err));
|
||||
bumpRead();
|
||||
}, [bumpRead]);
|
||||
|
||||
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
@@ -601,7 +556,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// The reactive participation set can lag a just-written participation, so a
|
||||
// second join checking only the set would write a DUPLICATE (breaking "exactly
|
||||
// one participation"). The broker query sees the real state regardless of lag.
|
||||
const already = await countUserParticipations(eventId, uid).catch(() => 0);
|
||||
const already = await countUserParticipations(username || uid || 'anon', eventId, uid).catch(() => 0);
|
||||
if (already > 0) {
|
||||
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
|
||||
return;
|
||||
@@ -620,13 +575,13 @@ function useNgData(): FestipodDataContextValue {
|
||||
event: iri(eventId), user: iri(uid), isConfirmed: bool(true),
|
||||
});
|
||||
registerDoc('protected', partGraph);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
const next = ngEvent.participantCount + 1;
|
||||
ngEvent.participantCount = next;
|
||||
// Persist the count durably (see updateEvent) so a reactive re-sync keeps it.
|
||||
// Fire-and-forget: don't block the join's critical path on this write.
|
||||
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
|
||||
// Bump the event's participantCount durably. The event `@id` is its own doc
|
||||
// NURI (graph = subject). Read the current count from the union-read `events`;
|
||||
// persist +1 via SPARQL so the re-query reflects it. Fire-and-forget.
|
||||
const curEvent = events.find(e => e.id === eventId);
|
||||
if (curEvent) {
|
||||
const next = curEvent.participantCount + 1;
|
||||
updateEntityField(eventId, eventId, 'participantCount', int(next))
|
||||
.catch(err => console.error('[FestipodData] persist participantCount (join) failed:', err));
|
||||
}
|
||||
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
|
||||
@@ -655,38 +610,34 @@ function useNgData(): FestipodDataContextValue {
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
|
||||
}
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId, username, registerDoc]);
|
||||
bumpRead();
|
||||
}, [events, currentUserId, username, registerDoc, bumpRead]);
|
||||
|
||||
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) return;
|
||||
// DÉSINSCRIPTION FIX (caveat_participation-deletion): `ngSet.delete()` alone
|
||||
// triggers reactivity but the item RESURRECTS via broker sync. The AUTHORITATIVE
|
||||
// deletion is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate),
|
||||
// which removes the Participation server-side so it does NOT come back after
|
||||
// re-sync. The delete targets the participation's own @graph (the doc it lives
|
||||
// in) — the participation's OWN per-entity document — and is identified by the
|
||||
// participation's OWN subject IRI (ngPart["@id"]), not a string-match on the
|
||||
// object IRIs (the F2 bug: object string-match could hit 0 rows on IRI-form
|
||||
// drift → silent no-op → resurrection).
|
||||
const graphNuri = ngPart["@graph"];
|
||||
const subjectIri = ngPart["@id"];
|
||||
// Find the participation in the union-read set. Each participation is its OWN
|
||||
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
|
||||
// the subject IRI AND the graph NURI it lives in.
|
||||
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
|
||||
if (!part) return;
|
||||
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
|
||||
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
|
||||
// removes the Participation server-side so it does NOT resurrect after re-sync.
|
||||
// The delete targets the participation's own document (part.id) and is
|
||||
// identified by its OWN subject IRI (part.id), not a string-match on object IRIs.
|
||||
const graphNuri = part.id;
|
||||
const subjectIri = part.id;
|
||||
let result;
|
||||
try {
|
||||
result = await deleteParticipation(graphNuri, eventId, uid, subjectIri);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] SPARQL DELETE participation failed:', err);
|
||||
// Do NOT flip the UI: the broker still holds the triple, so flipping the
|
||||
// reactive set would resurrect on the next sync. Surface the failure.
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
// AUTHORITATIVE verification: only flip the UI once the broker RE-QUERY confirms
|
||||
// the participation is actually gone (remaining === 0). If the delete matched
|
||||
// nothing (weak match / IRI-form drift / wrong graph), remaining stays > 0 —
|
||||
// flipping the reactive set here would show "not participating" while the broker
|
||||
// still holds the triple, and it would resurrect after re-sync. Surface instead.
|
||||
// AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms
|
||||
// the participation is gone (remaining === 0). If the delete matched nothing,
|
||||
// surface it rather than falsely flip the UI (it would resurrect on re-sync).
|
||||
if (result.remaining > 0) {
|
||||
const msg = `[FestipodData] leaveEvent: SPARQL delete removed nothing ` +
|
||||
`(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
|
||||
@@ -694,21 +645,17 @@ function useNgData(): FestipodDataContextValue {
|
||||
console.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
// Confirmed gone server-side → reflect it in the reactive UI. This is the LOCAL
|
||||
// reflection of the authoritative delete (not a second persistence path): the
|
||||
// button flips to not-registered and STAYS so — the broker no longer holds the
|
||||
// triple to resurrect. `isParticipating` reads this set, so the item must leave
|
||||
// it for the UI to update immediately.
|
||||
participationsShape.ngSet.delete(ngPart);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
const next = Math.max(0, ngEvent.participantCount - 1);
|
||||
ngEvent.participantCount = next;
|
||||
// Fire-and-forget (don't block the leave's critical path).
|
||||
updateEntityField(ngEvent["@graph"] || eventId, eventId, 'participantCount', int(next))
|
||||
// Confirmed gone server-side → persist the event's participantCount decrement
|
||||
// durably, then re-query the union read (the participation leaves the set on
|
||||
// re-read; `isParticipating` reflects it).
|
||||
const curEvent = events.find(e => e.id === eventId);
|
||||
if (curEvent) {
|
||||
const next = Math.max(0, curEvent.participantCount - 1);
|
||||
updateEntityField(eventId, eventId, 'participantCount', int(next))
|
||||
.catch(err => console.error('[FestipodData] persist participantCount (leave) failed:', err));
|
||||
}
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
|
||||
bumpRead();
|
||||
}, [participations, events, currentUserId, bumpRead]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
@@ -724,31 +671,30 @@ function useNgData(): FestipodDataContextValue {
|
||||
});
|
||||
}, [currentUserId]);
|
||||
|
||||
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
|
||||
const updateProfile = useCallback(async (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;
|
||||
}
|
||||
}, [usersShape.ngSet]);
|
||||
// The current user's profile is its own document (subject IRI = doc NURI).
|
||||
const target = currentUser ?? users[0];
|
||||
if (!target) return;
|
||||
const graph = target.id;
|
||||
const persists: Promise<void>[] = [];
|
||||
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
|
||||
if (updates.initials !== undefined) persists.push(updateEntityField(graph, graph, 'initials', str(updates.initials)));
|
||||
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
|
||||
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
|
||||
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
|
||||
await Promise.all(persists).catch(err => console.error('[FestipodData] persist profile update failed:', err));
|
||||
bumpRead();
|
||||
}, [currentUser, users, bumpRead]);
|
||||
|
||||
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
|
||||
console.log('[FestipodData] loadTestData (NG)');
|
||||
const result = await bootstrapWallet(
|
||||
eventsShape.ngSet as any,
|
||||
usersShape.ngSet as any,
|
||||
participationsShape.ngSet as any,
|
||||
createEntityDoc,
|
||||
);
|
||||
const walletHasData = events.length > 0 || users.length > 0;
|
||||
const result = await bootstrapWallet(walletHasData, createEntityDoc);
|
||||
result.createdDocs.public.forEach(d => registerDoc('public', d));
|
||||
result.createdDocs.protected.forEach(d => registerDoc('protected', d));
|
||||
return result;
|
||||
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet, registerDoc]);
|
||||
}, [events.length, users.length, registerDoc]);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* readEntities — the READ side of the one-document-per-entity model, mapping the
|
||||
* SDK's union read (`readModel.readUnion`) to app types. This is the LISTING
|
||||
* path: it asks the SDK to open/sync a set of documents and run ONE anchorless
|
||||
* union `sparql_query`, then maps each returned subject's property bag to the
|
||||
* corresponding Fp* type.
|
||||
*
|
||||
* WHY this replaces the ORM `useShape({ graphs })` fan-out: subscribing a fan-out
|
||||
* of per-entity documents through the reactive ORM HANGS (~75s) — a freshly
|
||||
* created / not-yet-synced doc makes `RepoNotFound` abort the whole subscription
|
||||
* (see the SDK's docs/read-model.md, verified on the real broker in T03.k). The
|
||||
* union query is one-shot, so there is no reactive union: reactivity = RE-QUERY on
|
||||
* a change signal (a doc was created / registered).
|
||||
*
|
||||
* The app asks the SDK by NEED — it passes the document NURIs to read (from the
|
||||
* discovery index for public events, or its own scope docs for my-entities) and
|
||||
* never builds a store id or picks the union-vs-anchor mode. Placement + the
|
||||
* union mechanism live in the SDK (read-model.ts); this file is only the Festipod
|
||||
* domain mapping (fp: predicates → Fp* fields).
|
||||
*/
|
||||
|
||||
import { readModel } from '@ng-eventually/client';
|
||||
import type { UnionSubject } from '@ng-eventually/client';
|
||||
import type { FpEventData, FpUserData, FpParticipationData } from './types';
|
||||
|
||||
const FP = 'http://festipod.org/';
|
||||
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
||||
const TYPE = {
|
||||
event: `${FP}Event`,
|
||||
user: `${FP}UserProfile`,
|
||||
participation: `${FP}Participation`,
|
||||
} as const;
|
||||
|
||||
/** First object value of a predicate on a subject (or `fallback`). */
|
||||
function one(s: UnionSubject, field: string, fallback = ''): string {
|
||||
return s.props[`${FP}${field}`]?.[0] ?? fallback;
|
||||
}
|
||||
function num(s: UnionSubject, field: string, fallback = 0): number {
|
||||
const v = s.props[`${FP}${field}`]?.[0];
|
||||
const n = v === undefined ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
function boolOf(s: UnionSubject, field: string): boolean {
|
||||
return (s.props[`${FP}${field}`]?.[0] ?? 'false') === 'true';
|
||||
}
|
||||
|
||||
function typeOf(s: UnionSubject): string | undefined {
|
||||
return s.props[RDF_TYPE]?.[0];
|
||||
}
|
||||
|
||||
function mapEvent(s: UnionSubject): FpEventData {
|
||||
return {
|
||||
id: s.subject,
|
||||
title: one(s, 'title'),
|
||||
description: one(s, 'description'),
|
||||
date: one(s, 'date'),
|
||||
location: one(s, 'location'),
|
||||
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
|
||||
participantCount: num(s, 'participantCount'),
|
||||
coverImage: one(s, 'coverImage') || undefined,
|
||||
hostName: one(s, 'hostName') || undefined,
|
||||
hostInitials: one(s, 'hostInitials') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUser(s: UnionSubject): FpUserData {
|
||||
return {
|
||||
id: s.subject,
|
||||
name: one(s, 'name'),
|
||||
initials: one(s, 'initials'),
|
||||
username: one(s, 'username'),
|
||||
role: one(s, 'role') || undefined,
|
||||
isPublic: s.props[`${FP}isPublic`] ? boolOf(s, 'isPublic') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapParticipation(s: UnionSubject): FpParticipationData {
|
||||
return {
|
||||
id: s.subject,
|
||||
eventId: one(s, 'event'),
|
||||
userId: one(s, 'user'),
|
||||
isConfirmed: boolOf(s, 'isConfirmed'),
|
||||
};
|
||||
}
|
||||
|
||||
/** All entities read from `docs` (union), split by RDF `@type`. */
|
||||
export interface ReadEntities {
|
||||
events: FpEventData[];
|
||||
users: FpUserData[];
|
||||
participations: FpParticipationData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/sync `docs` and run ONE union query (SDK `readModel.readUnion`), then map
|
||||
* each subject to its Fp* type by RDF `@type`. `docs` is the by-need set of
|
||||
* document NURIs to read (the app resolves it: index-discovered event docs +
|
||||
* my own scope docs). A subject whose participation carries no `fp:user` is
|
||||
* dropped (the SHEX `fp:user` is mandatory — matches the ORM read).
|
||||
*/
|
||||
export async function readEntities(docs: string[]): Promise<ReadEntities> {
|
||||
const subjects = await readModel.readUnion(docs);
|
||||
const out: ReadEntities = { events: [], users: [], participations: [] };
|
||||
for (const s of subjects) {
|
||||
switch (typeOf(s)) {
|
||||
case TYPE.event:
|
||||
out.events.push(mapEvent(s));
|
||||
break;
|
||||
case TYPE.user:
|
||||
out.users.push(mapUser(s));
|
||||
break;
|
||||
case TYPE.participation: {
|
||||
const p = mapParticipation(s);
|
||||
if (p.userId) out.participations.push(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { inbox, docs, escapeLiteral, escapeIri, assertNuri } from '@ng-eventually/client';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { resolveInboxAnchor, listEntityDocs } from '../utils/storeRegistry';
|
||||
import { resolveInboxAnchor, listMyEntityDocs } from '../utils/storeRegistry';
|
||||
import type { FpNotificationData } from './types';
|
||||
|
||||
/** Notification IRI/type constants (mirror the SHEX Notification shape). */
|
||||
@@ -152,18 +152,23 @@ export async function readRegistrationNotifications(
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTHORITATIVE count of a user's Participations to an event across ALL protected
|
||||
* per-entity documents (the broker, not the reactive set). Used to make join
|
||||
* IDEMPOTENT reliably: the reactive participation set can lag behind a just-written
|
||||
* participation, so a second join checking only the reactive set would write a
|
||||
* duplicate. Querying the broker sees the real state regardless of read lag.
|
||||
* AUTHORITATIVE count of a user's Participations to an event across the user's OWN
|
||||
* protected per-entity documents (the broker, not the reactive set). Used to make
|
||||
* join IDEMPOTENT reliably: the reactive participation set can lag behind a
|
||||
* just-written participation, so a second join checking only the reactive set would
|
||||
* write a duplicate. Querying the broker sees the real state regardless of read lag.
|
||||
*
|
||||
* Scoped to the CURRENT account (`username`) via `listMyEntityDocs` — a user's own
|
||||
* participations live in their own account, so there is NO need to fan out over all
|
||||
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
|
||||
*/
|
||||
export async function countUserParticipations(
|
||||
username: string,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const docs_ = await listEntityDocs('protected');
|
||||
const docs_ = await listMyEntityDocs(username, 'protected');
|
||||
let total = 0;
|
||||
for (const g of docs_) {
|
||||
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* useShapeWithDefaults — wrapper around the SDK ORM's useShape.
|
||||
*
|
||||
* Subscribes to a SCOPE-resolved graph NURI (obtained from the SDK by logical
|
||||
* scope — the app holds no store id), which opens the repo in the verifier
|
||||
* (required for writes). Maps results to app types. If the NG set is empty,
|
||||
* returns defaults.
|
||||
*
|
||||
* Must only be called when NG is connected (inside NgDataProvider).
|
||||
*/
|
||||
|
||||
import { useShape } from '@ng-eventually/client';
|
||||
import type { ShapeType, BaseType, DeepSignalSet } from '@ng-eventually/client';
|
||||
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
|
||||
/** Mapped items from NG store */
|
||||
items: AppT[];
|
||||
/** Raw NG signal set for mutations */
|
||||
ngSet: DeepSignalSet<NgT>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `scope` is either a single scope-resolved graph NURI (from the SDK) or a
|
||||
* `{ graphs }` set of document NURIs (a read fan-out). `useShape` accepts both
|
||||
* natively. Either way the value is opaque to the app — it never builds it.
|
||||
*/
|
||||
export type ShapeScope = string | { graphs: string[] } | undefined;
|
||||
|
||||
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
|
||||
shapeType: ShapeType<NgT>,
|
||||
storeNuri: ShapeScope,
|
||||
defaults: AppT[],
|
||||
mapFromNg: (item: NgT) => AppT,
|
||||
shapesReady: boolean,
|
||||
): ShapeWithDefaults<NgT, AppT> {
|
||||
// A single scope-resolved graph NURI opens the repo in the verifier (enables
|
||||
// writes); a { graphs } scope subscribes to several docs (read fan-out).
|
||||
const ngSet = useShape(shapeType, storeNuri as any) as DeepSignalSet<NgT>;
|
||||
const usingDefaults = !shapesReady;
|
||||
const items = usingDefaults ? defaults : [...ngSet].map(item => mapFromNg(item as unknown as NgT));
|
||||
|
||||
return { items, ngSet };
|
||||
}
|
||||
@@ -126,14 +126,14 @@ function ConnectedHarness() {
|
||||
// to the raw ORM set only if the app hasn't hydrated a user yet.
|
||||
const currentUserId = appData.currentUserId || [...users][0]?.['@id'] || '';
|
||||
|
||||
// T03.i round-trip fix. The app now writes ONE DOCUMENT PER ENTITY (events →
|
||||
// public per-entity docs, participations/users → protected per-entity docs)
|
||||
// via `createEntityDoc`, and reads a scope by subscribing to the SET of its
|
||||
// per-entity documents (`listEntityDocs` + registerDoc). The old bridge read
|
||||
// the STORE-ROOT NURI directly (`useShape(protectedNuri)`), which never sees
|
||||
// the per-entity docs — so seed/creation didn't round-trip. The step-facing
|
||||
// `events/users/participations` + mutations/queries now delegate to the APP
|
||||
// data context (`appData`), i.e. the exact per-entity path the screens use.
|
||||
// The app writes ONE DOCUMENT PER ENTITY (events → public per-entity docs,
|
||||
// participations/users → protected per-entity docs) via `createEntityDoc`,
|
||||
// and READS by the union model (T03.k): resolve the by-need doc NURIs (my own
|
||||
// scope docs + the discovery index) then run ONE anchorless union
|
||||
// `sparql_query` (`readEntities` → `readModel.readUnion`), re-querying on a
|
||||
// change signal — never the reactive per-entity ORM fan-out (that HANGS). The
|
||||
// step-facing `events/users/participations` + mutations/queries delegate to the
|
||||
// APP data context (`appData`), i.e. the exact union-read path the screens use.
|
||||
// The step contract (`[...td.events]` with `@id`/`title`/`participantCount`,
|
||||
// `.size`, `p.user`/`p.event`) is preserved by mapping the app types to that
|
||||
// shape in a Set-like adapter.
|
||||
@@ -510,6 +510,47 @@ function ConnectedHarness() {
|
||||
return nuri;
|
||||
},
|
||||
|
||||
/**
|
||||
* T03.k PROBE — pins down the read-model union premise against the REAL
|
||||
* broker (docs/read-model.md § Minimal broker probe). Creates two graph
|
||||
* docs A and B, writes a DISTINCT triple into each (anchored per-doc),
|
||||
* then queries GRAPH ?g { ?s ?p ?o } twice: once with NO anchor (expect
|
||||
* BOTH A and B — the LOCAL UNION) and once anchored to A (expect ONLY A).
|
||||
* Returns the graphs seen in each mode so the step can assert the model.
|
||||
*/
|
||||
async runUnionProbe() {
|
||||
const sid = session.session_id;
|
||||
const docA = await docs.docCreate(sid, 'Graph', 'data:graph', 'store', undefined);
|
||||
const docB = await docs.docCreate(sid, 'Graph', 'data:graph', 'store', undefined);
|
||||
const sA = `urn:probe:s:${Date.now().toString(36)}:a`;
|
||||
const sB = `urn:probe:s:${Date.now().toString(36)}:b`;
|
||||
await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docA}> { <${sA}> <urn:probe:p> "A" } }`, docA);
|
||||
await docs.sparqlUpdate(sid, `INSERT DATA { GRAPH <${docB}> { <${sB}> <urn:probe:p> "B" } }`, docB);
|
||||
// Query our OWN probe subjects (sA/sB) so the assertion is by triple,
|
||||
// not by the repo_graph_name (which carries an overlay suffix and won't
|
||||
// string-equal the doc NURI). ?g is still selected for observability.
|
||||
const q = `SELECT ?g ?s ?o WHERE { GRAPH ?g { ?s <urn:probe:p> ?o . FILTER(?s IN (<${sA}>, <${sB}>)) } }`;
|
||||
const readObjs = (res: any): string[] => {
|
||||
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
|
||||
return rows.map((r: any) => r?.o?.value).filter(Boolean);
|
||||
};
|
||||
// NO anchor → local union across all opened graphs.
|
||||
const unionRes = await docs.sparqlQuery(sid, q, undefined, undefined);
|
||||
const unionObjs = readObjs(unionRes);
|
||||
// Anchor = A → one repo only.
|
||||
const anchorRes = await docs.sparqlQuery(sid, q, undefined, docA);
|
||||
const anchorObjs = readObjs(anchorRes);
|
||||
return {
|
||||
docA, docB,
|
||||
unionObjs,
|
||||
anchorObjs,
|
||||
unionHasA: unionObjs.includes('A'),
|
||||
unionHasB: unionObjs.includes('B'),
|
||||
anchorHasA: anchorObjs.includes('A'),
|
||||
anchorHasB: anchorObjs.includes('B'),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Round-trip the sharedWalletShim through the wallet: create an account
|
||||
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
* them to the live subscription set (reactivity).
|
||||
*/
|
||||
|
||||
import type { DeepSignalSet } from '@ng-eventually/client';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import { normalizeUsername } from '../context/AccountContext';
|
||||
import {
|
||||
seedEvents,
|
||||
@@ -34,24 +32,22 @@ export interface BootstrapResult {
|
||||
|
||||
/**
|
||||
* Seed default data — ONE DOCUMENT PER ENTITY (rule_document-per-entity), written
|
||||
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`) rather
|
||||
* than via the reactive `ngSet.add`. The ngSets are read ONLY to detect an
|
||||
* already-seeded wallet (their `@graph`-scoped write path can't add into a
|
||||
* not-yet-subscribed per-entity document — that's the round-trip bug this fixes).
|
||||
* The created document NURIs are returned so the caller registers them into the
|
||||
* scope's `useShape({ graphs })` for the reactive READ.
|
||||
* DIRECTLY into each entity's own document (see `entityWrites.writeEntity`). The
|
||||
* created document NURIs are returned so the caller registers them into the read
|
||||
* model's doc set for the union READ.
|
||||
*
|
||||
* `walletHasData` tells the seed whether the wallet already carries entities (a
|
||||
* returning user → skip). The caller computes it from the union read (no ORM set
|
||||
* needed — the read side is now the one-shot union query, not a reactive fan-out).
|
||||
*/
|
||||
export async function bootstrapWallet(
|
||||
ngEvents: DeepSignalSet<FpEvent>,
|
||||
ngUsers: DeepSignalSet<FpUserProfile>,
|
||||
ngParticipations: DeepSignalSet<FpParticipation>,
|
||||
walletHasData: boolean,
|
||||
createEntityDoc: CreateEntityDoc,
|
||||
): Promise<BootstrapResult> {
|
||||
const createdDocs = { public: [] as string[], protected: [] as string[] };
|
||||
// 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(), createdDocs };
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const {
|
||||
ensureAccount,
|
||||
resolveWriteGraph,
|
||||
listEntityDocs,
|
||||
listMyEntityDocs,
|
||||
allAccounts,
|
||||
resolveReadGraphs,
|
||||
resetRegistryCache,
|
||||
|
||||
Reference in New Issue
Block a user