@aglyn/tenant-data-admin 1.0.0-beta.224 → 1.0.0-beta.225
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 +7 -7
- package/src/lib/server/after-response.d.ts +32 -0
- package/src/lib/server/after-response.js +94 -0
- package/src/lib/server/after-response.js.map +1 -0
- package/src/lib/server/capture-email-check.d.ts +2 -0
- package/src/lib/server/capture-email-check.js +20 -24
- package/src/lib/server/capture-email-check.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +12 -3
- package/src/lib/server/crm-records.js +40 -5
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +82 -1
- package/src/lib/server/render-monitor.js +205 -8
- package/src/lib/server/render-monitor.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +7 -0
- package/src/lib/server/serve-media-cdn.js +37 -26
- package/src/lib/server/serve-media-cdn.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport { type CrmListCollection, crmListFieldsPatch } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { notifyPluginRecordWritten } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/**\n * Restamp one record's list fields from the record as stored.\n *\n * Then tell the plugins a record was written (AGL-3336): every server writer\n * of an org record passes here after its write, so this is the one place a\n * plugin that acts on the record AFTER any write — the CRM's sharing rules —\n * is told, without an edit to each writer. Not for a record that no longer\n * exists, and after the restamp's own transaction, never inside it.\n */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n let outcome: 'restamped' | 'current' | 'missing'\n try {\n outcome = await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {})\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n if (outcome !== 'missing') {\n await notifyPluginRecordWritten({ path: ref.path, collection })\n }\n return outcome\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection)\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","crmListFieldsPatch","rampedDailyAllowance","notifyPluginRecordWritten","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","restampCrmListFieldsAt","outcome","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport {\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmListCollection,\n type CrmListFieldsContext,\n crmListFieldsPatch,\n effectiveCrmLeadSourcePicklist,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { notifyPluginRecordWritten } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/**\n * What a record's restamp reads beside it (AGL-3577): for a lead, the org's\n * lead source list, which its `leadSourceDirection` is read off. `{}` for\n * any other record, and for a lead whose org list cannot be read — the\n * restamp then leaves a held value's direction as stored rather than guess.\n */\nexport async function crmListFieldsContextFor(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<CrmListFieldsContext> {\n const org = collection === 'leads' ? ref.parent.parent : null\n if (!org) return {}\n try {\n const list = await org\n .collection(CRM_COLLECTIONS.picklists)\n .doc(CRM_LEAD_SOURCE_PICKLIST)\n .get()\n return { leadSources: effectiveCrmLeadSourcePicklist(list.data()) }\n } catch (error) {\n console.error(`[crm] lead source list unreadable for ${org.path}`, error)\n return {}\n }\n}\n\n/**\n * Restamp one record's list fields from the record as stored — and a lead's\n * direction from its org's lead source list, read here unless `context`\n * already holds it.\n *\n * Then tell the plugins a record was written (AGL-3336): every server writer\n * of an org record passes here after its write, so this is the one place a\n * plugin that acts on the record AFTER any write — the CRM's sharing rules —\n * is told, without an edit to each writer. Not for a record that no longer\n * exists, and after the restamp's own transaction, never inside it.\n */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n context?: CrmListFieldsContext,\n): Promise<'restamped' | 'current' | 'missing'> {\n let outcome: 'restamped' | 'current' | 'missing'\n try {\n // Read outside the transaction: the list is the org's, not the record's.\n const known = context ?? (await crmListFieldsContextFor(ref, collection))\n outcome = await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {}, known)\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n if (outcome !== 'missing') {\n await notifyPluginRecordWritten({ path: ref.path, collection })\n }\n return outcome\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // The org's lead source list, read once per org rather than per lead.\n const contexts = new Map<string, Promise<CrmListFieldsContext>>()\n const contextOf = (ref: FirebaseFirestore.DocumentReference) => {\n const key = ref.parent.parent?.path ?? ''\n let context = contexts.get(key)\n if (!context) {\n context = crmListFieldsContextFor(ref, collection)\n contexts.set(key, context)\n }\n return context\n }\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection, await contextOf(ref))\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","CRM_LEAD_SOURCE_PICKLIST","crmListFieldsPatch","effectiveCrmLeadSourcePicklist","rampedDailyAllowance","notifyPluginRecordWritten","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","crmListFieldsContextFor","parent","list","picklists","leadSources","path","restampCrmListFieldsAt","context","outcome","known","exists","patch","Object","keys","length","update","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","contexts","contextOf","key","worker","shift","undefined","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
|
|
@@ -145,6 +145,8 @@ export declare function originOf(value: string | undefined | null): string;
|
|
|
145
145
|
export declare function resolveRenderMonitorTargets(env: Readonly<Record<string, string | undefined>>): string[];
|
|
146
146
|
/** The state document id for one origin. */
|
|
147
147
|
export declare function renderMonitorCheckId(origin: string): string;
|
|
148
|
+
/** The state document id for one host, as {@link renderMonitorCheckId} names it. */
|
|
149
|
+
export declare function renderMonitorHostCheckId(host: string): string;
|
|
148
150
|
/** One render monitor state document. */
|
|
149
151
|
export interface RenderMonitorStateDoc extends OperatorHealthStateDoc {
|
|
150
152
|
origin: string;
|
|
@@ -153,8 +155,14 @@ export interface RenderMonitorStateDoc extends OperatorHealthStateDoc {
|
|
|
153
155
|
/** When the current run of failures began; null while passing. */
|
|
154
156
|
failingSinceMs: number | null;
|
|
155
157
|
lastOkAtMs: number | null;
|
|
158
|
+
/** Runs in a row where bot protection answered instead of the site. */
|
|
159
|
+
consecutiveBlindRuns?: number;
|
|
160
|
+
/** When the current blind stretch began; null while the monitor can see. */
|
|
161
|
+
blindSinceMs?: number | null;
|
|
162
|
+
/** When a run last saw the site itself: a pass or a real failure. */
|
|
163
|
+
lastObservedAtMs?: number | null;
|
|
156
164
|
}
|
|
157
|
-
export type RenderMonitorTransition = 'failing' | 'recovered' | null;
|
|
165
|
+
export type RenderMonitorTransition = 'failing' | 'recovered' | 'blind' | null;
|
|
158
166
|
/**
|
|
159
167
|
* The next state for one site, given what this run saw. Pure.
|
|
160
168
|
*
|
|
@@ -162,10 +170,16 @@ export type RenderMonitorTransition = 'failing' | 'recovered' | null;
|
|
|
162
170
|
* failed, so one slow cold start or one dropped connection says nothing. It
|
|
163
171
|
* turns back on the first passing run, because a render that works is
|
|
164
172
|
* proof, and a recovery held back is a recovery nobody hears about.
|
|
173
|
+
*
|
|
174
|
+
* A BLIND observation (every failure was a bot-protection challenge) changes
|
|
175
|
+
* nothing about the site: its status, failure count and since-time carry over
|
|
176
|
+
* untouched, and only the blind count moves. It transitions to `blind` once,
|
|
177
|
+
* on the `threshold`-th blind run in a row.
|
|
165
178
|
*/
|
|
166
179
|
export declare function nextRenderMonitorState(prior: Partial<RenderMonitorStateDoc> | null, observation: {
|
|
167
180
|
ok: boolean;
|
|
168
181
|
detail: string;
|
|
182
|
+
blind?: boolean;
|
|
169
183
|
}, context: {
|
|
170
184
|
origin: string;
|
|
171
185
|
label: string;
|
|
@@ -186,6 +200,8 @@ export interface RenderProbeRow extends RenderProbeVerdict {
|
|
|
186
200
|
export interface RenderMonitorSiteResult {
|
|
187
201
|
origin: string;
|
|
188
202
|
ok: boolean;
|
|
203
|
+
/** Every failure this run was a challenge, so it saw nothing of the site. */
|
|
204
|
+
blind: boolean;
|
|
189
205
|
probes: RenderProbeRow[];
|
|
190
206
|
/** The state written; absent on a dry run or a failed write. */
|
|
191
207
|
status?: 'ok' | 'degraded';
|
|
@@ -230,4 +246,69 @@ export declare function runRenderMonitor(options?: {
|
|
|
230
246
|
store?: RenderMonitorStore;
|
|
231
247
|
raise?: RaiseAlert;
|
|
232
248
|
}): Promise<RenderMonitorReport>;
|
|
249
|
+
/**
|
|
250
|
+
* One watched site as `/api/health/pages` reports it (AGL-3580).
|
|
251
|
+
*
|
|
252
|
+
* The shape of every other health check — `ok`, `ms`, a stable `code` — plus
|
|
253
|
+
* when the monitor last saw the site, so a reader can tell a fresh verdict
|
|
254
|
+
* from an old one without a second request.
|
|
255
|
+
*/
|
|
256
|
+
export interface RenderPagesCheck {
|
|
257
|
+
ok: boolean;
|
|
258
|
+
ms: number;
|
|
259
|
+
code?: string;
|
|
260
|
+
/** When a run last rendered or failed this site's pages, ISO. */
|
|
261
|
+
lastObservedAt: string | null;
|
|
262
|
+
/** When the site's current status began, ISO. */
|
|
263
|
+
since: string | null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A verdict older than this says the monitor stopped, not that the site did.
|
|
267
|
+
* Four missed five-minute runs.
|
|
268
|
+
*/
|
|
269
|
+
export declare const RENDER_PAGES_STALE_MS: number;
|
|
270
|
+
/** How many sites one `?site=` may ask about. */
|
|
271
|
+
export declare const RENDER_PAGES_MAX_SITES = 10;
|
|
272
|
+
/**
|
|
273
|
+
* Which watched hosts a `?site=` asks about. Pure.
|
|
274
|
+
*
|
|
275
|
+
* Absent or empty asks about every watched site. A host that is not watched
|
|
276
|
+
* is kept, so the caller can answer it red: a public monitor pointed at a
|
|
277
|
+
* site nobody renders is a monitor that can never go red, which is the
|
|
278
|
+
* mistake this door exists to undo.
|
|
279
|
+
*/
|
|
280
|
+
export declare function requestedRenderPagesHosts(query: string | null | undefined, watchedOrigins: readonly string[]): string[];
|
|
281
|
+
/**
|
|
282
|
+
* The public verdict on real pages, per site, from the render monitor's
|
|
283
|
+
* stored state (AGL-3580). Pure.
|
|
284
|
+
*
|
|
285
|
+
* This is what the status page's `Published sites` and `Marketing site`
|
|
286
|
+
* monitors read. Before it they read `/api/health/render/*`, which builds a
|
|
287
|
+
* node tree inside a route handler and never draws a page, and both stayed at
|
|
288
|
+
* 100% through two outages on 2026-10-05 in which every fresh page hung.
|
|
289
|
+
*
|
|
290
|
+
* Red is reserved for the one thing a visitor would notice:
|
|
291
|
+
*
|
|
292
|
+
* - `not-rendering` — the monitor saw the site fail to render pages it could
|
|
293
|
+
* not have served from a cache, `threshold` runs in a row.
|
|
294
|
+
* - `not-watched` — the monitor renders nothing for this host, so this row
|
|
295
|
+
* could never go red; fix the monitor's config or the URL.
|
|
296
|
+
* - `state-unavailable` — the verdicts could not be read at all.
|
|
297
|
+
*
|
|
298
|
+
* Green with a code is a verdict this door cannot improve on, and is never
|
|
299
|
+
* reported as an outage, because it is not one:
|
|
300
|
+
*
|
|
301
|
+
* - `awaiting-first-run` — a site added since the monitor last ran.
|
|
302
|
+
* - `monitor-blind` — bot protection answered the monitor; the site's last
|
|
303
|
+
* real verdict stands, and the operator alert says what to fix.
|
|
304
|
+
* - `observation-stale` — no run in {@link RENDER_PAGES_STALE_MS}; the
|
|
305
|
+
* monitor's own `render-monitor` row on `/api/health/crons` is what goes
|
|
306
|
+
* red for that, so a stopped scheduler is never told as a site outage.
|
|
307
|
+
*/
|
|
308
|
+
export declare function renderPagesHealth(hosts: readonly string[], watchedOrigins: readonly string[], states: ReadonlyMap<string, Partial<RenderMonitorStateDoc>> | null, ms: number, now?: number): Record<string, RenderPagesCheck>;
|
|
309
|
+
/**
|
|
310
|
+
* Read the stored verdicts for `hosts`, one batched read. Null when the read
|
|
311
|
+
* fails, which {@link renderPagesHealth} reports red rather than calm.
|
|
312
|
+
*/
|
|
313
|
+
export declare function readRenderMonitorStates(hosts: readonly string[]): Promise<Map<string, Partial<RenderMonitorStateDoc>> | null>;
|
|
233
314
|
export {};
|
|
@@ -46,7 +46,20 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
46
46
|
*
|
|
47
47
|
* A site that fails `threshold` runs in a row raises
|
|
48
48
|
* `system.siteRenderFailing` once; the first passing run after that raises
|
|
49
|
-
* `system.siteRenderRecovered` once.
|
|
49
|
+
* `system.siteRenderRecovered` once.
|
|
50
|
+
*
|
|
51
|
+
* ## A challenge is not a failed render (AGL-3580)
|
|
52
|
+
*
|
|
53
|
+
* When bot protection answers instead of the site, this run saw nothing about
|
|
54
|
+
* the site at all. On 2026-10-05 the console had no `AGLYN_PROBE_TOKEN`, every
|
|
55
|
+
* `/search` probe got the 429 checkpoint for three hours, and the monitor told
|
|
56
|
+
* the operator "Pages are not rendering on aglyn.com" and then "rendering
|
|
57
|
+
* again after 3 h" about a site that was serving pages the whole time. So a
|
|
58
|
+
* run whose only failures are challenges is BLIND: it leaves the site's
|
|
59
|
+
* verdict and failure count exactly as they were, and `threshold` blind runs
|
|
60
|
+
* in a row raise `system.renderMonitorBlind` once, which names the setup
|
|
61
|
+
* problem instead of an outage. A run with a real failure beside a challenge
|
|
62
|
+
* is still a failure, because the real one is evidence. The state lives in
|
|
50
63
|
* `operatorHealthState/render-monitor--<host>`, beside every other health
|
|
51
64
|
* check, so Staff → Operator alerts lists it with its status and since when.
|
|
52
65
|
*
|
|
@@ -330,7 +343,10 @@ function spellingLetters(host) {
|
|
|
330
343
|
];
|
|
331
344
|
}
|
|
332
345
|
/** The state document id for one origin. */ export function renderMonitorCheckId(origin) {
|
|
333
|
-
return
|
|
346
|
+
return renderMonitorHostCheckId(new URL(origin).host);
|
|
347
|
+
}
|
|
348
|
+
/** The state document id for one host, as {@link renderMonitorCheckId} names it. */ export function renderMonitorHostCheckId(host) {
|
|
349
|
+
return `${RENDER_MONITOR_CHECK_PREFIX}${host}`;
|
|
334
350
|
}
|
|
335
351
|
/**
|
|
336
352
|
* The next state for one site, given what this run saw. Pure.
|
|
@@ -339,15 +355,47 @@ function spellingLetters(host) {
|
|
|
339
355
|
* failed, so one slow cold start or one dropped connection says nothing. It
|
|
340
356
|
* turns back on the first passing run, because a render that works is
|
|
341
357
|
* proof, and a recovery held back is a recovery nobody hears about.
|
|
358
|
+
*
|
|
359
|
+
* A BLIND observation (every failure was a bot-protection challenge) changes
|
|
360
|
+
* nothing about the site: its status, failure count and since-time carry over
|
|
361
|
+
* untouched, and only the blind count moves. It transitions to `blind` once,
|
|
362
|
+
* on the `threshold`-th blind run in a row.
|
|
342
363
|
*/ export function nextRenderMonitorState(prior, observation, context) {
|
|
343
364
|
var _observation_detail, _ref, _ref1, _ref2, _ref3;
|
|
344
365
|
const { origin, label, threshold, now } = context;
|
|
345
366
|
const checkId = renderMonitorCheckId(origin);
|
|
346
367
|
const detail = String((_observation_detail = observation.detail) != null ? _observation_detail : '').slice(0, 1000);
|
|
347
368
|
const wasDegraded = (prior == null ? void 0 : prior.status) === 'degraded';
|
|
348
|
-
if (observation.ok) {
|
|
369
|
+
if (!observation.ok && observation.blind) {
|
|
370
|
+
var _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10;
|
|
371
|
+
const blindRuns = ((_ref4 = prior == null ? void 0 : prior.consecutiveBlindRuns) != null ? _ref4 : 0) + 1;
|
|
349
372
|
return {
|
|
350
373
|
next: {
|
|
374
|
+
checkId,
|
|
375
|
+
label,
|
|
376
|
+
origin,
|
|
377
|
+
status: wasDegraded ? 'degraded' : 'ok',
|
|
378
|
+
sinceMs: (_ref5 = prior == null ? void 0 : prior.sinceMs) != null ? _ref5 : now,
|
|
379
|
+
detail: `The monitor cannot see this site: ${detail}`.slice(0, 1000),
|
|
380
|
+
updatedAtMs: now,
|
|
381
|
+
consecutiveFailures: (_ref6 = prior == null ? void 0 : prior.consecutiveFailures) != null ? _ref6 : 0,
|
|
382
|
+
failingSinceMs: (_ref7 = prior == null ? void 0 : prior.failingSinceMs) != null ? _ref7 : null,
|
|
383
|
+
lastOkAtMs: (_ref8 = prior == null ? void 0 : prior.lastOkAtMs) != null ? _ref8 : null,
|
|
384
|
+
consecutiveBlindRuns: blindRuns,
|
|
385
|
+
blindSinceMs: (_ref9 = prior == null ? void 0 : prior.blindSinceMs) != null ? _ref9 : now,
|
|
386
|
+
lastObservedAtMs: (_ref10 = prior == null ? void 0 : prior.lastObservedAtMs) != null ? _ref10 : null
|
|
387
|
+
},
|
|
388
|
+
transition: blindRuns === Math.max(1, threshold) ? 'blind' : null
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const sighted = {
|
|
392
|
+
consecutiveBlindRuns: 0,
|
|
393
|
+
blindSinceMs: null,
|
|
394
|
+
lastObservedAtMs: now
|
|
395
|
+
};
|
|
396
|
+
if (observation.ok) {
|
|
397
|
+
return {
|
|
398
|
+
next: _extends({
|
|
351
399
|
checkId,
|
|
352
400
|
label,
|
|
353
401
|
origin,
|
|
@@ -358,7 +406,7 @@ function spellingLetters(host) {
|
|
|
358
406
|
consecutiveFailures: 0,
|
|
359
407
|
failingSinceMs: null,
|
|
360
408
|
lastOkAtMs: now
|
|
361
|
-
},
|
|
409
|
+
}, sighted),
|
|
362
410
|
transition: wasDegraded ? 'recovered' : null
|
|
363
411
|
};
|
|
364
412
|
}
|
|
@@ -366,7 +414,7 @@ function spellingLetters(host) {
|
|
|
366
414
|
const failingSinceMs = (_ref1 = prior == null ? void 0 : prior.failingSinceMs) != null ? _ref1 : now;
|
|
367
415
|
const degraded = wasDegraded || failures >= Math.max(1, threshold);
|
|
368
416
|
return {
|
|
369
|
-
next: {
|
|
417
|
+
next: _extends({
|
|
370
418
|
checkId,
|
|
371
419
|
label,
|
|
372
420
|
origin,
|
|
@@ -377,7 +425,7 @@ function spellingLetters(host) {
|
|
|
377
425
|
consecutiveFailures: failures,
|
|
378
426
|
failingSinceMs,
|
|
379
427
|
lastOkAtMs: (_ref3 = prior == null ? void 0 : prior.lastOkAtMs) != null ? _ref3 : null
|
|
380
|
-
},
|
|
428
|
+
}, sighted),
|
|
381
429
|
transition: degraded && !wasDegraded ? 'failing' : null
|
|
382
430
|
};
|
|
383
431
|
}
|
|
@@ -505,14 +553,16 @@ function probeLine(row) {
|
|
|
505
553
|
...pageProbe
|
|
506
554
|
]);
|
|
507
555
|
const ok = probes.every((probe)=>probe.ok);
|
|
556
|
+
const failing = probes.filter((probe)=>!probe.ok);
|
|
557
|
+
const blind = !ok && failing.every((probe)=>probe.challenged);
|
|
508
558
|
const result = {
|
|
509
559
|
origin,
|
|
510
560
|
ok,
|
|
561
|
+
blind,
|
|
511
562
|
probes
|
|
512
563
|
};
|
|
513
564
|
if (dryRun) return result;
|
|
514
565
|
const host = new URL(origin).host;
|
|
515
|
-
const failing = probes.filter((probe)=>!probe.ok);
|
|
516
566
|
const detail = ok ? 'Fresh renders pass.' : failing.map(probeLine).join('; ');
|
|
517
567
|
try {
|
|
518
568
|
const now = clock();
|
|
@@ -520,7 +570,8 @@ function probeLine(row) {
|
|
|
520
570
|
var _ref, _ref1;
|
|
521
571
|
const decided = nextRenderMonitorState(prior, {
|
|
522
572
|
ok,
|
|
523
|
-
detail
|
|
573
|
+
detail,
|
|
574
|
+
blind
|
|
524
575
|
}, {
|
|
525
576
|
origin,
|
|
526
577
|
label: `Page rendering on ${host}`,
|
|
@@ -548,6 +599,16 @@ function probeLine(row) {
|
|
|
548
599
|
detail
|
|
549
600
|
}
|
|
550
601
|
});
|
|
602
|
+
} else if (transition === 'blind') {
|
|
603
|
+
var _next_consecutiveBlindRuns;
|
|
604
|
+
await raise('system.renderMonitorBlind', {
|
|
605
|
+
dedupeKey: host,
|
|
606
|
+
context: {
|
|
607
|
+
site: host,
|
|
608
|
+
runs: (_next_consecutiveBlindRuns = next.consecutiveBlindRuns) != null ? _next_consecutiveBlindRuns : threshold,
|
|
609
|
+
detail
|
|
610
|
+
}
|
|
611
|
+
});
|
|
551
612
|
} else if (transition === 'recovered') {
|
|
552
613
|
await raise('system.siteRenderRecovered', {
|
|
553
614
|
dedupeKey: host,
|
|
@@ -569,5 +630,141 @@ function probeLine(row) {
|
|
|
569
630
|
sites
|
|
570
631
|
};
|
|
571
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* A verdict older than this says the monitor stopped, not that the site did.
|
|
635
|
+
* Four missed five-minute runs.
|
|
636
|
+
*/ export const RENDER_PAGES_STALE_MS = 20 * 60000;
|
|
637
|
+
/** How many sites one `?site=` may ask about. */ export const RENDER_PAGES_MAX_SITES = 10;
|
|
638
|
+
/**
|
|
639
|
+
* Which watched hosts a `?site=` asks about. Pure.
|
|
640
|
+
*
|
|
641
|
+
* Absent or empty asks about every watched site. A host that is not watched
|
|
642
|
+
* is kept, so the caller can answer it red: a public monitor pointed at a
|
|
643
|
+
* site nobody renders is a monitor that can never go red, which is the
|
|
644
|
+
* mistake this door exists to undo.
|
|
645
|
+
*/ export function requestedRenderPagesHosts(query, watchedOrigins) {
|
|
646
|
+
const asked = String(query != null ? query : '').split(/[\s,]+/).map((entry)=>entry.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')).filter((entry)=>/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(entry));
|
|
647
|
+
const hosts = asked.length ? asked : watchedOrigins.map((origin)=>new URL(origin).host);
|
|
648
|
+
return [
|
|
649
|
+
...new Set(hosts)
|
|
650
|
+
].slice(0, RENDER_PAGES_MAX_SITES);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The public verdict on real pages, per site, from the render monitor's
|
|
654
|
+
* stored state (AGL-3580). Pure.
|
|
655
|
+
*
|
|
656
|
+
* This is what the status page's `Published sites` and `Marketing site`
|
|
657
|
+
* monitors read. Before it they read `/api/health/render/*`, which builds a
|
|
658
|
+
* node tree inside a route handler and never draws a page, and both stayed at
|
|
659
|
+
* 100% through two outages on 2026-10-05 in which every fresh page hung.
|
|
660
|
+
*
|
|
661
|
+
* Red is reserved for the one thing a visitor would notice:
|
|
662
|
+
*
|
|
663
|
+
* - `not-rendering` — the monitor saw the site fail to render pages it could
|
|
664
|
+
* not have served from a cache, `threshold` runs in a row.
|
|
665
|
+
* - `not-watched` — the monitor renders nothing for this host, so this row
|
|
666
|
+
* could never go red; fix the monitor's config or the URL.
|
|
667
|
+
* - `state-unavailable` — the verdicts could not be read at all.
|
|
668
|
+
*
|
|
669
|
+
* Green with a code is a verdict this door cannot improve on, and is never
|
|
670
|
+
* reported as an outage, because it is not one:
|
|
671
|
+
*
|
|
672
|
+
* - `awaiting-first-run` — a site added since the monitor last ran.
|
|
673
|
+
* - `monitor-blind` — bot protection answered the monitor; the site's last
|
|
674
|
+
* real verdict stands, and the operator alert says what to fix.
|
|
675
|
+
* - `observation-stale` — no run in {@link RENDER_PAGES_STALE_MS}; the
|
|
676
|
+
* monitor's own `render-monitor` row on `/api/health/crons` is what goes
|
|
677
|
+
* red for that, so a stopped scheduler is never told as a site outage.
|
|
678
|
+
*/ export function renderPagesHealth(hosts, watchedOrigins, states, ms, now = Date.now()) {
|
|
679
|
+
const watched = new Set(watchedOrigins.map((origin)=>new URL(origin).host));
|
|
680
|
+
const iso = (value)=>typeof value === 'number' && Number.isFinite(value) ? new Date(value).toISOString() : null;
|
|
681
|
+
const checks = {};
|
|
682
|
+
if (!hosts.length) {
|
|
683
|
+
checks['sites'] = {
|
|
684
|
+
ok: false,
|
|
685
|
+
ms,
|
|
686
|
+
code: 'no-sites-watched',
|
|
687
|
+
lastObservedAt: null,
|
|
688
|
+
since: null
|
|
689
|
+
};
|
|
690
|
+
return checks;
|
|
691
|
+
}
|
|
692
|
+
for (const host of hosts){
|
|
693
|
+
var _ref, _state_lastObservedAtMs, _state_consecutiveBlindRuns, _state_updatedAtMs;
|
|
694
|
+
const blank = {
|
|
695
|
+
ms,
|
|
696
|
+
lastObservedAt: null,
|
|
697
|
+
since: null
|
|
698
|
+
};
|
|
699
|
+
if (!watched.has(host)) {
|
|
700
|
+
checks[host] = _extends({}, blank, {
|
|
701
|
+
ok: false,
|
|
702
|
+
code: 'not-watched'
|
|
703
|
+
});
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (states === null) {
|
|
707
|
+
checks[host] = _extends({}, blank, {
|
|
708
|
+
ok: false,
|
|
709
|
+
code: 'state-unavailable'
|
|
710
|
+
});
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const state = states.get(renderMonitorHostCheckId(host));
|
|
714
|
+
if (!state) {
|
|
715
|
+
checks[host] = _extends({}, blank, {
|
|
716
|
+
ok: true,
|
|
717
|
+
code: 'awaiting-first-run'
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const observed = (_ref = (_state_lastObservedAtMs = state.lastObservedAtMs) != null ? _state_lastObservedAtMs : state.lastOkAtMs) != null ? _ref : null;
|
|
722
|
+
const row = {
|
|
723
|
+
ms,
|
|
724
|
+
lastObservedAt: iso(observed),
|
|
725
|
+
since: iso(state.sinceMs)
|
|
726
|
+
};
|
|
727
|
+
if (state.status === 'degraded') {
|
|
728
|
+
checks[host] = _extends({}, row, {
|
|
729
|
+
ok: false,
|
|
730
|
+
code: 'not-rendering'
|
|
731
|
+
});
|
|
732
|
+
} else if (((_state_consecutiveBlindRuns = state.consecutiveBlindRuns) != null ? _state_consecutiveBlindRuns : 0) > 0) {
|
|
733
|
+
checks[host] = _extends({}, row, {
|
|
734
|
+
ok: true,
|
|
735
|
+
code: 'monitor-blind'
|
|
736
|
+
});
|
|
737
|
+
} else if (now - ((_state_updatedAtMs = state.updatedAtMs) != null ? _state_updatedAtMs : 0) > RENDER_PAGES_STALE_MS) {
|
|
738
|
+
checks[host] = _extends({}, row, {
|
|
739
|
+
ok: true,
|
|
740
|
+
code: 'observation-stale'
|
|
741
|
+
});
|
|
742
|
+
} else {
|
|
743
|
+
checks[host] = _extends({}, row, {
|
|
744
|
+
ok: true
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return checks;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Read the stored verdicts for `hosts`, one batched read. Null when the read
|
|
752
|
+
* fails, which {@link renderPagesHealth} reports red rather than calm.
|
|
753
|
+
*/ export async function readRenderMonitorStates(hosts) {
|
|
754
|
+
try {
|
|
755
|
+
const { default: firebaseAdmin } = await import("./firebase-admin.js");
|
|
756
|
+
const db = firebaseAdmin.app().firestore();
|
|
757
|
+
const refs = hosts.map((host)=>db.collection(RENDER_MONITOR_COLLECTION).doc(renderMonitorHostCheckId(host)));
|
|
758
|
+
const snapshots = refs.length ? await db.getAll(...refs) : [];
|
|
759
|
+
const states = new Map();
|
|
760
|
+
for (const snapshot of snapshots){
|
|
761
|
+
if (snapshot.exists) states.set(snapshot.id, snapshot.data());
|
|
762
|
+
}
|
|
763
|
+
return states;
|
|
764
|
+
} catch (error) {
|
|
765
|
+
console.error('[render-monitor] stored verdicts could not be read', error);
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
572
769
|
|
|
573
770
|
//# sourceMappingURL=render-monitor.js.map
|