@greatwait/contracts 0.3.1 → 0.3.2

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.
Files changed (95) hide show
  1. package/dist/attempt-diagnostics.d.ts +1 -1
  2. package/dist/attempt-diagnostics.js +13 -1
  3. package/dist/attempt-search.d.ts +2 -0
  4. package/dist/billing.d.ts +46 -0
  5. package/dist/billing.js +29 -0
  6. package/dist/callback-safety.js +5 -1
  7. package/dist/configuration.d.ts +2 -2
  8. package/dist/configuration.js +6 -3
  9. package/dist/delivery.d.ts +12 -1
  10. package/dist/delivery.js +1 -0
  11. package/dist/effective-limits.d.ts +4 -4
  12. package/dist/endpoints.d.ts +29 -0
  13. package/dist/endpoints.js +32 -1
  14. package/dist/forecast.d.ts +2 -2
  15. package/dist/http-request.d.ts +12 -2
  16. package/dist/http-request.js +31 -1
  17. package/dist/index.d.ts +8 -7
  18. package/dist/index.js +8 -7
  19. package/dist/mcp.js +1 -0
  20. package/dist/operational-config.d.ts +174 -22
  21. package/dist/operational-config.js +207 -74
  22. package/dist/operations.d.ts +334 -52
  23. package/dist/operations.js +195 -34
  24. package/dist/project-notifications.d.ts +35 -9
  25. package/dist/project-notifications.js +43 -12
  26. package/dist/protocol.d.ts +28 -0
  27. package/dist/protocol.js +23 -0
  28. package/dist/scheduling-records.d.ts +106 -16
  29. package/dist/scheduling-records.js +36 -12
  30. package/dist/scheduling.d.ts +155 -43
  31. package/dist/scheduling.js +188 -50
  32. package/dist/service-mode.d.ts +88 -0
  33. package/dist/service-mode.js +130 -0
  34. package/dist/tenancy.d.ts +54 -30
  35. package/dist/tenancy.js +35 -17
  36. package/dist/timing.d.ts +39 -0
  37. package/dist/timing.js +51 -0
  38. package/dist/zod-values.d.ts +1 -1
  39. package/dist/zod-values.js +1 -1
  40. package/dist/zod.d.ts +1 -1
  41. package/dist/zod.js +1 -1
  42. package/generated/v1/examples/billing.noticesdismiss.request.json +3 -0
  43. package/generated/v1/examples/billing.noticesdismiss.response.json +6 -0
  44. package/generated/v1/examples/billing.reconcile.response.json +12 -0
  45. package/generated/v1/examples/billing.summary.response.json +12 -0
  46. package/generated/v1/examples/executions.list.response.json +1 -0
  47. package/generated/v1/examples/limits.read.response.json +1 -1
  48. package/generated/v1/examples/projectnotifications.rotateSigningKey.response.json +1 -0
  49. package/generated/v1/examples/projects.list.response.json +0 -6
  50. package/generated/v1/examples/projects.read.response.json +14 -0
  51. package/generated/v1/examples/schedules.list.response.json +1 -3
  52. package/generated/v1/examples/schedules.summary.response.json +174 -0
  53. package/generated/v1/examples/timing.summary.response.json +33 -1
  54. package/generated/v1/manifest.json +79 -2
  55. package/generated/v1/openapi.json +4084 -2023
  56. package/generated/v1/policy-matrix.json +22 -1
  57. package/generated/v1/schemas/attempts.list.response.json +10 -0
  58. package/generated/v1/schemas/attempts.read.response.json +10 -0
  59. package/generated/v1/schemas/attempts.search.response.json +4 -0
  60. package/generated/v1/schemas/billing.notices.query.json +4 -0
  61. package/generated/v1/schemas/billing.noticesdismiss.request.json +12 -0
  62. package/generated/v1/schemas/billing.noticesdismiss.response.json +22 -0
  63. package/generated/v1/schemas/billing.reconcile.response.json +43 -0
  64. package/generated/v1/schemas/billing.summary.response.json +43 -0
  65. package/generated/v1/schemas/endpoints.list.query.json +15 -0
  66. package/generated/v1/schemas/executions.list.response.json +4 -0
  67. package/generated/v1/schemas/occurrences.list.response.json +4 -0
  68. package/generated/v1/schemas/occurrences.read.response.json +4 -0
  69. package/generated/v1/schemas/occurrences.redeliver.response.json +10 -0
  70. package/generated/v1/schemas/projectnotifications.read.response.json +4 -0
  71. package/generated/v1/schemas/projectnotifications.rotateSigningKey.response.json +4 -0
  72. package/generated/v1/schemas/projectnotifications.update.request.json +41 -17
  73. package/generated/v1/schemas/projectnotifications.update.response.json +4 -0
  74. package/generated/v1/schemas/projects.list.response.json +0 -27
  75. package/generated/v1/schemas/projects.read.response-headers.json +11 -0
  76. package/generated/v1/schemas/projects.read.response.json +79 -0
  77. package/generated/v1/schemas/schedules.archive.response.json +8 -0
  78. package/generated/v1/schemas/schedules.cancel.response.json +8 -0
  79. package/generated/v1/schemas/schedules.create.request.json +1 -1
  80. package/generated/v1/schemas/schedules.create.response.json +8 -0
  81. package/generated/v1/schemas/schedules.list.query.json +6 -0
  82. package/generated/v1/schemas/schedules.list.response.json +4 -0
  83. package/generated/v1/schemas/schedules.pause.response.json +8 -0
  84. package/generated/v1/schemas/schedules.read.response.json +8 -0
  85. package/generated/v1/schemas/schedules.replace.request.json +1 -1
  86. package/generated/v1/schemas/schedules.replace.response.json +8 -0
  87. package/generated/v1/schemas/schedules.resume.response.json +8 -0
  88. package/generated/v1/schemas/schedules.summary.query.json +11 -0
  89. package/generated/v1/schemas/schedules.summary.response.json +144 -0
  90. package/generated/v1/schemas/timing.summary.response.json +69 -0
  91. package/generated/v1/snippets/billing.noticesdismiss.json +6 -0
  92. package/generated/v1/snippets/projectnotifications.rotateSigningKey.json +1 -1
  93. package/generated/v1/snippets/projects.read.json +6 -0
  94. package/generated/v1/snippets/schedules.summary.json +6 -0
  95. package/package.json +1 -1
