@mcsherrylabs/pi-meerkat 0.10.1 → 0.11.0

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.
@@ -30,7 +30,7 @@
30
30
  // stores and floods the body it will later serve. This is why the group-chat deployment
31
31
  // has a "DA group = the app nodes" step, and why an app cannot simply write to a channel
32
32
  // it claimed with defaults.
33
- import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody, fString, fVarint, fMessage, concat, LEDGER, } from "@mcsherrylabs/evolver-client";
33
+ import { fString, fVarint, fMessage, concat } from "@mcsherrylabs/evolver-client";
34
34
  /**
35
35
  * The channel this app owns — ONE variable, read by the app AND by the node's
36
36
  * `subscribed-channels` declaration in docker-compose.devnet.yml, because the two must agree
@@ -47,23 +47,6 @@ import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody,
47
47
  * rather than a literal.
48
48
  */
49
49
  export const CHANNEL = process.env.EVOLVER_CHANNEL?.trim() || "announcements1";
50
- /**
51
- * The DA threshold. ZERO — best-effort — because this app's DA group has ONE member.
52
- *
53
- * The scheduler's own validation states the rule and I had it wrong: at one DA node a
54
- * threshold of 1 can never be satisfied, because a node is never its own flood target and
55
- * does not self-attest, so a write needing one attestation waits for one that will not come
56
- * and ends in `503 da_unavailable`.
57
- *
58
- * ⚠ And best-effort means what it says: one member is ONE VOLUME. Lose it and every message
59
- * that member alone held is unrecoverable — and any future processing node declaring this
60
- * channel WEDGES on the first such message, permanently, because apply is forward-only. That
61
- * is not hypothetical: it is what happened to `announcements`, and it is why this app is on
62
- * `announcements1` (evolver#83). Two members on separate volumes is the answer when the
63
- * messages matter.
64
- */
65
- export const DA_THRESHOLD_M = Number(process.env.EVOLVER_DA_THRESHOLD_M?.trim() || 0);
66
- const utf8 = (s) => new TextEncoder().encode(s);
67
50
  /** The channel's terms as a NAMED node sees them — pass the devnet url to ask the chain, or
68
51
  * this app's node to ask its own cursor. The difference is the point (fact 2). */
69
52
  export async function channelTerms(baseUrl, ledgerId = CHANNEL) {
@@ -111,27 +94,6 @@ export async function groupMembers(baseUrl, group) {
111
94
  return { known: false };
112
95
  }
113
96
  }
