morphit/apps/web/scripts/chat-read-state-threading-smoke.ts

186 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke: read state is per DISCUSSION, not per person (cp446, Ken).
*
* "If I read one thread from a user, it should not mark other threads with that
* user as read. Think of it like email."
*
* This crosses a signed on-chain op (`morphit_chat_read_v1`), so the guards here
* are about COMPATIBILITY as much as behaviour:
*
* '*' — a legacy peer-wide ack. What every pre-cp446 client sent, and what an
* old client still sends today. A client may NOT forge one.
* '' — the thread that cites no order. A real thread of its own.
* else — the permlink of the order that thread is about.
*
* Two silent regressions this exists to catch:
* 1. attributing a legacy ack (no order field) to the order-less thread —
* every other thread with that peer would look unread forever;
* 2. dropping legacy localStorage keys instead of migrating them — every
* existing user's inbox lights up unread on upgrade day.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB = join(__dirname, '..');
const REPO = join(WEB, '..', '..');
const strip = (src: string): string =>
src
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//'))
.join('\n');
const store = strip(readFileSync(join(WEB, 'src', 'lib', 'chat', 'readState.ts'), 'utf8'));
const op = strip(readFileSync(join(WEB, 'src', 'lib', 'blurt', 'ops', 'chatRead.ts'), 'utf8'));
const view = strip(readFileSync(join(WEB, 'src', 'lib', 'components', 'ConversationView.svelte'), 'utf8'));
const thread = strip(readFileSync(join(WEB, 'src', 'routes', '[lang]', 'chat', '[peer=account]', '+page.svelte'), 'utf8'));
const inbox = strip(readFileSync(join(WEB, 'src', 'routes', '[lang]', 'chat', '+page.svelte'), 'utf8'));
const badge = strip(readFileSync(join(WEB, 'src', 'lib', 'notifications', 'chatUnread.ts'), 'utf8'));
const handler = strip(readFileSync(join(REPO, 'apps', 'indexer', 'src', 'indexer', 'handlers', 'chatRead.ts'), 'utf8'));
const api = strip(readFileSync(join(REPO, 'apps', 'indexer', 'src', 'api', 'chatReadState.ts'), 'utf8'));
const migrations = strip(readFileSync(join(REPO, 'apps', 'indexer', 'src', 'db', 'migrations.ts'), 'utf8'));
const schema = readFileSync(join(REPO, 'apps', 'indexer', 'src', 'db', 'schema.sql'), 'utf8');
let pass = 0;
let fail = 0;
// v1.7.7 — optional failure DETAIL, printed only when a check fires. The
// lockstep checks below carry one because "the card judges unread via the shared
// predicate" is meaningless to whoever trips it six months from now; the detail
// is what tells them a raw isUnread on the page means a stale row and Ken's
// missing green border.
const check = (name: string, ok: boolean, detail?: string): void => {
if (ok) {
pass++;
console.log(` \u2713 ${name}`);
} else {
fail++;
console.error(` \u2717 ${name}${detail ? `\n ${detail}` : ''}`);
}
};
// ─── the store keys a DISCUSSION ─────────────────────────────────────
check('a read-state key is (peer, order), not a peer', /function threadKey\(peer: string, orderPermlink: string\)/.test(store));
check('…separated by NUL, which no name or permlink can contain', /\$\{peer\}\\u0000\$\{orderPermlink\}/.test(store));
check('the peer-wide sentinel is reserved', /export const PEER_WIDE = '\*';/.test(store));
check('unread honours BOTH this thread\u2019s ack and any peer-wide ack', /const own = state\[threadKey\(peer, orderPermlink\)\]/.test(store) && /const wide = state\[threadKey\(peer, PEER_WIDE\)\]/.test(store));
check('…taking the LATER of the two, so a thread can never be un-read', /new Date\(own\)\.getTime\(\) >= new Date\(wide\)\.getTime\(\) \? own : wide/.test(store));
check('a caller cannot forge a peer-wide ack', /if \(orderPermlink === PEER_WIDE \|\| orderPermlink\.length > 256\) return;/.test(store));
// ─── upgrade day: nothing lights up unread ───────────────────────────
check('a legacy bare-peer localStorage key is MIGRATED, not dropped', /out\[`\$\{k\}\\u0000\$\{PEER_WIDE\}`\] = v;/.test(store));
check('…and a remote ack with no order field is peer-wide, not order-less', /const order = entry\.order_permlink \?\? PEER_WIDE;/.test(store));
// ─── the on-chain op ─────────────────────────────────────────────────
check('the ack names the discussion it acknowledges', /readonly order_permlink: string;/.test(op));
check('broadcastChatRead takes the thread', /orderPermlink: string,/.test(op));
check('…and refuses to broadcast the reserved sentinel', /orderPermlink === '\*' \|\| orderPermlink\.length > 256/.test(op));
// ─── indexer: handler + storage + read endpoint ──────────────────────
check('the handler treats a MISSING order field as a legacy peer-wide ack', /orderPermlink = '\*'; /.test(handler) || /orderPermlink = '\*';/.test(handler));
check('…and rejects a client that tries to send the sentinel itself', /rawOrder === '\*'/.test(handler));
check('the upsert is keyed on (reader, peer, order)', /ON CONFLICT \(reader_account, peer_account, order_permlink\) DO UPDATE/.test(handler));
check('…and still advances monotonically', /WHERE chat_read_state\.last_read_at < EXCLUDED\.last_read_at/.test(handler));
check('the read endpoint returns the discussion', /SELECT peer_account AS peer, order_permlink, last_read_at/.test(api));
// ─── schema + migration ──────────────────────────────────────────────
check('migration 39 adds the column and re-keys the PK', /version: 39/.test(migrations) && /ADD PRIMARY KEY \(reader_account, peer_account, order_permlink\)/.test(migrations));
check('…defaulting existing rows to the peer-wide sentinel', /ADD COLUMN IF NOT EXISTS order_permlink TEXT NOT NULL DEFAULT '\*'/.test(migrations));
// schema.sql keeps the v1 baseline table and APPENDS each v37+ change as its
// own section (the v38 convention). So the v39 DDL lives at the bottom, not
// inside CREATE TABLE — asserting the latter would have quietly passed on a
// schema that never re-keys the PK.
check('schema.sql carries the v39 section', /-- \u2500\u2500\u2500 v39: chat read-state is per DISCUSSION/.test(schema));
check('…which adds the column with the peer-wide default', /ADD COLUMN IF NOT EXISTS order_permlink TEXT NOT NULL DEFAULT '\*'/.test(schema));
check('…and re-keys the primary key', /ADD PRIMARY KEY \(reader_account, peer_account, order_permlink\)/.test(schema));
check('…and warns about the indexer downgrade hazard', /DOWNGRADE HAZARD/.test(schema));
// ─── every caller passes the thread ──────────────────────────────────
check('the conversation view acks the thread it is showing', /markConversationRead\(peer, orderPermlink \?\? '', readAckTimestamp/.test(view));
check('the thread route acks on-chain with the order', /broadcastChatRead\(live, peer, orderPermlink \?\? ''\)/.test(thread));
// v1.7.7 — pins the REQUIREMENT (handleOpen is discussion-scoped: it takes an
// order permlink and acks THAT thread), not the literal signature. The old regex
// spelled the parameter list out exactly, so adding the `lastMessageAt` argument
// needed for the block-time clamp failed a check whose own name — "not a person"
// — the change never violated. A guard that fails on a correct change teaches
// people to edit the guard, which is how it ends up guarding nothing.
check(
'the inbox marks one discussion read, not a person',
/function handleOpen\(\s*peer: string,\s*orderPermlink: string/.test(inbox) &&
/markConversationRead\(\s*peer,\s*orderPermlink[,)]/.test(inbox)
);
check('mark-all-read walks discussions', /markConversationRead\(c\.peer, c\.order\?\.permlink \?\? '', now\)/.test(inbox));
check(
'the unread badge counts unread discussions, excluding archived (t.txt 10)',
/const order = c\.order\?\.permlink \?\? '';/.test(badge) &&
/isArchived\(c\.peer, order\)/.test(badge) &&
// v1.7.5 (t.txt #2) — this used to pin the THREE-arg
// `isUnread(c.peer, order, c.last_message_at)`, which is the call
// signature from BEFORE the cross-device fix. `isUnread` could not see
// who sent the last message, so a message you sent from your PC came
// back as unread on your phone: the per-device cursor had never seen it.
// The fix made `lastMessageIsMine` a REQUIRED 4th argument, and this
// check failed — the guard was pinning the bug's shape.
//
// Pin the requirement instead: the badge must pass the thread AND must
// tell isUnread whose message it was. Both properties, neither literal.
/isUnread\(\s*c\.peer,\s*order,\s*c\.last_message_at,\s*c\.last_message_is_mine/.test(badge)
);
// ── v1.7.7: the badge and the inbox cards must share ONE predicate ──
// cp452 established the invariant: a badge that disagrees with the visible cards
// is a bug in whichever direction it leans. It was enforced by two call sites
// applying "the same rule" — which is how they drifted. Ken's repro is the
// drift: a push landed, the thread resurrected into his Inbox, the badge stayed
// dark AND the card showed READ, because both read a durable `last_message_at`
// that the fast path never updates (ADR-0051 invariant #1).
//
// `threadIsUnread` folds the pending push into the rule ONCE. These checks pin
// that both surfaces go through it, and that the page does not quietly go back
// to judging unread by the stale durable row on its own.
const page = strip(readFileSync(join(WEB, 'src', 'routes', '[lang]', 'chat', '+page.svelte'), 'utf8'));
const unreadMod = strip(readFileSync(join(WEB, 'src', 'lib', 'notifications', 'chatUnread.ts'), 'utf8'));
check(
'the inbox card judges unread via the SHARED threadIsUnread predicate',
// v1.9.0 — the card now gates the flag on the folder first
// (`unread: folder !== 'archived' && threadIsUnread(...)`), so allow that
// clause between `unread:` and the predicate; the point is the card DELEGATES
// to threadIsUnread rather than re-implementing the rule.
/unread:[\s\S]{0,90}?threadIsUnread\(/.test(page),
'the card must not re-implement the rule against the durable last_message_at'
);
check(
'…imported from the badge channel, not re-typed locally',
/import \{[^}]*threadIsUnread[^}]*\} from '\$lib\/notifications\/chatUnread'/.test(page)
);
check(
'…and the card no longer calls raw isUnread directly',
!/\bisUnread\(/.test(page),
'raw isUnread on the page means the card sees a stale row again — Ken s missing green border'
);
check(
'threadIsUnread consults the pending-push overlay',
/export function threadIsUnread[\s\S]{0,600}?fastPending\.get\(/.test(unreadMod)
);
check(
'…and a stale durable row cannot veto a newer push',
/landed >= at/.test(unreadMod) || /landed\s*>=\s*at/.test(unreadMod)
);
check(
'the card re-derives when a push lands (reads the tick)',
/\$fastPendingTick/.test(page),
'without the tick the card only re-runs on the ~60s poll — the exact lag the fast path exists to remove'
);
console.log('');
if (fail === 0) console.log(`\u2713 all ${pass} chat-read-state-threading scenarios passed`);
else {
console.error(`\u2717 ${fail} of ${pass + fail} chat-read-state-threading checks FAILED`);
process.exit(1);
}