@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
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { attemptIdSchema, deliveryIdSchema, endpointIdSchema, occurrenceIdSchema, scheduleIdSchema, } from "./identifiers.js";
|
|
3
|
+
import { CALLBACK_ATTEMPT_DIAGNOSTICS } from "./delivery.js";
|
|
4
|
+
import { pageSchema, versionSchema } from "./protocol.js";
|
|
5
|
+
import { callbackTriggerSchema, } from "./signatures.js";
|
|
6
|
+
import { durationSecondsSchema, timeZoneSchema, timestampSchema } from "./time.js";
|
|
7
|
+
/**
|
|
8
|
+
* The scheduling resource family: schedules, the occurrences they produce, and
|
|
9
|
+
* the attempts made to deliver each occurrence.
|
|
10
|
+
*
|
|
11
|
+
* Three ideas make the difference between this and the legacy scheduler, where
|
|
12
|
+
* one row was the schedule, the occurrence and the delivery all at once.
|
|
13
|
+
*
|
|
14
|
+
* 1. **A schedule is a definition; an occurrence is a concrete instant.** A
|
|
15
|
+
* recurring series never advances by rewriting its row, because that loses
|
|
16
|
+
* the outcome of the occurrence it just overwrote. Each occurrence carries
|
|
17
|
+
* its own state, retries and terminal outcome.
|
|
18
|
+
* 2. **A generation makes a stale event harmless.** Replacing a definition
|
|
19
|
+
* increments it, so work already in flight against the old definition can be
|
|
20
|
+
* recorded but can never fire, retire, or advance the replacement.
|
|
21
|
+
* 3. **An attempt is immutable once classified.** A redelivery is a new
|
|
22
|
+
* attempt against the same occurrence; it never rewrites history.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here carries an authority tuple. A scheduling resource belongs to the
|
|
25
|
+
* partition that stores it, and that partition is bound to exactly one
|
|
26
|
+
* `(organization, project, environment)` for its whole life — see
|
|
27
|
+
* `docs/architecture/system-architecture.md` section 3. Repeating the tuple on
|
|
28
|
+
* every row would invite two answers to the same question.
|
|
29
|
+
*/
|
|
30
|
+
/* -------------------------------------------------------------------------- */
|
|
31
|
+
/* Schedule state */
|
|
32
|
+
/* -------------------------------------------------------------------------- */
|
|
33
|
+
/**
|
|
34
|
+
* Every state a schedule can be in, in lifecycle order.
|
|
35
|
+
*
|
|
36
|
+
* `paused` is reversible and `cancelled` is terminal. `completed` is what a
|
|
37
|
+
* finite schedule reaches on its own — a one-shot that fired, or a series that
|
|
38
|
+
* ran out of occurrences — and is distinct from `cancelled` because "it
|
|
39
|
+
* finished" and "someone stopped it" are different answers to a support
|
|
40
|
+
* question.
|
|
41
|
+
*/
|
|
42
|
+
export const SCHEDULE_STATUSES = [
|
|
43
|
+
"active",
|
|
44
|
+
"paused",
|
|
45
|
+
"cancelled",
|
|
46
|
+
"completed",
|
|
47
|
+
];
|
|
48
|
+
export const scheduleStatusSchema = z.enum(SCHEDULE_STATUSES);
|
|
49
|
+
/**
|
|
50
|
+
* How promptly an occurrence expects to be dispatched.
|
|
51
|
+
*
|
|
52
|
+
* Two classes, not a numeric priority: a number invites callers to bid against
|
|
53
|
+
* each other, and a scheduler that honours arbitrary priorities cannot promise
|
|
54
|
+
* anything to anybody. `realtime` gets preference without starving `bulk`.
|
|
55
|
+
*/
|
|
56
|
+
export const LATENCY_CLASSES = ["realtime", "bulk"];
|
|
57
|
+
export const latencyClassSchema = z.enum(LATENCY_CLASSES);
|
|
58
|
+
/* -------------------------------------------------------------------------- */
|
|
59
|
+
/* Delivery duration */
|
|
60
|
+
/* -------------------------------------------------------------------------- */
|
|
61
|
+
/**
|
|
62
|
+
* How long a receiver may take to answer, as a ladder rather than a number.
|
|
63
|
+
*
|
|
64
|
+
* **A ladder because every rung is a different product.** The first two are the
|
|
65
|
+
* ordinary path: a delivery is carried inside the partition's own dispatch and
|
|
66
|
+
* holds one of six in-flight slots for its whole duration, so the cost of a slow
|
|
67
|
+
* receiver is paid by every other schedule in that
|
|
68
|
+
* `(organization, project, environment)`. The rungs above are carried elsewhere
|
|
69
|
+
* and priced, because that is the only honest way to sell them.
|
|
70
|
+
*
|
|
71
|
+
* **Deliberately not a free-form integer.** A caller that could ask for 47
|
|
72
|
+
* seconds would be asking a question this product has no answer to: 47 is past
|
|
73
|
+
* the point where a delivery may occupy a partition slot and short of the point
|
|
74
|
+
* where it is worth carrying separately. The ladder is what makes "which side of
|
|
75
|
+
* the line is this" a decision somebody took rather than a comparison somebody
|
|
76
|
+
* wrote.
|
|
77
|
+
*/
|
|
78
|
+
export const DELIVERY_DURATION_TIERS = [
|
|
79
|
+
5, 30, 300, 900, 1_800, 3_600, 43_200, 86_400,
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* What a schedule gets without asking, in seconds.
|
|
83
|
+
*
|
|
84
|
+
* Five, and it is a **six-fold tightening of the 30 seconds that shipped**. The
|
|
85
|
+
* reason is `DEFAULT_MAX_OUTSTANDING`: a partition dispatches six deliveries at
|
|
86
|
+
* once because Cloudflare permits six connections per invocation to be waiting
|
|
87
|
+
* for response headers, so a partition's throughput is six divided by how long a
|
|
88
|
+
* delivery takes. At 30 seconds that is twelve deliveries a minute for
|
|
89
|
+
* everything a customer has scheduled in one project and environment; at five it
|
|
90
|
+
* is seventy-two. The everyday reminder webhook this product is designed around
|
|
91
|
+
* answers in well under a second, and the schedules that do not now have
|
|
92
|
+
* somewhere to say so.
|
|
93
|
+
*/
|
|
94
|
+
export const DEFAULT_DELIVERY_DURATION_SECONDS = 5;
|
|
95
|
+
/**
|
|
96
|
+
* The longest duration that is not a long-running schedule, in seconds.
|
|
97
|
+
*
|
|
98
|
+
* Thirty. A receiver that writes a row and calls one API can exceed five seconds
|
|
99
|
+
* without being a long-running job, and making that customer buy a plan to fix
|
|
100
|
+
* an occasional timeout would price the most ordinary failure there is. Above
|
|
101
|
+
* this the delivery is carried outside the partition and is a plan feature.
|
|
102
|
+
*
|
|
103
|
+
* EasyCron, which sells this ladder, allows **five seconds** on its free tier
|
|
104
|
+
* and nothing between that and twelve hours. The thirty-second rung is a
|
|
105
|
+
* deliberate difference from them rather than an oversight.
|
|
106
|
+
*/
|
|
107
|
+
export const FREE_DELIVERY_DURATION_SECONDS = 30;
|
|
108
|
+
/**
|
|
109
|
+
* The longest a receiver may hold the request itself open, in seconds.
|
|
110
|
+
*
|
|
111
|
+
* Thirty minutes, and it is a platform fact rather than a commercial one. A
|
|
112
|
+
* `response` delivery is carried by a Workflow step holding a live socket, and
|
|
113
|
+
* Cloudflare restarts the Workers runtime a few times a week, giving in-flight
|
|
114
|
+
* requests a thirty-second grace period and terminating whatever is left. Their
|
|
115
|
+
* own limits page calls that "very unlikely" — which is true of a five-second
|
|
116
|
+
* delivery and inverts as the hold grows: at roughly three restarts a week a
|
|
117
|
+
* twelve-hour hold overlaps one about one time in five, and a twenty-four-hour
|
|
118
|
+
* hold about two in five.
|
|
119
|
+
*
|
|
120
|
+
* A terminated hold is not a lost occurrence — the watchdog settles it
|
|
121
|
+
* `network_ambiguous` and the ladder retries it — but `network_ambiguous` on a
|
|
122
|
+
* twelve-hour job means "your receiver may have run this, and we are about to
|
|
123
|
+
* ask it to run again". Selling that as a duration would be selling a coin flip.
|
|
124
|
+
*
|
|
125
|
+
* So the rungs above this are `callback` only. A `callback` receiver answers
|
|
126
|
+
* immediately and reports afterwards, so there is no hold for a restart to
|
|
127
|
+
* terminate — the attempt waits in one indexed row, which is not running when
|
|
128
|
+
* the runtime restarts. Cloudflare's own "limit timeouts to 30 minutes or less"
|
|
129
|
+
* advice lands on the same number from the same direction, which is the reason
|
|
130
|
+
* this constant is 1,800 rather than a round guess.
|
|
131
|
+
*
|
|
132
|
+
* **This is a ceiling on the mechanism, not on the ladder.** Every rung is still
|
|
133
|
+
* for sale and the duration is still what is priced; what a customer buying more
|
|
134
|
+
* than thirty minutes has to do is answer immediately and call us back.
|
|
135
|
+
*/
|
|
136
|
+
export const RESPONSE_DELIVERY_MAX_DURATION_SECONDS = 1_800;
|
|
137
|
+
export const deliveryDurationSecondsSchema = z
|
|
138
|
+
.literal(DELIVERY_DURATION_TIERS)
|
|
139
|
+
.describe("How long the receiver may take to answer, in seconds");
|
|
140
|
+
/** Whether a duration is past the point where it needs a plan that allows it. */
|
|
141
|
+
export function isLongRunningDuration(seconds) {
|
|
142
|
+
return seconds > FREE_DELIVERY_DURATION_SECONDS;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* How the receiver tells us the work finished.
|
|
146
|
+
*
|
|
147
|
+
* - `response` — it answers the POST when it is done, and we hold the request
|
|
148
|
+
* open for up to `maxDurationSeconds`. Nothing to build on the receiver's
|
|
149
|
+
* side; its own stack has to tolerate a request open that long, which is the
|
|
150
|
+
* customer's constraint rather than ours and is the one they will meet first.
|
|
151
|
+
* Available up to {@link RESPONSE_DELIVERY_MAX_DURATION_SECONDS}.
|
|
152
|
+
* - `callback` — it answers immediately to say it has the work, does the work,
|
|
153
|
+
* and calls the completion URL in the envelope when it is done. More to build
|
|
154
|
+
* on the receiver's side and no upper bound worth naming.
|
|
155
|
+
*
|
|
156
|
+
* **What is sold is the duration rather than the mechanism.** The operator
|
|
157
|
+
* settled that on 2026-08-12: `callback` costs this product almost nothing to
|
|
158
|
+
* run — an instance waiting for an event hibernates, one holding a socket does
|
|
159
|
+
* not — so pricing it separately would charge for the option we would rather
|
|
160
|
+
* every customer took.
|
|
161
|
+
*
|
|
162
|
+
* **Both were available at every rung until 2026-08-13**, and what changed is
|
|
163
|
+
* not the pricing but a measured platform limit: see
|
|
164
|
+
* {@link RESPONSE_DELIVERY_MAX_DURATION_SECONDS} for why a held-open request
|
|
165
|
+
* stops being a promise somebody can keep at the top three rungs. The ladder is
|
|
166
|
+
* untouched, and so is what a rung costs.
|
|
167
|
+
*/
|
|
168
|
+
export const DELIVERY_COMPLETION_MODES = ["response", "callback"];
|
|
169
|
+
export const deliveryCompletionModeSchema = z.enum(DELIVERY_COMPLETION_MODES);
|
|
170
|
+
/**
|
|
171
|
+
* What to do when a recurring schedule comes due and its previous run has not
|
|
172
|
+
* finished.
|
|
173
|
+
*
|
|
174
|
+
* **There was no answer to this before long-running deliveries**, and the
|
|
175
|
+
* absence was deliberate rather than an oversight: `PartitionKernel.advanceSeries`
|
|
176
|
+
* never reads the head occurrence's state, because a series whose advance
|
|
177
|
+
* depended on delivery succeeding would stop a customer's daily schedule the
|
|
178
|
+
* first time their receiver had a bad afternoon. That reasoning is untouched for
|
|
179
|
+
* every schedule on the default duration, where a run lasts five seconds and an
|
|
180
|
+
* overlap is not a thing anyone can construct.
|
|
181
|
+
*
|
|
182
|
+
* It stops being true at twelve hours. An hourly schedule whose runs take twelve
|
|
183
|
+
* hours has twelve of them alive at once under the old rule, and no customer
|
|
184
|
+
* means that. So this setting exists **only on a long-running schedule**, which
|
|
185
|
+
* is the narrowest place it can live and still answer the question.
|
|
186
|
+
*
|
|
187
|
+
* - `skip` — do not run; record the occurrence as skipped. The default.
|
|
188
|
+
* - `queue` — run it when the previous one finishes.
|
|
189
|
+
*/
|
|
190
|
+
export const DELIVERY_OVERLAP_POLICIES = ["skip", "queue"];
|
|
191
|
+
export const deliveryOverlapPolicySchema = z.enum(DELIVERY_OVERLAP_POLICIES);
|
|
192
|
+
export const DEFAULT_DELIVERY_OVERLAP_POLICY = "skip";
|
|
193
|
+
/**
|
|
194
|
+
* A definition generation. Starts at 1 and increases on every replacement.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately separate from `version`. `version` moves for any write at all,
|
|
197
|
+
* including a pause, and drives ETags; `generation` moves only when the *work*
|
|
198
|
+
* changes, and is what a due event is checked against. Folding them together
|
|
199
|
+
* would make pausing and resuming a schedule invalidate occurrences that are
|
|
200
|
+
* still perfectly valid.
|
|
201
|
+
*/
|
|
202
|
+
export const generationSchema = z
|
|
203
|
+
.number()
|
|
204
|
+
.int("must be a whole number")
|
|
205
|
+
.positive("must be at least 1");
|
|
206
|
+
/**
|
|
207
|
+
* A customer-supplied fairness key.
|
|
208
|
+
*
|
|
209
|
+
* Bounded in length and charset because it is used as a telemetry label and as
|
|
210
|
+
* a pacing lane: an unbounded key would let one caller mint unbounded
|
|
211
|
+
* cardinality in our metrics, and a key carrying arbitrary bytes would end up
|
|
212
|
+
* in a log line that nothing downstream can parse. It selects a lane within the
|
|
213
|
+
* caller's own partition and can never change the authority tuple or reach
|
|
214
|
+
* another Durable Object namespace.
|
|
215
|
+
*/
|
|
216
|
+
export const PARTITION_KEY_MAX_LENGTH = 64;
|
|
217
|
+
export const partitionKeySchema = z
|
|
218
|
+
.string()
|
|
219
|
+
.min(1, "must not be empty")
|
|
220
|
+
.max(PARTITION_KEY_MAX_LENGTH, `must be at most ${String(PARTITION_KEY_MAX_LENGTH)} characters`)
|
|
221
|
+
.regex(/^[A-Za-z0-9._:-]+$/u, "must use only letters, digits, dot, underscore, colon, or hyphen");
|
|
222
|
+
/* -------------------------------------------------------------------------- */
|
|
223
|
+
/* Occurrence state */
|
|
224
|
+
/* -------------------------------------------------------------------------- */
|
|
225
|
+
/**
|
|
226
|
+
* Every state one occurrence can be in.
|
|
227
|
+
*
|
|
228
|
+
* `ambiguous` is a first-class state rather than a kind of failure. When a
|
|
229
|
+
* request times out the receiver may or may not have acted, and calling that
|
|
230
|
+
* either "succeeded" or "failed" is how an occurrence is silently dropped or
|
|
231
|
+
* silently duplicated.
|
|
232
|
+
*
|
|
233
|
+
* `skipped_stale` records an occurrence that became due too late to be worth
|
|
234
|
+
* firing, or whose definition was replaced underneath it. It is deliberately
|
|
235
|
+
* not `cancelled`: a customer who cancelled something and a customer whose
|
|
236
|
+
* occurrence went stale need different explanations.
|
|
237
|
+
*/
|
|
238
|
+
export const OCCURRENCE_STATES = [
|
|
239
|
+
"planned",
|
|
240
|
+
"due",
|
|
241
|
+
"dispatching",
|
|
242
|
+
"retry_wait",
|
|
243
|
+
"ambiguous",
|
|
244
|
+
"succeeded",
|
|
245
|
+
"terminal_failed",
|
|
246
|
+
"cancelled",
|
|
247
|
+
"skipped_stale",
|
|
248
|
+
];
|
|
249
|
+
export const occurrenceStateSchema = z.enum(OCCURRENCE_STATES);
|
|
250
|
+
/**
|
|
251
|
+
* The states an occurrence never leaves.
|
|
252
|
+
*
|
|
253
|
+
* Exported so a caller enumerates them rather than writing the list out and
|
|
254
|
+
* quietly missing one if another is ever added.
|
|
255
|
+
*/
|
|
256
|
+
export const TERMINAL_OCCURRENCE_STATES = [
|
|
257
|
+
"succeeded",
|
|
258
|
+
"terminal_failed",
|
|
259
|
+
"cancelled",
|
|
260
|
+
"skipped_stale",
|
|
261
|
+
];
|
|
262
|
+
export function isTerminalOccurrenceState(state) {
|
|
263
|
+
return TERMINAL_OCCURRENCE_STATES.includes(state);
|
|
264
|
+
}
|
|
265
|
+
/* -------------------------------------------------------------------------- */
|
|
266
|
+
/* Attempt state */
|
|
267
|
+
/* -------------------------------------------------------------------------- */
|
|
268
|
+
/**
|
|
269
|
+
* How one delivery attempt was classified. Immutable once set.
|
|
270
|
+
*
|
|
271
|
+
* These four are the attempt-level counterpart of `ResponseClass` in
|
|
272
|
+
* `signatures.ts`, which classifies a *response*. They are kept as separate
|
|
273
|
+
* vocabularies on purpose: a response class is a fact about what came back, and
|
|
274
|
+
* an attempt state is a fact about the attempt as a whole, including an attempt
|
|
275
|
+
* that never received a response at all. `attemptStateForResponse` is the only
|
|
276
|
+
* sanctioned mapping between them.
|
|
277
|
+
*/
|
|
278
|
+
export const ATTEMPT_STATES = [
|
|
279
|
+
"succeeded",
|
|
280
|
+
"retryable_failure",
|
|
281
|
+
"terminal_failure",
|
|
282
|
+
"ambiguous",
|
|
283
|
+
];
|
|
284
|
+
export const attemptStateSchema = z.enum(ATTEMPT_STATES);
|
|
285
|
+
/** Safe diagnostic codes produced by the W3-03 delivery classifier. */
|
|
286
|
+
export const attemptDiagnosticSchema = z.enum(CALLBACK_ATTEMPT_DIAGNOSTICS);
|
|
287
|
+
/**
|
|
288
|
+
* Bounded next-action hints for an attempt timeline.
|
|
289
|
+
*
|
|
290
|
+
* These are codes rather than prose so a client can render its own guidance
|
|
291
|
+
* without ever receiving an executor exception, destination, response body,
|
|
292
|
+
* payload, or secret. `review_retry_policy` names the historical category
|
|
293
|
+
* without promising that the occurrence still has automatic work to do.
|
|
294
|
+
*/
|
|
295
|
+
export const ATTEMPT_REMEDIATIONS = [
|
|
296
|
+
"none",
|
|
297
|
+
"review_retry_policy",
|
|
298
|
+
"inspect_receiver",
|
|
299
|
+
"verify_endpoint",
|
|
300
|
+
"replace_payload",
|
|
301
|
+
"contact_support",
|
|
302
|
+
"review_before_redelivery",
|
|
303
|
+
];
|
|
304
|
+
/**
|
|
305
|
+
* How much of one retained response header is published, in characters.
|
|
306
|
+
*
|
|
307
|
+
* The sender caps it at 256 **bytes**, and this is the schema's own bound on
|
|
308
|
+
* what may be presented — deliberately the looser of the two, because a schema
|
|
309
|
+
* that refused what the sender legitimately stored would make a delivery
|
|
310
|
+
* unreadable rather than a header untidy.
|
|
311
|
+
*/
|
|
312
|
+
export const ATTEMPT_RESPONSE_HEADER_MAX_LENGTH = 256;
|
|
313
|
+
/** How much of a failed receiver's body is published, in characters. */
|
|
314
|
+
export const ATTEMPT_FAILURE_SNIPPET_MAX_LENGTH = 4_096;
|
|
315
|
+
export const attemptRemediationSchema = z.enum(ATTEMPT_REMEDIATIONS);
|
|
316
|
+
/** Derive the one safe next-action code a public attempt may expose. */
|
|
317
|
+
export function remediationForAttempt(diagnostic) {
|
|
318
|
+
switch (diagnostic) {
|
|
319
|
+
case "delivered":
|
|
320
|
+
return "none";
|
|
321
|
+
case "endpoint_unavailable":
|
|
322
|
+
case "endpoint_unsafe":
|
|
323
|
+
return "verify_endpoint";
|
|
324
|
+
case "http_refused":
|
|
325
|
+
case "http_terminal":
|
|
326
|
+
// The same advice for two different futures, deliberately. Both mean the
|
|
327
|
+
// receiver answered and declined, so the receiver is what to look at;
|
|
328
|
+
// what differs is whether the occurrence still has attempts left, which
|
|
329
|
+
// this immutable row must not claim to know — see `http_retryable` below.
|
|
330
|
+
return "inspect_receiver";
|
|
331
|
+
case "identity_conflict":
|
|
332
|
+
return "contact_support";
|
|
333
|
+
case "payload_unavailable":
|
|
334
|
+
return "replace_payload";
|
|
335
|
+
case "completion_timed_out":
|
|
336
|
+
case "duplicate_inflight":
|
|
337
|
+
case "network_ambiguous":
|
|
338
|
+
// A receiver that acknowledged the work and then said nothing is exactly
|
|
339
|
+
// the ambiguous case, and the advice is the same one: it may have run.
|
|
340
|
+
// Anything more decisive would be this row claiming to know something no
|
|
341
|
+
// part of this system observed.
|
|
342
|
+
return "review_before_redelivery";
|
|
343
|
+
case "http_retryable":
|
|
344
|
+
case "rate_limited":
|
|
345
|
+
// This describes what the immutable diagnostic asks an operator to
|
|
346
|
+
// review, not what the occurrence will do next. The latter can change
|
|
347
|
+
// later through retry, give-up, cancellation, or redelivery; deriving
|
|
348
|
+
// from it would make this earlier attempt's representation mutable.
|
|
349
|
+
return "review_retry_policy";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Why this immutable attempt exists.
|
|
354
|
+
*
|
|
355
|
+
* Automatic work has no causal attempt. A manual redelivery names the
|
|
356
|
+
* retained attempt the caller chose to follow, which preserves both identities
|
|
357
|
+
* and makes the new row an honest link instead of a rewrite of the old one.
|
|
358
|
+
*
|
|
359
|
+
* **Deliberately the same schema the signed callback envelope carries**, rather
|
|
360
|
+
* than a second declaration of the same shape. From W3-08 the attempt's trigger
|
|
361
|
+
* is what reaches the receiver, so a reader of the timeline and a receiver
|
|
362
|
+
* branching on the wire are looking at one fact. Two definitions would drift,
|
|
363
|
+
* and the failure mode of that drift is a receiver that re-runs work for a
|
|
364
|
+
* cause the history says never happened.
|
|
365
|
+
*/
|
|
366
|
+
export const attemptTriggerSchema = callbackTriggerSchema;
|
|
367
|
+
/**
|
|
368
|
+
* The largest elapsed time JSON can carry without losing integer precision.
|
|
369
|
+
*
|
|
370
|
+
* Runtime timeout policies are ordinarily much smaller, but their configured
|
|
371
|
+
* ceiling is not a public invariant. Bounding the wire at the safe-integer
|
|
372
|
+
* edge keeps corrupt or non-finite timing out without clamping honest durable
|
|
373
|
+
* evidence into a value the executor did not report.
|
|
374
|
+
*/
|
|
375
|
+
export const ATTEMPT_DURATION_MAX_MS = Number.MAX_SAFE_INTEGER;
|
|
376
|
+
/** The attempt state a classified response produces. */
|
|
377
|
+
export function attemptStateForResponse(responseClass) {
|
|
378
|
+
switch (responseClass) {
|
|
379
|
+
case "success":
|
|
380
|
+
return "succeeded";
|
|
381
|
+
case "retryable":
|
|
382
|
+
return "retryable_failure";
|
|
383
|
+
case "terminal":
|
|
384
|
+
return "terminal_failure";
|
|
385
|
+
case "ambiguous":
|
|
386
|
+
return "ambiguous";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/* -------------------------------------------------------------------------- */
|
|
390
|
+
/* Bounds */
|
|
391
|
+
/* -------------------------------------------------------------------------- */
|
|
392
|
+
/**
|
|
393
|
+
* The runtime's default bounds, in one place.
|
|
394
|
+
*
|
|
395
|
+
* **These are engineering defaults, not plan limits.** They exist because a
|
|
396
|
+
* partition has to bound its own growth to stay affordable and predictable, and
|
|
397
|
+
* every one of them is enforced by the storage layer today. What they are *not*
|
|
398
|
+
* is a commercial decision: `docs/product/vision-and-strategy.md` section 11
|
|
399
|
+
* names retained history, payload size, active schedules, realtime rate,
|
|
400
|
+
* endpoints and forecast horizon as **plan limits**, and puts limits and
|
|
401
|
+
* retention behind the W7 commercial gate. W7 sets the per-plan numbers; these
|
|
402
|
+
* are what a partition enforces until it does, and the partition store takes
|
|
403
|
+
* them as an argument precisely so a plan can supply its own.
|
|
404
|
+
*
|
|
405
|
+
* Durations are whole seconds, per `docs/contracts/v1-conventions.md`.
|
|
406
|
+
*/
|
|
407
|
+
export const SCHEDULING_BOUNDS = {
|
|
408
|
+
/**
|
|
409
|
+
* Attempts made against one occurrence before it is terminal, including the
|
|
410
|
+
* first. Bounded so a permanently broken receiver cannot make one occurrence
|
|
411
|
+
* cost an unbounded amount of work.
|
|
412
|
+
*/
|
|
413
|
+
attemptsPerOccurrence: 8,
|
|
414
|
+
/**
|
|
415
|
+
* The longest fixed interval a recurring schedule may ask for, in seconds.
|
|
416
|
+
*
|
|
417
|
+
* Thirty days, which is the copied scheduler's `MAX_INTERVAL_SECONDS` kept
|
|
418
|
+
* rather than a new number invented beside it, and its reason is the
|
|
419
|
+
* platform's rather than the product's: a Durable Object alarm is not
|
|
420
|
+
* arbitrarily far in the future.
|
|
421
|
+
*/
|
|
422
|
+
everySecondsMax: 30 * 24 * 60 * 60,
|
|
423
|
+
/**
|
|
424
|
+
* The shortest fixed interval a recurring schedule may ask for, in seconds.
|
|
425
|
+
*
|
|
426
|
+
* One minute, and this one is **not** inherited: the copy admits a one-second
|
|
427
|
+
* interval, which is 86,400 alarm wakes a day for a single schedule. A minute
|
|
428
|
+
* is also the finest instant a five-field cron expression can name, so both
|
|
429
|
+
* ways of expressing a recurrence promise the same granularity rather than
|
|
430
|
+
* one of them quietly promising more. Widening this later is additive by
|
|
431
|
+
* `classifyChange` and narrowing it is breaking, which is the direction to be
|
|
432
|
+
* conservative in.
|
|
433
|
+
*/
|
|
434
|
+
everySecondsMin: 60,
|
|
435
|
+
/**
|
|
436
|
+
* How long an idempotency receipt is honoured. A client retrying a create for
|
|
437
|
+
* longer than a day is not retrying, it is making a new request; keeping
|
|
438
|
+
* receipts beyond that turns the partition into an unbounded key/value store.
|
|
439
|
+
*/
|
|
440
|
+
idempotencyReceiptSeconds: 24 * 60 * 60,
|
|
441
|
+
/**
|
|
442
|
+
* Concrete occurrences materialized **ahead of time** for one schedule.
|
|
443
|
+
*
|
|
444
|
+
* One, which is architecture section 15's default: a series is written one
|
|
445
|
+
* occurrence at a time rather than as a horizon, because a horizon multiplies
|
|
446
|
+
* row count by its own length and makes a replace rewrite all of it.
|
|
447
|
+
*
|
|
448
|
+
* **What it bounds is lead time, not live rows, and W2-04 settled that.**
|
|
449
|
+
* Read as "one non-terminal occurrence" it would forbid architecture section
|
|
450
|
+
* 6's own fire flow, which marks the current occurrence `due` and plans its
|
|
451
|
+
* successor in one transaction and therefore holds two. Read as this wording
|
|
452
|
+
* already says — materialized *ahead of time* — it means a schedule holds at
|
|
453
|
+
* most one `planned` occurrence whose instant has not yet arrived, which
|
|
454
|
+
* permits that transaction, permits an advance and a serve to land in either
|
|
455
|
+
* order within one wake, and still forbids the horizon it exists to forbid.
|
|
456
|
+
*
|
|
457
|
+
* Enforced by `PartitionStore.insertOccurrence`, where occurrences are
|
|
458
|
+
* planned, rather than by a unique index — an index cannot reference the
|
|
459
|
+
* present, and the present is what "ahead of time" is measured against.
|
|
460
|
+
*/
|
|
461
|
+
materializedOccurrencesPerSchedule: 1,
|
|
462
|
+
/**
|
|
463
|
+
* How long occurrence and attempt history is retained before it is swept.
|
|
464
|
+
* Long enough to explain last month's invoice, short enough that a busy
|
|
465
|
+
* partition's history does not outgrow its schedules.
|
|
466
|
+
*/
|
|
467
|
+
occurrenceHistorySeconds: 30 * 24 * 60 * 60,
|
|
468
|
+
/**
|
|
469
|
+
* How far ahead a one-shot may be asked for as a *delay*, in seconds — about
|
|
470
|
+
* five years, which is the copied scheduler's `MAX_ONESHOT_SECONDS` kept
|
|
471
|
+
* rather than a new number invented beside it.
|
|
472
|
+
*
|
|
473
|
+
* It bounds `delaySeconds` and deliberately does not bound `at`. Narrowing a
|
|
474
|
+
* request field that is already published is breaking by `classifyChange`,
|
|
475
|
+
* and `at` shipped with W1-08; a horizon on both belongs with the plan limits
|
|
476
|
+
* W7 owns, which is where a customer-visible ceiling can be set once for
|
|
477
|
+
* every way of expressing the same instant.
|
|
478
|
+
*/
|
|
479
|
+
oneShotDelaySeconds: 158_000_000,
|
|
480
|
+
/**
|
|
481
|
+
* The customer payload carried to the callback, in bytes, measured on the
|
|
482
|
+
* UTF-8 encoding of the JSON the caller sent. 64 KiB comfortably fits the
|
|
483
|
+
* command-shaped payloads this product is for, and refuses the ones that are
|
|
484
|
+
* really a file transfer.
|
|
485
|
+
*/
|
|
486
|
+
payloadBytes: 65_536,
|
|
487
|
+
/**
|
|
488
|
+
* How soon a one-shot has to be for `realtime` to be the class it gets by
|
|
489
|
+
* default, in seconds.
|
|
490
|
+
*
|
|
491
|
+
* Two minutes, which is the copied `validate.ts`'s own threshold kept rather
|
|
492
|
+
* than a number invented beside it: upstream classifies an `at` or a `delay`
|
|
493
|
+
* inside 120 seconds as realtime and everything else as bulk, on the
|
|
494
|
+
* reasoning that a wait that short is a human waiting on a screen. A caller
|
|
495
|
+
* who disagrees says so — `latencyClass` on the request is what makes this a
|
|
496
|
+
* default rather than a rule.
|
|
497
|
+
*/
|
|
498
|
+
realtimeOneShotSeconds: 120,
|
|
499
|
+
};
|
|
500
|
+
/* -------------------------------------------------------------------------- */
|
|
501
|
+
/* Recurrence */
|
|
502
|
+
/* -------------------------------------------------------------------------- */
|
|
503
|
+
/**
|
|
504
|
+
* The longest cron expression this surface accepts, in characters.
|
|
505
|
+
*
|
|
506
|
+
* A five-field expression naming every value it could name is well under a
|
|
507
|
+
* hundred characters, so this refuses a field list built to make a parser work
|
|
508
|
+
* hard without refusing anything a human would write.
|
|
509
|
+
*/
|
|
510
|
+
export const CRON_EXPRESSION_MAX_LENGTH = 120;
|
|
511
|
+
/**
|
|
512
|
+
* A cron expression, checked for shape and deliberately not for meaning.
|
|
513
|
+
*
|
|
514
|
+
* **What a schema can honestly promise here is the interesting part.** Whether
|
|
515
|
+
* an expression parses, whether its fields are in range, and whether it names
|
|
516
|
+
* an instant that ever arrives are all decisions belonging to the engine that
|
|
517
|
+
* resolves it — the partition, which owns timer authority and is the one place
|
|
518
|
+
* `cron-schedule` is a dependency. Encoding them here would mean either a
|
|
519
|
+
* second parser in this package or a JSON Schema pretending to a precision it
|
|
520
|
+
* does not have, and a published schema that lies about what it validates is
|
|
521
|
+
* worse than one that says less. So this bounds the length and the field count
|
|
522
|
+
* and stops; a request that passes it and cannot be resolved is refused by the
|
|
523
|
+
* partition as `invalid_request`, naming which part it could not resolve.
|
|
524
|
+
*
|
|
525
|
+
* Five fields, or the `@`-prefixed nicknames every cron understands. Six-field
|
|
526
|
+
* forms are not refused here — a six-field expression whose seconds field is
|
|
527
|
+
* zero says the same thing as its five-field spelling — but one that asks to
|
|
528
|
+
* fire on a second boundary is refused by the partition, because a minute is
|
|
529
|
+
* the granularity both arms of recurrence promise.
|
|
530
|
+
*/
|
|
531
|
+
export const cronExpressionSchema = z
|
|
532
|
+
.string()
|
|
533
|
+
.min(1, "must not be empty")
|
|
534
|
+
.max(CRON_EXPRESSION_MAX_LENGTH, `must be at most ${String(CRON_EXPRESSION_MAX_LENGTH)} characters`)
|
|
535
|
+
.refine((expression) => expression.startsWith("@") ||
|
|
536
|
+
[5, 6].includes(expression.trim().split(/\s+/u).length), { message: "must be five cron fields, six fields, or an @-nickname" });
|
|
537
|
+
/**
|
|
538
|
+
* How a schedule recurs, as a caller reads it back.
|
|
539
|
+
*
|
|
540
|
+
* A discriminated union rather than three optional fields, because "which of
|
|
541
|
+
* these is set" is a question a client should not have to answer by
|
|
542
|
+
* elimination — and because a one-shot is a recurrence of one rather than an
|
|
543
|
+
* absence of recurrence, which is what lets every consumer `switch`
|
|
544
|
+
* exhaustively and be told by its compiler when a fourth kind arrives.
|
|
545
|
+
*
|
|
546
|
+
* `timeZone` is present on the `cron` arm and nowhere else, which is the
|
|
547
|
+
* conventions' rule made structural: a timezone means something alongside a
|
|
548
|
+
* wall-clock recurrence and nothing alongside an instant, where it would only
|
|
549
|
+
* invite a caller to believe an instant had been reinterpreted. It is required
|
|
550
|
+
* on that arm rather than optional, so a read always says which wall clock a
|
|
551
|
+
* cron schedule is on instead of leaving the caller to assume UTC.
|
|
552
|
+
*/
|
|
553
|
+
export const recurrenceSchema = z.discriminatedUnion("kind", [
|
|
554
|
+
/**
|
|
555
|
+
* A one-shot, which carries no instant of its own.
|
|
556
|
+
*
|
|
557
|
+
* The instant is the occurrence's `plannedFor` and is stated once, there —
|
|
558
|
+
* repeating it here would invite the two to disagree after a replace, and the
|
|
559
|
+
* question this field answers is "does this fire again", not "when". A
|
|
560
|
+
* listing, which carries no occurrence, is a summary by design; detail is one
|
|
561
|
+
* call away and is where the occurrence is authoritative.
|
|
562
|
+
*/
|
|
563
|
+
z.object({ kind: z.literal("at") }),
|
|
564
|
+
z.object({
|
|
565
|
+
everySeconds: durationSecondsSchema
|
|
566
|
+
.min(SCHEDULING_BOUNDS.everySecondsMin, `must be at least ${String(SCHEDULING_BOUNDS.everySecondsMin)} seconds`)
|
|
567
|
+
.max(SCHEDULING_BOUNDS.everySecondsMax, `must be at most ${String(SCHEDULING_BOUNDS.everySecondsMax)} seconds`),
|
|
568
|
+
kind: z.literal("every"),
|
|
569
|
+
}),
|
|
570
|
+
z.object({
|
|
571
|
+
cron: cronExpressionSchema,
|
|
572
|
+
kind: z.literal("cron"),
|
|
573
|
+
timeZone: timeZoneSchema,
|
|
574
|
+
}),
|
|
575
|
+
]);
|
|
576
|
+
/* -------------------------------------------------------------------------- */
|
|
577
|
+
/* Public resources */
|
|
578
|
+
/* -------------------------------------------------------------------------- */
|
|
579
|
+
/**
|
|
580
|
+
* A schedule, as a client sees it.
|
|
581
|
+
*
|
|
582
|
+
* A callback schedule exposes the reusable endpoint it references, but never
|
|
583
|
+
* the payload stored for that endpoint and never its signing secret. The
|
|
584
|
+
* endpoint id is useful definition metadata; the encrypted payload is content
|
|
585
|
+
* and stays only in the authoritative partition.
|
|
586
|
+
*/
|
|
587
|
+
export const scheduleSchema = z.object({
|
|
588
|
+
/**
|
|
589
|
+
* When the schedule was filed away, and absent until it is.
|
|
590
|
+
*
|
|
591
|
+
* A timestamp rather than a `status` member, for two independent reasons.
|
|
592
|
+
* Adding an enum member a client must *receive* is breaking by
|
|
593
|
+
* `classifyChange`, and archiving is orthogonal to the lifecycle anyway: a
|
|
594
|
+
* schedule is archived *because* it is `cancelled` or `completed`, and
|
|
595
|
+
* folding the two would lose which of those it was. It is set only from a
|
|
596
|
+
* terminal status — archiving is filing, not stopping.
|
|
597
|
+
*/
|
|
598
|
+
archivedAt: timestampSchema.optional(),
|
|
599
|
+
createdAt: timestampSchema,
|
|
600
|
+
/**
|
|
601
|
+
* The delivery settings in force, with the defaults filled in.
|
|
602
|
+
*
|
|
603
|
+
* **Present on every callback schedule rather than only on one that asked**,
|
|
604
|
+
* which is a deliberate departure from how `partitionKey` and `endpoint` read
|
|
605
|
+
* back. Those are absent when unset because absent is the truth about them.
|
|
606
|
+
* These three always have a value — five seconds, answer the request, skip an
|
|
607
|
+
* overlapping run — and a schedule that reported nothing would leave a caller
|
|
608
|
+
* to hardcode the defaults in order to display them, which is the shape of
|
|
609
|
+
* duplication that goes stale the first time a default moves.
|
|
610
|
+
*
|
|
611
|
+
* Absent on a timer-only schedule, which delivers to nothing.
|
|
612
|
+
*
|
|
613
|
+
* **`maxDurationSeconds` reads back as a plain duration, not as the ladder the
|
|
614
|
+
* request accepts**, and the asymmetry is the point. Narrowing a request field
|
|
615
|
+
* is breaking and widening it is additive, so the request takes the closed
|
|
616
|
+
* ladder; a *response* field is the other way round — adding a value a client
|
|
617
|
+
* must receive is what breaks it — so the response promises only "a number of
|
|
618
|
+
* seconds" and a rung added later costs no contract break.
|
|
619
|
+
*/
|
|
620
|
+
delivery: z
|
|
621
|
+
.object({
|
|
622
|
+
completion: deliveryCompletionModeSchema,
|
|
623
|
+
maxDurationSeconds: durationSecondsSchema,
|
|
624
|
+
onOverlap: deliveryOverlapPolicySchema,
|
|
625
|
+
})
|
|
626
|
+
.optional(),
|
|
627
|
+
/** The verified reusable callback endpoint this schedule delivers to. */
|
|
628
|
+
endpoint: endpointIdSchema.optional(),
|
|
629
|
+
generation: generationSchema,
|
|
630
|
+
id: scheduleIdSchema,
|
|
631
|
+
latencyClass: latencyClassSchema,
|
|
632
|
+
partitionKey: partitionKeySchema.optional(),
|
|
633
|
+
/**
|
|
634
|
+
* How this schedule recurs, in the vocabulary it will keep being read in.
|
|
635
|
+
*
|
|
636
|
+
* Required rather than optional, which is additive by `classifyChange`
|
|
637
|
+
* (`required` on a *response* field is what a client may now rely on
|
|
638
|
+
* receiving, not a new obligation on it). A schedule that could omit it would
|
|
639
|
+
* make "does this fire again?" unanswerable from a read, and that is the
|
|
640
|
+
* question a caller has immediately after creating one.
|
|
641
|
+
*
|
|
642
|
+
* A schedule created as `delaySeconds` reports `{ kind: "at" }`: the delay
|
|
643
|
+
* was converted to an instant once, at acceptance, and reporting the delay
|
|
644
|
+
* back would describe a schedule whose next fire moves every time it is read.
|
|
645
|
+
*/
|
|
646
|
+
recurrence: recurrenceSchema,
|
|
647
|
+
status: scheduleStatusSchema,
|
|
648
|
+
updatedAt: timestampSchema,
|
|
649
|
+
version: versionSchema,
|
|
650
|
+
});
|
|
651
|
+
/**
|
|
652
|
+
* A page of schedules.
|
|
653
|
+
*
|
|
654
|
+
* Items are schedules rather than schedule *views*. A cross-partition listing is
|
|
655
|
+
* served from the D1 projection W2-06 built, and that projection holds a route
|
|
656
|
+
* and a summary rather than every partition's live occurrence — so a page that
|
|
657
|
+
* promised an occurrence would either lie about its freshness or force the
|
|
658
|
+
* listing back through every partition it spans. Detail is where the occurrence
|
|
659
|
+
* is authoritative, and detail is one call away.
|
|
660
|
+
*/
|
|
661
|
+
export const schedulePageSchema = pageSchema(scheduleSchema);
|
|
662
|
+
/**
|
|
663
|
+
* One concrete occurrence of a schedule.
|
|
664
|
+
*
|
|
665
|
+
* `sequence` is the occurrence's position in its series, starting at 1, and is
|
|
666
|
+
* scoped to a generation: replacing a definition starts a new generation whose
|
|
667
|
+
* numbering starts again. Together `(schedule, generation, sequence)` is what
|
|
668
|
+
* makes an occurrence identifiable without depending on when it was written.
|
|
669
|
+
*/
|
|
670
|
+
export const occurrenceSchema = z
|
|
671
|
+
.object({
|
|
672
|
+
attemptCount: z.number().int().nonnegative(),
|
|
673
|
+
generation: generationSchema,
|
|
674
|
+
id: occurrenceIdSchema,
|
|
675
|
+
/** When the occurrence was planned to fire. Never earlier than this. */
|
|
676
|
+
plannedFor: timestampSchema,
|
|
677
|
+
schedule: scheduleIdSchema,
|
|
678
|
+
sequence: z.number().int().positive(),
|
|
679
|
+
/** Present exactly when the occurrence is in a terminal state. */
|
|
680
|
+
settledAt: timestampSchema.optional(),
|
|
681
|
+
state: occurrenceStateSchema,
|
|
682
|
+
})
|
|
683
|
+
// Settlement and terminality are one fact, so the wire must not be able to
|
|
684
|
+
// express them separately. The partition enforces the same pairing, and a
|
|
685
|
+
// document the storage layer could never hold is a document no client should
|
|
686
|
+
// ever be handed: "succeeded, settled at — unknown" is not a state anything
|
|
687
|
+
// can explain.
|
|
688
|
+
.refine((occurrence) => isTerminalOccurrenceState(occurrence.state) ===
|
|
689
|
+
(occurrence.settledAt !== undefined), {
|
|
690
|
+
message: "settledAt must be present for a terminal state and absent otherwise",
|
|
691
|
+
path: ["settledAt"],
|
|
692
|
+
});
|
|
693
|
+
/**
|
|
694
|
+
* One delivery attempt against one occurrence.
|
|
695
|
+
*
|
|
696
|
+
* `delivery` is the identifier the callback signature covers and the receiver
|
|
697
|
+
* dedupes on, so it is per-attempt rather than per-occurrence: a retry is a new
|
|
698
|
+
* delivery, and a receiver that saw the first one must be able to tell.
|
|
699
|
+
*
|
|
700
|
+
* `state` is absent while the attempt is in flight. That is the ambiguity the
|
|
701
|
+
* watchdog resolves, and modelling it as absent rather than defaulting it to a
|
|
702
|
+
* failure is what stops an in-flight attempt from being counted as one that
|
|
703
|
+
* came back.
|
|
704
|
+
*/
|
|
705
|
+
/**
|
|
706
|
+
* Bounded, non-secret evidence about the response a receiver sent.
|
|
707
|
+
*
|
|
708
|
+
* **A closed allowlist rather than "the headers".** A response header is
|
|
709
|
+
* receiver-controlled text that lands in durable history and on a support
|
|
710
|
+
* screen, so the question is not "is this one dangerous" but "does this one
|
|
711
|
+
* earn its place": the media type explains what the snippet is, `Retry-After`
|
|
712
|
+
* explains a pacing decision the customer can see, and one receiver-side
|
|
713
|
+
* identifier turns "your receiver refused this" into something an integrator
|
|
714
|
+
* can look up in their own logs. Cookies, `Authorization` and
|
|
715
|
+
* `WWW-Authenticate` have no representation here at all.
|
|
716
|
+
*/
|
|
717
|
+
export const attemptResponseHeaderSchema = z.object({
|
|
718
|
+
/** Lowercase, from the published allowlist. */
|
|
719
|
+
name: z.string().min(1).max(64),
|
|
720
|
+
value: z.string().max(ATTEMPT_RESPONSE_HEADER_MAX_LENGTH),
|
|
721
|
+
});
|
|
722
|
+
/**
|
|
723
|
+
* What a failing receiver said, bounded and access-controlled.
|
|
724
|
+
*
|
|
725
|
+
* Present only on a **failed** attempt whose response carried a text or JSON
|
|
726
|
+
* media type, and only where the plan retains one. A successful response body
|
|
727
|
+
* is never retained, and neither is a pending acknowledgement's: the customer
|
|
728
|
+
* content this product keeps is the content that helps somebody fix a broken
|
|
729
|
+
* receiver, and nothing else.
|
|
730
|
+
*
|
|
731
|
+
* `truncated` is carried rather than inferred from the length, because a body
|
|
732
|
+
* that is exactly the bound and a body that was cut at the bound are different
|
|
733
|
+
* facts and only one of them should show a customer an ellipsis.
|
|
734
|
+
*/
|
|
735
|
+
export const attemptFailureSnippetSchema = z.object({
|
|
736
|
+
/** The media type it was captured from. Not customer content. */
|
|
737
|
+
contentType: z.string().min(1).max(128),
|
|
738
|
+
text: z.string().max(ATTEMPT_FAILURE_SNIPPET_MAX_LENGTH),
|
|
739
|
+
truncated: z.boolean(),
|
|
740
|
+
});
|
|
741
|
+
export const attemptSchema = z
|
|
742
|
+
.object({
|
|
743
|
+
attemptNumber: z.number().int().positive(),
|
|
744
|
+
/** Present exactly when the attempt has been classified. */
|
|
745
|
+
classifiedAt: timestampSchema.optional(),
|
|
746
|
+
delivery: deliveryIdSchema,
|
|
747
|
+
/** Safe classifier code; absent when durable evidence predates diagnostics. */
|
|
748
|
+
diagnostic: attemptDiagnosticSchema.optional(),
|
|
749
|
+
/** Executor-observed elapsed time; absent when no durable timing survived. */
|
|
750
|
+
durationMs: z
|
|
751
|
+
.number()
|
|
752
|
+
.int()
|
|
753
|
+
.nonnegative()
|
|
754
|
+
.max(ATTEMPT_DURATION_MAX_MS)
|
|
755
|
+
.optional(),
|
|
756
|
+
id: attemptIdSchema,
|
|
757
|
+
occurrence: occurrenceIdSchema,
|
|
758
|
+
/** Stable next-action code paired with a diagnostic when one is known. */
|
|
759
|
+
remediation: attemptRemediationSchema.optional(),
|
|
760
|
+
/**
|
|
761
|
+
* What a failing receiver said, where the plan retains it.
|
|
762
|
+
*
|
|
763
|
+
* Absent on a success, on an in-flight attempt, on a response whose media
|
|
764
|
+
* type is not text or JSON, and on a plan whose retention does not include
|
|
765
|
+
* one. Its absence is never evidence about the delivery.
|
|
766
|
+
*/
|
|
767
|
+
failureSnippet: attemptFailureSnippetSchema.optional(),
|
|
768
|
+
/** The fixed diagnostic-header allowlist, when a response carried any. */
|
|
769
|
+
responseHeaders: z.array(attemptResponseHeaderSchema).max(8).optional(),
|
|
770
|
+
/** Absent when no response was received at all. */
|
|
771
|
+
responseStatus: z.number().int().min(100).max(599).optional(),
|
|
772
|
+
startedAt: timestampSchema,
|
|
773
|
+
state: attemptStateSchema.optional(),
|
|
774
|
+
trigger: attemptTriggerSchema,
|
|
775
|
+
})
|
|
776
|
+
// Classification and its timestamp arrive together or not at all — the same
|
|
777
|
+
// pairing the partition's CHECK constraint enforces. Independently optional
|
|
778
|
+
// fields would let an adapter publish a classified attempt with no
|
|
779
|
+
// classification time, which validates here and is unstorable there.
|
|
780
|
+
.refine((attempt) => (attempt.state === undefined) === (attempt.classifiedAt === undefined), {
|
|
781
|
+
message: "state and classifiedAt must both be present or both absent",
|
|
782
|
+
path: ["classifiedAt"],
|
|
783
|
+
})
|
|
784
|
+
// A diagnostic and the safe action derived from it are one public fact. Old
|
|
785
|
+
// retained attempts may have neither; publishing only one would make the
|
|
786
|
+
// other look accidentally lost and tempt a client to infer it.
|
|
787
|
+
.refine((attempt) => (attempt.diagnostic === undefined) ===
|
|
788
|
+
(attempt.remediation === undefined), {
|
|
789
|
+
message: "diagnostic and remediation must both be present or both absent",
|
|
790
|
+
path: ["remediation"],
|
|
791
|
+
})
|
|
792
|
+
// An in-flight attempt has no classified delivery evidence. In particular,
|
|
793
|
+
// a response status may not be presented early and then contradicted by the
|
|
794
|
+
// immutable outcome that lands later — and neither may the response headers
|
|
795
|
+
// or a failure snippet, which is the same rule one field wider since W8-11.
|
|
796
|
+
.refine((attempt) => attempt.state !== undefined ||
|
|
797
|
+
(attempt.diagnostic === undefined &&
|
|
798
|
+
attempt.durationMs === undefined &&
|
|
799
|
+
attempt.failureSnippet === undefined &&
|
|
800
|
+
attempt.remediation === undefined &&
|
|
801
|
+
attempt.responseHeaders === undefined &&
|
|
802
|
+
attempt.responseStatus === undefined), {
|
|
803
|
+
message: "classification metadata must be absent while an attempt is in flight",
|
|
804
|
+
path: ["state"],
|
|
805
|
+
});
|
|
806
|
+
/** An authoritative page over one schedule's retained occurrences. */
|
|
807
|
+
export const occurrencePageSchema = pageSchema(occurrenceSchema).extend({
|
|
808
|
+
freshness: z.object({ kind: z.literal("authoritative") }),
|
|
809
|
+
});
|
|
810
|
+
/** An authoritative occurrence detail response. */
|
|
811
|
+
export const occurrenceViewSchema = z.object({
|
|
812
|
+
freshness: z.object({ kind: z.literal("authoritative") }),
|
|
813
|
+
occurrence: occurrenceSchema,
|
|
814
|
+
});
|
|
815
|
+
/** An authoritative page over one retained occurrence's immutable attempts. */
|
|
816
|
+
export const attemptPageSchema = pageSchema(attemptSchema).extend({
|
|
817
|
+
freshness: z.object({ kind: z.literal("authoritative") }),
|
|
818
|
+
});
|
|
819
|
+
/** An authoritative attempt detail or redelivery response. */
|
|
820
|
+
export const attemptViewSchema = z.object({
|
|
821
|
+
attempt: attemptSchema,
|
|
822
|
+
freshness: z.object({ kind: z.literal("authoritative") }),
|
|
823
|
+
});
|
|
824
|
+
/**
|
|
825
|
+
* A schedule together with the occurrence that is next to happen.
|
|
826
|
+
*
|
|
827
|
+
* One document rather than two calls, because "when does this next fire" is the
|
|
828
|
+
* question every caller has immediately after creating a schedule, and a client
|
|
829
|
+
* that has to make a second request to answer it will cache the first answer and
|
|
830
|
+
* be wrong.
|
|
831
|
+
*/
|
|
832
|
+
export const scheduleViewSchema = z.object({
|
|
833
|
+
occurrence: occurrenceSchema,
|
|
834
|
+
schedule: scheduleSchema,
|
|
835
|
+
});
|
|
836
|
+
/**
|
|
837
|
+
* The four ways a caller may say when a schedule happens.
|
|
838
|
+
*
|
|
839
|
+
* Exported so a caller enumerates them rather than writing the list out and
|
|
840
|
+
* quietly missing one when a fifth arrives.
|
|
841
|
+
*/
|
|
842
|
+
export const RECURRENCE_REQUEST_FIELDS = [
|
|
843
|
+
"at",
|
|
844
|
+
"cron",
|
|
845
|
+
"delaySeconds",
|
|
846
|
+
"everySeconds",
|
|
847
|
+
];
|
|
848
|
+
/**
|
|
849
|
+
* What a caller sends to create or replace a schedule.
|
|
850
|
+
*
|
|
851
|
+
* Exactly one of `at`, `delaySeconds`, `everySeconds` and `cron`. A callback
|
|
852
|
+
* schedule references a reusable endpoint and may carry any JSON value as its
|
|
853
|
+
* data. Nothing here names an organization, project or environment: those come
|
|
854
|
+
* from the verified principal, and the endpoint lookup is bound to that same
|
|
855
|
+
* tuple. The idempotency key is not here either — it is a header, because a
|
|
856
|
+
* retry of the *same* request must be byte-identical, and a key inside the body
|
|
857
|
+
* would make every retry a different request from the one it is retrying.
|
|
858
|
+
*
|
|
859
|
+
* **All fields optional, with refinements, rather than a union.** A union of
|
|
860
|
+
* object schemas accepts a body carrying two arms' fields, since each arm
|
|
861
|
+
* strips what it does not know; the copied validator refuses that as
|
|
862
|
+
* `ambiguous_schedule`, and a scheduler that silently picks one of two instants
|
|
863
|
+
* a customer named is the worst kind of helpful.
|
|
864
|
+
*/
|
|
865
|
+
export const scheduleRequestSchema = z
|
|
866
|
+
.object({
|
|
867
|
+
/** The instant the first occurrence is planned for. */
|
|
868
|
+
at: timestampSchema.optional(),
|
|
869
|
+
/**
|
|
870
|
+
* A cron expression the schedule recurs on, read against `timeZone`.
|
|
871
|
+
*
|
|
872
|
+
* The first occurrence is the first instant the expression names after the
|
|
873
|
+
* request is accepted, so a caller does not give both an expression and a
|
|
874
|
+
* starting point — the expression already is one.
|
|
875
|
+
*/
|
|
876
|
+
cron: cronExpressionSchema.optional(),
|
|
877
|
+
/**
|
|
878
|
+
* Customer data placed in the signed callback envelope.
|
|
879
|
+
*
|
|
880
|
+
* Any JSON value is accepted, including `null`, arrays and scalars. It is
|
|
881
|
+
* never returned by schedule reads or listings. The accepting adapter
|
|
882
|
+
* serializes it once, enforces `SCHEDULING_BOUNDS.payloadBytes` against
|
|
883
|
+
* those UTF-8 bytes, then encrypts those same bytes before persistence.
|
|
884
|
+
*/
|
|
885
|
+
data: z.json().optional(),
|
|
886
|
+
/**
|
|
887
|
+
* How long from acceptance the first occurrence is planned for. Converted
|
|
888
|
+
* to an absolute instant once, **in the adapter that accepts the request**,
|
|
889
|
+
* against the clock that request arrived on — never in the partition, which
|
|
890
|
+
* is reached again by every retry and every replay and would convert a
|
|
891
|
+
* second time. A schedule created as a delay and one created as an instant
|
|
892
|
+
* are the same schedule afterwards, and neither drifts when it is read
|
|
893
|
+
* again.
|
|
894
|
+
*/
|
|
895
|
+
delaySeconds: durationSecondsSchema
|
|
896
|
+
.positive("must be at least 1 second")
|
|
897
|
+
.max(SCHEDULING_BOUNDS.oneShotDelaySeconds, `must be at most ${String(SCHEDULING_BOUNDS.oneShotDelaySeconds)} seconds`)
|
|
898
|
+
.optional(),
|
|
899
|
+
/**
|
|
900
|
+
* How long the receiver may take, and what happens while it is taking it.
|
|
901
|
+
*
|
|
902
|
+
* Omitted is the ordinary case and the one this product is designed around:
|
|
903
|
+
* a reminder webhook answers in well under a second and needs none of this.
|
|
904
|
+
*
|
|
905
|
+
* **Grouped rather than three sibling fields**, because two of the three are
|
|
906
|
+
* meaningless without the first. `onOverlap` only answers a question a long
|
|
907
|
+
* run can ask, and a caller who set it beside a five-second delivery would
|
|
908
|
+
* be configuring something that cannot happen. Nesting makes the dependency
|
|
909
|
+
* visible in the shape instead of only in a refusal message.
|
|
910
|
+
*/
|
|
911
|
+
delivery: z
|
|
912
|
+
.object({
|
|
913
|
+
/**
|
|
914
|
+
* What to do when this schedule comes due and its previous occurrence
|
|
915
|
+
* has not finished. Only meaningful on a recurring, long-running
|
|
916
|
+
* schedule; refused elsewhere rather than ignored.
|
|
917
|
+
*/
|
|
918
|
+
onOverlap: deliveryOverlapPolicySchema.optional(),
|
|
919
|
+
/**
|
|
920
|
+
* How the receiver signals it is finished — by answering the request,
|
|
921
|
+
* or by calling the completion URL in the envelope.
|
|
922
|
+
*/
|
|
923
|
+
completion: deliveryCompletionModeSchema.optional(),
|
|
924
|
+
/**
|
|
925
|
+
* How long the receiver may take to answer, in seconds. One of
|
|
926
|
+
* `DELIVERY_DURATION_TIERS`.
|
|
927
|
+
*/
|
|
928
|
+
maxDurationSeconds: deliveryDurationSecondsSchema.optional(),
|
|
929
|
+
})
|
|
930
|
+
.strict()
|
|
931
|
+
.optional(),
|
|
932
|
+
/**
|
|
933
|
+
* A fixed interval the schedule recurs on, in seconds. The first occurrence
|
|
934
|
+
* falls one interval after acceptance.
|
|
935
|
+
*
|
|
936
|
+
* A duration and not a wall clock: `86400` is twenty-four hours and stays
|
|
937
|
+
* twenty-four hours across a daylight-saving transition, which is why it
|
|
938
|
+
* takes no `timeZone`. A caller who means "every day at nine" is naming a
|
|
939
|
+
* wall clock and wants `cron`.
|
|
940
|
+
*/
|
|
941
|
+
everySeconds: durationSecondsSchema
|
|
942
|
+
.min(SCHEDULING_BOUNDS.everySecondsMin, `must be at least ${String(SCHEDULING_BOUNDS.everySecondsMin)} seconds`)
|
|
943
|
+
.max(SCHEDULING_BOUNDS.everySecondsMax, `must be at most ${String(SCHEDULING_BOUNDS.everySecondsMax)} seconds`)
|
|
944
|
+
.optional(),
|
|
945
|
+
/**
|
|
946
|
+
* A verified reusable callback endpoint owned by this request's authority.
|
|
947
|
+
*
|
|
948
|
+
* Optional so timer-only schedules remain valid. On replace, omission
|
|
949
|
+
* preserves the existing endpoint; sending `data` without this field is
|
|
950
|
+
* valid only when the stored schedule already has an endpoint.
|
|
951
|
+
*/
|
|
952
|
+
endpoint: endpointIdSchema.optional(),
|
|
953
|
+
/**
|
|
954
|
+
* How promptly this schedule's occurrences expect to be served.
|
|
955
|
+
*
|
|
956
|
+
* Optional, and omitting it is the ordinary case: the default is derived
|
|
957
|
+
* from how the schedule recurs (`deriveLatencyClass`). Stating it is for
|
|
958
|
+
* the caller whose nightly cron really is a human waiting, or whose
|
|
959
|
+
* thirty-second one-shot really is a campaign — the derivation is a good
|
|
960
|
+
* guess about intent and cannot be a correct one.
|
|
961
|
+
*
|
|
962
|
+
* It buys preference, not a promise. `realtime` is served ahead of `bulk`
|
|
963
|
+
* within the same partition; it does not raise the partition's rate, so a
|
|
964
|
+
* caller who marks everything `realtime` has re-labelled their work rather
|
|
965
|
+
* than obtained more of the service.
|
|
966
|
+
*/
|
|
967
|
+
latencyClass: latencyClassSchema.optional(),
|
|
968
|
+
/**
|
|
969
|
+
* A fairness lane within the caller's own project and environment.
|
|
970
|
+
*
|
|
971
|
+
* Two schedules with different keys are paced independently, so one key's
|
|
972
|
+
* herd does not delay another's work. It is a label and a lane and nothing
|
|
973
|
+
* more: it cannot widen the authority tuple, cannot select a different
|
|
974
|
+
* database shard or timer actor, and cannot raise anybody's rate — the
|
|
975
|
+
* lanes share the project's ceiling, so minting more keys divides the same
|
|
976
|
+
* budget rather than multiplying it.
|
|
977
|
+
*
|
|
978
|
+
* **Fixed once the schedule exists.** A replace may carry the key it read
|
|
979
|
+
* back or omit it to preserve the stored value. Adding, changing, or
|
|
980
|
+
* clearing it is refused as `invalid_request` with `/partitionKey` named in
|
|
981
|
+
* the field errors. Widening this later is additive and narrowing it later
|
|
982
|
+
* is breaking, so a lane that cannot move is the direction to ship in until
|
|
983
|
+
* somebody asks otherwise.
|
|
984
|
+
*/
|
|
985
|
+
partitionKey: partitionKeySchema.optional(),
|
|
986
|
+
/**
|
|
987
|
+
* The IANA timezone `cron` is read against. `UTC` when omitted.
|
|
988
|
+
*
|
|
989
|
+
* Accepted only alongside `cron`. Sent with an instant or an interval it
|
|
990
|
+
* would be a caller believing something had been reinterpreted that had
|
|
991
|
+
* not, so it is refused rather than ignored — an ignored field is a
|
|
992
|
+
* misunderstanding a customer cannot see.
|
|
993
|
+
*/
|
|
994
|
+
timeZone: timeZoneSchema.optional(),
|
|
995
|
+
})
|
|
996
|
+
// Ordinary creation and replacement mint every authority and record
|
|
997
|
+
// identity on the service side. Stripping an unknown `id` or `authority`
|
|
998
|
+
// would make a caller believe it selected something it did not, so the body
|
|
999
|
+
// is closed rather than permissive.
|
|
1000
|
+
.strict()
|
|
1001
|
+
// `superRefine` rather than two `refine`s, because a pointer is the whole
|
|
1002
|
+
// value of this refusal to a caller and a fixed one stopped being honest when
|
|
1003
|
+
// a third and fourth way of saying "when" arrived. A body naming two of them
|
|
1004
|
+
// now points at **both**, so a dashboard highlights the pair that conflict
|
|
1005
|
+
// rather than one field chosen alphabetically; a body naming none points at
|
|
1006
|
+
// `at`, because a pointer has to name something a document could contain and
|
|
1007
|
+
// the message names the rest. The published `pointer` must start with `/`, so
|
|
1008
|
+
// "the document as a whole" is not a value this surface can express.
|
|
1009
|
+
.superRefine((request, context) => {
|
|
1010
|
+
const supplied = RECURRENCE_REQUEST_FIELDS.filter((field) => request[field] !== undefined);
|
|
1011
|
+
if (supplied.length !== 1) {
|
|
1012
|
+
for (const path of supplied.length === 0 ? ["at"] : supplied) {
|
|
1013
|
+
context.addIssue({
|
|
1014
|
+
code: "custom",
|
|
1015
|
+
message: "give exactly one of `at`, `delaySeconds`, `everySeconds` and `cron`",
|
|
1016
|
+
path: [path],
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (request.timeZone !== undefined && request.cron === undefined) {
|
|
1021
|
+
context.addIssue({
|
|
1022
|
+
code: "custom",
|
|
1023
|
+
message: "`timeZone` is only meaningful with `cron`",
|
|
1024
|
+
path: ["timeZone"],
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
if (request.delivery?.onOverlap !== undefined) {
|
|
1028
|
+
// A one-shot has nothing to overlap with. Refused rather than ignored, for
|
|
1029
|
+
// the reason `timeZone` is: an ignored field is a misunderstanding the
|
|
1030
|
+
// customer cannot see, and this one would look like a guarantee.
|
|
1031
|
+
if (request.cron === undefined && request.everySeconds === undefined) {
|
|
1032
|
+
context.addIssue({
|
|
1033
|
+
code: "custom",
|
|
1034
|
+
message: "`onOverlap` is only meaningful with `cron` or `everySeconds`",
|
|
1035
|
+
path: ["delivery", "onOverlap"],
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
// Only checked when the duration is in *this* request. A replace may carry
|
|
1039
|
+
// `onOverlap` while the stored schedule holds the duration, exactly as it
|
|
1040
|
+
// may carry `data` while the stored schedule holds the endpoint, and the
|
|
1041
|
+
// partition is where a request is resolved against what is stored.
|
|
1042
|
+
const duration = request.delivery.maxDurationSeconds;
|
|
1043
|
+
if (duration !== undefined && !isLongRunningDuration(duration)) {
|
|
1044
|
+
context.addIssue({
|
|
1045
|
+
code: "custom",
|
|
1046
|
+
message: `\`onOverlap\` is only meaningful above ${String(FREE_DELIVERY_DURATION_SECONDS)} seconds, where a run can still be going when the next one is due`,
|
|
1047
|
+
path: ["delivery", "onOverlap"],
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Held open past the point a held-open request survives. Checked against the
|
|
1052
|
+
// *derived* pair rather than against what the body names, because
|
|
1053
|
+
// `completion` defaults to `response`: a caller who asks for twelve hours and
|
|
1054
|
+
// says nothing about the mechanism has asked for a twelve-hour held socket
|
|
1055
|
+
// whether or not the word appears in their document. Pointing at
|
|
1056
|
+
// `delivery.completion` therefore names a field the body may not contain,
|
|
1057
|
+
// which is the same choice the recurrence refusal above makes and for the
|
|
1058
|
+
// same reason — a pointer has to name what the caller must change.
|
|
1059
|
+
if (request.delivery?.maxDurationSeconds !== undefined &&
|
|
1060
|
+
request.delivery.maxDurationSeconds >
|
|
1061
|
+
RESPONSE_DELIVERY_MAX_DURATION_SECONDS &&
|
|
1062
|
+
(request.delivery.completion ?? "response") === "response") {
|
|
1063
|
+
context.addIssue({
|
|
1064
|
+
code: "custom",
|
|
1065
|
+
message: `a receiver may hold the request open for at most ${String(RESPONSE_DELIVERY_MAX_DURATION_SECONDS)} seconds; above that set \`completion\` to \`callback\` and call the completion URL in the envelope when the work is done`,
|
|
1066
|
+
path: ["delivery", "completion"],
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
/**
|
|
1071
|
+
* The delivery settings a request runs under: the caller's, or the defaults.
|
|
1072
|
+
*
|
|
1073
|
+
* Here rather than in either adapter for the same reason `deriveLatencyClass`
|
|
1074
|
+
* is: REST and MCP have to reach the same answer for the same body, and a
|
|
1075
|
+
* default two surfaces derive separately is a default they will eventually
|
|
1076
|
+
* disagree about.
|
|
1077
|
+
*
|
|
1078
|
+
* **`onOverlap` resolves to its default even for a schedule that cannot
|
|
1079
|
+
* overlap**, and that is deliberate rather than sloppy. The alternative is an
|
|
1080
|
+
* optional field the storage layer has to keep re-deciding about, and a stored
|
|
1081
|
+
* `skip` on a five-second one-shot describes something true — the previous run
|
|
1082
|
+
* is never still going — rather than something wrong.
|
|
1083
|
+
*/
|
|
1084
|
+
export function deriveDeliverySettings(request) {
|
|
1085
|
+
return {
|
|
1086
|
+
completion: request.delivery?.completion ?? "response",
|
|
1087
|
+
maxDurationSeconds: request.delivery?.maxDurationSeconds ?? DEFAULT_DELIVERY_DURATION_SECONDS,
|
|
1088
|
+
onOverlap: request.delivery?.onOverlap ?? DEFAULT_DELIVERY_OVERLAP_POLICY,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
export function deriveLatencyClass(request, nowMs) {
|
|
1092
|
+
if (request.latencyClass !== undefined) {
|
|
1093
|
+
return request.latencyClass;
|
|
1094
|
+
}
|
|
1095
|
+
if (request.cron !== undefined || request.everySeconds !== undefined) {
|
|
1096
|
+
return "bulk";
|
|
1097
|
+
}
|
|
1098
|
+
const threshold = SCHEDULING_BOUNDS.realtimeOneShotSeconds * 1000;
|
|
1099
|
+
if (request.delaySeconds !== undefined) {
|
|
1100
|
+
return request.delaySeconds * 1000 <= threshold ? "realtime" : "bulk";
|
|
1101
|
+
}
|
|
1102
|
+
if (request.at === undefined) {
|
|
1103
|
+
// No recurrence at all, which the schema refuses before this is reached.
|
|
1104
|
+
// Answered rather than thrown so a caller of this function on an unvalidated
|
|
1105
|
+
// body gets the conservative class instead of an exception.
|
|
1106
|
+
return "bulk";
|
|
1107
|
+
}
|
|
1108
|
+
const atMs = Date.parse(request.at);
|
|
1109
|
+
if (Number.isNaN(atMs)) {
|
|
1110
|
+
return "bulk";
|
|
1111
|
+
}
|
|
1112
|
+
// An instant already in the past is as immediate as an instant gets, so the
|
|
1113
|
+
// comparison is against the delay from now and not against its absolute
|
|
1114
|
+
// value: `at` one second ago is realtime, and `at` a year out is not.
|
|
1115
|
+
return atMs - nowMs <= threshold ? "realtime" : "bulk";
|
|
1116
|
+
}
|