@greatwait/contracts 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/authority.d.ts +217 -0
- package/dist/authority.js +261 -0
- package/dist/callback-ownership.d.ts +58 -0
- package/dist/callback-ownership.js +71 -0
- package/dist/callback-safety.d.ts +38 -0
- package/dist/callback-safety.js +403 -0
- package/dist/compatibility.d.ts +82 -0
- package/dist/compatibility.js +115 -0
- package/dist/delivery.d.ts +401 -0
- package/dist/delivery.js +311 -0
- package/dist/endpoints.d.ts +305 -0
- package/dist/endpoints.js +179 -0
- package/dist/events.d.ts +62 -0
- package/dist/events.js +38 -0
- package/dist/first-value-journey.d.ts +305 -0
- package/dist/first-value-journey.js +466 -0
- package/dist/forecast.d.ts +443 -0
- package/dist/forecast.js +729 -0
- package/dist/identifiers.d.ts +109 -0
- package/dist/identifiers.js +131 -0
- package/dist/identity.d.ts +196 -0
- package/dist/identity.js +182 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/mcp.d.ts +51 -0
- package/dist/mcp.js +56 -0
- package/dist/oauth.d.ts +796 -0
- package/dist/oauth.js +708 -0
- package/dist/operations.d.ts +3985 -0
- package/dist/operations.js +1837 -0
- package/dist/partition.d.ts +771 -0
- package/dist/partition.js +206 -0
- package/dist/policy.d.ts +93 -0
- package/dist/policy.js +180 -0
- package/dist/problems.d.ts +158 -0
- package/dist/problems.js +104 -0
- package/dist/protocol.d.ts +308 -0
- package/dist/protocol.js +515 -0
- package/dist/scheduling-records.d.ts +395 -0
- package/dist/scheduling-records.js +284 -0
- package/dist/scheduling.d.ts +1098 -0
- package/dist/scheduling.js +1116 -0
- package/dist/secrets.d.ts +383 -0
- package/dist/secrets.js +272 -0
- package/dist/signatures.d.ts +704 -0
- package/dist/signatures.js +867 -0
- package/dist/tenancy.d.ts +178 -0
- package/dist/tenancy.js +145 -0
- package/dist/test-sink.d.ts +986 -0
- package/dist/test-sink.js +755 -0
- package/dist/time.d.ts +45 -0
- package/dist/time.js +128 -0
- package/dist/usage.d.ts +121 -0
- package/dist/usage.js +200 -0
- package/dist/webhooks.d.ts +116 -0
- package/dist/webhooks.js +161 -0
- package/dist/workspace.d.ts +92 -0
- package/dist/workspace.js +59 -0
- package/generated/v1/callback-delivery-signature.json +467 -0
- package/generated/v1/callback-endpoint-verification.json +59 -0
- package/generated/v1/callback-receiver.json +241 -0
- package/generated/v1/examples/attempts.list.response.json +24 -0
- package/generated/v1/examples/attempts.read.response.json +20 -0
- package/generated/v1/examples/credentials.create.request.json +5 -0
- package/generated/v1/examples/credentials.create.response.json +10 -0
- package/generated/v1/examples/credentials.list.response.json +17 -0
- package/generated/v1/examples/credentials.revoke.response.json +11 -0
- package/generated/v1/examples/credentials.rotate.request.json +3 -0
- package/generated/v1/examples/credentials.rotate.response.json +22 -0
- package/generated/v1/examples/endpoints.create.request.json +3 -0
- package/generated/v1/examples/endpoints.create.response.json +16 -0
- package/generated/v1/examples/endpoints.disable.response.json +15 -0
- package/generated/v1/examples/endpoints.list.response.json +21 -0
- package/generated/v1/examples/endpoints.read.response.json +14 -0
- package/generated/v1/examples/endpoints.rotateSigningKey.request.json +3 -0
- package/generated/v1/examples/endpoints.rotateSigningKey.response.json +23 -0
- package/generated/v1/examples/endpoints.update.request.json +3 -0
- package/generated/v1/examples/endpoints.update.response.json +13 -0
- package/generated/v1/examples/endpoints.verify.response.json +15 -0
- package/generated/v1/examples/forecast.read.response.json +28 -0
- package/generated/v1/examples/forecast.windows.response.json +21 -0
- package/generated/v1/examples/grants.list.response.json +17 -0
- package/generated/v1/examples/grants.revoke.response.json +16 -0
- package/generated/v1/examples/meta.scaffold.response.json +5 -0
- package/generated/v1/examples/meta.version.response.json +5 -0
- package/generated/v1/examples/occurrences.list.response.json +17 -0
- package/generated/v1/examples/occurrences.read.response.json +15 -0
- package/generated/v1/examples/occurrences.redeliver.response.json +16 -0
- package/generated/v1/examples/projects.create.request.json +3 -0
- package/generated/v1/examples/projects.create.response.json +5 -0
- package/generated/v1/examples/projects.list.response.json +9 -0
- package/generated/v1/examples/schedules.archive.response.json +25 -0
- package/generated/v1/examples/schedules.cancel.response.json +24 -0
- package/generated/v1/examples/schedules.create.request.json +9 -0
- package/generated/v1/examples/schedules.create.response.json +25 -0
- package/generated/v1/examples/schedules.list.response.json +23 -0
- package/generated/v1/examples/schedules.pause.response.json +23 -0
- package/generated/v1/examples/schedules.read.response.json +25 -0
- package/generated/v1/examples/schedules.replace.request.json +8 -0
- package/generated/v1/examples/schedules.replace.response.json +25 -0
- package/generated/v1/examples/schedules.resume.response.json +23 -0
- package/generated/v1/examples/sinkdeliveries.list.response.json +19 -0
- package/generated/v1/examples/sinkdeliveries.read.response.json +16 -0
- package/generated/v1/examples/testsinks.configure.request.json +3 -0
- package/generated/v1/examples/testsinks.configure.response.json +13 -0
- package/generated/v1/examples/testsinks.create.request.json +8 -0
- package/generated/v1/examples/testsinks.create.response.json +13 -0
- package/generated/v1/examples/testsinks.delete.response.json +13 -0
- package/generated/v1/examples/testsinks.list.response.json +20 -0
- package/generated/v1/examples/testsinks.read.response.json +14 -0
- package/generated/v1/examples/usage.read.response.json +20 -0
- package/generated/v1/first-value-journey.json +436 -0
- package/generated/v1/manifest.json +1037 -0
- package/generated/v1/openapi.json +23665 -0
- package/generated/v1/policy-matrix.json +305 -0
- package/generated/v1/schemas/attempts.list.query.json +16 -0
- package/generated/v1/schemas/attempts.list.response.json +175 -0
- package/generated/v1/schemas/attempts.read.response.json +168 -0
- package/generated/v1/schemas/credentials.create.request.json +39 -0
- package/generated/v1/schemas/credentials.create.response.json +88 -0
- package/generated/v1/schemas/credentials.list.query.json +16 -0
- package/generated/v1/schemas/credentials.list.response.json +124 -0
- package/generated/v1/schemas/credentials.revoke.response.json +84 -0
- package/generated/v1/schemas/credentials.rotate.request.json +11 -0
- package/generated/v1/schemas/credentials.rotate.response.json +171 -0
- package/generated/v1/schemas/endpoints.create.request.json +12 -0
- package/generated/v1/schemas/endpoints.create.response.json +115 -0
- package/generated/v1/schemas/endpoints.disable.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.disable.response.json +100 -0
- package/generated/v1/schemas/endpoints.list.query.json +16 -0
- package/generated/v1/schemas/endpoints.list.response.json +146 -0
- package/generated/v1/schemas/endpoints.read.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.read.response.json +100 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.request.json +11 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.rotateSigningKey.response.json +118 -0
- package/generated/v1/schemas/endpoints.update.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.update.request.json +12 -0
- package/generated/v1/schemas/endpoints.update.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.update.response.json +100 -0
- package/generated/v1/schemas/endpoints.verify.request-headers.json +10 -0
- package/generated/v1/schemas/endpoints.verify.response-headers.json +11 -0
- package/generated/v1/schemas/endpoints.verify.response.json +100 -0
- package/generated/v1/schemas/forecast.read.query.json +26 -0
- package/generated/v1/schemas/forecast.read.response.json +100 -0
- package/generated/v1/schemas/forecast.windows.query.json +26 -0
- package/generated/v1/schemas/forecast.windows.response.json +94 -0
- package/generated/v1/schemas/grants.list.response.json +98 -0
- package/generated/v1/schemas/grants.revoke.response.json +95 -0
- package/generated/v1/schemas/meta.scaffold.response.json +36 -0
- package/generated/v1/schemas/meta.version.response.json +19 -0
- package/generated/v1/schemas/occurrences.list.query.json +16 -0
- package/generated/v1/schemas/occurrences.list.response.json +83 -0
- package/generated/v1/schemas/occurrences.read.response.json +76 -0
- package/generated/v1/schemas/occurrences.redeliver.request-headers.json +13 -0
- package/generated/v1/schemas/occurrences.redeliver.response-headers.json +10 -0
- package/generated/v1/schemas/occurrences.redeliver.response.json +168 -0
- package/generated/v1/schemas/projects.create.request.json +12 -0
- package/generated/v1/schemas/projects.create.response.json +25 -0
- package/generated/v1/schemas/projects.list.response.json +35 -0
- package/generated/v1/schemas/schedules.archive.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.archive.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.archive.response.json +189 -0
- package/generated/v1/schemas/schedules.cancel.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.cancel.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.cancel.response.json +189 -0
- package/generated/v1/schemas/schedules.create.request-headers.json +12 -0
- package/generated/v1/schemas/schedules.create.request.json +99 -0
- package/generated/v1/schemas/schedules.create.response-headers.json +15 -0
- package/generated/v1/schemas/schedules.create.response.json +189 -0
- package/generated/v1/schemas/schedules.list.query.json +16 -0
- package/generated/v1/schemas/schedules.list.response.json +170 -0
- package/generated/v1/schemas/schedules.pause.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.pause.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.pause.response.json +189 -0
- package/generated/v1/schemas/schedules.read.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.read.response.json +189 -0
- package/generated/v1/schemas/schedules.replace.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.replace.request.json +99 -0
- package/generated/v1/schemas/schedules.replace.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.replace.response.json +189 -0
- package/generated/v1/schemas/schedules.resume.request-headers.json +10 -0
- package/generated/v1/schemas/schedules.resume.response-headers.json +11 -0
- package/generated/v1/schemas/schedules.resume.response.json +189 -0
- package/generated/v1/schemas/sinkdeliveries.list.query.json +16 -0
- package/generated/v1/schemas/sinkdeliveries.list.response.json +126 -0
- package/generated/v1/schemas/sinkdeliveries.read.response.json +98 -0
- package/generated/v1/schemas/testsinks.configure.request.json +66 -0
- package/generated/v1/schemas/testsinks.configure.response.json +115 -0
- package/generated/v1/schemas/testsinks.create.request.json +76 -0
- package/generated/v1/schemas/testsinks.create.response.json +115 -0
- package/generated/v1/schemas/testsinks.delete.response.json +115 -0
- package/generated/v1/schemas/testsinks.list.query.json +16 -0
- package/generated/v1/schemas/testsinks.list.response.json +161 -0
- package/generated/v1/schemas/testsinks.read.response.json +115 -0
- package/generated/v1/schemas/usage.read.query.json +15 -0
- package/generated/v1/schemas/usage.read.response.json +71 -0
- package/generated/v1/snippets/attempts.list.json +5 -0
- package/generated/v1/snippets/attempts.read.json +5 -0
- package/generated/v1/snippets/credentials.create.json +5 -0
- package/generated/v1/snippets/credentials.list.json +5 -0
- package/generated/v1/snippets/credentials.revoke.json +5 -0
- package/generated/v1/snippets/credentials.rotate.json +5 -0
- package/generated/v1/snippets/endpoints.create.json +5 -0
- package/generated/v1/snippets/endpoints.disable.json +5 -0
- package/generated/v1/snippets/endpoints.list.json +5 -0
- package/generated/v1/snippets/endpoints.read.json +5 -0
- package/generated/v1/snippets/endpoints.rotateSigningKey.json +5 -0
- package/generated/v1/snippets/endpoints.update.json +5 -0
- package/generated/v1/snippets/endpoints.verify.json +5 -0
- package/generated/v1/snippets/forecast.read.json +5 -0
- package/generated/v1/snippets/forecast.windows.json +5 -0
- package/generated/v1/snippets/grants.list.json +5 -0
- package/generated/v1/snippets/grants.revoke.json +5 -0
- package/generated/v1/snippets/meta.scaffold.json +5 -0
- package/generated/v1/snippets/meta.version.json +5 -0
- package/generated/v1/snippets/occurrences.list.json +5 -0
- package/generated/v1/snippets/occurrences.read.json +5 -0
- package/generated/v1/snippets/occurrences.redeliver.json +5 -0
- package/generated/v1/snippets/projects.create.json +5 -0
- package/generated/v1/snippets/projects.list.json +5 -0
- package/generated/v1/snippets/schedules.archive.json +5 -0
- package/generated/v1/snippets/schedules.cancel.json +5 -0
- package/generated/v1/snippets/schedules.create.json +5 -0
- package/generated/v1/snippets/schedules.list.json +5 -0
- package/generated/v1/snippets/schedules.pause.json +5 -0
- package/generated/v1/snippets/schedules.read.json +5 -0
- package/generated/v1/snippets/schedules.replace.json +5 -0
- package/generated/v1/snippets/schedules.resume.json +5 -0
- package/generated/v1/snippets/sinkdeliveries.list.json +5 -0
- package/generated/v1/snippets/sinkdeliveries.read.json +5 -0
- package/generated/v1/snippets/testsinks.configure.json +5 -0
- package/generated/v1/snippets/testsinks.create.json +5 -0
- package/generated/v1/snippets/testsinks.delete.json +5 -0
- package/generated/v1/snippets/testsinks.list.json +5 -0
- package/generated/v1/snippets/testsinks.read.json +5 -0
- package/generated/v1/snippets/usage.read.json +5 -0
- package/package.json +153 -0
package/dist/forecast.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { freshnessSchema, projectedFreshnessSchema, } from "./protocol.js";
|
|
3
|
+
import { timestampSchema } from "./time.js";
|
|
4
|
+
/**
|
|
5
|
+
* What is about to happen, in bounded time buckets.
|
|
6
|
+
*
|
|
7
|
+
* A capacity planner asks one question — how much work is coming, and when —
|
|
8
|
+
* and the answer has to be bounded in three directions at once: the horizon it
|
|
9
|
+
* covers, the number of buckets it returns, and the work the service does to
|
|
10
|
+
* compute it. Architecture section 13 lists the forecast horizon among the cost
|
|
11
|
+
* controls for exactly that reason.
|
|
12
|
+
*
|
|
13
|
+
* **`owner` appears nowhere.** The copied service answered
|
|
14
|
+
* `/v1/analytics/:owner/forecast` and trusted the segment; here the authority
|
|
15
|
+
* tuple comes from the verified principal as it does everywhere else, and a
|
|
16
|
+
* legacy `owner` is a W6 migration input rather than authorization.
|
|
17
|
+
*/
|
|
18
|
+
/* -------------------------------------------------------------------------- */
|
|
19
|
+
/* Bounds */
|
|
20
|
+
/* -------------------------------------------------------------------------- */
|
|
21
|
+
/**
|
|
22
|
+
* The bounds a forecast is served under.
|
|
23
|
+
*
|
|
24
|
+
* The horizon, the finest bucket and the slack below `now` are **the copied
|
|
25
|
+
* `ForecastCache`'s numbers, kept rather than re-derived**: 25 hours, 60-second
|
|
26
|
+
* buckets, 120 seconds of slack. That file says why — the horizon must exceed
|
|
27
|
+
* the largest served window plus the maximum staleness so a sliding window
|
|
28
|
+
* stays covered between refreshes, and 25 hours comfortably covers a 24-hour
|
|
29
|
+
* poll. Re-choosing them here would have discarded a tuning that arrived
|
|
30
|
+
* proven.
|
|
31
|
+
*
|
|
32
|
+
* `maxBuckets` is the one bound that is ours, and it is arithmetic rather than
|
|
33
|
+
* taste: the longest window over the finest bucket is 1,500 buckets, so stating
|
|
34
|
+
* it makes the response size bounded by a calculation instead of by hope.
|
|
35
|
+
*/
|
|
36
|
+
export const FORECAST_BOUNDS = {
|
|
37
|
+
defaultBucketSeconds: 30 * 60,
|
|
38
|
+
/** Window served when a caller names neither `from` nor `to`. */
|
|
39
|
+
defaultWindowSeconds: 24 * 60 * 60,
|
|
40
|
+
maxBucketSeconds: 24 * 60 * 60,
|
|
41
|
+
/** `maxWindowSeconds / minBucketSeconds`, so the size is arithmetic. */
|
|
42
|
+
maxBuckets: 1_500,
|
|
43
|
+
/**
|
|
44
|
+
* Distinct `(minute, latencyClass, partitionKey)` groups one partition may
|
|
45
|
+
* aggregate before it reports the answer as short.
|
|
46
|
+
*/
|
|
47
|
+
maxGroupsPerPartition: 100_000,
|
|
48
|
+
/**
|
|
49
|
+
* How stale an answer may be before it is refused rather than served.
|
|
50
|
+
*
|
|
51
|
+
* Twice the partition's own refresh ceiling, so a healthy answer never trips
|
|
52
|
+
* it and a broken one always does. It exists because the consumer this shape
|
|
53
|
+
* was measured against reads `count` and not `freshness`: a live-but-badly-
|
|
54
|
+
* stale 200 is indistinguishable, to it, from a fresh one, and the whole
|
|
55
|
+
* argument for refusing rather than under-reporting collapses if staleness is
|
|
56
|
+
* the one degradation that gets through as a number.
|
|
57
|
+
*/
|
|
58
|
+
maxLagSeconds: 600,
|
|
59
|
+
/**
|
|
60
|
+
* Instants one partition may project into a single snapshot before it reports
|
|
61
|
+
* the answer as short.
|
|
62
|
+
*
|
|
63
|
+
* Published rather than kept internal so a refusal is classifiable: a shadow
|
|
64
|
+
* comparison that sees a refusal needs to be able to tell "we are near a
|
|
65
|
+
* stated bound" from "something is broken".
|
|
66
|
+
*/
|
|
67
|
+
maxProjectedOccurrencesPerPartition: 20_000,
|
|
68
|
+
/** Recurring series one partition may project from in a single snapshot. */
|
|
69
|
+
maxRecurringSeriesPerPartition: 20_000,
|
|
70
|
+
/**
|
|
71
|
+
* How far ahead of `now` a forecast may look.
|
|
72
|
+
*
|
|
73
|
+
* The copied cache's `DEFAULT_FORECAST_CACHE_HORIZON_SECONDS`. A window
|
|
74
|
+
* reaching past it cannot be served from a snapshot taken at `now`, and the
|
|
75
|
+
* legacy service's seven-day ceiling bounded the *query* with no snapshot
|
|
76
|
+
* behind it at all.
|
|
77
|
+
*/
|
|
78
|
+
maxWindowSeconds: 25 * 60 * 60,
|
|
79
|
+
minBucketSeconds: 60,
|
|
80
|
+
/**
|
|
81
|
+
* Longest a partition lets its snapshot stand when nothing has been written.
|
|
82
|
+
*
|
|
83
|
+
* Published because it is the honest answer to "how out of date can this be",
|
|
84
|
+
* and a caller who has to discover that by experiment will discover it during
|
|
85
|
+
* an incident. `freshness.lagSeconds` reports the actual age on every answer;
|
|
86
|
+
* this is the bound it lives inside.
|
|
87
|
+
*/
|
|
88
|
+
refreshCeilingSeconds: 300,
|
|
89
|
+
/**
|
|
90
|
+
* Shortest gap between refreshes once something *has* been written.
|
|
91
|
+
*
|
|
92
|
+
* So a caller who has just created or cancelled a schedule knows when it will
|
|
93
|
+
* appear: within this, not instantly. A forecast is a projection, and a
|
|
94
|
+
* projection that rebuilt on every poll would be the cost this whole design
|
|
95
|
+
* exists to avoid.
|
|
96
|
+
*/
|
|
97
|
+
refreshFloorSeconds: 30,
|
|
98
|
+
/**
|
|
99
|
+
* How far below `now` a window may begin.
|
|
100
|
+
*
|
|
101
|
+
* The copied cache's `DEFAULT_FORECAST_CACHE_SLACK_SECONDS`, kept for its
|
|
102
|
+
* reason: it absorbs the skew between a caller's clock and ours, so the
|
|
103
|
+
* ordinary request — "from now, for six hours" — does not fail intermittently
|
|
104
|
+
* because the caller's `now` is a second behind. Further back than that is a
|
|
105
|
+
* question about the past, which a forecast does not answer.
|
|
106
|
+
*/
|
|
107
|
+
slackSeconds: 120,
|
|
108
|
+
/**
|
|
109
|
+
* Customer keys named individually in one bucket before the rest are summed.
|
|
110
|
+
*
|
|
111
|
+
* The copied route's `FORECAST_TOP_TENANTS`. A customer's key space is
|
|
112
|
+
* unbounded by design, so a breakdown naming every key would let one caller
|
|
113
|
+
* decide the size of our response.
|
|
114
|
+
*/
|
|
115
|
+
topPartitionKeys: 10,
|
|
116
|
+
};
|
|
117
|
+
/* -------------------------------------------------------------------------- */
|
|
118
|
+
/* Request */
|
|
119
|
+
/* -------------------------------------------------------------------------- */
|
|
120
|
+
/**
|
|
121
|
+
* Whether the answer carries a per-customer-key breakdown.
|
|
122
|
+
*
|
|
123
|
+
* Off by default. The breakdown is the axis whose size a caller controls — a
|
|
124
|
+
* bucket's payload grows with how many keys fired in it — and most callers want
|
|
125
|
+
* the total and the latency split, which every answer carries.
|
|
126
|
+
*/
|
|
127
|
+
export const FORECAST_BREAKDOWNS = ["none", "partitionKey"];
|
|
128
|
+
export const forecastBreakdownSchema = z.enum(FORECAST_BREAKDOWNS);
|
|
129
|
+
export const forecastRequestSchema = z.object({
|
|
130
|
+
breakdown: forecastBreakdownSchema.default("none"),
|
|
131
|
+
/**
|
|
132
|
+
* Bucket width in whole seconds, aligned to the epoch.
|
|
133
|
+
*
|
|
134
|
+
* Seconds as a number rather than the legacy `bucket=30m` duration string:
|
|
135
|
+
* "durations are whole seconds as numbers" is the convention this product
|
|
136
|
+
* publishes, and a second spelling of a duration is a second thing to get
|
|
137
|
+
* wrong.
|
|
138
|
+
*/
|
|
139
|
+
bucketSeconds: z
|
|
140
|
+
.number()
|
|
141
|
+
.int()
|
|
142
|
+
.min(FORECAST_BOUNDS.minBucketSeconds)
|
|
143
|
+
.max(FORECAST_BOUNDS.maxBucketSeconds)
|
|
144
|
+
// A whole number of the finest bucket, so re-aggregating the internal
|
|
145
|
+
// minute-granular snapshot into the requested width is exact rather than
|
|
146
|
+
// best-effort. The copied cache says of itself that it is exact only when
|
|
147
|
+
// the width is a multiple of sixty and best-effort otherwise; that was
|
|
148
|
+
// forced on it by callers who could ask for a width that straddled, and
|
|
149
|
+
// refusing such a width is cheaper than publishing an approximate capacity
|
|
150
|
+
// number nobody can reconcile against a bill.
|
|
151
|
+
.multipleOf(FORECAST_BOUNDS.minBucketSeconds)
|
|
152
|
+
.default(FORECAST_BOUNDS.defaultBucketSeconds),
|
|
153
|
+
/** Inclusive lower bound. Defaults to the instant the request is served. */
|
|
154
|
+
from: timestampSchema.optional(),
|
|
155
|
+
/** Exclusive upper bound. Defaults to `from` plus the default window. */
|
|
156
|
+
to: timestampSchema.optional(),
|
|
157
|
+
});
|
|
158
|
+
/** `[name, numeric]` — the coercion each parameter needs off a URL. */
|
|
159
|
+
const FORECAST_PARAMETERS = [
|
|
160
|
+
["breakdown", false],
|
|
161
|
+
["bucketSeconds", true],
|
|
162
|
+
["from", false],
|
|
163
|
+
["to", false],
|
|
164
|
+
];
|
|
165
|
+
function asNumberIfInteger(value) {
|
|
166
|
+
return /^-?\d+$/u.test(value) ? Number(value) : value;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Reads a forecast request off a URL.
|
|
170
|
+
*
|
|
171
|
+
* Values arrive as strings, so the coercion lives here rather than in every
|
|
172
|
+
* adapter — the reason `parsePageRequest` exists. A repeated parameter is a
|
|
173
|
+
* refusal rather than a last-one-wins guess: two different windows in one
|
|
174
|
+
* request is a caller bug, and answering one of them silently is how it stays a
|
|
175
|
+
* bug.
|
|
176
|
+
*/
|
|
177
|
+
export function parseForecastRequest(query) {
|
|
178
|
+
const errors = [];
|
|
179
|
+
const raw = {};
|
|
180
|
+
for (const [field, numeric] of FORECAST_PARAMETERS) {
|
|
181
|
+
const values = query.getAll(field);
|
|
182
|
+
if (values.length > 1) {
|
|
183
|
+
errors.push({
|
|
184
|
+
pointer: `/${field}`,
|
|
185
|
+
reason: "must be given at most once",
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const value = values[0];
|
|
190
|
+
if (value === undefined) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
raw[field] = numeric ? asNumberIfInteger(value) : value;
|
|
194
|
+
}
|
|
195
|
+
if (errors.length > 0) {
|
|
196
|
+
return { errors, ok: false };
|
|
197
|
+
}
|
|
198
|
+
const parsed = forecastRequestSchema.safeParse(raw);
|
|
199
|
+
if (parsed.success) {
|
|
200
|
+
return { ok: true, request: parsed.data };
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
errors: parsed.error.issues.map((issue) => ({
|
|
204
|
+
pointer: `/${issue.path.join("/")}`,
|
|
205
|
+
reason: issue.message,
|
|
206
|
+
})),
|
|
207
|
+
ok: false,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Turns a parsed request into the window that will be served, or says why not.
|
|
212
|
+
*
|
|
213
|
+
* Separate from the schema because every refusal here is a relation between two
|
|
214
|
+
* fields rather than a property of one, and a refinement that fails on the
|
|
215
|
+
* object cannot say which field to point at.
|
|
216
|
+
*
|
|
217
|
+
* **The horizon is measured from `now`, not from `from`.** A caller asking for
|
|
218
|
+
* a window that starts tomorrow and ends the day after is asking for something
|
|
219
|
+
* no snapshot taken now can cover; answering it from a snapshot that stops at
|
|
220
|
+
* 25 hours would be a confident wrong answer rather than a refusal.
|
|
221
|
+
*/
|
|
222
|
+
export function resolveForecastWindow(request, nowMs) {
|
|
223
|
+
const fromMs = request.from === undefined ? nowMs : Date.parse(request.from);
|
|
224
|
+
const toMs = request.to === undefined
|
|
225
|
+
? fromMs + FORECAST_BOUNDS.defaultWindowSeconds * 1000
|
|
226
|
+
: Date.parse(request.to);
|
|
227
|
+
if (toMs <= fromMs) {
|
|
228
|
+
return { ok: false, refusal: "range-empty" };
|
|
229
|
+
}
|
|
230
|
+
if (toMs - fromMs > FORECAST_BOUNDS.maxWindowSeconds * 1000) {
|
|
231
|
+
return { ok: false, refusal: "range-too-long" };
|
|
232
|
+
}
|
|
233
|
+
if (fromMs < nowMs - FORECAST_BOUNDS.slackSeconds * 1000) {
|
|
234
|
+
return { ok: false, refusal: "window-in-the-past" };
|
|
235
|
+
}
|
|
236
|
+
if (toMs > nowMs + FORECAST_BOUNDS.maxWindowSeconds * 1000) {
|
|
237
|
+
return { ok: false, refusal: "window-past-horizon" };
|
|
238
|
+
}
|
|
239
|
+
// **There is deliberately no bucket-count refusal here.** `maxBuckets` is the
|
|
240
|
+
// longest window over the finest bucket *exactly*, so a request the checks
|
|
241
|
+
// above admit can never produce more buckets than it — a guard for it would
|
|
242
|
+
// be a branch nothing could reach, which reads as though it covers something
|
|
243
|
+
// and does not. `forecast.test.ts` holds the two bounds in that relation, so
|
|
244
|
+
// widening the window without widening the bound fails a test rather than
|
|
245
|
+
// quietly removing the ceiling on a response's size.
|
|
246
|
+
// **Snapped to the finest bucket, and the snapped window is what the answer
|
|
247
|
+
// reports.** A forecast is served from a snapshot held at minute
|
|
248
|
+
// granularity — the copied cache's design and its reason — so a window whose
|
|
249
|
+
// edges fall inside a minute cannot be answered exactly. The copy resolved
|
|
250
|
+
// that by filtering on bucket starts and calling the edges best-effort; here
|
|
251
|
+
// the window is snapped instead and echoed back, so what a caller is told was
|
|
252
|
+
// counted is exactly what was counted. Both ends floor, so the counted range
|
|
253
|
+
// can never reach past the horizon the checks above just cleared.
|
|
254
|
+
const grainMs = FORECAST_BOUNDS.minBucketSeconds * 1000;
|
|
255
|
+
const snappedFromMs = Math.floor(fromMs / grainMs) * grainMs;
|
|
256
|
+
const snappedToMs = Math.floor(toMs / grainMs) * grainMs;
|
|
257
|
+
if (snappedToMs <= snappedFromMs) {
|
|
258
|
+
return { ok: false, refusal: "range-empty" };
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
window: {
|
|
263
|
+
bucketSeconds: request.bucketSeconds,
|
|
264
|
+
fromMs: snappedFromMs,
|
|
265
|
+
toMs: snappedToMs,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The epoch-aligned bucket an instant belongs to, in milliseconds.
|
|
271
|
+
*
|
|
272
|
+
* Aligned to the epoch rather than to `from`, so two callers asking for
|
|
273
|
+
* different windows at different moments agree about where a bucket begins —
|
|
274
|
+
* which is what makes a shadow comparison between two services a comparison of
|
|
275
|
+
* counts rather than of alignment. The consequence is that the first bucket of
|
|
276
|
+
* a window usually begins before the window does, and that is published.
|
|
277
|
+
*/
|
|
278
|
+
export function forecastBucketStartMs(instantMs, bucketSeconds) {
|
|
279
|
+
const bucketMs = bucketSeconds * 1000;
|
|
280
|
+
return Math.floor(instantMs / bucketMs) * bucketMs;
|
|
281
|
+
}
|
|
282
|
+
/* -------------------------------------------------------------------------- */
|
|
283
|
+
/* Response */
|
|
284
|
+
/* -------------------------------------------------------------------------- */
|
|
285
|
+
/**
|
|
286
|
+
* One count per latency class, with every class present.
|
|
287
|
+
*
|
|
288
|
+
* Written out rather than derived from `LATENCY_CLASSES`, so the published JSON
|
|
289
|
+
* Schema names both keys. `forecast.test.ts` holds the two lists equal, so a
|
|
290
|
+
* third latency class fails a test here rather than silently vanishing from
|
|
291
|
+
* every forecast.
|
|
292
|
+
*/
|
|
293
|
+
export const forecastLatencyCountsSchema = z.object({
|
|
294
|
+
bulk: z.number().int().nonnegative(),
|
|
295
|
+
realtime: z.number().int().nonnegative(),
|
|
296
|
+
});
|
|
297
|
+
export const forecastBucketSchema = z.object({
|
|
298
|
+
byLatencyClass: forecastLatencyCountsSchema,
|
|
299
|
+
/**
|
|
300
|
+
* The busiest customer keys in this bucket, present exactly when the caller
|
|
301
|
+
* asked for the breakdown — and then always paired with
|
|
302
|
+
* `otherPartitionKeyCount`, so the arithmetic below can be relied on rather
|
|
303
|
+
* than guessed at.
|
|
304
|
+
*
|
|
305
|
+
* Real keys only, ordered by count and then by key so the tenth and eleventh
|
|
306
|
+
* of a tie resolve the same way on every read. The remainder is
|
|
307
|
+
* `otherPartitionKeyCount`, a sibling field rather than a reserved key,
|
|
308
|
+
* because `partitionKeySchema` admits the literal string `other` — and admits
|
|
309
|
+
* `__proto__`, which is why every producer of this map builds it with a null
|
|
310
|
+
* prototype.
|
|
311
|
+
*/
|
|
312
|
+
byPartitionKey: z.record(z.string(), z.number().int().positive()).optional(),
|
|
313
|
+
/**
|
|
314
|
+
* Occurrences expected in this bucket, across every latency class and every
|
|
315
|
+
* customer key. Never a top-N subtotal.
|
|
316
|
+
*/
|
|
317
|
+
count: z.number().int().positive(),
|
|
318
|
+
/**
|
|
319
|
+
* Occurrences that are the *next* firing of their schedule.
|
|
320
|
+
*
|
|
321
|
+
* A recurring series contributes to this in exactly one bucket however many
|
|
322
|
+
* times it fires, so summing it across a window gives the number of schedules
|
|
323
|
+
* that fire in that window — which is precisely what the legacy service
|
|
324
|
+
* reported as its total. It is published rather than derived so a shadow
|
|
325
|
+
* comparison against that service is a subtraction rather than an inference,
|
|
326
|
+
* and it answers a question of its own: one herd of a thousand schedules and
|
|
327
|
+
* one schedule firing a thousand times are the same `count` and very
|
|
328
|
+
* different work.
|
|
329
|
+
*/
|
|
330
|
+
nextFireCount: z.number().int().nonnegative(),
|
|
331
|
+
/**
|
|
332
|
+
* Occurrences not attributed to a named key: those from schedules that chose
|
|
333
|
+
* no `partitionKey`, plus the keys beyond the top ten. Present with the
|
|
334
|
+
* breakdown.
|
|
335
|
+
*/
|
|
336
|
+
otherPartitionKeyCount: z.number().int().nonnegative().optional(),
|
|
337
|
+
/** The bucket's epoch-aligned start. May precede the requested `from`. */
|
|
338
|
+
start: timestampSchema,
|
|
339
|
+
});
|
|
340
|
+
export const forecastSchema = z.object({
|
|
341
|
+
/** Ascending by `start`. A bucket with no occurrences is omitted. */
|
|
342
|
+
buckets: z.array(forecastBucketSchema),
|
|
343
|
+
/**
|
|
344
|
+
* How old the answer is.
|
|
345
|
+
*
|
|
346
|
+
* Always `projected`, and now the schema says so rather than the comment. A
|
|
347
|
+
* partition serves this from a snapshot it refreshes on a floor rather than
|
|
348
|
+
* on every read — which is what makes a ten-second poll affordable — so
|
|
349
|
+
* `authoritative` would be true only by accident, on the call that happened
|
|
350
|
+
* to trigger a refresh. Admitting an arm the service never sends made every
|
|
351
|
+
* reader handle it, and put the age and the snapshot instant behind a narrow
|
|
352
|
+
* nothing could justify.
|
|
353
|
+
*/
|
|
354
|
+
freshness: projectedFreshnessSchema,
|
|
355
|
+
window: z.object({
|
|
356
|
+
bucketSeconds: z.number().int().positive(),
|
|
357
|
+
from: timestampSchema,
|
|
358
|
+
to: timestampSchema,
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
/**
|
|
362
|
+
* Sum one bucket across every shard, ascending by start.
|
|
363
|
+
*
|
|
364
|
+
* Both maps are null-prototype, and that is not defensive style: a customer's
|
|
365
|
+
* `partitionKey` may contain underscores, so `__proto__` is a legal key, and
|
|
366
|
+
* assigning it on an ordinary object runs the prototype setter and silently
|
|
367
|
+
* drops the count. The copied route took the same care for the same reason, and
|
|
368
|
+
* this product's charset makes the key easier to reach rather than harder.
|
|
369
|
+
*/
|
|
370
|
+
export function mergeForecastBuckets(buckets) {
|
|
371
|
+
const merged = new Map();
|
|
372
|
+
for (const bucket of buckets) {
|
|
373
|
+
let into = merged.get(bucket.bucketStartMs);
|
|
374
|
+
if (into === undefined) {
|
|
375
|
+
into = {
|
|
376
|
+
bucketStartMs: bucket.bucketStartMs,
|
|
377
|
+
byLatencyClass: Object.create(null),
|
|
378
|
+
byPartitionKey: Object.create(null),
|
|
379
|
+
count: 0,
|
|
380
|
+
nextFireCount: 0,
|
|
381
|
+
unkeyedCount: 0,
|
|
382
|
+
};
|
|
383
|
+
merged.set(bucket.bucketStartMs, into);
|
|
384
|
+
}
|
|
385
|
+
for (const [latencyClass, n] of Object.entries(bucket.byLatencyClass)) {
|
|
386
|
+
into.byLatencyClass[latencyClass] =
|
|
387
|
+
(into.byLatencyClass[latencyClass] ?? 0) + n;
|
|
388
|
+
}
|
|
389
|
+
for (const { key, n } of bucket.partitionKeys) {
|
|
390
|
+
into.byPartitionKey[key] = (into.byPartitionKey[key] ?? 0) + n;
|
|
391
|
+
}
|
|
392
|
+
into.count += bucket.count;
|
|
393
|
+
into.nextFireCount += bucket.nextFireCount;
|
|
394
|
+
into.unkeyedCount += bucket.unkeyedCount;
|
|
395
|
+
}
|
|
396
|
+
return [...merged.values()].sort((left, right) => left.bucketStartMs - right.bucketStartMs);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The busiest customer keys in one bucket, and everything else as one number.
|
|
400
|
+
*
|
|
401
|
+
* Ordered by count and then by key, so the tenth and eleventh of a tie resolve
|
|
402
|
+
* the same way on every read rather than by whatever order the shards answered
|
|
403
|
+
* in — a breakdown that reshuffles between polls looks like a change to
|
|
404
|
+
* anything comparing two readings.
|
|
405
|
+
*
|
|
406
|
+
* Occurrences from schedules that chose **no** key are counted in the
|
|
407
|
+
* remainder rather than published under a reserved name, because `default` and
|
|
408
|
+
* `other` are both legal keys and a reserved one would absorb a real
|
|
409
|
+
* customer's count. The published invariant is what survives:
|
|
410
|
+
* `count = sum(named) + other`.
|
|
411
|
+
*/
|
|
412
|
+
export function topPartitionKeys(bucket) {
|
|
413
|
+
const ranked = Object.entries(bucket.byPartitionKey).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
414
|
+
const byPartitionKey = Object.create(null);
|
|
415
|
+
let otherPartitionKeyCount = bucket.unkeyedCount;
|
|
416
|
+
ranked.forEach(([key, n], index) => {
|
|
417
|
+
if (index < FORECAST_BOUNDS.topPartitionKeys) {
|
|
418
|
+
byPartitionKey[key] = n;
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
otherPartitionKeyCount += n;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
return { byPartitionKey, otherPartitionKeyCount };
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Whether a gathered forecast may be published, and how old it is.
|
|
428
|
+
*
|
|
429
|
+
* **Every degradation this operation can suffer ends here, and every one of
|
|
430
|
+
* them is a refusal.** That is the whole safety argument of the shape, and it
|
|
431
|
+
* follows from a measured fact about the consumer it exists for: it reads the
|
|
432
|
+
* bucket total and nothing else — not a degraded flag, not `freshness` — so a
|
|
433
|
+
* degraded answer reads to it as a quiet book, and a quiet book makes it
|
|
434
|
+
* release capacity. Its own failure path is fail-safe: a request that errors
|
|
435
|
+
* makes it hold its last good reading. So the honest degradation is the one
|
|
436
|
+
* that errors.
|
|
437
|
+
*
|
|
438
|
+
* The three are distinct because they mean different things to whoever is
|
|
439
|
+
* looking: a partition that did not answer is an availability problem, a book
|
|
440
|
+
* past the projection bound is a capacity problem with a published number
|
|
441
|
+
* beside it, and an answer older than the bound is a partition that has stopped
|
|
442
|
+
* refreshing.
|
|
443
|
+
*
|
|
444
|
+
* `asOfMs` is the **oldest** across the shards, for the reason
|
|
445
|
+
* `projectionCoverage` takes the oldest checkpoint: one busy shard must not be
|
|
446
|
+
* allowed to vouch for three quiet ones.
|
|
447
|
+
*/
|
|
448
|
+
export function decideForecastAnswer(answers, nowMs) {
|
|
449
|
+
if (answers.some((answer) => "refused" in answer)) {
|
|
450
|
+
return { ok: false, refusal: "partition-unavailable" };
|
|
451
|
+
}
|
|
452
|
+
if (answers.some((answer) => "truncated" in answer && answer.truncated)) {
|
|
453
|
+
return { ok: false, refusal: "book-too-large" };
|
|
454
|
+
}
|
|
455
|
+
// **Only a partition that built a snapshot vouches for freshness.** One that
|
|
456
|
+
// holds nothing has nothing to be stale about, and letting it contribute the
|
|
457
|
+
// instant of the read made `asOf` move between two reads of an unchanged
|
|
458
|
+
// book — a project spans several shards and most of them are usually empty.
|
|
459
|
+
// With none of them holding a snapshot the answer really is current.
|
|
460
|
+
const instants = answers.flatMap((answer) => "asOfMs" in answer && answer.asOfMs !== undefined ? [answer.asOfMs] : []);
|
|
461
|
+
const asOfMs = instants.length === 0 ? nowMs : Math.min(...instants);
|
|
462
|
+
const lagSeconds = Math.max(0, Math.ceil((nowMs - asOfMs) / 1000));
|
|
463
|
+
if (lagSeconds > FORECAST_BOUNDS.maxLagSeconds) {
|
|
464
|
+
return { ok: false, refusal: "too-stale" };
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
freshness: {
|
|
468
|
+
asOf: new Date(asOfMs).toISOString(),
|
|
469
|
+
kind: "projected",
|
|
470
|
+
lagSeconds,
|
|
471
|
+
},
|
|
472
|
+
ok: true,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/* -------------------------------------------------------------------------- */
|
|
476
|
+
/* The migration adapter */
|
|
477
|
+
/* -------------------------------------------------------------------------- */
|
|
478
|
+
/**
|
|
479
|
+
* The version of the transitional window shape.
|
|
480
|
+
*
|
|
481
|
+
* Dated like the callback envelope, for the same reason: a consumer pins a
|
|
482
|
+
* literal, and a literal that moves is a change the consumer can be told about
|
|
483
|
+
* rather than one it discovers.
|
|
484
|
+
*/
|
|
485
|
+
export const FORECAST_WINDOW_VERSION = "2026-08-09";
|
|
486
|
+
export const forecastWindowBucketSchema = z.object({
|
|
487
|
+
/**
|
|
488
|
+
* The legacy spelling of the customer-key breakdown.
|
|
489
|
+
*
|
|
490
|
+
* Upstream a partition *was* a tenant, so its breakdown was `byTenant`; here
|
|
491
|
+
* the equivalent division is the customer's own `partitionKey`. The name is
|
|
492
|
+
* kept because the migrating consumer carries this map through untouched, and
|
|
493
|
+
* renaming it would be a change with no benefit on either side.
|
|
494
|
+
*/
|
|
495
|
+
byTenant: z.record(z.string(), z.number().int().positive()),
|
|
496
|
+
count: z.number().int().positive(),
|
|
497
|
+
/** `forecastBucketSchema.nextFireCount`, under the same name. */
|
|
498
|
+
nextFireCount: z.number().int().nonnegative(),
|
|
499
|
+
otherCount: z.number().int().nonnegative(),
|
|
500
|
+
start: timestampSchema,
|
|
501
|
+
});
|
|
502
|
+
export const forecastWindowsSchema = z.object({
|
|
503
|
+
/**
|
|
504
|
+
* The width of every bucket, in seconds.
|
|
505
|
+
*
|
|
506
|
+
* Not in the legacy shape, and the one addition worth its bytes: without it a
|
|
507
|
+
* consumer cannot tell whether a bucket is still ahead of it. The measured
|
|
508
|
+
* consumer drops any bucket whose `start` is not strictly after its own
|
|
509
|
+
* `now`, and buckets are aligned to the epoch, so the bucket carrying the next
|
|
510
|
+
* few minutes of work is the one it throws away on almost every poll. With the
|
|
511
|
+
* width published, `start + bucketSeconds > now` is the filter that keeps it.
|
|
512
|
+
*/
|
|
513
|
+
bucketSeconds: z.number().int().positive(),
|
|
514
|
+
buckets: z.array(forecastWindowBucketSchema),
|
|
515
|
+
/** Stated here too, so a shadow comparison can compare staleness. */
|
|
516
|
+
freshness: freshnessSchema,
|
|
517
|
+
version: z.literal(FORECAST_WINDOW_VERSION),
|
|
518
|
+
});
|
|
519
|
+
/**
|
|
520
|
+
* What the migrating consumer may rely on, published as data.
|
|
521
|
+
*
|
|
522
|
+
* Prose describing a wire contract drifts from the code that produces it —
|
|
523
|
+
* three times during W3-08, and the third published a rule that would have had
|
|
524
|
+
* a customer take a payment twice. So the rules live here, beside the schema,
|
|
525
|
+
* and `scripts/contract-projection.test.ts` holds `docs/contracts/forecast.md`
|
|
526
|
+
* against them.
|
|
527
|
+
*
|
|
528
|
+
* Every entry was read out of the consumer rather than assumed. It parses
|
|
529
|
+
* `start` with `Date.parse` and drops any bucket that does not parse; it reads
|
|
530
|
+
* `count` raw and sums it across buckets to size capacity; it carries
|
|
531
|
+
* `byTenant` through untouched; and it reads no other field — not `otherCount`,
|
|
532
|
+
* not the legacy `partial`, not `unavailablePartitions`. That last fact is what
|
|
533
|
+
* makes "a refusal, never a short answer" a rule rather than an implementation
|
|
534
|
+
* note: a degraded answer this consumer cannot see reads as a quiet book, and a
|
|
535
|
+
* quiet book makes it scale *down*.
|
|
536
|
+
*/
|
|
537
|
+
export const FORECAST_COMPATIBILITY = {
|
|
538
|
+
/**
|
|
539
|
+
* What a consumer must tolerate or do for itself. Not checkable in a
|
|
540
|
+
* response: each is a permission this producer has, or an obligation the
|
|
541
|
+
* consumer owes, rather than something the producer owes.
|
|
542
|
+
*/
|
|
543
|
+
consumerNotes: [
|
|
544
|
+
"a bucket's start may precede the requested from, because buckets are aligned to the epoch rather than to the window; filter on start + bucketSeconds > now rather than on start > now, or the bucket carrying the next few minutes of work is discarded on almost every poll",
|
|
545
|
+
"a window this service cannot answer completely is a refusal rather than a short list of buckets, so a non-2xx is no reading rather than an empty book",
|
|
546
|
+
"the legacy partial and unavailablePartitions fields are gone, because there is no partial answer left for them to describe",
|
|
547
|
+
"freshness is published on every answer and an answer staler than FORECAST_BOUNDS.maxLagSeconds is refused rather than served, so a 200 is always inside that bound",
|
|
548
|
+
],
|
|
549
|
+
/** The only fields the migrating consumer reads today. */
|
|
550
|
+
consumedFields: ["byTenant", "count", "start"],
|
|
551
|
+
/** What a caller must change, beyond the base URL. */
|
|
552
|
+
migrationDelta: [
|
|
553
|
+
"authenticate with a scoped Greatwait credential holding forecast:read, not the legacy shared bearer token",
|
|
554
|
+
"drop the :owner path segment, because authority comes from the credential and a request can no longer name a project",
|
|
555
|
+
"send bucketSeconds=600 rather than bucket=10m, because durations on this surface are whole seconds as numbers",
|
|
556
|
+
"expect a recurring series to contribute every instant it fires in the window, where the legacy answer counted each schedule once at its next fire time — sum nextFireCount to reproduce the legacy total, and retune any threshold calibrated against it before cutting over",
|
|
557
|
+
"assert version equals the published constant and treat a mismatch as staleness, because nothing else tells a consumer the shape moved",
|
|
558
|
+
],
|
|
559
|
+
/**
|
|
560
|
+
* What this check cannot see, stated so nobody mistakes a pass for a proof.
|
|
561
|
+
*
|
|
562
|
+
* A response body carries counts, not the reasoning that produced them, so an
|
|
563
|
+
* implementation that quietly stopped expanding recurring series would
|
|
564
|
+
* satisfy every rule below: its counts would simply be smaller. That property
|
|
565
|
+
* is pinned by running the un-expanded implementation through
|
|
566
|
+
* `partition-forecast.test.ts` and the worker acceptance tests and watching
|
|
567
|
+
* them fail, which is the only place it can be pinned.
|
|
568
|
+
*/
|
|
569
|
+
notCheckedHere: [
|
|
570
|
+
"that recurring series were expanded at all, because a smaller count is still an arithmetically consistent one",
|
|
571
|
+
"that the counts match the partition's own state, because this check never sees it",
|
|
572
|
+
],
|
|
573
|
+
/**
|
|
574
|
+
* What this producer owes, in the order `forecastWindowViolations` checks
|
|
575
|
+
* them. Every rule has a case that trips it, which `forecast.test.ts` asserts
|
|
576
|
+
* by construction rather than by inspection.
|
|
577
|
+
*/
|
|
578
|
+
rules: [
|
|
579
|
+
"start is an RFC 3339 UTC timestamp with millisecond precision and a literal Z, because a consumer parsing it with Date.parse reads a bucket it cannot parse as no work at all",
|
|
580
|
+
"count is the total across every customer key and latency class in the bucket, never the top-ten subtotal",
|
|
581
|
+
"count equals the sum of byTenant plus otherCount",
|
|
582
|
+
"nextFireCount is at most count, because a bucket cannot hold more first firings than firings",
|
|
583
|
+
"buckets ascend strictly by start",
|
|
584
|
+
"a bucket with no occurrences is omitted rather than published with a count of zero",
|
|
585
|
+
],
|
|
586
|
+
version: FORECAST_WINDOW_VERSION,
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* What the canonical forecast owes, checked by `forecastViolations`.
|
|
590
|
+
*
|
|
591
|
+
* Published separately from `FORECAST_COMPATIBILITY` because the adapter is
|
|
592
|
+
* dated and scheduled for deletion once W6-02 has cut over, and the shape that
|
|
593
|
+
* outlives it must not be the one with no gate. Two of these are the same
|
|
594
|
+
* obligations in the canonical spelling; the latency-class arithmetic has no
|
|
595
|
+
* counterpart in the adapter, which carries no class breakdown.
|
|
596
|
+
*/
|
|
597
|
+
export const FORECAST_RULES = [
|
|
598
|
+
"start is an RFC 3339 UTC timestamp with millisecond precision and a literal Z",
|
|
599
|
+
"count equals byLatencyClass.bulk plus byLatencyClass.realtime",
|
|
600
|
+
"byPartitionKey and otherPartitionKeyCount are present together or not at all, so a caller never has to guess whether a breakdown was requested",
|
|
601
|
+
"when the breakdown is present, count equals the sum of byPartitionKey plus otherPartitionKeyCount",
|
|
602
|
+
"nextFireCount is at most count",
|
|
603
|
+
"buckets ascend strictly by start",
|
|
604
|
+
"a bucket with no occurrences is omitted rather than published with a count of zero",
|
|
605
|
+
];
|
|
606
|
+
function looseBuckets(body) {
|
|
607
|
+
if (typeof body !== "object" || body === null) {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
const buckets = body.buckets;
|
|
611
|
+
if (!Array.isArray(buckets)) {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
return buckets.filter((bucket) => typeof bucket === "object" && bucket !== null);
|
|
615
|
+
}
|
|
616
|
+
function sumOf(map) {
|
|
617
|
+
if (typeof map !== "object" || map === null) {
|
|
618
|
+
return Number.NaN;
|
|
619
|
+
}
|
|
620
|
+
return Object.values(map).reduce((total, value) => total + (typeof value === "number" ? value : Number.NaN), 0);
|
|
621
|
+
}
|
|
622
|
+
function numberOr(value, fallback) {
|
|
623
|
+
return typeof value === "number" ? value : fallback;
|
|
624
|
+
}
|
|
625
|
+
function ascendingViolated(buckets) {
|
|
626
|
+
return buckets.some((bucket, index) => {
|
|
627
|
+
const previous = buckets[index - 1];
|
|
628
|
+
return (previous !== undefined &&
|
|
629
|
+
typeof bucket.start === "string" &&
|
|
630
|
+
typeof previous.start === "string" &&
|
|
631
|
+
bucket.start <= previous.start);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Which published rules a canonical forecast breaks.
|
|
636
|
+
*
|
|
637
|
+
* The same shape of check as `forecastWindowViolations`, over `FORECAST_RULES`,
|
|
638
|
+
* so the shape that outlives the migration adapter is gated too. See that
|
|
639
|
+
* function for why both read the body loosely rather than through their schema.
|
|
640
|
+
*/
|
|
641
|
+
export function forecastViolations(body) {
|
|
642
|
+
const buckets = looseBuckets(body);
|
|
643
|
+
if (buckets === undefined) {
|
|
644
|
+
return [...FORECAST_RULES];
|
|
645
|
+
}
|
|
646
|
+
const [spelling, classes, pairing, breakdown, heads, ascending, empties] = FORECAST_RULES;
|
|
647
|
+
const violations = [];
|
|
648
|
+
if (buckets.some((bucket) => !timestampSchema.safeParse(bucket.start).success)) {
|
|
649
|
+
violations.push(spelling);
|
|
650
|
+
}
|
|
651
|
+
if (buckets.some((bucket) => bucket.count !== sumOf(bucket.byLatencyClass))) {
|
|
652
|
+
violations.push(classes);
|
|
653
|
+
}
|
|
654
|
+
if (buckets.some((bucket) => (bucket.byPartitionKey === undefined) !==
|
|
655
|
+
(bucket.otherPartitionKeyCount === undefined))) {
|
|
656
|
+
violations.push(pairing);
|
|
657
|
+
}
|
|
658
|
+
if (buckets.some((bucket) => bucket.byPartitionKey !== undefined &&
|
|
659
|
+
bucket.count !==
|
|
660
|
+
sumOf(bucket.byPartitionKey) +
|
|
661
|
+
numberOr(bucket.otherPartitionKeyCount, Number.NaN))) {
|
|
662
|
+
violations.push(breakdown);
|
|
663
|
+
}
|
|
664
|
+
if (buckets.some((bucket) => numberOr(bucket.nextFireCount, Number.NaN) >
|
|
665
|
+
numberOr(bucket.count, Number.NaN) ||
|
|
666
|
+
Number.isNaN(numberOr(bucket.nextFireCount, Number.NaN)))) {
|
|
667
|
+
violations.push(heads);
|
|
668
|
+
}
|
|
669
|
+
if (ascendingViolated(buckets)) {
|
|
670
|
+
violations.push(ascending);
|
|
671
|
+
}
|
|
672
|
+
if (buckets.some((bucket) => bucket.count === 0)) {
|
|
673
|
+
violations.push(empties);
|
|
674
|
+
}
|
|
675
|
+
return violations;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Which published rules a window response breaks.
|
|
679
|
+
*
|
|
680
|
+
* Deliberately a predicate over a **response body** rather than over the code
|
|
681
|
+
* that produced it, so it can be pointed at a knowingly wrong implementation
|
|
682
|
+
* and watched failing. W3-08 shipped a conformance suite that compared HTTP
|
|
683
|
+
* status codes, and a receiver carrying the exact defect the story existed to
|
|
684
|
+
* fix passed twenty of twenty published cases; reading a suite cannot show what
|
|
685
|
+
* it fails to distinguish.
|
|
686
|
+
*
|
|
687
|
+
* It reads the body loosely rather than through `forecastWindowsSchema`,
|
|
688
|
+
* because the schema refuses several of these outright and a check that could
|
|
689
|
+
* only say "did not parse" would name no rule — which is the same defect in a
|
|
690
|
+
* different coat.
|
|
691
|
+
*
|
|
692
|
+
* Returns the broken rules in `FORECAST_COMPATIBILITY.rules` order. An empty
|
|
693
|
+
* array is the only pass.
|
|
694
|
+
*/
|
|
695
|
+
export function forecastWindowViolations(body) {
|
|
696
|
+
const buckets = looseBuckets(body);
|
|
697
|
+
if (buckets === undefined) {
|
|
698
|
+
return [...FORECAST_COMPATIBILITY.rules];
|
|
699
|
+
}
|
|
700
|
+
const violations = [];
|
|
701
|
+
const [spelling, total, arithmetic, heads, ascending, empties] = FORECAST_COMPATIBILITY.rules;
|
|
702
|
+
if (buckets.some((bucket) => !timestampSchema.safeParse(bucket.start).success)) {
|
|
703
|
+
violations.push(spelling);
|
|
704
|
+
}
|
|
705
|
+
// The discriminating case for "count is the total, never the subtotal": an
|
|
706
|
+
// implementation that published the top ten's sum as the count while a
|
|
707
|
+
// remainder existed. Arithmetic below catches it too, and both firing is the
|
|
708
|
+
// honest report — one names the defect, the other names the symptom.
|
|
709
|
+
if (buckets.some((bucket) => bucket.count === sumOf(bucket.byTenant) &&
|
|
710
|
+
numberOr(bucket.otherCount, 0) > 0)) {
|
|
711
|
+
violations.push(total);
|
|
712
|
+
}
|
|
713
|
+
if (buckets.some((bucket) => bucket.count !==
|
|
714
|
+
sumOf(bucket.byTenant) + numberOr(bucket.otherCount, Number.NaN))) {
|
|
715
|
+
violations.push(arithmetic);
|
|
716
|
+
}
|
|
717
|
+
if (buckets.some((bucket) => numberOr(bucket.nextFireCount, Number.NaN) >
|
|
718
|
+
numberOr(bucket.count, Number.NaN) ||
|
|
719
|
+
Number.isNaN(numberOr(bucket.nextFireCount, Number.NaN)))) {
|
|
720
|
+
violations.push(heads);
|
|
721
|
+
}
|
|
722
|
+
if (ascendingViolated(buckets)) {
|
|
723
|
+
violations.push(ascending);
|
|
724
|
+
}
|
|
725
|
+
if (buckets.some((bucket) => bucket.count === 0)) {
|
|
726
|
+
violations.push(empties);
|
|
727
|
+
}
|
|
728
|
+
return violations;
|
|
729
|
+
}
|