fedipod-bb 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-bb",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/gateway.mjs CHANGED
@@ -27,7 +27,9 @@ export const isForumRow = (rec) => !!rec?.inboxUrl && (rec.kind === 'group' || r
27
27
  export async function forumFor(ctx, handle, rec) {
28
28
  const forumHandle = rec.kind === 'application' ? handle : rec.forum;
29
29
  if (!forumHandle) return { skipped: 'the category names no forum; run `fedipod-bb keep` again' };
30
- const forumRec = forumHandle === handle ? rec : await ctx.lookup(forumHandle);
30
+ // Read fresh where the front offers it: the forum's row is switched on
31
+ // last, and its first run starts in the same breath.
32
+ const forumRec = forumHandle === handle ? rec : await (ctx.lookupFresh || ctx.lookup)(forumHandle);
31
33
  if (!forumRec || !isForumRow(forumRec) || !forumRec.keeper) return { skipped: `@${forumHandle} is not a forum this gateway keeps` };
32
34
  if (!forumRec.webId || !forumRec.podHome) return { skipped: 'the forum row names no owner or pod' };
33
35
  const home = forumRec.podHome.replace(/\/?$/u, '/');
@@ -89,7 +91,7 @@ async function readConfig(ctx, f, podFetch) {
89
91
  export async function ensureForumCopies(ctx, f, config, log) {
90
92
  const rows = [[f.forumHandle, f.forumRec]];
91
93
  for (const c of config?.categories || []) {
92
- const r = await ctx.lookup(c.slug);
94
+ const r = await (ctx.lookupFresh || ctx.lookup)(c.slug);
93
95
  if (r && isForumRow(r)) rows.push([c.slug, r]);
94
96
  }
95
97
  const have = new Set();
@@ -139,6 +141,33 @@ function nextRun(forum, asksPending) {
139
141
  * Returns what the keeper's bookkeeping expects: { drained, waiting, nextAt }
140
142
  * or { skipped, retry }.
141
143
  */
144
+ // A queued delivery's identity: where it goes and what it carries.
145
+ const queueKey = (item) => `${item.inbox}\u0000${typeof item.activity === 'string' ? item.activity : JSON.stringify(item.activity)}`;
146
+
147
+ /**
148
+ * The queue after a carry that ran with nobody holding the forum: `before`
149
+ * is what the run took out to carry, `after` what the carry left of it
150
+ * (delivered items gone, failed ones with their next time), `fresh` the
151
+ * queue as the copy holds it now, which the door may have added to in the
152
+ * meantime. A door only ever appends, so: an item the run took out is kept
153
+ * as the carry left it or dropped if delivered; anything else is the door's
154
+ * and stays.
155
+ */
156
+ export function mergeQueues(before, after, fresh) {
157
+ const taken = new Set(before.map(queueKey));
158
+ const left = new Map(after.map((i) => [queueKey(i), i]));
159
+ const out = [];
160
+ for (const item of fresh) {
161
+ const k = queueKey(item);
162
+ if (!taken.has(k)) { out.push(item); continue; }
163
+ if (left.has(k)) out.push(left.get(k));
164
+ }
165
+ return out;
166
+ }
167
+
168
+ const storesOf = (forum) => [forum.store, ...forum.categories.map((c) => c.store)];
169
+ const deliverersOf = (forum) => [...forum.categories.map((c) => c.deliverer), forum.siteAgent.deliverer];
170
+
142
171
  export async function keepOnce(ctx, handle, rec, { log = console.log, session = null, remote: given = null, prepare = null } = {}) {
143
172
  const f = await forumFor(ctx, handle, rec);
144
173
  if (f.skipped) return { skipped: f.skipped };
@@ -149,10 +178,19 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
149
178
  const config = await readConfig(ctx, f, podFetch);
150
179
  if (!config) return { skipped: 'no forum on the pod' };
151
180
  const copies = await ensureForumCopies(ctx, f, config, say);
181
+ // The forum is held only while its state changes: the drain, the asks, the
182
+ // queue, the heartbeat, the write to the copies. The carry — one signed
183
+ // POST per follower server, each with its own timeout — runs with the forum
184
+ // free, so a post arriving at the door meanwhile is placed at once instead
185
+ // of waiting for the run to end. The queue the carry changes is written
186
+ // back under the lock, merged with whatever the door queued in between.
152
187
  const unlock = await lockCopy(ctx.copyKv, f.forumHandle);
153
188
  if (!unlock) return { skipped: 'the gateway is busy with this forum', retry: 'soon' };
189
+ let forum;
190
+ let before;
191
+ let asks = 0;
154
192
  try {
155
- const forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'run' });
193
+ forum = agentFor(ctx, f, { remote, podFetch, copies, log: say, mode: 'run' });
156
194
  if (!await forum.connect({ act: false })) return { skipped: 'no forum on the pod' };
157
195
  prepare?.(forum); // a test's origins, in place of the network
158
196
  if (!await forum.lease.acquire()) return { skipped: 'another host has the forum', retry: 'soon' };
@@ -161,22 +199,42 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
161
199
  await forum.intake.drain();
162
200
  await forum.applyVerifiedAsks().catch((e) => say(`asks: ${e.message}`));
163
201
  await forum.publishModQueue().catch((e) => say(`queue: ${e.message}`));
164
- for (const cat of forum.categories) await cat.deliverer.drainQueue();
165
- await forum.siteAgent.deliverer.drainQueue();
166
202
  await publish.publishHeartbeat(forum.siteAgent).catch((e) => say(`heartbeat: ${e.message}`));
167
- await Promise.allSettled([forum.store.flush(), ...forum.categories.map((c) => c.store.flush())]);
168
- const waiting = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + (s.getQueue?.() || []).length, 0);
169
- const asks = [forum.store, ...forum.categories.map((c) => c.store)].reduce((n, s) => n + s.read('modqueue.json', []).length, 0);
170
- // The run wrote on the pod (what the door could not place, the queue,
171
- // the heartbeat): the edge's copies of the forum's documents go.
172
- if (ctx.purge) {
173
- const tags = [`u-${f.forumHandle}`, ...(config.categories || []).map((c) => `u-${c.slug}`)];
174
- await ctx.purge(tags).catch((e) => say(`purge: ${e.message}`));
175
- }
176
- say(`done: inbox drained, ${waiting} delivery(ies) waiting, ${asks} ask(s) pending`);
177
- return { drained: true, waiting, nextAt: nextRun(forum, asks) };
203
+ await Promise.allSettled(storesOf(forum).map((s) => s.flush()));
204
+ asks = storesOf(forum).reduce((n, s) => n + s.read('modqueue.json', []).length, 0);
205
+ before = new Map(storesOf(forum).map((s) => [s, s.getQueue?.() || []]));
178
206
  } finally { await forum.lease.release().catch(() => {}); }
179
207
  } finally { await unlock(); }
208
+
209
+ // The carry, with the forum free.
210
+ for (const d of deliverersOf(forum)) await d.drainQueue().catch((e) => say(`carry: ${e.message}`));
211
+ const after = new Map(storesOf(forum).map((s) => [s, s.getQueue?.() || []]));
212
+
213
+ // What the carry left, written back under the lock beside what the door
214
+ // queued meanwhile. The door holds the forum for seconds, so the wait is short.
215
+ const relock = await lockCopy(ctx.copyKv, f.forumHandle, { waitMs: 20_000 });
216
+ if (!relock) { say('the queue could not be written back: the forum stayed busy'); return { drained: true, waiting: null, retry: 'soon' }; }
217
+ let waiting = 0;
218
+ try {
219
+ // The copy takes a write only from the lease's holder; the lease again, briefly.
220
+ if (!await forum.lease.acquire()) { say('the queue could not be written back: another host has the forum'); return { drained: true, waiting: null, retry: 'soon' }; }
221
+ for (const s of storesOf(forum)) {
222
+ let fresh = null;
223
+ try { await s.load(); fresh = s.getQueue?.() || []; } catch { fresh = null; }
224
+ const merged = fresh ? mergeQueues(before.get(s) || [], after.get(s) || [], fresh) : (after.get(s) || []);
225
+ s.setQueue?.(merged);
226
+ waiting += merged.length;
227
+ }
228
+ await Promise.allSettled(storesOf(forum).map((s) => s.flush()));
229
+ } finally { await forum.lease.release().catch(() => {}); await relock(); }
230
+ // The run wrote on the pod (what the door could not place, the queue, the
231
+ // heartbeat): the edge's copies of the forum's documents go.
232
+ if (ctx.purge) {
233
+ const tags = [`u-${f.forumHandle}`, ...(config.categories || []).map((c) => `u-${c.slug}`)];
234
+ await ctx.purge(tags).catch((e) => say(`purge: ${e.message}`));
235
+ }
236
+ say(`done: inbox drained, ${waiting} delivery(ies) waiting, ${asks} ask(s) pending`);
237
+ return { drained: true, waiting, nextAt: nextRun(forum, asks) };
180
238
  }
181
239
 
182
240
  /**
package/src/keep.mjs CHANGED
@@ -12,11 +12,19 @@ import { keepRows } from './setup.mjs';
12
12
  // does, from the pod's own rules, which name it beside the owner from here
13
13
  // on. `on: false` takes the name out again.
14
14
  export async function keepForum(forum, { front, on = true }) {
15
- const { keeper, handles } = await keepRows({ fetch: (u, i) => forum.remote.session.fetch(u, i), front, config: forum.config, on });
16
- forum.keepers = on && keeper ? [keeper] : [];
17
- forum.remote.keepers = [...forum.keepers];
18
- forum.remote.aclOwner = forum.readCredential()?.webId || forum.remote.aclOwner || null;
19
- await forum.restateRules();
15
+ // Handing over: the categories' rows, then every rule on the tree with the
16
+ // keeper's name, then the forum's own row — whose switch starts the
17
+ // Gateway's first run, which reads the pod under those rules. Taking it
18
+ // back: the rows, then the rules without the name.
19
+ const restate = async (keeper) => {
20
+ forum.keepers = on && keeper ? [keeper] : [];
21
+ forum.remote.keepers = [...forum.keepers];
22
+ forum.remote.aclOwner = forum.readCredential()?.webId || forum.remote.aclOwner || null;
23
+ await forum.restateRules();
24
+ };
25
+ const { keeper, handles } = await keepRows({ fetch: (u, i) => forum.remote.session.fetch(u, i), front, config: forum.config, on,
26
+ between: on ? restate : null });
27
+ if (!on) await restate(null);
20
28
  return { keeper: on ? keeper : null, handles, on };
21
29
  }
22
30
 
package/src/setup.mjs CHANGED
@@ -99,15 +99,18 @@ export async function attachRows({ fetch: f, front, config, plain }, { log = ()
99
99
  /**
100
100
  * Let a Gateway keep the forum (or stop): every row, the categories first and
101
101
  * the forum's own row last, because turning the forum's row on is what starts
102
- * its first run, and that run wants every category kept already. Returns the
103
- * keeper's WebID.
102
+ * its first run, and that run wants every category kept already. `between`,
103
+ * given the keeper's WebID, runs after the categories and before that last
104
+ * switch: the caller names the keeper in the pod's rules there, so the first
105
+ * run can read the pod the moment it starts. Returns the keeper's WebID.
104
106
  */
105
- export async function keepRows({ fetch: f, front, config, on = true }) {
107
+ export async function keepRows({ fetch: f, front, config, on = true, between = null }) {
106
108
  const origin = String(front).replace(/\/+$/u, '');
107
109
  const forumHandle = config.handle;
108
110
  const handles = [...(config.categories || []).map(c => c.slug), forumHandle];
109
111
  let keeper = null;
110
112
  for (const handle of handles) {
113
+ if (handle === forumHandle && between) await between(keeper);
111
114
  const res = await f(`${origin}/api/keeper`, {
112
115
  method: 'POST', headers: { 'content-type': 'application/json' },
113
116
  body: JSON.stringify({ handle, on, ...(handle === forumHandle ? {} : { forum: forumHandle }) }),
@@ -156,8 +159,8 @@ export async function setUpForumAtGateway({ remote, storageFor, fetch: f, front,
156
159
  await writeConfig(storage, { ...config, gateway, republish: true });
157
160
  onStep('attach', 'ok');
158
161
  onStep('keep', 'running');
159
- const { keeper } = await keepRows({ fetch: f, front, config, on: true });
160
- await nameKeeperInRules(remote, { config, plain, keeper, ownerWebId });
162
+ const { keeper } = await keepRows({ fetch: f, front, config, on: true,
163
+ between: (k) => nameKeeperInRules(remote, { config, plain, keeper: k, ownerWebId }) });
161
164
  onStep('keep', 'ok');
162
165
  return { handle: config.handle, front: gateway.front, handles, keeper };
163
166
  }