@@ -3,11 +3,13 @@ import { executionSelectionSchema, normalizeExecutionSelection, } from "./execut
3
3
  import { httpRequestSchema, publicHttpRequestSchema } from "./http-request.js";
4
4
  import { FIXED_INTERVAL_MIN_SECONDS, FIXED_INTERVAL_MAX_SECONDS, MAX_INLINE_PAYLOAD_BYTES, } from "./configuration-bounds.js";
5
5
  import { z } from "./zod.js";
6
+ import { SERVICE_MODE_POLICY } from "./operational-config.js";
7
+ import { REQUESTED_LATENCY_CLASSES, oneOffSpacingMs, resolveService, } from "./service-mode.js";
6
8
  import { externalIdentifierSchema, scheduleTagsSchema, } from "./host-correlation.js";
7
9
  import { attemptIdSchema, deliveryIdSchema, endpointIdSchema, occurrenceIdSchema, scheduleIdSchema, } from "./identifiers.js";
8
10
  import { CALLBACK_ATTEMPT_DIAGNOSTICS } from "./delivery.js";
9
11
  import { callbackUrlSchema, signingSecretNoticeSchema } from "./endpoints.js";
10
- import { pageRequestSchema, pageSchema, versionSchema, } from "./protocol.js";
12
+ import { pageRequestSchema, pageSchema, projectedFreshnessSchema, versionSchema, } from "./protocol.js";
11
13
  import { callbackTriggerSchema, } from "./signatures.js";
12
14
  import { durationSecondsSchema, timeZoneSchema, timestampSchema } from "./time.js";
13
15
  /**
@@ -104,6 +106,13 @@ export function isTerminalScheduleStatus(status) {
104
106
  */
105
107
  export const LATENCY_CLASSES = ["realtime", "bulk"];
106
108
  export const latencyClassSchema = /* @__PURE__ */ (() => z.enum(LATENCY_CLASSES))();
109
+ /**
110
+ * The service mode a request asks for (W12-02, FXO-3171): `auto`, or one of
111
+ * the concrete classes. Omission means `auto`. A read reports the concrete
112
+ * class an occurrence resolved to as `latencyClass`, and what was asked for as
113
+ * `requestedLatencyClass`; see `service-mode.ts` for the resolution.
114
+ */
115
+ export const requestedLatencyClassSchema = /* @__PURE__ */ (() => z.enum(REQUESTED_LATENCY_CLASSES))();
107
116
  /**
108
117
  * What happens when an occurrence reaches its first attempt after its planned
109
118
  * instant.
@@ -612,13 +621,11 @@ export const SCHEDULING_BOUNDS = {
612
621
  * was the copied scheduler's `MAX_INTERVAL_SECONDS`, carried over on the
613
622
  * belief that "a Durable Object alarm is not arbitrarily far in the future".
614
623
  * Cloudflare documents no maximum distance for `setAlarm()`, and the belief
615
- * predates the PostgreSQL authority besides: the durable fact is a row, and
616
- * the alarm is only what wakes a bucket to read it.
624
+ * predates the PostgreSQL authority besides: the durable fact is a row.
617
625
  *
618
- * What makes a distant alarm safe is not the alarm. `TimerBucketRepairer`
619
- * asks PostgreSQL for occurrences whose instant has passed and re-signals
620
- * their bucket, on committed truth, never asking whether an alarm was armed
621
- * — so an alarm that is somehow lost costs lateness rather than the run.
626
+ * Since W13-03 there is no alarm at all. The Node runtime polls
627
+ * `core.occurrence` for rows whose `due_at` has passed, so a year-distant
628
+ * occurrence is the same row read on the same poll as a minute-distant one.
622
629
  *
623
630
  * Widening a bound is additive by `classifyChange` and narrowing it is
624
631
  * breaking, so this direction is the safe one to move in.
@@ -689,17 +696,15 @@ export const SCHEDULING_BOUNDS = {
689
696
  */
690
697
  payloadBytes: MAX_INLINE_PAYLOAD_BYTES,
691
698
  /**
692
- * How soon a one-shot has to be for `realtime` to be the class it gets by
693
- * default, in seconds.
694
- *
695
- * Two minutes, which is the copied `validate.ts`'s own threshold kept rather
696
- * than a number invented beside it: upstream classifies an `at` or a `delay`
697
- * inside 120 seconds as realtime and everything else as bulk, on the
698
- * reasoning that a wait that short is a human waiting on a screen. A caller
699
- * who disagrees says so — `latencyClass` on the request is what makes this a
700
- * default rather than a rule.
699
+ * How soon a one-shot has to be for `auto` to resolve it to `realtime`, in
700
+ * seconds, exclusive: a one-shot planned less than a minute after it was
701
+ * accepted is realtime, and one planned exactly a minute out is bulk
702
+ * (W12-02, FXO-3171; owner decision 2026-10-14). The single owner is
703
+ * `SERVICE_MODE_POLICY.autoRealtimeBelowMs`. A caller who disagrees says so
704
+ * — `latencyClass` on the request is what makes this a default rather than a
705
+ * rule.
701
706
  */