114
- /**
115
- * This node's OWN on-chain identity, which the app needs because the DA group's member is the
116
- * NODE, not the app.
117
- *
118
- * Read from `/status`, which is an HTML page — the honest state of things: a node reports its
119
- * own identity to a human and has no machine-readable equivalent. A JSON status route would
120
- * make this a question instead of a parse (see evolver#82, which asks for the same thing about
121
- * readiness).
122
- */
123
- export async function nodeSelfId(baseUrl) {
124
- try {
125
- const res = await fetch(`${baseUrl}/status`, { signal: AbortSignal.timeout(10_000) });
126
- if (!res.ok)
127
- return null;
128
- const m = /<h1>\s*Node:\s*([A-Za-z0-9]+)\s*<\/h1>/.exec(await res.text());
129
- return m ? m[1] : null;
130
- }
131
- catch {
132
- return null;
133
- }
134
- }
135
97
  /**
136
98
  * Submit a write and then CONFIRM IT BY READING — the only reliable shape on a node whose
137
99
  * apply this app paces (fact 3).
@@ -176,61 +138,6 @@ export async function submitAndConfirm(node, stx, label, confirm, say, opts) {
176
138
  }
177
139
  throw new Error(`${label}: not visible on chain within the deadline — outcome UNKNOWN, nothing resent`);
178
140
  }
179
- /**
180
- * Create the DA group and put this app's NODE in it.
181
- *
182
- * Both steps are best-effort and SAY what happened, because neither is the gate: from run two
183
- * onward "already exists" is the normal answer, and on a paced node a submit routinely returns
184
- * `timeout` and commits anyway. The real gate is the CLAIM below — it cannot succeed unless the
185
- * group exists with this node in it, so a silent failure here surfaces there rather than
186
- * passing unnoticed.
187
- */
188
- export async function ensureDaGroup(node, baseUrl, owner, signer, group, memberNodeId, say, pace) {
189
- say(` DA group '${group}', member '${memberNodeId}' — the NODE's identity, not the app's`);
190
- // READ FIRST, AND SUBMIT ONLY WHAT IS MISSING.
191
- //
192
- // This used to submit CreateGroup and AddMember unconditionally on every run. A CreateGroup
193
- // for a name that already exists is not simply refused: the node ACCEPTS it, then rejects it
194
- // PRE-COMMIT every round ("name 'x' taken by group") and RETAINS it, re-proposing until its
195
- // TTL. On the live devnet that did two things — three rejection warnings per round in the
196
- // leader's log, and, far worse, it kept the tx queue permanently non-empty, which SUPPRESSES
197
- // wake-on-work for the whole chain (evolver#85: the wake fires only on the queue's empty →
198
- // non-empty edge). Every submitter, ours and everyone else's, then waited up to the
199
- // five-minute idle heartbeat — which is also what burned an identity name through
200
- // evolver#86. A pointless resubmission is not free, and on a shared chain it is not local.
201
- const before = await groupMembers(baseUrl, group);
202
- if (before.known && "members" in before && before.members.includes(memberNodeId)) {
203
- say(` group '${group}' exists with '${memberNodeId}' as a member — nothing to submit`);
204
- return;
205
- }
206
- if (!before.known) {
207
- throw new Error(`could not read group '${group}' from ${baseUrl} — refusing to guess: ` +
208
- `submitting a CreateGroup for a name that already exists poisons the ` +
209
- `chain's tx queue (evolver#85), so this needs an answer, not an attempt.`);
210
- }
211
- const steps = [];
212
- if ("absent" in before) {
213
- steps.push([`create group '${group}'`, createGroupBody(group),
214
- async () => { const g = await groupMembers(baseUrl, group); return g.known && !("absent" in g); }]);
215
- }
216
- else {
217
- say(` group '${group}' already exists — adding the member only`);
218
- }
219
- steps.push([`add '${memberNodeId}' to '${group}'`, addMemberBody(group, memberNodeId),
220
- async () => {
221
- const g = await groupMembers(baseUrl, group);
222
- return g.known && "members" in g && g.members.includes(memberNodeId);
223
- }]);
224
- for (const [label, body, confirm] of steps) {
225
- const stx = buildSignedTx({
226
- ledgerId: LEDGER.groups, senderNodeId: owner.nodeId, body,
227
- ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
228
- });
229
- // Confirmed by READING, like every other write here: a submit that times out has an
230
- // unknown outcome, and on a quiescent chain the block is minutes away.
231
- await submitAndConfirm(node, stx, label, confirm, say, { pace });
232
- }
233
- }
234
141
  /**
235
142
  * `UpdateChannelDaTxBody` (097) — OwnersTxBody variant 4.
236
143
  *
@@ -251,198 +158,3 @@ export function updateChannelDaBody(args) {
251
158
  parts.push(...fVarint(3, args.daThresholdM));
252
159
  return concat(fMessage(4, concat(parts)));
253
160
  }
254
- /**
255
- * Make the channel exist, owned by us, WITH a DA group — the only shape that accepts a
256
- * body-form write (fact 4).
257
- *
258
- * Idempotent, and the three cases are genuinely different: nothing claimed (claim it, carrying
259
- * the DA policy), claimed by us with the WRONG DA policy (converge it in place with
260
- * `UpdateChannelDa`), claimed by somebody else (stop: that is a real conflict on a shared
261
- * chain).
262
- */
263
- export async function ensureChannelWithDa(node, chainUrl, owner, signer, group, say, pace) {
264
- const ask = () => channelTerms(chainUrl);
265
- const read = await ask();
266
- if (!read.ok)
267
- throw new Error(`could not read the terms of '${CHANNEL}' from ${chainUrl}: ${read.why}\n` +
268
- ` Refusing to guess: an unreadable answer used to mean "unclaimed", and acting on ` +
269
- `that submits a Claim for a channel someone else may own — accepted, then rejected ` +
270
- `every round until its TTL (evolver#85).`);
271
- const terms = read.terms;
272
- const ownedByUs = terms.owners.some((o) => o.name === owner.nodeId);
273
- const someoneElse = terms.owners.length > 0 && !ownedByUs;
274
- // A CHANNEL NAME IS GLOBAL ON A SHARED CHAIN, and whoever claims it owns it. A second
275
- // deployment of this app — new identities, same channel name — is therefore a READER, not a
276
- // broken installation: it can declare the channel, subscribe, and fold the ordered stream,
277
- // which is most of what an announcements channel is for. Only the owner may write (the
278
- // default write policy is owner-or-group; 156 lets an owner open it to any writer).
279
- //
280
- // Dying here instead would be wrong in the ordinary case and would make the name a
281
- // first-come landmine.
282
- if (someoneElse) {
283
- say(` '${CHANNEL}' is owned by ${terms.owners.map((o) => o.name).join(", ")} — this app will READ it, not write`);
284
- return "reader";
285
- }
286
- // THE POLICY MUST MATCH, not merely exist — and "wrong" includes a threshold this app now
287
- // knows is unsatisfiable. A mismatch is converged with `UpdateChannelDa`, in place; a second
288
- // Claim would not do it (on an already-claimed ledger a Claim is a committed NO-OP).
289
- //
290
- // Measured on the deployment: `announcements1` was claimed at m=1 with a single-member
291
- // group, this function said "already claimed by us with a DA group — reusing", and the very
292
- // next write died with `fetch failed` — the attestation that can never arrive, waited out
293
- // until the connection dropped. Checking only for PRESENCE is what let that through twice.
294
- const daOk = !!terms.da && terms.da.group === group && terms.da.thresholdM === DA_THRESHOLD_M;
295
- if (ownedByUs && daOk) {
296
- say(` '${CHANNEL}' already claimed by us with DA group '${group}' (m=${DA_THRESHOLD_M}) — reusing`);
297
- return "owner";
298
- }
299
- if (ownedByUs && !daOk) {
300
- const had = terms.da ? `DA group '${terms.da.group}' (m=${terms.da.thresholdM})` : "NO DA group";
301
- say(` '${CHANNEL}' is claimed by us with ${had}, but this app needs '${group}' (m=${DA_THRESHOLD_M})`);
302
- // ⚠️ A TARGETED UPDATE, NOT UNCLAIM-THEN-RECLAIM.
303
- //
304
- // This used to drop the claim and re-claim, on the belief that a DA policy is set at claim
305
- // time and cannot be changed. That belief is wrong — `UpdateChannelDa` (097) exists and is
306
- // owner-gated (OwnersLedgerOrchestrator validates it and sets the policy in place) — and
307
- // the belief made the app do something irreversible to fix something reversible:
308
- // `clearChannelPolicies` drops all three policy rows, and BETWEEN the unclaim and the
309
- // re-claim the channel name is UNOWNED, so on a shared chain anyone can claim it and the
310
- // name is lost for good. A second Claim would not even have converged it: on an
311
- // already-claimed ledger a Claim is a committed NO-OP, which is why the unclaim was there
312
- // at all. evolver-scheduler's provisioner had this right first.
313
- say(` converging in place with UpdateChannelDa — an unclaim would expose the name to being taken`);
314
- const upd = buildSignedTx({
315
- ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
316
- body: updateChannelDaBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
317
- ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
318
- });
319
- await submitAndConfirm(node, upd, `update '${CHANNEL}' DA policy to '${group}' (m=${DA_THRESHOLD_M})`, async () => {
320
- const r = await ask();
321
- return r.ok && !!r.terms.da && r.terms.da.group === group &&
322
- r.terms.da.thresholdM === DA_THRESHOLD_M;
323
- }, say, { pace });
324
- return "owner";
325
- }
326
- // Nothing owns it: claim it, carrying the DA policy in the claim itself.
327
- const claim = buildSignedTx({
328
- ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
329
- body: claimBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
330
- ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
331
- });
332
- await submitAndConfirm(node, claim, `claim '${CHANNEL}' with DA group '${group}' (m=${DA_THRESHOLD_M})`, async () => {
333
- const r = await ask();
334
- return r.ok && !!r.terms.da && r.terms.da.group === group &&
335
- r.terms.da.thresholdM === DA_THRESHOLD_M &&
336
- r.terms.owners.some((o) => o.name === owner.nodeId);
337
- }, say, { pace });
338
- return "owner";
339
- }
340
- /** Write one announcement. Plain UTF-8: this channel is public by intent. */
341
- export async function announce(node, from, signer, text, say) {
342
- const stx = buildSignedTx({
343
- ledgerId: CHANNEL, senderNodeId: from.nodeId,
344
- body: channelBody(utf8(text)), ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
345
- });
346
- // submitTxWithDa: the node collects the channel's DA attestations over the overlay and
347
- // assembles the transaction. A client cannot do that for itself — it has no overlay.
348
- const r = await node.submitTxWithDa(stx);
349
- if (r.kind === "committed") {
350
- say(` announced: "${text}"`);
351
- return;
352
- }
353
- if (r.kind === "timeout") {
354
- say(` announce: submit timed out — the stream below is the confirmation`);
355
- return;
356
- }
357
- // A duplicate is not a failure of this app: the txId is a function of the body, the sender
358
- // and the TTL, so an identical announcement that already committed answers `duplicate` —
359
- // which means the message IS on the channel, which is exactly what the stream below checks.
360
- if (r.kind === "failed" && r.reason === "duplicate") {
361
- say(` announce: this exact message is already on the chain — the stream below is the confirmation`);
362
- return;
363
- }
364
- throw new Error(`the announcement was refused: ${JSON.stringify(r)}\n` +
365
- ` insufficient_da means the DA group could not be mustered — is this app's node a member?\n` +
366
- ` out-of-band means the channel has no DA group at all (see ensureChannelWithDa).`);
367
- }
368
- // The demo reads the shared collector's durable inbox. There is no local handler map,
369
- // volatile receive buffer, cursor probe or second consumer of the channel route.
370
- export async function retainedMessages(store, channel) {
371
- const messages = [];
372
- let after;
373
- do {
374
- const page = await store.inboxPage(channel, { limit: 100, after });
375
- for (const entry of page.entries)
376
- if (entry.body !== null)
377
- messages.push(new TextDecoder().decode(entry.body));
378
- if (!page.more)
379
- return messages;
380
- if (!page.next || (after && (BigInt(page.next.round) < BigInt(after.round) ||
381
- (page.next.round === after.round && BigInt(page.next.index) <= BigInt(after.index)))))
382
- throw new Error("inbox readback did not advance its continuation");
383
- after = page.next;
384
- } while (true);
385
- }
386
- /** Pace the processor forward until this node stops making progress — which is how a processing
387
- * node applies blocks at all (fact 2). Anything of ours it passes on the way is KEPT. */
388
- export async function paceApply(pace, say) {
389
- const MAX = 200;
390
- // A walk stops at its budget whether or not the cursor reached the tip, so the report
391
- // distinguishes the two. Claiming "to its tip" after a truncated walk is a statement the code
392
- // cannot make — a backlog longer than MAX leaves apply behind, and the reads that follow would
393
- // silently answer as of an earlier round.
394
- let walked = 0;
395
- let caughtUp = false;
396
- for (let attempts = 0; attempts < 10 && walked < MAX; attempts++) {
397
- const r = await pace();
398
- walked += r.inboxed + r.missed + r.duplicates;
399
- if (r.stoppedBecause === "body-pending")
400
- throw new Error("node is waiting for a channel body; nothing skipped");
401
- if (r.stoppedBecause === "caught-up") {
402
- caughtUp = true;
403
- break;
404
- }
405
- }
406
- if (!caughtUp)
407
- say(` paced this node's apply, stopping at ${walked} delivery(ies) — the backlog is longer, so apply may still be behind`);
408
- else
409
- say(` paced this node's apply to its tip; original deliveries are retained in the inbox`);
410
- }
411
- /** Pacing with no narration, for use inside a wait loop. Keeps what it walks past, as above. */
412
- export async function paceQuietly(pace) {
413
- await pace();
414
- }
415
- /**
416
- * Follow the stream until our own message arrives.
417
- *
418
- * The delivery is the app's write coming back THROUGH CONSENSUS, which is the only evidence
419
- * that matters: a submit says the transaction was ordered; a delivery says the app can read
420
- * what was ordered, in order, from its own node.
421
- *
422
- * A submit that timed out has an UNKNOWN outcome and the block carrying it can be minutes away
423
- * on a quiescent chain, so this waits to a DEADLINE rather than for a fixed number of idle polls
424
- * (20s of idle was the old bound, and it expired before the block existed). Nothing is ever
425
- * resent — the transaction id is fixed, so waiting is the only correct move.
426
- */
427
- export async function readBack(pace, want, say, opts) {
428
- // ⚠️ A CALLER THAT CANNOT MATCH MUST PASS ITS OWN DEADLINE. This waits for a specific
429
- // message, so with a sentinel that never matches it waits out the whole budget — five
430
- // minutes of "following the stream" for the reader-mode caller, which only wants to watch
431
- // briefly. That regression arrived with the deadline itself, from changing this function's
432
- // contract without revisiting its second call site.
433
- const deadline = Date.now() + (opts.deadlineMs ?? 300_000);
434
- let received = [...await opts.messages()];
435
- while (!received.some((t) => t.includes(want)) && Date.now() < deadline) {
436
- // Pending/quiet is an outcome; storage, module and transport failures are errors. Never
437
- // disguise one as an empty read or a successful wait. The app's finally releases its owner.
438
- const r = await pace();
439
- received = [...await opts.messages()];
440
- if ((r.stoppedBecause === "caught-up" || r.stoppedBecause === "body-pending") && !received.some(t => t.includes(want)))
441
- await new Promise(res => setTimeout(res, 500));
442
- }
443
- const found = received.some((t) => t.includes(want));
444
- say(` retained ${received.length} message(s) on '${CHANNEL}'`);
445
- return { seen: [...received], found };
446
- }
447
- // Only the node's body-unavailable outcome permits advancement. No application timer
448
- // converts pending/unreadable outcomes into absence; the shared collector owns that rule.