File size: 19,343 Bytes
c1278e4 f546440 c1278e4 f546440 c1278e4 f546440 c1278e4 f546440 c1278e4 f546440 c1278e4 f546440 c1278e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 | // ---------------------------------------------------------------------------
// alerts/alertsModel.ts β WAVE 20 item 25 (contract C-ALERT): the inbox's PURE
// half. React-free and fetch-free, so `verify_alerts.py` runs it under node.
//
// An alert says "tell me when a record ENTERS this view". The client half is
// small, and every part of it fails silently when it is wrong:
//
// Β· an unread count taken from `items.length` rather than from the server's
// own `unread` disagrees with the badge the moment a page is capped or a
// read lands in another tab β and a badge that says 3 when the list shows 9
// teaches the reader to ignore the badge;
// Β· a notification whose `viewId` no longer resolves must open NOTHING rather
// than a wrong view β alerts outlive the views they were made from;
// Β· `topic` is the surface's scope key ("customer", "product", "ut_β¦"), and
// the ROUTE is a registry key ("customer_data") β mapping one to the other
// by guesswork sends every click to a page that does not exist.
// ---------------------------------------------------------------------------
/**
* ββ WAVE 32 Β· T20 Β· CONTRACT C3 β WHERE AN INBOX ITEM OPENS.
*
* `module` is the destination surface (`"database"` or `"automation"`) and `id` is what to open
* in it. `tab` is the SUB-SELECTION inside that module: the literal `"runs"` for an automation's
* run log, or the VIEW ID to select on a database. One optional key, two destinations.
*
* β EVERY FIELD IS A PLAIN `string`, NEVER A UNION, and that is alertsModel's wave-9 law rather
* than laziness: the vocabulary is the SERVER's, and a client union over it turns "the server grew
* a module" into "the client silently drops the row". An unknown module is refused by the
* FRAME's dispatcher, out loud, which is a different thing from never arriving.
*/
export interface NotificationTarget {
module: string;
id: string;
tab?: string;
}
/** C3's `kind` vocabulary, mirroring `routes_alerts.NOTIF_KIND_*`. Held as constants so the
* Inbox's branch and the gate's fixtures cannot disagree about the word. */
export const NOTIF_KIND_ALERT = "alert";
export const NOTIF_KIND_AUTOMATION = "automation";
export const NOTIF_KIND_SHARE = "share";
/** C3's `target.module` vocabulary, and the automation sub-selection. */
export const TARGET_MODULE_DATABASE = "database";
export const TARGET_MODULE_AUTOMATION = "automation";
export const TARGET_TAB_RUNS = "runs";
/** One notification, as `GET /api/v1/notifications` sends it. */
export interface Notification {
id: string;
alertId: string;
viewId: string;
topic: string;
rowId: string;
/** What entered β the record's own label. */
label: string;
/** What the alert is called, so a row reads without opening anything. */
alertLabel: string;
/** UTC WITH OFFSET (D-18). Kept as the server's STRING: re-formatting it here
* would re-introduce the browser-clock drift the offset exists to remove. */
at: string;
read: boolean;
/**
* WAVE 23 (contract C6) β WHAT KIND of notification this is.
*
* Absent (and every notification written before this wave) means the original one: a record
* ENTERED a watched view, routed by `topic` + `viewId`. `"automation_review"` means a card
* arrived at a review stage and routes by `autoId` instead β a different destination reached
* from the same list.
*
* β A STRING, NEVER A UNION, and that is the wave-9 law rather than laziness: the vocabulary
* is the SERVER's, and a client union over it turns "the server grew a kind" into "the client
* silently drops the row". Unknown kinds fall through to the view route, which is exactly what
* they did before this field existed.
*/
kind?: string;
/** `automation_review` only: the automation whose review stage a card reached. Absent on
* every other kind β and an `automation_review` row that arrives WITHOUT one opens nothing
* rather than guessing, the same posture `viewId` gets. */
autoId?: string;
/** Advisory. A surface that does not scroll to a stage simply selects the automation. */
stageId?: string;
/** How many cards arrived in the batch. C6 queues ONE notification per run naming the count,
* never one per record β so this is the number the row's own text is built from. */
count?: number;
/**
* β WAVE 32 Β· C3 β THE HEADER LINE, so the Inbox can be laid out like mail.
*
* `subject` is what the item is ABOUT (the alert's name, the automation's name, the database
* that was shared); `label` stays what HAPPENED (the record that entered, the run summary).
* They were one field, which is why the pane could only ever render a sentence with no sender.
* Absent on a server that predates this wave β {@link subjectOf} falls back rather than
* rendering a blank header.
*/
subject?: string;
/**
* ββ WAVE 33 Β· W33-T28 β WHO IT IS FROM. Mail has a sender; this list did not.
*
* A `verifier` reading the finished wave-32 Inbox found the row's sender POSITION occupied by
* `kindLabel(n.kind)` β the literals "Alert" / "Automation" / "Shared with you", a CATEGORY
* standing where a who belongs β and no sender anywhere on the wire. Derived by the server
* (`routes_alerts.notification_view`), never here: only it knows that a share has a person
* behind it and an alert has a machine.
* β Optional, so a payload from a server that predates this wave keeps today's shape;
* {@link senderOf} falls back rather than rendering a blank From column.
*/
sender?: string;
/** β WAVE 32 Β· C3 β where clicking it goes. ABSENT when this product cannot resolve a
* destination (an alert on a table this account can no longer route to), and that absence is
* load-bearing: the Inbox renders such a row as plainly unclickable rather than as a click
* that silently does nothing. */
target?: NotificationTarget;
}
/** One alert, as `GET /api/v1/alerts` sends it. */
export interface Alert {
id: string;
viewId: string;
topic: string;
owner: string;
label: string;
createdAt: string;
/** How many records are in its remembered set right now. */
matched: number;
seeded: boolean;
lastRunAt: string;
lastError: string;
}
export interface Inbox {
unread: number;
items: Notification[];
/**
* ββ WAVE 33 Β· W33-T28 / D-208 β THE SERVER'S CLOCK, so a stamp can be mail-shaped.
*
* β It exists so {@link stampText} never reads the BROWSER's clock, which is D-208's exit
* condition word for word. `at` is UTC with its offset (D-18) precisely so every reader sees
* the same instant; asking "is this today?" of a local clock would put the drift back.
* β Optional: absent, `stampText` returns the old absolute string rather than guessing.
*/
now?: string;
}
export const EMPTY_INBOX: Inbox = { unread: 0, items: [] };
/**
* ββ WAVE 31 Β· T23 (owner item 1) β WHAT THE PANE IS ENTITLED TO SAY, AS A FUNCTION.
*
* Owner, verbatim: *"Alerts shows notification, but when clicked it says nothing, and it doesn't
* remove the notification number."* Both halves are ONE mechanism. `AlertsPane` held its own
* `inbox` seeded to {@link EMPTY_INBOX} and had no pending state, so between opening the panel
* and `GET /notifications` answering β measured at **3,280 ms live** β and for ever after a
* failed fetch:
* Β· `items` was `[]`, so the pane printed **"Nothing new."** β a claim, not a wait;
* Β· `unread` was `0`, so **"Mark all read" was `disabled`**, and the badge the frame had
* already loaded could never be cleared.
* A confident sentence about somebody's inbox, and the one control that would fix it, both
* switched off by the same uninitialised state.
*
* β IT IS A FUNCTION BECAUSE THE PANE IS TSX AND TSX IS NOT UNDER TEST HERE. `verify_alerts`
* compiles and RUNS this module under node; markup it cannot reach is markup no control can
* mutate. Deciding here means the truth table is asserted and each branch is load-bearing.
*/
export type PaneView = "pending" | "rows" | "empty" | "error";
/**
* `pending` while the first read is in flight Β· `error` when it failed and we have nothing to
* show Β· `rows` when there is something Β· `empty` ONLY when a successful read returned nothing.
*
* β ROWS WIN OVER AN ERROR, and that is deliberate rather than lax: a refresh that fails while
* the pane already holds notifications should not blank them β the reader loses real information
* to a transient. The error still reaches them as the pane's message line.
*/
export function paneView(
phase: "pending" | "ready" | "error",
rowCount: number,
seededUnread = 0
): PaneView {
if (rowCount > 0) return "rows";
if (phase === "pending") return "pending";
if (phase === "error") return "error";
// β A SUCCESSFUL READ WITH NO ROWS AND A NON-ZERO COUNT IS NOT "nothing new". The badge says
// there is something; the page we were given does not contain it. Saying "Nothing new" there
// is the same false confidence in a different costume.
return seededUnread > 0 ? "error" : "empty";
}
/**
* May "Mark all read" be pressed?
*
* β NOT `unread === 0`, WHICH IS THE SHIPPED BUG. That test asked the PANE's own state β zero
* until its fetch lands, zero for ever if the fetch fails β so the control was dead in exactly
* the situations the owner hit. The question is about the ACCOUNT, so it is asked of the count
* the frame already holds, and a failed read does not take the verb away: `POST /notifications/
* read` with `ids: null` clears the account's inbox whether or not we managed to list it.
*/
export function canMarkAll(unread: number, busy = false): boolean {
return !busy && Math.max(0, Math.floor(unread)) > 0;
}
const str = (v: unknown): string => (typeof v === "string" ? v : "");
const num = (v: unknown): number => (typeof v === "number" && isFinite(v) ? v : 0);
/**
* β WAVE 32 Β· C3 β one wire `target` β a {@link NotificationTarget}, or `null`.
*
* β BOTH `module` AND `id` ARE REQUIRED, and dropping either test is the failure this guard is
* for: a target with a module and no id dispatches an open request naming NOTHING β a click that
* appears to work and silently does not, which is this repo's most-repeated shape. `null` is
* rendered as an unclickable row, which a reader can SEE.
*/
export function parseTarget(raw: unknown): NotificationTarget | null {
if (!raw || typeof raw !== "object") return null;
const t = raw as Record<string, unknown>;
const module = str(t.module).trim();
const id = str(t.id).trim();
if (!module || !id) return null;
const tab = str(t.tab).trim();
return { module, id, ...(tab ? { tab } : {}) };
}
/**
* `GET /notifications` β the inbox, fail-closed.
*
* β `unread` COMES FROM THE SERVER, and is not recounted from `items`. The two
* can legitimately differ β the list is what this page holds, the count is what
* the account has β and recomputing it here would make the badge a function of
* whatever the last fetch happened to include.
*/
export function parseInbox(body: unknown): Inbox {
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
const raw = Array.isArray(b.items) ? b.items : [];
const items: Notification[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const n = item as Record<string, unknown>;
const id = str(n.id);
// An id-less notification cannot be marked read, so it would sit unread for
// ever and hold the badge up. Dropped, not rendered.
if (!id) continue;
items.push({
id,
alertId: str(n.alertId),
viewId: str(n.viewId),
topic: str(n.topic),
rowId: String(n.rowId ?? ""),
label: str(n.label) || String(n.rowId ?? ""),
alertLabel: str(n.alertLabel),
at: str(n.at),
read: n.read === true,
// WAVE 23 C6 β ADDITIVE and spread-conditional, exactly like `NavPage`'s flags: a payload
// that predates this wave keeps today's shape rather than gaining four `undefined` keys,
// and an `automation_review` row missing its `autoId` is left WITHOUT one rather than
// with an empty string that would render as a real destination.
...(str(n.kind) ? { kind: str(n.kind) } : {}),
...(str(n.autoId) ? { autoId: str(n.autoId) } : {}),
...(str(n.stageId) ? { stageId: str(n.stageId) } : {}),
...(num(n.count) > 0 ? { count: num(n.count) } : {}),
// β WAVE 32 Β· C3 β additive and spread-conditional, exactly like the four above.
...(str(n.subject) ? { subject: str(n.subject) } : {}),
// β WAVE 33 Β· W33-T28 β additive and spread-conditional, like every flag above it.
...(str(n.sender) ? { sender: str(n.sender) } : {}),
...(parseTarget(n.target) ? { target: parseTarget(n.target)! } : {}),
});
}
// β W33-T28 / D-208 β the server's clock rides through, spread-conditionally like every other
// additive key here, so a server that predates this wave yields the same object it always did.
return {
unread: Math.max(0, num(b.unread)),
items,
...(str(b.now) ? { now: str(b.now) } : {}),
};
}
/** `GET /alerts` β the alert list, fail-closed. */
export function parseAlerts(body: unknown): Alert[] {
const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
const raw = Array.isArray(b.alerts) ? b.alerts : [];
const out: Alert[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const a = item as Record<string, unknown>;
const id = str(a.id);
if (!id) continue;
out.push({
id,
viewId: str(a.viewId),
topic: str(a.topic),
owner: str(a.owner),
label: str(a.label) || "Untitled alert",
createdAt: str(a.createdAt),
matched: num(a.matched),
seeded: a.seeded === true,
lastRunAt: str(a.lastRunAt),
lastError: str(a.lastError),
});
}
return out;
}
/**
* A topic (the grid's scope key) β the hash route that renders it.
*
* The two built-ins are the only pair that differ, and they differ because the
* REGISTRY names the surface while the GRID names the scope; a user table is its
* own key in both. `null` for anything else: a notification for a topic this
* client cannot route to must do nothing, not navigate somewhere plausible.
*/
export function routeForTopic(topic: string): string | null {
const t = str(topic).trim();
if (t === "customer") return "customer_data";
if (t === "product") return "product_data";
if (/^ut_[A-Za-z0-9_]+$/.test(t)) return t;
return null;
}
/**
* The stamp, made readable WITHOUT touching a clock.
*
* β NO `new Date()`, NO `toLocaleString()`, and that is the whole design. The
* server sends UTC WITH ITS OFFSET (D-18) precisely so every reader sees the
* same instant; parsing it into a browser Date and formatting it back would
* re-introduce the drift the offset exists to remove β a tenant a day ahead
* being told an event happened tomorrow ([[date-window-vocabulary]]). This is
* STRING SURGERY: keep the date and the minutes, drop the seconds and the `T`.
* Anything that does not look like an ISO stamp passes through untouched, so a
* format this function has never seen is shown as sent rather than mangled.
*/
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
export function stampText(at: string, now?: string): string {
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(str(at));
if (!m) return str(at);
const [, day, hhmm] = m;
// ββ W33-T28 / D-208 β MAIL-SHAPED, AND STILL STRING SURGERY.
//
// β `now` COMES FROM THE SERVER (`inbox.now`), NEVER FROM `new Date()`. That is D-208's exit
// condition word for word β "without reading the browser clock" β and the reason is the same
// one this function has always carried: `at` is sent as UTC WITH its offset (D-18) so every
// reader sees the same instant, and deciding "is this today?" against a browser clock would
// re-introduce exactly the drift the offset removes. Both operands are the server's.
//
// β AND WITHOUT `now` IT DEGRADES TO THE OLD FORMAT RATHER THAN GUESSING. A caller that has no
// server clock gets `2026-08-13 09:41` β the pre-wave string, unambiguous and never wrong β
// instead of a relative stamp computed from something we do not trust.
const today = /^(\d{4}-\d{2}-\d{2})/.exec(str(now));
if (!today) return `${day} ${hhmm}`;
if (today[1] === day) return hhmm;
const [y, mo, d] = day.split("-");
const month = MONTHS[Number(mo) - 1] || mo;
// The year only when it differs β a mail client does not print "2026" on a message from March.
return today[1].slice(0, 4) === y
? `${month} ${Number(d)}`
: `${month} ${Number(d)}, ${y}`;
}
/**
* The badge's text. Never the raw number past 99: a nav row is 236px wide and a
* four-digit badge pushes the label out of it.
*/
export function badgeText(unread: number): string {
const n = Math.max(0, Math.floor(unread));
if (n <= 0) return "";
return n > 99 ? "99+" : String(n);
}
/**
* Apply a read/unread change LOCALLY, mirroring what the server just stored, and
* return the new inbox with the count corrected.
*
* `ids === null` is "all of them" (the API's own convention for mark-all). The
* count is derived from the ITEMS here β deliberately, and it is the one place
* that is right to do so: the server's answer is in flight, and the alternative
* is a badge that keeps its old number until the refetch lands.
*/
export function applyRead(inbox: Inbox, ids: string[] | null, read: boolean): Inbox {
const wanted = ids === null ? null : new Set(ids);
const items = inbox.items.map((n) =>
wanted === null || wanted.has(n.id) ? { ...n, read } : n
);
const seenUnread = items.filter((n) => !n.read).length;
// A page can hold fewer notifications than the account has, so a partial read
// must SUBTRACT from the server's count rather than replace it with this
// page's tally β except when marking everything, where zero is the answer.
if (wanted === null) return { unread: read ? 0 : items.length, items };
const changed = inbox.items.filter(
(n) => wanted.has(n.id) && n.read !== read
).length;
const delta = read ? -changed : changed;
return { unread: Math.max(seenUnread, inbox.unread + delta), items };
}
/**
* The shellβrail channel for "make an alert out of this view" β the view rail
* raises it, the frame (which knows the current route, and therefore the topic)
* answers. Same reason as C-SHARE's event: the rail is host-neutral and cannot
* import the shell, and it does not know its own scope key.
*/
export const ALERT_CREATE_EVENT = "aios:alert-create";
export interface AlertCreateRequest {
viewId: string;
label: string;
}
export function parseAlertCreate(detail: unknown): AlertCreateRequest | null {
if (!detail || typeof detail !== "object") return null;
const d = detail as Record<string, unknown>;
const viewId = str(d.viewId).trim();
if (!viewId) return null;
return { viewId, label: str(d.label).trim() || viewId };
}
|