702
- realtimeOneShotSeconds: 120,
707
+ realtimeOneShotSeconds: SERVICE_MODE_POLICY.autoRealtimeBelowMs / 1000,
703
708
  };
704
709
  /* -------------------------------------------------------------------------- */
705
710
  /* Recurrence */
@@ -881,7 +886,18 @@ export const scheduleSchema = /* @__PURE__ */ (() => z.object({
881
886
  endpointUrl: z.string().url().optional(),
882
887
  generation: generationSchema,
883
888
  id: scheduleIdSchema,
889
+ /**
890
+ * The concrete class this schedule's occurrences are served and charged
891
+ * as: what `auto` resolved to, or the class the caller named.
892
+ */
884
893
  latencyClass: latencyClassSchema,
894
+ /**
895
+ * The mode the caller asked for (W12-02): `auto` when the request named
896
+ * none. Absent on a schedule accepted before the mode was recorded; its
897
+ * `latencyClass` is what it was accepted as, and a replacement that leaves
898
+ * its timing alone keeps it.
899
+ */
900
+ requestedLatencyClass: requestedLatencyClassSchema.optional(),
885
901
  /**
886
902
  * What this schedule does with an occurrence that reaches its first attempt
887
903
  * late. Present on every schedule so a caller never has to duplicate the
@@ -946,6 +962,76 @@ export const scheduleSchema = /* @__PURE__ */ (() => z.object({
946
962
  * is authoritative, and detail is one call away.
947
963
  */
948
964
  export const schedulePageSchema = /* @__PURE__ */ (() => pageSchema(scheduleSchema))();
965
+ /** Local days a schedule summary covers, today included (W9-80, FXO-3446). */
966
+ export const SCHEDULE_SUMMARY_DAYS = 30;
967
+ /** Auto-paused schedules a summary names beside their count. */
968
+ export const SCHEDULE_SUMMARY_PAUSED_SAMPLE = 5;
969
+ /** The oldest a served schedule summary may be, in seconds. */
970
+ export const SCHEDULE_SUMMARY_MAX_AGE_SECONDS = 300;
971
+ const localDateSchema = /* @__PURE__ */ (() => z
972
+ .string()
973
+ .regex(/^\d{4}-\d{2}-\d{2}$/u, "must be a calendar date, YYYY-MM-DD"))();
974
+ /** A schedule summary request: the reader's time zone for the day buckets. */
975
+ export const scheduleSummaryRequestSchema = /* @__PURE__ */ (() => z.object({
976
+ timeZone: timeZoneSchema
977
+ .default("UTC")
978
+ .describe("IANA time zone the per-day buckets are counted in; defaults to UTC."),
979
+ }))();
980
+ /**
981
+ * Whole-project schedule counts for the caller's project and environment
982
+ * (W9-80, FXO-3446).
983
+ *
984
+ * **Exact as of `freshness.asOf`, and at most five minutes old.** The counts
985
+ * read every retained, unarchived schedule in one statement, which is too
986
+ * costly to repeat on every view of a large project, so an answer is computed
987
+ * on read and reused for up to {@link SCHEDULE_SUMMARY_MAX_AGE_SECONDS}
988
+ * seconds per project, environment and time zone. `freshness` is always
989
+ * `projected`: `asOf` is when the counts were taken and `lagSeconds` how long
990
+ * ago that was, so a client can say "as of 2 minutes ago" rather than present
991
+ * the numbers as live. Counts cover retained schedules only: retention removes
992
+ * cancelled schedules and one-offs that ran once their history horizon passes.
993
+ */
994
+ export const scheduleSummarySchema = /* @__PURE__ */ (() => z.object({
995
+ counts: z.object({
996
+ active: z.number().int().nonnegative(),
997
+ paused: z.number().int().nonnegative(),
998
+ completed: z.number().int().nonnegative(),
999
+ cancelled: z.number().int().nonnegative(),
1000
+ }),
1001
+ /**
1002
+ * Paused schedules whose newest run paused them after repeated failures,
1003
+ * and up to five of them (id, name and why), most recently paused first.
1004
+ * Read a schedule, or list `status=paused`, for the rest.
1005
+ */
1006
+ pausedAfterFailures: z.object({
1007
+ count: z.number().int().nonnegative(),
1008
+ schedules: z
1009
+ .array(z.object({
1010
+ id: scheduleIdSchema,
1011
+ name: z.string().optional(),
1012
+ autoPauseReason: autoPauseReasonSchema,
1013
+ }))
1014
+ .max(SCHEDULE_SUMMARY_PAUSED_SAMPLE),
1015
+ }),
1016
+ /**
1017
+ * One entry per local day of the window, oldest first, zero days
1018
+ * included: schedules created that day, and schedules that finished that
1019
+ * day (completed by their final run, or cancelled).
1020
+ */
1021
+ days: z
1022
+ .array(z.object({
1023
+ date: localDateSchema,
1024
+ created: z.number().int().nonnegative(),
1025
+ finished: z.number().int().nonnegative(),
1026
+ }))
1027
+ .length(SCHEDULE_SUMMARY_DAYS),
1028
+ window: z.object({
1029
+ from: localDateSchema,
1030
+ to: localDateSchema,
1031
+ timeZone: timeZoneSchema,
1032
+ }),
1033
+ freshness: projectedFreshnessSchema,
1034
+ }))();
949
1035
  function hasCompleteTimingEvidence(value) {
950
1036
  return ((value.firstAttemptStartedAt === undefined) ===
951
1037
  (value.firstAttemptLatenessMs === undefined) &&
@@ -970,7 +1056,12 @@ const timingEvidenceShape = {
970
1056
  .optional(),
971
1057
  timingEligibilityReason: z.string().min(1).max(128).optional(),
972
1058
  /** Policy pinned when trusted first-attempt timing evidence is recorded. */
973
- timingPolicyVersion: z.string().min(1).max(64).optional(),
1059
+ timingPolicyVersion: z
1060
+ .string()
1061
+ .min(1)
1062
+ .max(64)
1063
+ .optional()
1064
+ .describe("Timing policy pinned with trusted first-outbound evidence. Banded occurrences use timing-v2-service-bands (realtime 1000ms, short bulk 10000ms, bulk 60000ms from plannedFor); legacy occurrences without a band retain their recorded policy, including payg-v1, and 5000ms/15000ms thresholds. Absent timing evidence means unknown."),
974
1065
  };
975
1066
  /**
976
1067
  * One concrete occurrence of a schedule.
@@ -989,6 +1080,13 @@ export const occurrenceSchema = /* @__PURE__ */ (() => z
989
1080
  /** When the occurrence was planned to fire. Never earlier than this. */
990
1081
  plannedFor: timestampSchema,
991
1082
  schedule: scheduleIdSchema,
1083
+ /**
1084
+ * The schedule's name, on the project executions listing
1085
+ * (`executions.list`) only, read from the same schedule rows that page
1086
+ * already joins — so a list of runs can say whose they are without a
1087
+ * schedule read per row (W9-80). Absent elsewhere.
1088
+ */
1089
+ scheduleName: z.string().optional(),
992
1090
  sequence: z.number().int().positive(),
993
1091
  /** Present exactly when the occurrence is in a terminal state. */
994
1092
  settledAt: timestampSchema.optional(),
@@ -1151,6 +1249,22 @@ export const attemptSchema = /* @__PURE__ */ (() => z
1151
1249
  response: attemptResponseSchema.optional(),
1152
1250
  request: attemptRequestSchema.optional(),
1153
1251
  requestState: z.enum(["captured", "expired", "not_captured"]).optional(),
1252
+ /**
1253
+ * Why a successful attempt's request cannot be shown, when
1254
+ * `requestState` is `not_captured` for that reason. A successful
1255
+ * attempt's request is rebuilt from the schedule's payload and its
1256
+ * endpoint, so it is shown only while neither has changed since the run
1257
+ * and the payload still exists; it is never shown from a later version.
1258
+ * Absent otherwise.
1259
+ */
1260
+ requestUnavailable: z
1261
+ .enum([
1262
+ "schedule_changed",
1263
+ "endpoint_changed",
1264
+ "endpoint_deleted",
1265
+ "payload_deleted",
1266
+ ])
1267
+ .optional(),
1154
1268
  /** The fixed diagnostic-header allowlist, when a response carried any. */
1155
1269
  responseHeaders: z.array(attemptResponseHeaderSchema).max(8).optional(),
1156
1270
  /** Absent when no response was received at all. */
@@ -1372,20 +1486,27 @@ export const scheduleRequestSchema = /* @__PURE__ */ (() => z
1372
1486
  */
1373
1487
  url: callbackUrlSchema().optional(),
1374
1488
  /**
1375
- * How promptly this schedule's occurrences expect to be served.
1489
+ * How promptly this schedule's occurrences expect to be served: `auto`,
1490
+ * `bulk` or `realtime` (W12-02, FXO-3171).
1376
1491
  *
1377
- * Optional, and omitting it is the ordinary case: the default is derived
1378
- * from how the schedule recurs (`deriveLatencyClass`). Stating it is for
1492
+ * Omitting it means `auto`, the ordinary case. `auto` makes a one-shot
1493
+ * planned less than a minute after acceptance `realtime` and everything
1494
+ * else `bulk`; a recurrence is classified by the gap between its runs, so
1495
+ * it is `bulk`. Naming `realtime` or `bulk` always gets that class — for
1379
1496
  * the caller whose nightly cron really is a human waiting, or whose
1380
- * thirty-second one-shot really is a campaign — the derivation is a good
1381
- * guess about intent and cannot be a correct one.
1497
+ * thirty-second one-shot really is a campaign.
1382
1498
  *
1383
- * It buys preference, not a promise. `realtime` is served ahead of `bulk`
1384
- * within the same partition; it does not raise the partition's rate, so a
1385
- * caller who marks everything `realtime` has re-labelled their work rather
1386
- * than obtained more of the service.
1499
+ * Bulk work becomes eligible on a 5-second boundary when its runs are
1500
+ * under five minutes apart (or it is due within five minutes) and on a
1501
+ * 50-second boundary otherwise; realtime on a 250-millisecond boundary.
1502
+ * The occurrence's `plannedFor` is unchanged. Each occurrence is charged
1503
+ * as the class it resolved to.
1504
+ *
1505
+ * On a replacement, omission keeps the schedule's mode. Changing the
1506
+ * timing resolves again under that mode; changing anything else keeps
1507
+ * the class and price the schedule was accepted under.
1387
1508
  */
1388
- latencyClass: latencyClassSchema.optional(),
1509
+ latencyClass: requestedLatencyClassSchema.optional(),
1389
1510
  /**
1390
1511
  * What to do when an occurrence reaches its first attempt late.
1391
1512
  *
@@ -1540,29 +1661,46 @@ export function deriveDeliverySettings(request) {
1540
1661
  onOverlap: request.delivery?.onOverlap ?? DEFAULT_DELIVERY_OVERLAP_POLICY,
1541
1662
  };
1542
1663
  }
1664
+ /**
1665
+ * The create request as its idempotency fingerprint reads it (W12-02).
1666
+ *
1667
+ * An omitted `latencyClass` means `auto`, so the two spellings are one request
1668
+ * and must fingerprint alike. They are made alike by writing the default the
1669
+ * way the request has always been able to write it — by omission — so a
1670
+ * receipt issued before `auto` existed still recognizes its own retry.
1671
+ */
1672
+ export function fingerprintedScheduleRequest(request) {
1673
+ if (request.latencyClass !== "auto")
1674
+ return request;
1675
+ const { latencyClass: _auto, ...rest } = request;
1676
+ void _auto;
1677
+ return rest;
1678
+ }
1679
+ /**
1680
+ * The concrete class a create request resolves to at `nowMs`, its acceptance
1681
+ * time. Here so REST, MCP and the dashboard's preview reach the same answer
1682
+ * for the same body; `service-mode.ts` owns the rule.
1683
+ *
1684
+ * A recurrence is classified by the gap between runs, at least a minute for
1685
+ * anything this surface accepts, so `auto` makes it `bulk` without evaluating
1686
+ * a cron expression here. `nowMs` is a parameter so the boundary can be asked
1687
+ * about one millisecond either side of it.
1688
+ */
1543
1689
  export function deriveLatencyClass(request, nowMs) {
1544
- if (request.latencyClass !== undefined) {
1545
- return request.latencyClass;
1546
- }
1690
+ const requested = request.latencyClass ?? "auto";
1547
1691
  if (request.cron !== undefined || request.everySeconds !== undefined) {
1548
- return "bulk";
1549
- }
1550
- const threshold = SCHEDULING_BOUNDS.realtimeOneShotSeconds * 1000;
1551
- if (request.delaySeconds !== undefined) {
1552
- return request.delaySeconds * 1000 <= threshold ? "realtime" : "bulk";
1553
- }
1554
- if (request.at === undefined) {
1555
- // No recurrence at all, which the schema refuses before this is reached.
1556
- // Answered rather than thrown so a caller of this function on an unvalidated
1557
- // body gets the conservative class instead of an exception.
1558
- return "bulk";
1559
- }
1560
- const atMs = Date.parse(request.at);
1561
- if (Number.isNaN(atMs)) {
1562
- return "bulk";
1692
+ return resolveService(requested, request.everySeconds === undefined
1693
+ ? SCHEDULING_BOUNDS.everySecondsMin * 1000
1694
+ : request.everySeconds * 1000).latencyClass;
1563
1695
  }
1564
- // An instant already in the past is as immediate as an instant gets, so the
1565
- // comparison is against the delay from now and not against its absolute
1566
- // value: `at` one second ago is realtime, and `at` a year out is not.
1567
- return atMs - nowMs <= threshold ? "realtime" : "bulk";
1696
+ const atMs = request.delaySeconds !== undefined
1697
+ ? nowMs + request.delaySeconds * 1000
1698
+ : request.at === undefined
1699
+ ? Number.NaN
1700
+ : Date.parse(request.at);
1701
+ // No instant at all, which the schema refuses before this is reached, is
1702
+ // answered conservatively rather than thrown.
1703
+ return Number.isNaN(atMs)
1704
+ ? resolveService(requested, Infinity).latencyClass
1705
+ : resolveService(requested, oneOffSpacingMs(nowMs, atMs)).latencyClass;
1568
1706
  }
@@ -0,0 +1,88 @@
1
+ /** The modes a request may name. `auto` is what omission means. */
2
+ export declare const REQUESTED_LATENCY_CLASSES: readonly ["auto", "bulk", "realtime"];
3
+ export type RequestedLatencyClass = (typeof REQUESTED_LATENCY_CLASSES)[number];
4
+ /** The concrete services an occurrence resolves to. */
5
+ export type ResolvedLatencyClass = "bulk" | "realtime";
6
+ /** The internal bands. Not on the wire: only the concrete service is. */
7
+ export declare const SERVICE_BANDS: readonly ["realtime", "short_bulk", "bulk"];
8
+ export type ServiceBand = (typeof SERVICE_BANDS)[number];
9
+ export interface ServiceResolution {
10
+ readonly band: ServiceBand;
11
+ readonly latencyClass: ResolvedLatencyClass;
12
+ readonly quantumMs: number;
13
+ }
14
+ /** The concrete service a band is charged and reported as. */
15
+ export declare function bandLatencyClass(band: ServiceBand): ResolvedLatencyClass;
16
+ /**
17
+ * The service, band and quantum for one occurrence.
18
+ *
19
+ * `spacingMs` is the one-off lead or the recurrence's shorter adjacent gap
20
+ * (see the module comment). A non-finite spacing — a recurrence with no
21
+ * adjacent instant — is treated as long.
22
+ */
23
+ export declare function resolveService(requested: RequestedLatencyClass, spacingMs: number): ServiceResolution;
24
+ /** A one-off's lead: planned instant minus the captured acceptance time. */
25
+ export declare function oneOffSpacingMs(acceptedAtMs: number, plannedForMs: number): number;
26
+ /**
27
+ * A recurrence occurrence's shorter adjacent planned gap. Either neighbour may
28
+ * be unknown — a series' first occurrence has no predecessor — and the gap to
29
+ * a known neighbour decides alone.
30
+ */
31
+ export declare function recurrenceSpacingMs(previousMs: number | undefined, plannedForMs: number, nextMs: number | undefined): number;
32
+ /**
33
+ * An instant's first eligibility in its band: `ceil(instant / quantum) *
34
+ * quantum`, so a boundary stays where it is.
35
+ *
36
+ * Applied once, to the planned instant or to a retry's backed-off instant.
37
+ * Work that becomes claimable late is claimable now, never at another
38
+ * boundary: nothing rounds a rounded instant, and nothing rounds `now`. The
39
+ * last quantum of year 9999 has no boundary the contract can name, so it is
40
+ * eligible at the end of that year instead.
41
+ */
42
+ export declare function roundEligibilityMs(instantMs: number, band: ServiceBand): number;
43
+ export interface OccurrenceResolution extends ServiceResolution {
44
+ /** `plannedForMs` rounded up to the band's quantum. */
45
+ readonly eligibleAtMs: number;
46
+ }
47
+ /**
48
+ * One occurrence's service, band and rounded first eligibility: the entry
49
+ * point create, replacement and a recurrence's successor all use.
50
+ *
51
+ * A one-off is measured from its captured acceptance time; a recurrence by its
52
+ * neighbours, which the caller took from the existing recurrence evaluator.
53
+ */
54
+ export declare function resolveOccurrence(input: {
55
+ readonly kind: "one-off";
56
+ readonly requested: RequestedLatencyClass;
57
+ readonly acceptedAtMs: number;
58
+ readonly plannedForMs: number;
59
+ } | {
60
+ readonly kind: "recurring";
61
+ readonly requested: RequestedLatencyClass;
62
+ readonly plannedForMs: number;
63
+ readonly previousMs?: number;
64
+ readonly nextMs?: number;
65
+ }): OccurrenceResolution;
66
+ /** Stored requested intent; `legacy` predates recording it (W12-02). */
67
+ export type StoredRequestedLatencyClass = RequestedLatencyClass | "legacy";
68
+ /**
69
+ * What a replacement runs under (implementation-guide § W02, step 4).
70
+ *
71
+ * - A request naming a mode other than the stored one is a mode edit.
72
+ * - A timing or cadence edit resolves again. An explicit stored mode survives
73
+ * it; a legacy schedule's enters `auto`.
74
+ * - Anything else — payload, headers, name, tags, a mode named as it already
75
+ * was — preserves the stored service and tariff, and a legacy schedule stays
76
+ * legacy.
77
+ */
78
+ export declare function replacementMode(input: {
79
+ readonly stored: StoredRequestedLatencyClass;
80
+ readonly requested: RequestedLatencyClass | undefined;
81
+ readonly timingChanged: boolean;
82
+ }): {
83
+ readonly kind: "preserve";
84
+ readonly requested: StoredRequestedLatencyClass;
85
+ } | {
86
+ readonly kind: "resolve";
87
+ readonly requested: RequestedLatencyClass;
88
+ };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * W12-02 (FXO-3171): the one resolver for service modes.
3
+ *
4
+ * Design: docs/archive/warm-worker-scheduling/project-spec.md § Customer
5
+ * contract, and implementation-guide.md § W02. A request names a mode —
6
+ * `auto`, `bulk` or `realtime` — and every occurrence resolves to a concrete
7
+ * service (`bulk` or `realtime`, what is charged and what a read reports), an
8
+ * internal band (`realtime`, `short_bulk` or `bulk`) and that band's quantum.
9
+ *
10
+ * Pure: every input is a parameter, nothing here reads a clock, and the
11
+ * spacing it classifies by is measured by the caller from instants the
12
+ * existing recurrence evaluator produced. There is deliberately no cron
13
+ * arithmetic in this module: a second evaluator is a second opinion about when
14
+ * a schedule fires, and two opinions eventually disagree.
15
+ *
16
+ * The spacing is:
17
+ *
18
+ * - for a one-off, the lead from the captured acceptance time to the original
19
+ * planned instant (an instant already past has no lead), measured once at
20
+ * acceptance — a replayed request reads the stored answer and never measures
21
+ * again; and
22
+ * - for a recurrence, the shorter adjacent planned gap. A one-minute heartbeat
23
+ * is short bulk however soon its next run happens to be due.
24
+ *
25
+ * Every recurrence the product accepts is at least a minute apart — `every`
26
+ * starts at sixty seconds and cron names whole minutes — so `auto` never
27
+ * resolves a recurrence to `realtime`, and an occurrence's service is the
28
+ * schedule's for its whole series. Only the band moves with irregular gaps.
29
+ */
30
+ import { SERVICE_MODE_POLICY } from "./operational-config.js";
31
+ /** The modes a request may name. `auto` is what omission means. */
32
+ export const REQUESTED_LATENCY_CLASSES = ["auto", "bulk", "realtime"];
33
+ /** The internal bands. Not on the wire: only the concrete service is. */
34
+ export const SERVICE_BANDS = ["realtime", "short_bulk", "bulk"];
35
+ /** The concrete service a band is charged and reported as. */
36
+ export function bandLatencyClass(band) {
37
+ return band === "realtime" ? "realtime" : "bulk";
38
+ }
39
+ /**
40
+ * The service, band and quantum for one occurrence.
41
+ *
42
+ * `spacingMs` is the one-off lead or the recurrence's shorter adjacent gap
43
+ * (see the module comment). A non-finite spacing — a recurrence with no
44
+ * adjacent instant — is treated as long.
45
+ */
46
+ export function resolveService(requested, spacingMs) {
47
+ const spacing = Number.isNaN(spacingMs) ? Infinity : Math.max(0, spacingMs);
48
+ const band = requested === "realtime" ||
49
+ (requested === "auto" && spacing < SERVICE_MODE_POLICY.autoRealtimeBelowMs)
50
+ ? "realtime"
51
+ : spacing < SERVICE_MODE_POLICY.shortBulkBelowMs
52
+ ? "short_bulk"
53
+ : "bulk";
54
+ return {
55
+ band,
56
+ latencyClass: bandLatencyClass(band),
57
+ quantumMs: SERVICE_MODE_POLICY.quantumMs[band],
58
+ };
59
+ }
60
+ /** A one-off's lead: planned instant minus the captured acceptance time. */
61
+ export function oneOffSpacingMs(acceptedAtMs, plannedForMs) {
62
+ return plannedForMs - acceptedAtMs;
63
+ }
64
+ /**
65
+ * A recurrence occurrence's shorter adjacent planned gap. Either neighbour may
66
+ * be unknown — a series' first occurrence has no predecessor — and the gap to
67
+ * a known neighbour decides alone.
68
+ */
69
+ export function recurrenceSpacingMs(previousMs, plannedForMs, nextMs) {
70
+ const gaps = [
71
+ previousMs === undefined ? Infinity : plannedForMs - previousMs,
72
+ nextMs === undefined ? Infinity : nextMs - plannedForMs,
73
+ ].filter((gap) => gap > 0);
74
+ return gaps.length === 0 ? Infinity : Math.min(...gaps);
75
+ }
76
+ /** The last instant the contract's four-digit-year timestamps express. */
77
+ const LATEST_EXPRESSIBLE_MS = Date.parse("9999-12-31T23:59:59.999Z");
78
+ /**
79
+ * An instant's first eligibility in its band: `ceil(instant / quantum) *
80
+ * quantum`, so a boundary stays where it is.
81
+ *
82
+ * Applied once, to the planned instant or to a retry's backed-off instant.
83
+ * Work that becomes claimable late is claimable now, never at another
84
+ * boundary: nothing rounds a rounded instant, and nothing rounds `now`. The
85
+ * last quantum of year 9999 has no boundary the contract can name, so it is
86
+ * eligible at the end of that year instead.
87
+ */
88
+ export function roundEligibilityMs(instantMs, band) {
89
+ const quantum = SERVICE_MODE_POLICY.quantumMs[band];
90
+ return Math.min(Math.ceil(instantMs / quantum) * quantum, Math.max(instantMs, LATEST_EXPRESSIBLE_MS));
91
+ }
92
+ /**
93
+ * One occurrence's service, band and rounded first eligibility: the entry
94
+ * point create, replacement and a recurrence's successor all use.
95
+ *
96
+ * A one-off is measured from its captured acceptance time; a recurrence by its
97
+ * neighbours, which the caller took from the existing recurrence evaluator.
98
+ */
99
+ export function resolveOccurrence(input) {
100
+ const spacing = input.kind === "one-off"
101
+ ? oneOffSpacingMs(input.acceptedAtMs, input.plannedForMs)
102
+ : recurrenceSpacingMs(input.previousMs, input.plannedForMs, input.nextMs);
103
+ const resolution = resolveService(input.requested, spacing);
104
+ return {
105
+ ...resolution,
106
+ eligibleAtMs: roundEligibilityMs(input.plannedForMs, resolution.band),
107
+ };
108
+ }
109
+ /**
110
+ * What a replacement runs under (implementation-guide § W02, step 4).
111
+ *
112
+ * - A request naming a mode other than the stored one is a mode edit.
113
+ * - A timing or cadence edit resolves again. An explicit stored mode survives
114
+ * it; a legacy schedule's enters `auto`.
115
+ * - Anything else — payload, headers, name, tags, a mode named as it already
116
+ * was — preserves the stored service and tariff, and a legacy schedule stays
117
+ * legacy.
118
+ */
119
+ export function replacementMode(input) {
120
+ if (input.requested !== undefined && input.requested !== input.stored) {
121
+ return { kind: "resolve", requested: input.requested };
122
+ }
123
+ if (input.timingChanged) {
124
+ return {
125
+ kind: "resolve",
126
+ requested: input.stored === "legacy" ? "auto" : input.stored,
127
+ };
128
+ }
129
+ return { kind: "preserve", requested: input.stored };
130
+ }
package/dist/tenancy.d.ts CHANGED
@@ -133,20 +133,19 @@ export declare const provisionedProjectSchema: import("zod").ZodObject<{
133
133
  }, import("zod/v4/core").$strip>;
134
134
  }, import("zod/v4/core").$strip>;
135
135
  export type ProvisionedProject = z.infer<typeof provisionedProjectSchema>;
136
+ /** Exactly what a permanent deletion of the project would destroy now. */
137
+ export declare const projectDeletionCountsSchema: import("zod").ZodObject<{
138
+ endpoints: import("zod").ZodNumber;
139
+ keys: import("zod").ZodNumber;
140
+ runs: import("zod").ZodNumber;
141
+ schedules: import("zod").ZodNumber;
142
+ }, import("zod/v4/core").$strip>;
136
143
  /**
137
- * What a signed-in member is told about a project they may act in.
144
+ * One project, with the exact counts a permanent deletion would destroy now.
138
145
  *
139
- * **Not `provisionedProjectSchema`, deliberately.** That shape carries
140
- * `organization`, `status`, `version` and both environment *identifiers*, and
141
- * publishing environment ids would make them public names — while nothing on
142
- * `/v1` takes one: every operation that names an environment names its
143
- * `kind`. Publishing an identifier no operation accepts invites a client to
144
- * build on it and then constrains us to keep it.
145
- *
146
- * This remains a display aid rather than an authorization control. Whatever the
147
- * list shows, the authority for a request is re-resolved from the member's
148
- * organization when the request arrives. Lifecycle state and version make an
149
- * archived row manageable without making it a valid work target.
146
+ * Returned by the single-project read (`projects.read`) and by the writes that
147
+ * answer with the project they changed. Never by the list: see
148
+ * `projectListItemSchema`.
150
149
  */
151
150
  export declare const projectSummarySchema: import("zod").ZodObject<{
152
151
  createdAt: import("zod").ZodNullable<import("zod").ZodString>;
@@ -169,12 +168,43 @@ export declare const projectSummarySchema: import("zod").ZodObject<{
169
168
  version: import("zod").ZodNumber;
170
169
  }, import("zod/v4/core").$strip>;
171
170
  export type ProjectSummary = z.infer<typeof projectSummarySchema>;
172
- export declare const projectDeletionCountsSchema: import("zod").ZodObject<{
173
- endpoints: import("zod").ZodNumber;
174
- keys: import("zod").ZodNumber;
175
- runs: import("zod").ZodNumber;
176
- schedules: import("zod").ZodNumber;
171
+ /**
172
+ * What a signed-in member is told about a project they may act in.
173
+ *
174
+ * **Not `provisionedProjectSchema`, deliberately.** That shape carries
175
+ * `organization`, `status`, `version` and both environment *identifiers*, and
176
+ * publishing environment ids would make them public names — while nothing on
177
+ * `/v1` takes one: every operation that names an environment names its
178
+ * `kind`. Publishing an identifier no operation accepts invites a client to
179
+ * build on it and then constrains us to keep it.
180
+ *
181
+ * This remains a display aid rather than an authorization control. Whatever the
182
+ * list shows, the authority for a request is re-resolved from the member's
183
+ * organization when the request arrives. Lifecycle state and version make an
184
+ * archived row manageable without making it a valid work target.
185
+ *
186
+ * **No deletion counts (W9-88).** Counting a project's schedules and runs
187
+ * reads every one of them, so a list that counted every project cost the sum
188
+ * of the organization's whole history — 2.2 s for an organization with
189
+ * 290,000 schedules, longer than the API's pool would wait, and the landing
190
+ * page failed every time. Exact counts are a deletion preview; they belong to
191
+ * the one project somebody is about to delete: `projectSummarySchema`.
192
+ */
193
+ export declare const projectListItemSchema: import("zod").ZodObject<{
194
+ status: import("zod").ZodEnum<{
195
+ active: "active";
196
+ archived: "archived";
197
+ }>;
198
+ name: import("zod").ZodString;
199
+ version: import("zod").ZodNumber;
200
+ id: import("zod").ZodString;
201
+ createdAt: import("zod").ZodNullable<import("zod").ZodString>;
202
+ environmentKinds: import("zod").ZodArray<import("zod").ZodEnum<{
203
+ test: "test";
204
+ live: "live";
205
+ }>>;
177
206
  }, import("zod/v4/core").$strip>;
207
+ export type ProjectListItem = z.infer<typeof projectListItemSchema>;
178
208
  export type ProjectDeletionCounts = z.infer<typeof projectDeletionCountsSchema>;
179
209
  /**
180
210
  * Every active or archived project the member can access. Administrators see all.
@@ -190,24 +220,18 @@ export type ProjectDeletionCounts = z.infer<typeof projectDeletionCountsSchema>;
190
220
  export declare const PROJECT_LIST_LIMIT = 200;
191
221
  export declare const projectListSchema: import("zod").ZodObject<{
192
222
  data: import("zod").ZodArray<import("zod").ZodObject<{
193
- createdAt: import("zod").ZodNullable<import("zod").ZodString>;
194
- deletionCounts: import("zod").ZodObject<{
195
- endpoints: import("zod").ZodNumber;
196
- keys: import("zod").ZodNumber;
197
- runs: import("zod").ZodNumber;
198
- schedules: import("zod").ZodNumber;
199
- }, import("zod/v4/core").$strip>;
200
- environmentKinds: import("zod").ZodArray<import("zod").ZodEnum<{
201
- test: "test";
202
- live: "live";
203
- }>>;
204
- id: import("zod").ZodString;
205
- name: import("zod").ZodString;
206
223
  status: import("zod").ZodEnum<{
207
224
  active: "active";
208
225
  archived: "archived";
209
226
  }>;
227
+ name: import("zod").ZodString;
210
228
  version: import("zod").ZodNumber;
229
+ id: import("zod").ZodString;
230
+ createdAt: import("zod").ZodNullable<import("zod").ZodString>;
231
+ environmentKinds: import("zod").ZodArray<import("zod").ZodEnum<{
232
+ test: "test";
233
+ live: "live";
234
+ }>>;
211
235
  }, import("zod/v4/core").$strip>>;
212
236
  total: import("zod").ZodNumber;
213
237
  }, import("zod/v4/core").$strip>;