oxedyne/daimond/dev/verify_chatexpiry.mjs
21.7 KiB, 1 run
created by r2519314175:269, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_chatexpiry.mjs — the algebra a chat's lifetime rests on. |
| 2 | // |
| 3 | // A chat is throw-away: untouched for the operator's few days it goes to the |
| 4 | // trash on its own, and the trash destroys it some weeks later. Both clocks run |
| 5 | // in the browser, on every device, with no server to arbitrate. So the whole |
| 6 | // feature is a claim about a MERGE, and this file is where that claim is put |
| 7 | // under load — two devices, driven directly, with parcels handed between them |
| 8 | // in whatever order the case wants. |
| 9 | // |
| 10 | // It runs under Node with no browser: `www/js/trash.js` is a classic script |
| 11 | // whose store half touches only `localStorage`, `window` and `fetch`, all three |
| 12 | // of which are shimmed below. The panel half is left unbuilt (no `document` |
| 13 | // nodes are asked for), which is deliberate — this file is about the record, |
| 14 | // and the panel is checked in the browser by verify_chatlife.mjs. |
| 15 | // |
| 16 | // WHAT IS ACTUALLY ASSERTED, and why each one is here: |
| 17 | // |
| 18 | // 1. A chat untouched past the window is trashed; one touched inside it is |
| 19 | // not. The boundary, from both sides, because a check that only pushed a |
| 20 | // chat far past the deadline would pass against code that trashed |
| 21 | // everything. |
| 22 | // |
| 23 | // 2. THE OPERATOR SETTING MOVES THE BOUNDARY. The same chat, at the same |
| 24 | // instant, under two different policies, comes out on opposite sides. This |
| 25 | // is the check that fails if the knob is read once at boot, or read from |
| 26 | // the wrong route, or ignored in favour of a constant. |
| 27 | // |
| 28 | // 3. TWO DEVICES CONVERGE RATHER THAN DUPLICATING. Two devices expire the |
| 29 | // same chat days apart and must produce the SAME RECORD, byte for byte — |
| 30 | // not two records, and not one record whose retention clock restarted. It |
| 31 | // is asserted on the serialised parcel because that is what the push |
| 32 | // comparison reads: a merge that differed by a field would push for ever. |
| 33 | // |
| 34 | // 4. AN AUTOMATIC ACT NEVER OUTRANKS A HUMAN ONE. A device that has been away |
| 35 | // comes back with a deadline that has passed and must not bury a restore |
| 36 | // somebody made by hand while it was gone. This is the property that made |
| 37 | // `expire` take the deadline as an argument instead of stamping the clock, |
| 38 | // and it is the one whose absence loses work. |
| 39 | // |
| 40 | // 5. A LOWERED RETENTION DOES NOT REACH BACK. The term is pinned on the |
| 41 | // record when it is trashed, so moving the knob governs what happens next |
| 42 | // and never brings forward a destruction date the panel has already shown |
| 43 | // somebody. |
| 44 | // |
| 45 | // 6. THE TOMBSTONE TERM OUTLIVES A STALE PEER, including a peer working to a |
| 46 | // retention this device has never been set to — which it learns from the |
| 47 | // record the peer sends. |
| 48 | // |
| 49 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` patches |
| 50 | // www/js/trash.js in memory before it is evaluated, and the run is then expected |
| 51 | // to FAIL. A break whose anchor does not appear exactly once aborts, so a break |
| 52 | // that has rotted cannot report a quiet pass. |
| 53 | // |
| 54 | // node dev/verify_chatexpiry.mjs --break stampsnow # 3, 4: expire() stamps Date.now() |
| 55 | // node dev/verify_chatexpiry.mjs --break repushes # 3: expire() re-stamps an item already trashed |
| 56 | // node dev/verify_chatexpiry.mjs --break buriesback # 4: expire() ignores a restore |
| 57 | // node dev/verify_chatexpiry.mjs --break livepolicy # 5: retention read live, not pinned |
| 58 | // node dev/verify_chatexpiry.mjs --break shorttomb # 6: the old seven-day tombstone term |
| 59 | // node dev/verify_chatexpiry.mjs --break ignoreknob # 2: the expiry window is a constant |
| 60 | // node dev/verify_chatexpiry.mjs # and then, clean |
| 61 | // |
| 62 | // No world and no server: it opens no port and touches no scratch directory. |
| 63 | import { readFileSync } from 'fs'; |
| 64 | import vm from 'node:vm'; |
| 65 | |
| 66 | const SRC = new URL('../www/js/trash.js', import.meta.url); |
| 67 | |
| 68 | const BREAK = (() => { |
| 69 | const i = process.argv.indexOf('--break'); |
| 70 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 71 | })(); |
| 72 | |
| 73 | const DAY = 24 * 3600 * 1000; |
| 74 | |
| 75 | const ok = [], bad = []; |
| 76 | const check = (name, pass, detail) => { |
| 77 | (pass ? ok : bad).push(name); |
| 78 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 79 | }; |
| 80 | |
| 81 | // ── The breaks ─────────────────────────────────────────────────────── |
| 82 | // |
| 83 | // Every one of these is a shape the code could plausibly have had. Two of them |
| 84 | // are shapes an earlier draft DID have. |
| 85 | const BREAKS = { |
| 86 | // The obvious implementation: expiry is a trashing, so call the trashing |
| 87 | // function. It is wrong because the stamp then says when this device |
| 88 | // noticed rather than when the deadline passed, and two devices that |
| 89 | // noticed at different times disagree for ever. |
| 90 | stampsnow: { |
| 91 | find: '\t\tr.at = when;', |
| 92 | with: '\t\tr.at = Math.max(Date.now(), r.back + 1);', |
| 93 | }, |
| 94 | // The guard against re-expiring something already in the trash is dropped. |
| 95 | // Every sweep then rewrites the record and pushes the destruction date out, |
| 96 | // so a trashed chat is never actually destroyed. |
| 97 | repushes: { |
| 98 | find: '\t\tif (r && r.at > r.back) return false;\t\t// already in the trash', |
| 99 | with: '', |
| 100 | }, |
| 101 | // The guard against outranking a restore is dropped. |
| 102 | buriesback: { |
| 103 | find: '\t\tif (r && when <= r.back) return false;\t\t// a restore outranks this deadline', |
| 104 | with: '', |
| 105 | }, |
| 106 | // The retention is read live from the policy instead of off the record — |
| 107 | // which is what the file did before the figure was settable, and what makes |
| 108 | // lowering the knob destroy things early. |
| 109 | livepolicy: { |
| 110 | find: '\t\t\t\tif (now >= r.at + r.r * DAY) {', |
| 111 | with: '\t\t\t\tif (now >= r.at + retainDays() * DAY) {', |
| 112 | }, |
| 113 | // The tombstone and restore-record term goes back to the flat seven days it |
| 114 | // was, which is shorter than the retention it has to outlive. |
| 115 | shorttomb: { |
| 116 | find: '\t\ttombTtlMs: function () { return (load().high + GRACE_DAYS) * DAY; },', |
| 117 | with: '\t\ttombTtlMs: function () { return 7 * DAY; },', |
| 118 | }, |
| 119 | // The expiry window ignores the operator entirely. |
| 120 | ignoreknob: { |
| 121 | find: '\t\tchatExpireMs: function () { return load().expire * DAY; },', |
| 122 | with: '\t\tchatExpireMs: function () { return 3 * DAY; },', |
| 123 | }, |
| 124 | }; |
| 125 | |
| 126 | if (BREAK && !BREAKS[BREAK]) { |
| 127 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 128 | process.exit(2); |
| 129 | } |
| 130 | |
| 131 | /// The module source, damaged if asked, or a hard stop. |
| 132 | function source() { |
| 133 | let src = readFileSync(SRC, 'utf8'); |
| 134 | if (!BREAK) return src; |
| 135 | const spec = BREAKS[BREAK]; |
| 136 | const n = src.split(spec.find).length - 1; |
| 137 | if (n !== 1) { |
| 138 | console.error(`break '${BREAK}': the anchor appears ${n} times in trash.js, ` |
| 139 | + 'so nothing was broken and the run below would prove nothing.'); |
| 140 | process.exit(2); |
| 141 | } |
| 142 | return src.replace(spec.find, spec.with); |
| 143 | } |
| 144 | |
| 145 | // ── One simulated device ───────────────────────────────────────────── |
| 146 | // |
| 147 | // A fresh evaluation of trash.js against its own `localStorage` and its own |
| 148 | // clock. Two of these is two devices; there is no shared state between them |
| 149 | // except the parcels this file hands over, which is the point. |
| 150 | function device(label) { |
| 151 | const store = new Map(); |
| 152 | // A REAL vm context, in which `window` IS the global object — not a plain |
| 153 | // object passed in under that name. It matters: trash.js publishes with |
| 154 | // `window.DaimondTrash = …` and then reads `DaimondPolicy` as a BARE |
| 155 | // identifier, which is only the same thing when `window` is the global. A |
| 156 | // shim that got this wrong reported the shipped defaults for every policy |
| 157 | // read and three checks below failed against correct code. |
| 158 | const ctx = { |
| 159 | localStorage: { |
| 160 | getItem: (k) => (store.has(k) ? store.get(k) : null), |
| 161 | setItem: (k, v) => store.set(k, String(v)), |
| 162 | removeItem: (k) => store.delete(k), |
| 163 | }, |
| 164 | // No gateway in this harness. `refresh()` catches the rejection and |
| 165 | // leaves the cached figures in place, which is the offline path. |
| 166 | fetch: () => Promise.reject(new Error('no gateway in this harness')), |
| 167 | // Just enough of a document for the PANEL half of the file to attach its |
| 168 | // delegated listeners and then find nothing. It draws nothing here: every |
| 169 | // render path returns early on a missing `#trash-list`, and none is |
| 170 | // called. A fuller shim would be a second implementation of the browser |
| 171 | // to keep in step, which is what this file most wants to avoid. |
| 172 | document: { |
| 173 | addEventListener: () => {}, |
| 174 | getElementById: () => null, |
| 175 | querySelectorAll: () => [], |
| 176 | }, |
| 177 | setTimeout, clearTimeout, |
| 178 | console: { debug: () => {}, warn: () => {}, log: () => {} }, |
| 179 | Date, JSON, Math, Object, String, Number, Promise, isFinite, parseInt, |
| 180 | // The cross-tab `storage` listener. One device here is one tab, so |
| 181 | // nothing ever fires it; it exists because the module attaches it at load. |
| 182 | addEventListener: () => {}, |
| 183 | }; |
| 184 | ctx.window = ctx; |
| 185 | ctx.globalThis = ctx; |
| 186 | vm.createContext(ctx); |
| 187 | vm.runInContext(source(), ctx, { filename: 'trash.js[' + label + ']' }); |
| 188 | if (!ctx.DaimondTrash || !ctx.DaimondPolicy) { |
| 189 | console.error(`device ${label}: trash.js did not publish its two modules`); |
| 190 | process.exit(2); |
| 191 | } |
| 192 | // Asserted rather than assumed: if the bare-global lookup ever stops |
| 193 | // working, every policy read below silently falls back to the shipped |
| 194 | // figures and this file's checks become vacuous. |
| 195 | ctx.DaimondPolicy.set(11, 22); |
| 196 | if (ctx.DaimondTrash.retainMs() !== 22 * DAY) { |
| 197 | console.error(`device ${label}: the trash store cannot see DaimondPolicy — ` |
| 198 | + 'every check in this file would be measuring the shipped defaults.'); |
| 199 | process.exit(2); |
| 200 | } |
| 201 | ctx.DaimondPolicy.reset(); store.clear(); |
| 202 | return { label, trash: ctx.DaimondTrash, policy: ctx.DaimondPolicy, store }; |
| 203 | } |
| 204 | |
| 205 | /// Hand one device's state to the other, exactly as a parcel does. |
| 206 | const send = (from, to) => to.trash.adopt(from.trash.snapshot()); |
| 207 | |
| 208 | /// The bytes a push would compare. Two devices that have converged produce the |
| 209 | /// same string; two that have not, do not. |
| 210 | const bytes = (d) => JSON.stringify(d.trash.snapshot()); |
| 211 | |
| 212 | /// A chat's deadline, as `chatDueAt` in daimond.js computes it. |
| 213 | const dueOf = (d, touchedAt) => touchedAt + d.policy.chatExpireMs(); |
| 214 | |
| 215 | const CHAT = 'c-abc'; |
| 216 | const T0 = Date.parse('2026-06-01T09:00:00Z'); // when the chat was last touched |
| 217 | |
| 218 | // ── 1. The boundary, from both sides ───────────────────────────────── |
| 219 | { |
| 220 | const d = device('A'); |
| 221 | d.policy.set(3, 30); |
| 222 | const due = dueOf(d, T0); |
| 223 | check('the window is the operator figure, in days', due - T0 === 3 * DAY, |
| 224 | `${(due - T0) / DAY} days`); |
| 225 | |
| 226 | // Inside the window: a device looking on the last day must NOT trash it. |
| 227 | // The caller is what decides, so the caller's rule is what is exercised: |
| 228 | // `now >= due`. |
| 229 | const insideNow = T0 + 3 * DAY - 1000; |
| 230 | check('a chat touched inside the window is not yet due', insideNow < due); |
| 231 | |
| 232 | // Past it: trashed, and trashed AT THE DEADLINE. |
| 233 | const movedOut = d.trash.expire(CHAT, 'chat', due); |
| 234 | check('a chat untouched past the window is moved to the trash', movedOut === true); |
| 235 | check('and it is IN the trash, not merely recorded', d.trash.has(CHAT) === true); |
| 236 | check('and the record says the clock did it, not a person', d.trash.isAuto(CHAT) === true); |
| 237 | const rec = d.trash.raw()[CHAT]; |
| 238 | check('the stamp written is the DEADLINE, not the moment it was noticed', |
| 239 | rec.at === due, `at=${rec.at} due=${due}`); |
| 240 | } |
| 241 | |
| 242 | // ── 2. The operator setting moves the boundary ─────────────────────── |
| 243 | // |
| 244 | // The SAME chat and the SAME instant, judged under two policies. If the window |
| 245 | // were a constant this pair could not disagree. |
| 246 | { |
| 247 | const strict = device('strict'); strict.policy.set(1, 30); |
| 248 | const loose = device('loose'); loose.policy.set(30, 30); |
| 249 | const now = T0 + 5 * DAY; |
| 250 | |
| 251 | const dueStrict = dueOf(strict, T0), dueLoose = dueOf(loose, T0); |
| 252 | check('a one-day policy has this chat overdue at five days', now >= dueStrict); |
| 253 | check('a thirty-day policy does not', now < dueLoose); |
| 254 | check('the two policies genuinely disagree about the same chat', |
| 255 | (now >= dueStrict) !== (now >= dueLoose), |
| 256 | `strict due ${(dueStrict - T0) / DAY}d, loose due ${(dueLoose - T0) / DAY}d`); |
| 257 | |
| 258 | strict.trash.expire(CHAT, 'chat', dueStrict); |
| 259 | check('the chat is trashed under the short policy', strict.trash.has(CHAT) === true); |
| 260 | check('and is untouched under the long one', loose.trash.has(CHAT) === false); |
| 261 | } |
| 262 | |
| 263 | // ── 3. Two devices converge rather than duplicating ────────────────── |
| 264 | // |
| 265 | // A and B expire the same chat nine days apart. Both compute the deadline from |
| 266 | // the SAME synced `updatedAt`, so both must write the same record — and the |
| 267 | // serialised parcels must be identical, because that string is what the push |
| 268 | // compares against the last one sent. |
| 269 | { |
| 270 | const A = device('A'), B = device('B'); |
| 271 | A.policy.set(3, 30); B.policy.set(3, 30); |
| 272 | const due = dueOf(A, T0); |
| 273 | |
| 274 | A.trash.expire(CHAT, 'chat', due); // A notices on the day |
| 275 | B.trash.expire(CHAT, 'chat', dueOf(B, T0)); // B, nine days later, same deadline |
| 276 | |
| 277 | check('both devices hold the chat as trashed', |
| 278 | A.trash.has(CHAT) && B.trash.has(CHAT)); |
| 279 | check('and their records are byte-identical BEFORE any parcel is exchanged', |
| 280 | bytes(A) === bytes(B), `\n A: ${bytes(A)}\n B: ${bytes(B)}`); |
| 281 | |
| 282 | // The merge is then a no-op in both directions, which is what stops the two |
| 283 | // devices telling each other about the same trashing for ever. |
| 284 | const aMoved = send(B, A), bMoved = send(A, B); |
| 285 | check('adopting the other device\'s parcel moves neither of them', |
| 286 | aMoved === false && bMoved === false, `A moved:${aMoved} B moved:${bMoved}`); |
| 287 | check('and they are still identical afterwards', bytes(A) === bytes(B)); |
| 288 | |
| 289 | // ONE record, not two. The trash is keyed by id, so a duplicate would show |
| 290 | // as a second entry — which is exactly what a scheme keyed by anything else |
| 291 | // would have produced. |
| 292 | check('there is exactly one trash record for the chat', |
| 293 | Object.keys(A.trash.raw()).length === 1, |
| 294 | Object.keys(A.trash.raw()).join(', ')); |
| 295 | |
| 296 | // And the destruction date has not drifted: a device that restamped would |
| 297 | // have pushed it nine days out on whichever side noticed second. |
| 298 | check('the destruction date is the same on both, and is measured from the deadline', |
| 299 | A.trash.dueAt(CHAT) === B.trash.dueAt(CHAT) |
| 300 | && A.trash.dueAt(CHAT) === due + 30 * DAY, |
| 301 | `A ${A.trash.dueAt(CHAT)} B ${B.trash.dueAt(CHAT)} want ${due + 30 * DAY}`); |
| 302 | } |
| 303 | |
| 304 | // ── 3b. The clock does not keep rewriting a record it has already written ── |
| 305 | // |
| 306 | // The sweep runs hourly for the life of the app, so `expire` is offered the |
| 307 | // same chat again and again after it is already in the trash. Two things must |
| 308 | // not happen, and neither is caught by the convergence checks above, because |
| 309 | // there both offers carried the SAME deadline. |
| 310 | { |
| 311 | const d = device('A'); |
| 312 | d.policy.set(3, 30); |
| 313 | |
| 314 | // (a) A HUMAN DELETION IS NOT RELABELLED, and its date is not moved. |
| 315 | // Somebody deletes a chat by hand today that they last touched a fortnight |
| 316 | // ago. Its computed deadline is therefore a fortnight in the PAST, so an |
| 317 | // `expire` that did not check would drag `at` backwards — bringing the |
| 318 | // destruction date forward by a fortnight and telling the panel a clock did |
| 319 | // what the person did. |
| 320 | const deletedNow = Date.now(); |
| 321 | d.trash.put(CHAT, 'chat'); |
| 322 | const byHand = d.trash.raw()[CHAT].at; |
| 323 | check('a chat deleted by hand is stamped now, not at some past deadline', |
| 324 | Math.abs(byHand - deletedNow) < 5000); |
| 325 | const staleDue = T0 + d.policy.chatExpireMs(); // a fortnight ago |
| 326 | const relabelled = d.trash.expire(CHAT, 'chat', staleDue); |
| 327 | check('the sweep does not relabel a hand-deleted chat as expired', |
| 328 | relabelled === false && d.trash.isAuto(CHAT) === false); |
| 329 | check('and it does not drag the destruction date backwards', |
| 330 | d.trash.raw()[CHAT].at === byHand, |
| 331 | `at moved from ${byHand} to ${d.trash.raw()[CHAT].at}`); |
| 332 | |
| 333 | // (b) AN ALREADY-EXPIRED CHAT IS NOT PUSHED OUT. A parcel can carry a |
| 334 | // fresher `updatedAt` for a chat that is already in the trash — the other |
| 335 | // device worked on it before it heard about the trashing — which makes the |
| 336 | // computed deadline LATER than the stamp on the record. Rewriting it would |
| 337 | // restart the retention clock, and the hourly sweep would restart it again |
| 338 | // every time the chat was touched anywhere. Nothing would ever be destroyed. |
| 339 | const e = device('B'); |
| 340 | e.policy.set(3, 30); |
| 341 | const due = T0 + e.policy.chatExpireMs(); |
| 342 | e.trash.expire(CHAT, 'chat', due); |
| 343 | const settled = e.trash.dueAt(CHAT); |
| 344 | const later = due + 10 * DAY; |
| 345 | const pushed = e.trash.expire(CHAT, 'chat', later); |
| 346 | check('a later deadline does not move a chat already in the trash', |
| 347 | pushed === false, 'the record was rewritten'); |
| 348 | check('so the retention clock does not restart', |
| 349 | e.trash.dueAt(CHAT) === settled, |
| 350 | `destruction date moved by ${(e.trash.dueAt(CHAT) - settled) / DAY} days`); |
| 351 | } |
| 352 | |
| 353 | // ── 4. An automatic act never outranks a human one ─────────────────── |
| 354 | // |
| 355 | // The case that loses work if it is got wrong. The chat expires on both |
| 356 | // devices; the user restores it on A; B has been switched off throughout and |
| 357 | // comes back holding a deadline that passed a fortnight ago. |
| 358 | { |
| 359 | const A = device('A'), B = device('B'); |
| 360 | A.policy.set(3, 30); B.policy.set(3, 30); |
| 361 | const due = dueOf(A, T0); |
| 362 | |
| 363 | A.trash.expire(CHAT, 'chat', due); |
| 364 | B.trash.expire(CHAT, 'chat', due); |
| 365 | A.trash.back(CHAT); // the user presses Restore on A |
| 366 | check('the restore takes it out of the trash on A', A.trash.has(CHAT) === false); |
| 367 | |
| 368 | // B boots, sees its own stale deadline, and tries again. It must decline. |
| 369 | const buried = B.trash.expire(CHAT, 'chat', due); |
| 370 | check('B re-expiring on its stale record changes nothing on B', |
| 371 | buried === false || B.trash.raw()[CHAT].at === due); |
| 372 | |
| 373 | send(A, B); |
| 374 | check('once the parcel arrives, B agrees the chat is restored', |
| 375 | B.trash.has(CHAT) === false); |
| 376 | |
| 377 | // And now the sharp end: B tries once more, with the restore in hand. |
| 378 | const again = B.trash.expire(CHAT, 'chat', due); |
| 379 | check('an expiry whose deadline predates the restore is REFUSED', |
| 380 | again === false, 'the automatic act overruled the person'); |
| 381 | check('so the chat is still out of the trash on B', B.trash.has(CHAT) === false); |
| 382 | send(B, A); |
| 383 | check('and B cannot push the trashing back onto A either', |
| 384 | A.trash.has(CHAT) === false); |
| 385 | |
| 386 | // The way back in is a NEW deadline, which is what a restore earns by |
| 387 | // stamping the chat: the window starts again from the restore. |
| 388 | const restoredAt = A.trash.raw()[CHAT].back; |
| 389 | const nextDue = restoredAt + A.policy.chatExpireMs(); |
| 390 | check('a deadline EARNED after the restore is accepted', |
| 391 | A.trash.expire(CHAT, 'chat', nextDue) === true); |
| 392 | check('so a restored chat rejoins the cycle rather than becoming immortal', |
| 393 | A.trash.has(CHAT) === true); |
| 394 | } |
| 395 | |
| 396 | // ── 5. A lowered retention does not reach back ─────────────────────── |
| 397 | { |
| 398 | const d = device('A'); |
| 399 | d.policy.set(3, 90); // a generous operator |
| 400 | const due = dueOf(d, T0); |
| 401 | d.trash.expire(CHAT, 'chat', due); |
| 402 | const promised = d.trash.dueAt(CHAT); |
| 403 | check('the panel is promised ninety days', promised === due + 90 * DAY, |
| 404 | `${(promised - due) / DAY} days`); |
| 405 | |
| 406 | d.policy.set(3, 7); // the operator changes their mind |
| 407 | check('lowering the knob does not move a date already promised', |
| 408 | d.trash.dueAt(CHAT) === promised, |
| 409 | `was ${(promised - due) / DAY}d, now ${(d.trash.dueAt(CHAT) - due) / DAY}d`); |
| 410 | check('and the new figure does govern the NEXT thing trashed', |
| 411 | d.policy.trashRetainMs() === 7 * DAY); |
| 412 | |
| 413 | // The sweep has to agree with the tile. A sweep reading the live policy |
| 414 | // would destroy this on day seven, eighty-three days before the date its |
| 415 | // owner was shown. |
| 416 | const early = due + 8 * DAY; |
| 417 | const realNow = Date.now; |
| 418 | Date.now = () => early; |
| 419 | try { |
| 420 | const dueNow = d.trash.sweep().map((x) => x.id); |
| 421 | check('and the sweep does not destroy it eighty-three days early', |
| 422 | dueNow.indexOf(CHAT) === -1, `swept: ${dueNow.join(', ') || 'nothing'}`); |
| 423 | } finally { Date.now = realNow; } |
| 424 | } |
| 425 | |
| 426 | // ── 6. The tombstone term outlives a stale peer ────────────────────── |
| 427 | { |
| 428 | const d = device('A'); |
| 429 | d.policy.set(3, 30); |
| 430 | check('the tombstone term is longer than the retention it must outlive', |
| 431 | d.policy.tombTtlMs() > d.policy.trashRetainMs(), |
| 432 | `tomb ${d.policy.tombTtlMs() / DAY}d vs retain ${d.policy.trashRetainMs() / DAY}d`); |
| 433 | // The concrete failure it exists to stop: destroy by hand on day one, be |
| 434 | // away for the peer's whole retention, and the tombstone must still be in |
| 435 | // the parcel when the peer returns. |
| 436 | const peerStillHoldingUntil = 30 * DAY; |
| 437 | check('a tombstone laid the day after a trashing survives until the peer\'s own verdict', |
| 438 | d.policy.tombTtlMs() >= peerStillHoldingUntil, |
| 439 | `${d.policy.tombTtlMs() / DAY}d`); |
| 440 | |
| 441 | // And a peer working to a LONGER retention than this device has ever been |
| 442 | // set to teaches it, because the term rides on the record. |
| 443 | const far = device('far'); |
| 444 | far.policy.set(3, 365); |
| 445 | far.trash.put(CHAT, 'chat'); |
| 446 | const before = d.policy.tombTtlMs(); |
| 447 | send(far, d); |
| 448 | check('adopting a peer\'s record raises this device\'s tombstone term to cover it', |
| 449 | d.policy.tombTtlMs() >= 365 * DAY, |
| 450 | `was ${before / DAY}d, now ${d.policy.tombTtlMs() / DAY}d`); |
| 451 | check('and it does not fall again when the operator lowers the figure here', |
| 452 | (d.policy.set(3, 5), d.policy.tombTtlMs() >= 365 * DAY), |
| 453 | `${d.policy.tombTtlMs() / DAY}d`); |
| 454 | } |
| 455 | |
| 456 | // ── Report ─────────────────────────────────────────────────────────── |
| 457 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 458 | if (bad.length) { |
| 459 | console.log('failed: ' + bad.join('; ')); |
| 460 | if (BREAK) console.log(`\n(expected: --break ${BREAK} is meant to fail)`); |
| 461 | process.exit(1); |
| 462 | } |
| 463 | if (BREAK) { |
| 464 | console.log(`\nBREAK '${BREAK}' PASSED EVERYTHING — the checks above do not ` |
| 465 | + 'actually test what they claim to.'); |
| 466 | process.exit(1); |
| 467 | } |
| 468 | process.exit(0); |