@tokenoftrust/storefront-runner 1.3.4-rc.3 → 1.4.0
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/apps/storefront/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +496 -74
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook event/delivery storage — PrivateApps epic, D4 (Chunk A: Storage
|
|
3
|
+
* only). Owns the `webhook_events` / `webhook_deliveries` /
|
|
4
|
+
* `webhook_delivery_attempts` / `webhook_install_health` rows described in
|
|
5
|
+
* `../d1/schema.ts`. Signing (Chunk B), the retry/backoff dispatcher
|
|
6
|
+
* (Chunk C), event emission/targeting (Chunk D), and the `/webhooks/*`
|
|
7
|
+
* routes (Chunk E) all build on this store; none of that policy lives here.
|
|
8
|
+
*
|
|
9
|
+
* Pure over the `Queryable` seam (see `../d1/catalog.ts`) — the same shape
|
|
10
|
+
* `AppRegistryService` uses, so this runs unchanged against real D1 in the
|
|
11
|
+
* Worker and an in-memory `node:sqlite` in tests.
|
|
12
|
+
*
|
|
13
|
+
* Two ids matter and are easy to conflate:
|
|
14
|
+
* - `eventId` (`webhook_events.id`) — the semantic event; ONE per business
|
|
15
|
+
* occurrence (e.g. one order.created). Never sent on the wire directly.
|
|
16
|
+
* - `deliveryId` (`webhook_deliveries.delivery_id`) — the CloudEvents
|
|
17
|
+
* envelope `id` for one (event × target install) delivery. This is what
|
|
18
|
+
* goes on the wire and is what receivers dedupe on; it stays stable
|
|
19
|
+
* across that delivery's own retries AND across a manual replay (Chunk
|
|
20
|
+
* E's `requeueForReplay` resets state/attempts on the SAME row rather
|
|
21
|
+
* than minting a new one — a receiver that already acked a delivered
|
|
22
|
+
* delivery must still dedupe a replayed resend of it).
|
|
23
|
+
*
|
|
24
|
+
* Responsibility split for the two mutating paths a dispatcher (Chunk C)
|
|
25
|
+
* drives per attempt:
|
|
26
|
+
* - `recordAttempt` is dumb bookkeeping of ONE HTTP attempt's outcome: it
|
|
27
|
+
* appends to `webhook_delivery_attempts`, bumps the delivery's
|
|
28
|
+
* `attempts`/`last_*` columns, and folds the outcome into the install's
|
|
29
|
+
* health rollup (`lastSuccessAt`/`failureStreak`/`last_*`/recent trace
|
|
30
|
+
* ids). It never changes `state`.
|
|
31
|
+
* - `transitionState` is the retry-POLICY decision (how many attempts,
|
|
32
|
+
* what backoff, when to give up) — deliberately left to the caller, not
|
|
33
|
+
* computed here. It only moves `state` (and `next_attempt_at`), and
|
|
34
|
+
* bumps `dlqCount` on the one transition that means "gave up":
|
|
35
|
+
* dead-lettering.
|
|
36
|
+
*
|
|
37
|
+
* The `Queryable` seam has no multi-statement transaction primitive, so
|
|
38
|
+
* "transactionally-in-spirit" here means: do the writes in a fixed order
|
|
39
|
+
* (attempt row, then delivery row, then health rollup) and always re-read
|
|
40
|
+
* the delivery via `getOrThrowDelivery` before returning it, the same
|
|
41
|
+
* read-after-write discipline `AppRegistryService` uses.
|
|
42
|
+
*/
|
|
43
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
44
|
+
|
|
45
|
+
/** Mirrors the `Delivery.topic` / CloudEvents channel enum in the D1/D2 contract. */
|
|
46
|
+
export type WebhookTopic =
|
|
47
|
+
| "app.installed"
|
|
48
|
+
| "app.uninstalled"
|
|
49
|
+
| "catalog.product.updated"
|
|
50
|
+
| "inventory.changed"
|
|
51
|
+
| "order.created"
|
|
52
|
+
| "order.fulfilled"
|
|
53
|
+
| "customer.marketing_consent.updated"
|
|
54
|
+
| "attribution.finalized";
|
|
55
|
+
|
|
56
|
+
/** Mirrors `Delivery.state` in `docs/private-apps/contract/openapi.yaml` EXACTLY. */
|
|
57
|
+
export type DeliveryState = "pending" | "retrying" | "delivered" | "dead-lettered";
|
|
58
|
+
|
|
59
|
+
export interface WebhookEvent {
|
|
60
|
+
id: string;
|
|
61
|
+
tenantId: string;
|
|
62
|
+
topic: WebhookTopic;
|
|
63
|
+
dataschemaVersion: string;
|
|
64
|
+
/** The CloudEvents `data` payload — event-specific shape, opaque to the store. */
|
|
65
|
+
payload: unknown;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface WebhookDelivery {
|
|
70
|
+
deliveryId: string;
|
|
71
|
+
eventId: string;
|
|
72
|
+
installId: string;
|
|
73
|
+
tenantId: string;
|
|
74
|
+
topic: WebhookTopic;
|
|
75
|
+
state: DeliveryState;
|
|
76
|
+
attempts: number;
|
|
77
|
+
lastStatus: number | null;
|
|
78
|
+
lastErrorClass: string | null;
|
|
79
|
+
lastTraceId: string | null;
|
|
80
|
+
nextAttemptAt: string | null;
|
|
81
|
+
createdAt: string;
|
|
82
|
+
updatedAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The openapi `InstallHealth` shape — no field is required there, so a fresh install reads as healthy/zeroed. */
|
|
86
|
+
export interface InstallHealth {
|
|
87
|
+
status: "healthy" | "degraded" | "unhealthy";
|
|
88
|
+
lastSuccessAt?: string;
|
|
89
|
+
failureStreak: number;
|
|
90
|
+
dlqCount: number;
|
|
91
|
+
/** p95 delivery latency (ms) across the install's recent attempts — see {@link WebhookDeliveryStore.getInstallHealth}. Absent when no attempt has a recorded duration. */
|
|
92
|
+
p95DeliveryMs?: number;
|
|
93
|
+
lastStatus?: number;
|
|
94
|
+
lastErrorClass?: string;
|
|
95
|
+
recentTraceIds: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CreateEventInput {
|
|
99
|
+
/** Optional caller-supplied event id (e.g. for a caller-owned idempotency key). Defaults to a fresh UUID. */
|
|
100
|
+
eventId?: string;
|
|
101
|
+
tenantId: string;
|
|
102
|
+
topic: WebhookTopic;
|
|
103
|
+
dataschemaVersion: string;
|
|
104
|
+
payload: unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DeliveryTarget {
|
|
108
|
+
installId: string;
|
|
109
|
+
/** Optional caller-supplied delivery id. Defaults to a fresh UUID (the wire CloudEvents `id`). */
|
|
110
|
+
deliveryId?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface CreateEventResult {
|
|
114
|
+
event: WebhookEvent;
|
|
115
|
+
deliveries: WebhookDelivery[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ListDeliveriesOptions {
|
|
119
|
+
state?: DeliveryState;
|
|
120
|
+
/** Cap the result, newest first. Used internally by {@link WebhookDeliveryStore.getInstallHealth} to bound its p95 sample; unset means unbounded (the historical behavior). */
|
|
121
|
+
limit?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface RecordAttemptInput {
|
|
125
|
+
deliveryId: string;
|
|
126
|
+
attemptNo: number;
|
|
127
|
+
startedAt: string;
|
|
128
|
+
durationMs?: number | null;
|
|
129
|
+
httpStatus?: number | null;
|
|
130
|
+
errorClass?: string | null;
|
|
131
|
+
traceId?: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface TransitionStateInput {
|
|
135
|
+
deliveryId: string;
|
|
136
|
+
toState: DeliveryState;
|
|
137
|
+
/** Required (and only meaningful) when `toState` is `"retrying"`. */
|
|
138
|
+
nextAttemptAt?: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ScanDueRetriesInput {
|
|
142
|
+
/** Deliveries with `next_attempt_at <= now` are due. */
|
|
143
|
+
now: string;
|
|
144
|
+
limit?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type WebhookStoreErrorCode = "not_found" | "invalid_transition";
|
|
148
|
+
|
|
149
|
+
/** Thrown by every rule violation below — callers branch on `.code`. */
|
|
150
|
+
export class WebhookStoreError extends Error {
|
|
151
|
+
constructor(
|
|
152
|
+
message: string,
|
|
153
|
+
readonly code: WebhookStoreErrorCode,
|
|
154
|
+
) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = "WebhookStoreError";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const MAX_RECENT_TRACE_IDS = 10;
|
|
161
|
+
|
|
162
|
+
// How many of an install's most recent deliveries feed the p95 sample in
|
|
163
|
+
// `getInstallHealth` — a bound, not a precise percentile guarantee; recent
|
|
164
|
+
// behavior is what the health view cares about, not all-time history.
|
|
165
|
+
const P95_SAMPLE_SIZE = 50;
|
|
166
|
+
|
|
167
|
+
// A delivery only ever moves forward via `transitionState`; `delivered`/
|
|
168
|
+
// `dead-lettered` are terminal THERE. A manual replay (Chunk E's
|
|
169
|
+
// `requeueForReplay`) is the one deliberate exception: it resets a delivery
|
|
170
|
+
// out of a terminal state back to `pending` on the SAME row, bypassing this
|
|
171
|
+
// table entirely — see that method's own doc comment for why.
|
|
172
|
+
const ALLOWED_TRANSITIONS: Record<DeliveryState, readonly DeliveryState[]> = {
|
|
173
|
+
pending: ["retrying", "delivered", "dead-lettered"],
|
|
174
|
+
retrying: ["retrying", "delivered", "dead-lettered"],
|
|
175
|
+
delivered: [],
|
|
176
|
+
"dead-lettered": [],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Failure-streak thresholds for the `InstallHealth.status` rollup. A DLQ hit
|
|
180
|
+
// always reads as unhealthy, regardless of streak.
|
|
181
|
+
const DEGRADED_FAILURE_STREAK = 1;
|
|
182
|
+
const UNHEALTHY_FAILURE_STREAK = 5;
|
|
183
|
+
|
|
184
|
+
function isSuccessAttempt(httpStatus: number | null | undefined): boolean {
|
|
185
|
+
return httpStatus != null && httpStatus >= 200 && httpStatus < 300;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Prepend a trace id (if present) and cap the list, newest first. */
|
|
189
|
+
function pushTraceId(existing: string[], traceId: string | null | undefined): string[] {
|
|
190
|
+
if (!traceId) return existing;
|
|
191
|
+
return [traceId, ...existing].slice(0, MAX_RECENT_TRACE_IDS);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Raw `webhook_events` row shape (snake_case columns). */
|
|
195
|
+
interface EventRow {
|
|
196
|
+
id: string;
|
|
197
|
+
tenant_id: string;
|
|
198
|
+
topic: string;
|
|
199
|
+
dataschema_version: string;
|
|
200
|
+
payload: string;
|
|
201
|
+
created_at: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Raw `webhook_deliveries` row shape (snake_case columns). */
|
|
205
|
+
interface DeliveryRow {
|
|
206
|
+
delivery_id: string;
|
|
207
|
+
event_id: string;
|
|
208
|
+
install_id: string;
|
|
209
|
+
tenant_id: string;
|
|
210
|
+
topic: string;
|
|
211
|
+
state: string;
|
|
212
|
+
attempts: number;
|
|
213
|
+
last_status: number | null;
|
|
214
|
+
last_error_class: string | null;
|
|
215
|
+
last_trace_id: string | null;
|
|
216
|
+
next_attempt_at: string | null;
|
|
217
|
+
created_at: string;
|
|
218
|
+
updated_at: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Raw `webhook_install_health` row shape (snake_case columns). */
|
|
222
|
+
interface HealthRow {
|
|
223
|
+
install_id: string;
|
|
224
|
+
last_success_at: string | null;
|
|
225
|
+
failure_streak: number;
|
|
226
|
+
dlq_count: number;
|
|
227
|
+
last_status: number | null;
|
|
228
|
+
last_error_class: string | null;
|
|
229
|
+
recent_trace_ids: string;
|
|
230
|
+
updated_at: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function toDomain(row: DeliveryRow): WebhookDelivery {
|
|
234
|
+
return {
|
|
235
|
+
deliveryId: row.delivery_id,
|
|
236
|
+
eventId: row.event_id,
|
|
237
|
+
installId: row.install_id,
|
|
238
|
+
tenantId: row.tenant_id,
|
|
239
|
+
topic: row.topic as WebhookTopic,
|
|
240
|
+
state: row.state as DeliveryState,
|
|
241
|
+
attempts: row.attempts,
|
|
242
|
+
lastStatus: row.last_status,
|
|
243
|
+
lastErrorClass: row.last_error_class,
|
|
244
|
+
lastTraceId: row.last_trace_id,
|
|
245
|
+
nextAttemptAt: row.next_attempt_at,
|
|
246
|
+
createdAt: row.created_at,
|
|
247
|
+
updatedAt: row.updated_at,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function toEventDomain(row: EventRow): WebhookEvent {
|
|
252
|
+
return {
|
|
253
|
+
id: row.id,
|
|
254
|
+
tenantId: row.tenant_id,
|
|
255
|
+
topic: row.topic as WebhookTopic,
|
|
256
|
+
dataschemaVersion: row.dataschema_version,
|
|
257
|
+
payload: JSON.parse(row.payload) as unknown,
|
|
258
|
+
createdAt: row.created_at,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function healthStatus(row: HealthRow | null): InstallHealth["status"] {
|
|
263
|
+
if (!row) return "healthy";
|
|
264
|
+
if (row.dlq_count > 0 || row.failure_streak >= UNHEALTHY_FAILURE_STREAK) return "unhealthy";
|
|
265
|
+
if (row.failure_streak >= DEGRADED_FAILURE_STREAK) return "degraded";
|
|
266
|
+
return "healthy";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function toInstallHealth(row: HealthRow | null): InstallHealth {
|
|
270
|
+
return {
|
|
271
|
+
status: healthStatus(row),
|
|
272
|
+
lastSuccessAt: row?.last_success_at ?? undefined,
|
|
273
|
+
failureStreak: row?.failure_streak ?? 0,
|
|
274
|
+
dlqCount: row?.dlq_count ?? 0,
|
|
275
|
+
lastStatus: row?.last_status ?? undefined,
|
|
276
|
+
lastErrorClass: row?.last_error_class ?? undefined,
|
|
277
|
+
recentTraceIds: row ? (JSON.parse(row.recent_trace_ids) as string[]) : [],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export class WebhookDeliveryStore {
|
|
282
|
+
constructor(
|
|
283
|
+
private readonly db: Queryable,
|
|
284
|
+
private readonly now: () => string = () => new Date().toISOString(),
|
|
285
|
+
) {}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Write one immutable event, then fan it out to one `pending` delivery per
|
|
289
|
+
* target install. Targeting (which installs get the event) is the caller's
|
|
290
|
+
* job (Chunk D) — this just persists whatever target list it's handed.
|
|
291
|
+
*/
|
|
292
|
+
async createEvent(input: CreateEventInput, targets: readonly DeliveryTarget[]): Promise<CreateEventResult> {
|
|
293
|
+
const eventId = input.eventId ?? crypto.randomUUID();
|
|
294
|
+
const now = this.now();
|
|
295
|
+
|
|
296
|
+
await this.db.run(
|
|
297
|
+
`INSERT INTO webhook_events (id, tenant_id, topic, dataschema_version, payload, created_at)
|
|
298
|
+
VALUES (?,?,?,?,?,?)`,
|
|
299
|
+
eventId,
|
|
300
|
+
input.tenantId,
|
|
301
|
+
input.topic,
|
|
302
|
+
input.dataschemaVersion,
|
|
303
|
+
JSON.stringify(input.payload),
|
|
304
|
+
now,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const deliveries: WebhookDelivery[] = [];
|
|
308
|
+
for (const target of targets) {
|
|
309
|
+
const deliveryId = target.deliveryId ?? crypto.randomUUID();
|
|
310
|
+
await this.db.run(
|
|
311
|
+
`INSERT INTO webhook_deliveries
|
|
312
|
+
(delivery_id, event_id, install_id, tenant_id, topic, state, attempts, created_at, updated_at)
|
|
313
|
+
VALUES (?,?,?,?,?,'pending',0,?,?)`,
|
|
314
|
+
deliveryId,
|
|
315
|
+
eventId,
|
|
316
|
+
target.installId,
|
|
317
|
+
input.tenantId,
|
|
318
|
+
input.topic,
|
|
319
|
+
now,
|
|
320
|
+
now,
|
|
321
|
+
);
|
|
322
|
+
deliveries.push(await this.getOrThrowDelivery(deliveryId));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
event: {
|
|
327
|
+
id: eventId,
|
|
328
|
+
tenantId: input.tenantId,
|
|
329
|
+
topic: input.topic,
|
|
330
|
+
dataschemaVersion: input.dataschemaVersion,
|
|
331
|
+
payload: input.payload,
|
|
332
|
+
createdAt: now,
|
|
333
|
+
},
|
|
334
|
+
deliveries,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Read one delivery by id, or `null` if it doesn't exist. */
|
|
339
|
+
async getDelivery(deliveryId: string): Promise<WebhookDelivery | null> {
|
|
340
|
+
const row = await this.db.first<DeliveryRow>(
|
|
341
|
+
`SELECT * FROM webhook_deliveries WHERE delivery_id = ?`,
|
|
342
|
+
deliveryId,
|
|
343
|
+
);
|
|
344
|
+
return row ? toDomain(row) : null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read one event by id, or `null` if it doesn't exist. Added for the
|
|
349
|
+
* dispatcher/delivery engine (Chunk C): `attemptDelivery` resolves a
|
|
350
|
+
* delivery's `eventId` via `getDelivery`, then needs this to fetch the
|
|
351
|
+
* `payload`/`dataschemaVersion` that only `webhook_events` (not
|
|
352
|
+
* `webhook_deliveries`) carries, in order to build the CloudEvents
|
|
353
|
+
* envelope's `data`.
|
|
354
|
+
*/
|
|
355
|
+
async getEvent(eventId: string): Promise<WebhookEvent | null> {
|
|
356
|
+
const row = await this.db.first<EventRow>(`SELECT * FROM webhook_events WHERE id = ?`, eventId);
|
|
357
|
+
return row ? toEventDomain(row) : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** All deliveries for one install (an install only ever sees its own — enforced by the caller), newest first. Optionally capped via `opts.limit`. */
|
|
361
|
+
async listDeliveriesByInstall(
|
|
362
|
+
installId: string,
|
|
363
|
+
opts: ListDeliveriesOptions = {},
|
|
364
|
+
): Promise<WebhookDelivery[]> {
|
|
365
|
+
const params: unknown[] = [installId];
|
|
366
|
+
let sql = `SELECT * FROM webhook_deliveries WHERE install_id = ?`;
|
|
367
|
+
if (opts.state) {
|
|
368
|
+
sql += ` AND state = ?`;
|
|
369
|
+
params.push(opts.state);
|
|
370
|
+
}
|
|
371
|
+
sql += ` ORDER BY created_at DESC`;
|
|
372
|
+
if (opts.limit != null) {
|
|
373
|
+
sql += ` LIMIT ?`;
|
|
374
|
+
params.push(opts.limit);
|
|
375
|
+
}
|
|
376
|
+
const rows = await this.db.all<DeliveryRow>(sql, ...params);
|
|
377
|
+
return rows.map(toDomain);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* All deliveries for a tenant, across every install (the ops/support admin
|
|
382
|
+
* view — an install only ever sees its OWN deliveries via
|
|
383
|
+
* {@link listDeliveriesByInstall}; this is the tenant-wide superset), newest
|
|
384
|
+
* first. Added for D4 Chunk E's admin route; Chunk A/B/C never call this.
|
|
385
|
+
*/
|
|
386
|
+
async listDeliveriesByTenant(
|
|
387
|
+
tenantId: string,
|
|
388
|
+
opts: ListDeliveriesOptions = {},
|
|
389
|
+
): Promise<WebhookDelivery[]> {
|
|
390
|
+
const rows = opts.state
|
|
391
|
+
? await this.db.all<DeliveryRow>(
|
|
392
|
+
`SELECT * FROM webhook_deliveries WHERE tenant_id = ? AND state = ? ORDER BY created_at DESC`,
|
|
393
|
+
tenantId,
|
|
394
|
+
opts.state,
|
|
395
|
+
)
|
|
396
|
+
: await this.db.all<DeliveryRow>(
|
|
397
|
+
`SELECT * FROM webhook_deliveries WHERE tenant_id = ? ORDER BY created_at DESC`,
|
|
398
|
+
tenantId,
|
|
399
|
+
);
|
|
400
|
+
return rows.map(toDomain);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Record the outcome of one HTTP delivery attempt: append it to
|
|
405
|
+
* `webhook_delivery_attempts`, bump the delivery's `attempts`/`last_*`
|
|
406
|
+
* columns, and fold the outcome into the install's health rollup. Does
|
|
407
|
+
* NOT change `state` — see {@link transitionState}.
|
|
408
|
+
*/
|
|
409
|
+
async recordAttempt(input: RecordAttemptInput): Promise<WebhookDelivery> {
|
|
410
|
+
const row = await this.mustFindDeliveryRow(input.deliveryId);
|
|
411
|
+
const now = this.now();
|
|
412
|
+
|
|
413
|
+
await this.db.run(
|
|
414
|
+
`INSERT INTO webhook_delivery_attempts
|
|
415
|
+
(id, delivery_id, attempt_no, started_at, duration_ms, http_status, error_class, trace_id)
|
|
416
|
+
VALUES (?,?,?,?,?,?,?,?)`,
|
|
417
|
+
crypto.randomUUID(),
|
|
418
|
+
input.deliveryId,
|
|
419
|
+
input.attemptNo,
|
|
420
|
+
input.startedAt,
|
|
421
|
+
input.durationMs ?? null,
|
|
422
|
+
input.httpStatus ?? null,
|
|
423
|
+
input.errorClass ?? null,
|
|
424
|
+
input.traceId ?? null,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
await this.db.run(
|
|
428
|
+
`UPDATE webhook_deliveries
|
|
429
|
+
SET attempts = ?, last_status = ?, last_error_class = ?, last_trace_id = ?, updated_at = ?
|
|
430
|
+
WHERE delivery_id = ?`,
|
|
431
|
+
row.attempts + 1,
|
|
432
|
+
input.httpStatus ?? null,
|
|
433
|
+
input.errorClass ?? null,
|
|
434
|
+
input.traceId ?? null,
|
|
435
|
+
now,
|
|
436
|
+
input.deliveryId,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
await this.foldAttemptIntoHealth(row.install_id, input, now);
|
|
440
|
+
|
|
441
|
+
return this.getOrThrowDelivery(input.deliveryId);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Move a delivery's `state` forward per `ALLOWED_TRANSITIONS`. The
|
|
446
|
+
* caller (Chunk C's dispatcher) decides WHEN to retry vs. give up; this
|
|
447
|
+
* only records that decision. Dead-lettering bumps the install's
|
|
448
|
+
* `dlqCount` — the one rollup field {@link recordAttempt} doesn't own.
|
|
449
|
+
*/
|
|
450
|
+
async transitionState(input: TransitionStateInput): Promise<WebhookDelivery> {
|
|
451
|
+
const row = await this.mustFindDeliveryRow(input.deliveryId);
|
|
452
|
+
const from = row.state as DeliveryState;
|
|
453
|
+
if (!ALLOWED_TRANSITIONS[from].includes(input.toState)) {
|
|
454
|
+
throw new WebhookStoreError(
|
|
455
|
+
`Cannot transition delivery from "${from}" to "${input.toState}".`,
|
|
456
|
+
"invalid_transition",
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (input.toState === "retrying" && !input.nextAttemptAt) {
|
|
460
|
+
throw new WebhookStoreError(`A transition to "retrying" requires nextAttemptAt.`, "invalid_transition");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const now = this.now();
|
|
464
|
+
const nextAttemptAt = input.toState === "retrying" ? (input.nextAttemptAt as string) : null;
|
|
465
|
+
await this.db.run(
|
|
466
|
+
`UPDATE webhook_deliveries SET state = ?, next_attempt_at = ?, updated_at = ? WHERE delivery_id = ?`,
|
|
467
|
+
input.toState,
|
|
468
|
+
nextAttemptAt,
|
|
469
|
+
now,
|
|
470
|
+
input.deliveryId,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (input.toState === "dead-lettered") {
|
|
474
|
+
await this.bumpDlqCount(row.install_id, now);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return this.getOrThrowDelivery(input.deliveryId);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* D4 Chunk E's replay support: force a delivery back to `pending` with a
|
|
482
|
+
* fresh attempt budget — from ANY current state, INCLUDING the terminal
|
|
483
|
+
* `delivered`/`dead-lettered` states `transitionState`/`ALLOWED_TRANSITIONS`
|
|
484
|
+
* refuse to leave. This is deliberately a separate write path, not a call
|
|
485
|
+
* through `transitionState`: a replay is an operator/app decision to retry
|
|
486
|
+
* something the normal retry policy already gave up on (or already
|
|
487
|
+
* finished), not a policy-driven transition.
|
|
488
|
+
*
|
|
489
|
+
* The delivery's `deliveryId` is NEVER reassigned — see the header note on
|
|
490
|
+
* why it must stay stable across a replay (a receiver that already acked a
|
|
491
|
+
* `delivered` delivery needs the resend to carry the same wire id to
|
|
492
|
+
* dedupe it). `attempts` resets to 0 (a fresh {@link MAX_DELIVERY_ATTEMPTS}
|
|
493
|
+
* budget, tracked in `dispatcher.ts`) rather than continuing the old count
|
|
494
|
+
* — a replay is a new delivery cycle, not "attempt 7" of the old one.
|
|
495
|
+
* `webhook_delivery_attempts` — the append-only audit trail — is NEVER
|
|
496
|
+
* touched here; only this row's own counters move.
|
|
497
|
+
*
|
|
498
|
+
* A delivery leaving `dead-lettered` is no longer parked in the DLQ, so the
|
|
499
|
+
* install health rollup's `dlqCount` — read here as a CURRENT gauge of
|
|
500
|
+
* still-dead-lettered deliveries, not a lifetime counter — is decremented
|
|
501
|
+
* (floored at 0) to match.
|
|
502
|
+
*/
|
|
503
|
+
async requeueForReplay(deliveryId: string): Promise<WebhookDelivery> {
|
|
504
|
+
const row = await this.mustFindDeliveryRow(deliveryId);
|
|
505
|
+
const now = this.now();
|
|
506
|
+
|
|
507
|
+
await this.db.run(
|
|
508
|
+
`UPDATE webhook_deliveries
|
|
509
|
+
SET state = 'pending', attempts = 0, next_attempt_at = ?, updated_at = ?
|
|
510
|
+
WHERE delivery_id = ?`,
|
|
511
|
+
now,
|
|
512
|
+
now,
|
|
513
|
+
deliveryId,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (row.state === "dead-lettered") {
|
|
517
|
+
await this.decrementDlqCount(row.install_id, now);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return this.getOrThrowDelivery(deliveryId);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The `InstallHealth` rollup for one install — a never-attempted install
|
|
525
|
+
* reads as healthy/zeroed. `p95DeliveryMs` is computed here at READ time
|
|
526
|
+
* (not tracked as a running rollup column): no migration, no extra write
|
|
527
|
+
* on the `recordAttempt` hot path, just this one bounded read.
|
|
528
|
+
*/
|
|
529
|
+
async getInstallHealth(installId: string): Promise<InstallHealth> {
|
|
530
|
+
const row = await this.db.first<HealthRow>(
|
|
531
|
+
`SELECT * FROM webhook_install_health WHERE install_id = ?`,
|
|
532
|
+
installId,
|
|
533
|
+
);
|
|
534
|
+
const health = toInstallHealth(row);
|
|
535
|
+
const p95DeliveryMs = await this.computeP95DeliveryMs(installId);
|
|
536
|
+
return p95DeliveryMs != null ? { ...health, p95DeliveryMs } : health;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* p95 across `webhook_delivery_attempts.duration_ms` for the install's
|
|
541
|
+
* {@link P95_SAMPLE_SIZE} most recent deliveries — one bounded `IN (...)`
|
|
542
|
+
* read over that fixed set of delivery ids, sorted in JS (the sample is
|
|
543
|
+
* small enough that a DB-side percentile isn't worth the SQL). Nearest-rank
|
|
544
|
+
* method: `ceil(n * 0.95)`th smallest value, 1-indexed. `undefined` when no
|
|
545
|
+
* attempt in the sample has a recorded duration.
|
|
546
|
+
*/
|
|
547
|
+
private async computeP95DeliveryMs(installId: string): Promise<number | undefined> {
|
|
548
|
+
const recent = await this.listDeliveriesByInstall(installId, { limit: P95_SAMPLE_SIZE });
|
|
549
|
+
if (recent.length === 0) return undefined;
|
|
550
|
+
|
|
551
|
+
const placeholders = recent.map(() => "?").join(",");
|
|
552
|
+
const rows = await this.db.all<{ duration_ms: number | null }>(
|
|
553
|
+
`SELECT duration_ms FROM webhook_delivery_attempts WHERE delivery_id IN (${placeholders})`,
|
|
554
|
+
...recent.map((d) => d.deliveryId),
|
|
555
|
+
);
|
|
556
|
+
const durations = rows
|
|
557
|
+
.map((r) => r.duration_ms)
|
|
558
|
+
.filter((d): d is number => d != null)
|
|
559
|
+
.sort((a, b) => a - b);
|
|
560
|
+
if (durations.length === 0) return undefined;
|
|
561
|
+
|
|
562
|
+
const index = Math.min(durations.length - 1, Math.ceil(durations.length * 0.95) - 1);
|
|
563
|
+
return durations[index];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Deliveries in `retrying` whose `next_attempt_at` is due, earliest first. Uses `idx_webhook_deliveries_due`. */
|
|
567
|
+
async scanDueRetries(params: ScanDueRetriesInput): Promise<WebhookDelivery[]> {
|
|
568
|
+
const limit = params.limit ?? 100;
|
|
569
|
+
const rows = await this.db.all<DeliveryRow>(
|
|
570
|
+
`SELECT * FROM webhook_deliveries
|
|
571
|
+
WHERE state = 'retrying' AND next_attempt_at <= ?
|
|
572
|
+
ORDER BY next_attempt_at ASC
|
|
573
|
+
LIMIT ?`,
|
|
574
|
+
params.now,
|
|
575
|
+
limit,
|
|
576
|
+
);
|
|
577
|
+
return rows.map(toDomain);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async foldAttemptIntoHealth(installId: string, attempt: RecordAttemptInput, now: string): Promise<void> {
|
|
581
|
+
const existing = await this.findHealthRow(installId);
|
|
582
|
+
const success = isSuccessAttempt(attempt.httpStatus);
|
|
583
|
+
const recentTraceIds = pushTraceId(
|
|
584
|
+
existing ? (JSON.parse(existing.recent_trace_ids) as string[]) : [],
|
|
585
|
+
attempt.traceId,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
await this.writeHealthRow(installId, now, {
|
|
589
|
+
lastSuccessAt: success ? now : existing?.last_success_at ?? null,
|
|
590
|
+
failureStreak: success ? 0 : (existing?.failure_streak ?? 0) + 1,
|
|
591
|
+
dlqCount: existing?.dlq_count ?? 0,
|
|
592
|
+
lastStatus: attempt.httpStatus ?? existing?.last_status ?? null,
|
|
593
|
+
lastErrorClass: success ? null : attempt.errorClass ?? existing?.last_error_class ?? null,
|
|
594
|
+
recentTraceIds,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async bumpDlqCount(installId: string, now: string): Promise<void> {
|
|
599
|
+
const existing = await this.findHealthRow(installId);
|
|
600
|
+
await this.writeHealthRow(installId, now, {
|
|
601
|
+
lastSuccessAt: existing?.last_success_at ?? null,
|
|
602
|
+
failureStreak: existing?.failure_streak ?? 0,
|
|
603
|
+
dlqCount: (existing?.dlq_count ?? 0) + 1,
|
|
604
|
+
lastStatus: existing?.last_status ?? null,
|
|
605
|
+
lastErrorClass: existing?.last_error_class ?? null,
|
|
606
|
+
recentTraceIds: existing ? (JSON.parse(existing.recent_trace_ids) as string[]) : [],
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** {@link requeueForReplay}'s counterpart to `bumpDlqCount` — floors at 0; a no-op if the install has no health row yet. */
|
|
611
|
+
private async decrementDlqCount(installId: string, now: string): Promise<void> {
|
|
612
|
+
const existing = await this.findHealthRow(installId);
|
|
613
|
+
if (!existing) return;
|
|
614
|
+
await this.writeHealthRow(installId, now, {
|
|
615
|
+
lastSuccessAt: existing.last_success_at,
|
|
616
|
+
failureStreak: existing.failure_streak,
|
|
617
|
+
dlqCount: Math.max(0, existing.dlq_count - 1),
|
|
618
|
+
lastStatus: existing.last_status,
|
|
619
|
+
lastErrorClass: existing.last_error_class,
|
|
620
|
+
recentTraceIds: JSON.parse(existing.recent_trace_ids) as string[],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private async findHealthRow(installId: string): Promise<HealthRow | null> {
|
|
625
|
+
return this.db.first<HealthRow>(`SELECT * FROM webhook_install_health WHERE install_id = ?`, installId);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private async writeHealthRow(
|
|
629
|
+
installId: string,
|
|
630
|
+
now: string,
|
|
631
|
+
patch: {
|
|
632
|
+
lastSuccessAt: string | null;
|
|
633
|
+
failureStreak: number;
|
|
634
|
+
dlqCount: number;
|
|
635
|
+
lastStatus: number | null;
|
|
636
|
+
lastErrorClass: string | null;
|
|
637
|
+
recentTraceIds: string[];
|
|
638
|
+
},
|
|
639
|
+
): Promise<void> {
|
|
640
|
+
await this.db.run(
|
|
641
|
+
`INSERT OR REPLACE INTO webhook_install_health
|
|
642
|
+
(install_id, last_success_at, failure_streak, dlq_count, last_status, last_error_class, recent_trace_ids, updated_at)
|
|
643
|
+
VALUES (?,?,?,?,?,?,?,?)`,
|
|
644
|
+
installId,
|
|
645
|
+
patch.lastSuccessAt,
|
|
646
|
+
patch.failureStreak,
|
|
647
|
+
patch.dlqCount,
|
|
648
|
+
patch.lastStatus,
|
|
649
|
+
patch.lastErrorClass,
|
|
650
|
+
JSON.stringify(patch.recentTraceIds),
|
|
651
|
+
now,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Fetch a row or throw the generic fail-closed `not_found` — no enumeration detail. */
|
|
656
|
+
private async mustFindDeliveryRow(deliveryId: string): Promise<DeliveryRow> {
|
|
657
|
+
const row = await this.db.first<DeliveryRow>(
|
|
658
|
+
`SELECT * FROM webhook_deliveries WHERE delivery_id = ?`,
|
|
659
|
+
deliveryId,
|
|
660
|
+
);
|
|
661
|
+
if (!row) throw new WebhookStoreError(`Webhook delivery not found.`, "not_found");
|
|
662
|
+
return row;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async getOrThrowDelivery(deliveryId: string): Promise<WebhookDelivery> {
|
|
666
|
+
return toDomain(await this.mustFindDeliveryRow(deliveryId));
|
|
667
|
+
}
|
|
668
|
+
}
|