feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -11,7 +11,8 @@ import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
|
||||
* comparison and calculation runs in SQL against now() - the database
|
||||
* clock - never against a JS Date.
|
||||
*/
|
||||
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
|
||||
export type OutboxTable =
|
||||
'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery';
|
||||
|
||||
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
|
||||
|
||||
@@ -29,6 +30,9 @@ interface OutboxTableSql {
|
||||
successStatus: 'DONE' | 'DELIVERED';
|
||||
}
|
||||
|
||||
/** Outbox tables whose entries can be put back or skipped by the sender. */
|
||||
export type DeliveryOutboxTable = 'NotificationDelivery';
|
||||
|
||||
/**
|
||||
* Table, enum and column names cannot be bound as parameters. They come
|
||||
* only from these closed maps - never from caller input - which is what
|
||||
@@ -47,6 +51,12 @@ const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
NotificationDelivery: {
|
||||
table: Prisma.raw('"NotificationDelivery"'),
|
||||
statusType: Prisma.raw('"NotificationDeliveryStatus"'),
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
};
|
||||
|
||||
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
@@ -54,6 +64,10 @@ const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
createdAt: Prisma.raw('"createdAt"'),
|
||||
};
|
||||
|
||||
/** Longest wait deferClaim allows: a day. */
|
||||
const MAX_DEFER_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_SKIP_REASON_CHARS = 200;
|
||||
|
||||
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
|
||||
|
||||
function outboxTable(table: OutboxTable): OutboxTableSql {
|
||||
@@ -206,3 +220,56 @@ export async function settleFailure(
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a claimed entry back to PENDING, due after `delayMs` by the database
|
||||
* clock, without counting an attempt: the entry did not fail, it has to wait
|
||||
* (rate limit from the receiver, quiet period, paused bot); the delay is
|
||||
* capped at 24 hours. Compare-and-set
|
||||
* on id + claimedAt; false if the claim was lost.
|
||||
*/
|
||||
export async function deferClaim(
|
||||
db: DbClient,
|
||||
table: DeliveryOutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
delayMs: number,
|
||||
): Promise<boolean> {
|
||||
if (!Number.isInteger(delayMs) || delayMs < 0) {
|
||||
throw new RangeError(
|
||||
`Defer delay must be a non-negative integer, got ${delayMs}`,
|
||||
);
|
||||
}
|
||||
const { table: name } = outboxTable(table);
|
||||
const cappedDelayMs = Math.min(delayMs, MAX_DEFER_MS);
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = 'PENDING',
|
||||
"claimedAt" = NULL,
|
||||
"availableAt" = now() + (${cappedDelayMs} * interval '1 millisecond')
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a claimed entry as SKIPPED: it will never be sent (the recipient
|
||||
* unlinked, the message went stale). `reason` lands in lastError and must
|
||||
* not contain personal data or secrets; it is cut to 200 characters. Compare-and-set on id + claimedAt;
|
||||
* false if the claim was lost.
|
||||
*/
|
||||
export async function settleSkipped(
|
||||
db: DbClient,
|
||||
table: DeliveryOutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
reason: string,
|
||||
): Promise<boolean> {
|
||||
const { table: name, statusType } = outboxTable(table);
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = CAST(${'SKIPPED'} AS ${statusType}),
|
||||
"claimedAt" = NULL,
|
||||
"lastError" = ${reason.slice(0, MAX_SKIP_REASON_CHARS)}
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
Reference in new issue
Block a user