// The last participation act applied for each (meeting point, participant WebID) — what makes attendance // independent of the order notifications arrive in, and what remembers which member URN that act put in // (or took out of) the meeting point's attendee list. // // WHY. A participant's Join and Leave reach the backend as separate notifications, handled // concurrently; the per-meeting-point lock (`keyed-serializer.js`) serializes them, but in the order // their handlers reach it — after verification reads of unequal duration — not in the order the // participant acted. "Join then Leave" could be applied as Leave (nothing to remove) then Join, and leave // her listed. A Join or a Leave says what to do, not what the state should be, so the decision must // compare it with what was already applied. // // THE RULE (`isNewerAct`). Each act is placed by the time the participant's pod stamped on it — its // `published` (the pod overwrites any client value when the activity is posted to the outbox) — then by // its URI, to break a tie. Under the lock, an act is applied only when it is newer than the last act // applied for that pair; an older one (a late notification, a replay) changes nothing. Whatever the // arrival order, the pair ends in the state of the participant's latest act: "join then leave" ends not // attending, "leave then join" ends attending. All acts of one participant are stamped by her own pod, // so they share one clock. // // THE MEMBER is STICKY per pair. The URN listed for a participant is computed from the secret on their // profile, which they may rewrite between acts. So the entry remembers the URN once chosen and reuses it // for every later act of the pair — `member` is persisted (`persistMember`) BEFORE the first attendee-list // write, WRITE-ONCE (inserted only when absent, never replaced, not rewritten when an act is recorded), // and never recomputed afterwards: a later Join keeps it, a Leave removes that one. This is what // keeps the pair listed under exactly ONE URN even when an act fails half-way (the add lands, a later step // throws, the act is left unrecorded) and the participant then rewrites their secret and acts again — // without it, the second act would compute a second URN and the list would carry both. // // The member and the act are persisted SEPARATELY: `member` the moment a URN is first chosen for the pair, // the act (its URI, its stamp, its kind) only once BOTH collections match it. So the entry may hold a // member with no act yet (a first act that failed before recording) — `lastAttendance` returns it so the // retry reuses the URN, while the absent act makes the retry the "first" act for ordering (`isNewerAct`). // // THE KEY is the participant's WebID, not the member URN. This exposes nothing new at rest: the entry // already holds the act's URI, which lies in the participant's pod and so already identifies them. // // WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph // (persisted by Fuseki), so the rule holds across a backend restart. One entry per pair, under a minted // `urn:festipod:attendance:` subject, with four Festipod predicates: the act's URI, its stamp, its kind, // the member URN applied. const { iriRef, stringLiteral } = require('./catalog-store'); const ACT = 'http://festipod.org/attendanceAct'; const PUBLISHED = 'http://festipod.org/attendancePublished'; const CHANGE = 'http://festipod.org/attendanceChange'; const MEMBER = 'http://festipod.org/attendanceMember'; /** The subject of the (meeting point, participant) entry: both URIs percent-encoded into one URN. */ function attendanceSubject(meetingPointUri, participantUri) { return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(participantUri)}`; } // The pair's entry, anchored on its sticky `member`: a row iff a URN has been chosen for the pair, the // act fields bound only once an act was recorded (OPTIONAL — a member may be persisted with no act yet). function lastAttendanceQuery(meetingPointUri, participantUri) { const s = iriRef(attendanceSubject(meetingPointUri, participantUri)); return `SELECT ?act ?published ?change ?member WHERE { ${s} <${MEMBER}> ?member . OPTIONAL { ${s} <${ACT}> ?act ; <${PUBLISHED}> ?published ; <${CHANGE}> ?change } } LIMIT 1`; } // Set the pair's sticky member URN ONLY IF IT HAS NONE: the member is written once and never replaced, // so a write still in flight from an act that timed out cannot overwrite the URN a later act already // chose and listed. The caller reads the entry back and applies whatever member it holds. function persistMemberUpdate(meetingPointUri, participantUri, member) { const s = iriRef(attendanceSubject(meetingPointUri, participantUri)); return ` INSERT { ${s} <${MEMBER}> ${stringLiteral(member)} } WHERE { FILTER NOT EXISTS { ${s} <${MEMBER}> ?member } } `; } /** Replace the pair's recorded act `{ activityUri, published, change }`; its member is left as persisted. */ function recordAttendanceUpdate(meetingPointUri, participantUri, { activityUri, published, change }) { const s = iriRef(attendanceSubject(meetingPointUri, participantUri)); return ` DELETE { ${s} ?p ?o } INSERT { ${s} <${ACT}> ${iriRef(activityUri)} ; <${PUBLISHED}> ${stringLiteral(published)} ; <${CHANGE}> ${stringLiteral(change)} } WHERE { OPTIONAL { ${s} ?p ?o . FILTER(?p IN (<${ACT}>, <${PUBLISHED}>, <${CHANGE}>)) } } `; } /** * isNewerAct(candidate, last) → boolean. `candidate` and `last` are `{ activityUri, published }` * (`last` undefined when nothing was applied yet). Newer = a later `published` instant, or the same * instant and a greater URI. A candidate without a readable stamp is never newer (it cannot be placed). */ function isNewerAct(candidate, last) { const at = Date.parse(candidate && candidate.published); if (Number.isNaN(at)) return false; if (!last) return true; const lastAt = Date.parse(last.published); if (Number.isNaN(lastAt) || at > lastAt) return true; if (at < lastAt) return false; return String(candidate.activityUri) > String(last.activityUri); } const termValue = term => (term && typeof term === 'object' ? term.value : term); // ── Store operations: `ctx` is a moleculer context (anything with `call`) ── /** * The pair's entry, `{ activityUri, published, change, member }`, or undefined when no URN was ever * chosen for it. `member` is always set when a row is returned; the act fields are undefined when a * member was persisted but no act recorded yet (a first act that failed before recording). */ async function lastAttendance(ctx, { dataset, meetingPointUri, participantUri }) { const rows = await ctx.call('triplestore.query', { query: lastAttendanceQuery(meetingPointUri, participantUri), dataset, webId: 'system' }); const row = Array.isArray(rows) ? rows[0] : undefined; if (!row) return undefined; return { activityUri: termValue(row.act), published: termValue(row.published), change: termValue(row.change), member: termValue(row.member) }; } /** * Persist `member` as the pair's sticky URN unless it already has one — before any attendee-list write * (see the header) — then resolve the member the entry holds NOW: `member` itself, or the one another * act persisted first. The caller applies that one, never its own candidate. */ async function persistMember(ctx, { dataset, meetingPointUri, participantUri, member }) { await ctx.call('triplestore.update', { query: persistMemberUpdate(meetingPointUri, participantUri, member), dataset, webId: 'system' }); const entry = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri }); if (!entry || !entry.member) throw new Error(`the member URN of ${participantUri} on ${meetingPointUri} could not be persisted`); return entry.member; } async function recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act }) { await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, participantUri, act), dataset, webId: 'system' }); } module.exports = { attendanceSubject, lastAttendanceQuery, persistMemberUpdate, recordAttendanceUpdate, isNewerAct, lastAttendance, persistMember, recordAttendance };