slotlock 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/teams.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { SlotlockSql } from './store.js';
2
+ /** What the dashboard reads and changes of the organizations turned on (SLOTLOCK_DASHBOARD_ORGS=enabled). */
3
+ export interface SlotlockDashboardTeams {
4
+ /** Of these organization ids, the ones turned on. */
5
+ enabled(ids: readonly string[]): Promise<ReadonlySet<string>>;
6
+ /** Turn Slotlock on for an organization, by an owner of it (`github:<id>`). True when it was off. */
7
+ enable(org: {
8
+ id: string;
9
+ login: string;
10
+ }, by: string): Promise<boolean>;
11
+ /** Turn it off: its keys stop at once, and its data stay. True when it was on. */
12
+ disable(id: string, by: string): Promise<boolean>;
13
+ }
14
+ /** The organizations turned on, through slotlock.enabled_organizations and its two writers. */
15
+ export declare function createSlotlockDashboardTeams(sql: SlotlockSql): SlotlockDashboardTeams;
16
+ /**
17
+ * Whether an organization is turned on, for `serve`'s keys with SLOTLOCK_DASHBOARD_ORGS=enabled:
18
+ * read through slotlock.enabled_organizations and kept for `ttlMs` (30 seconds), so turning one off
19
+ * stops its keys on every server within that, without a query per request.
20
+ */
21
+ export declare function createSlotlockOrganizationCheck(sql: SlotlockSql, options?: {
22
+ ttlMs?: number;
23
+ now?: () => number;
24
+ maxEntries?: number;
25
+ }): (orgId: string) => Promise<boolean>;
package/teams.js ADDED
@@ -0,0 +1,63 @@
1
+ const ORGANIZATION_ID = /^[1-9]\d{0,19}$/;
2
+ const GITHUB_LOGIN = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/;
3
+ const PERSON = /^github:[1-9]\d{0,19}$/;
4
+ function refuse(message) {
5
+ throw Object.assign(new Error(`Slotlock organizations: ${message}`), { code: 'invalid_organization' });
6
+ }
7
+ /** The organizations turned on, through slotlock.enabled_organizations and its two writers. */
8
+ export function createSlotlockDashboardTeams(sql) {
9
+ return {
10
+ async enabled(ids) {
11
+ const valid = ids.filter((id) => ORGANIZATION_ID.test(id));
12
+ if (valid.length === 0)
13
+ return new Set();
14
+ const rows = await sql `
15
+ SELECT org_id FROM slotlock.enabled_organizations(${valid.join(',')}::text) AS org_id`;
16
+ return new Set(rows.map((row) => row.org_id));
17
+ },
18
+ async enable(org, by) {
19
+ if (!ORGANIZATION_ID.test(org.id) || !GITHUB_LOGIN.test(org.login) || !PERSON.test(by)) {
20
+ refuse('needs an organization id, its login and the person turning it on');
21
+ }
22
+ const [row] = await sql `
23
+ SELECT slotlock.enable_organization(${org.id}::text, ${org.login}::text, ${by}::text) AS changed`;
24
+ return row?.changed === true;
25
+ },
26
+ async disable(id, by) {
27
+ if (!ORGANIZATION_ID.test(id) || !PERSON.test(by))
28
+ refuse('needs an organization id and the person turning it off');
29
+ const [row] = await sql `
30
+ SELECT slotlock.disable_organization(${id}::text, ${by}::text) AS changed`;
31
+ return row?.changed === true;
32
+ },
33
+ };
34
+ }
35
+ /**
36
+ * Whether an organization is turned on, for `serve`'s keys with SLOTLOCK_DASHBOARD_ORGS=enabled:
37
+ * read through slotlock.enabled_organizations and kept for `ttlMs` (30 seconds), so turning one off
38
+ * stops its keys on every server within that, without a query per request.
39
+ */
40
+ export function createSlotlockOrganizationCheck(sql, options = {}) {
41
+ const ttlMs = options.ttlMs ?? 30_000;
42
+ const now = options.now ?? Date.now;
43
+ const maxEntries = options.maxEntries ?? 10_000;
44
+ const known = new Map();
45
+ const teams = createSlotlockDashboardTeams(sql);
46
+ return (orgId) => {
47
+ if (!ORGANIZATION_ID.test(orgId))
48
+ return Promise.resolve(false);
49
+ const cached = known.get(orgId);
50
+ if (cached && now() - cached.at < ttlMs)
51
+ return cached.enabled;
52
+ const enabled = teams.enabled([orgId]).then((found) => found.has(orgId), (error) => {
53
+ // A failed read is not remembered: the next request asks again.
54
+ known.delete(orgId);
55
+ throw error;
56
+ });
57
+ known.delete(orgId);
58
+ known.set(orgId, { at: now(), enabled });
59
+ if (known.size > maxEntries)
60
+ known.delete(known.keys().next().value);
61
+ return enabled;
62
+ };
63
+ }
package/timezone.d.ts CHANGED
@@ -8,9 +8,8 @@ export interface LocalDateTime {
8
8
  millisecond?: number;
9
9
  }
10
10
  /**
11
- * Which instant a wall time names where the clocks change. RFC 5545 §3.3.5 reads a time in a fold
12
- * (an hour the clocks repeat) as its first instant, and a time in a gap (an hour they skip) with the
13
- * offset in force before the gap, which moves it forward by the gap: the defaults.
11
+ * Which instant a wall time names where the clocks change. RFC 5545 §3.3.5 defaults: a fold
12
+ * (repeated hour) reads as its first instant, a gap (skipped hour) is moved forward by the gap.
14
13
  */
15
14
  export interface WallTimeChoice {
16
15
  /** In a fold: the earlier instant (default), or the later one. */
@@ -20,45 +19,41 @@ export interface WallTimeChoice {
20
19
  }
21
20
  export interface TimeZoneConverter {
22
21
  /**
23
- * Convert UTC-framed wall-clock fields to the instant that reads as those fields in this zone.
24
- * By default the first instant for a fold, and shifted forward by the gap for a hole.
22
+ * UTC-framed wall-clock fields to the instant that reads as them in this zone (WallTimeChoice
23
+ * defaults).
25
24
  */
26
25
  wallFrameToUtc(wallFrameMs: number, choice?: WallTimeChoice): Date;
27
26
  }
28
27
  /**
29
- * The instant, in ms, that reads as `wallFrameMs` (wall-clock fields framed as UTC) in a zone whose
30
- * offset at an instant `offsetAt` gives, in ms. The offsets in force within fourteen hours either
31
- * side are the candidates (no zone is further than that from UTC, so they include both sides of any
32
- * change the wall time is near); one that maps back to the wall time names it. Two do in a fold,
33
- * none in a gap, where the offset before the gap is the smaller one.
28
+ * The instant (ms) that reads as `wallFrameMs` (wall-clock fields framed as UTC) in a zone with
29
+ * offsets from `offsetAt`. Candidates are the offsets within fourteen hours either side (no zone is
30
+ * further from UTC); one that maps back names it. Two do in a fold, none in a gap.
34
31
  */
35
32
  export declare function wallFrameToInstant(wallFrameMs: number, offsetAt: (utcMs: number) => number, choice?: WallTimeChoice): number;
36
33
  /**
37
- * Date.UTC remaps years 0..99 to 1900..1999. Slotlock accepts ISO calendar years down to 1,
38
- * so construct UTC frames through setUTCFullYear instead of inheriting that legacy quirk.
34
+ * Date.UTC remaps years 0..99 to 1900..1999 and Slotlock accepts years down to 1, so frames are
35
+ * built with setUTCFullYear.
39
36
  */
40
37
  export declare function utcFrame(year: number, month: number, day: number, hour: number, minute: number, second: number, millisecond: number): Date;
41
38
  /** The UTC day an instant falls in, as ZoneOffsets reads a zone's offsets: days since 1970. */
42
39
  export declare function zoneOffsetDay(utcMs: number): number;
43
40
  /**
44
- * How far either side of a wall time converting it to its instant reads the zone's offsets: no zone
45
- * is further than this from UTC. The days from one end to the other hold every offset it reads.
41
+ * How far either side of a wall time its conversion reads offsets: no zone is further than this
42
+ * from UTC.
46
43
  */
47
44
  export declare const ZONE_OFFSET_REACH_MS: number;
48
45
  /**
49
- * How many instants reading `timezone`'s offsets on UTC day `day` asks Intl for with none of them
50
- * kept, beyond the day's two midnights (one each): about twenty to find each change between them.
51
- * A caller that must charge the same for an input however much the process has kept charges each
52
- * midnight and this the first time it reads them.
46
+ * How many Intl reads finding the changes on UTC day `day` takes beyond its two midnights (about
47
+ * twenty per change). Callers that must charge the same whatever the process has cached charge this
48
+ * on first read.
53
49
  */
54
50
  export declare function zoneOffsetChangeReads(timezone: string, day: number): number;
55
51
  export declare function createTimeZoneConverter(timezone: string): TimeZoneConverter;
56
52
  /**
57
- * The spelling Slotlock keeps of an IANA zone name: Intl's, when Intl reads the name as itself in
58
- * another case (`europe/london` is `Europe/London`); otherwise the name as given (`US/Eastern` stays,
59
- * although this Node's ICU may call it `America/New_York`, so a stored name never depends on the
60
- * ICU version that wrote it). A UTC offset (`+05:00`, which Intl accepts as a zone) is not an IANA
61
- * name and is refused, as is anything Intl does not know: both with `code: 'invalid_timezone'`.
53
+ * The spelling Slotlock keeps of an IANA zone: Intl's when it reads the name as itself in another
54
+ * case (`europe/london` is `Europe/London`), else the name as given (`US/Eastern` stays, so a
55
+ * stored name never depends on the ICU version). A UTC offset such as `+05:00` and unknown names
56
+ * are refused with `code: 'invalid_timezone'`.
62
57
  */
63
58
  export declare function canonicalTimeZoneName(timezone: string): string;
64
59
  export interface ZonedCalendarDate {
@@ -68,9 +63,8 @@ export interface ZonedCalendarDate {
68
63
  isoWeekday: number;
69
64
  }
70
65
  /**
71
- * The civil date and ISO weekday an instant falls on in `timezone` — the inverse direction of
72
- * {@link zonedDateTimeToUtc}. Pricing reads "which day is this night" through here so a pickup at
73
- * 00:30 BST on a Friday is a Friday, not the Thursday its UTC timestamp reads as.
66
+ * The civil date and ISO weekday an instant falls on in `timezone` (the inverse of {@link
67
+ * zonedDateTimeToUtc}): a pickup at 00:30 BST on a Friday is a Friday, whatever UTC says.
74
68
  */
75
69
  export declare function zonedCalendarDate(instant: Date, timezone: string): ZonedCalendarDate;
76
70
  export interface ZonedWallTime {
@@ -80,9 +74,8 @@ export interface ZonedWallTime {
80
74
  time: string;
81
75
  }
82
76
  /**
83
- * The civil date and wall-clock time an instant reads as in `timezone`. Agreements and receipts
84
- * state a booking's start and end as a date and a time on the resource's own clock, not the
85
- * server's and not UTC.
77
+ * The civil date and wall-clock time an instant reads as in `timezone`, as agreements and receipts
78
+ * state a booking: on the resource's own clock.
86
79
  */
87
80
  export declare function zonedWallTime(instant: Date, timezone: string): ZonedWallTime;
88
81
  /** How far `timezone` is ahead of UTC at `instant`, in whole minutes (negative west of UTC). */
@@ -98,7 +91,7 @@ export interface ZonedDateTimeFields {
98
91
  }
99
92
  /**
100
93
  * The wall-clock fields an instant reads as in `timezone`, to the second: what a calendar writes
101
- * beside a TZID. Reads through the cached formatters, as every conversion here does.
94
+ * beside a TZID.
102
95
  */
103
96
  export declare function zonedDateTimeFields(instant: Date, timezone: string): ZonedDateTimeFields;
104
97
  export declare function zonedDateTimeToUtc(value: LocalDateTime, timezone: string, choice?: WallTimeChoice): Date;
@@ -111,8 +104,7 @@ export interface ZoneTransition {
111
104
  after: number;
112
105
  }
113
106
  /**
114
- * Every change of `timezone`'s offset from `fromMs` to `toMs`, as Intl reads the zone, from its
115
- * offsets a day at a time (ZoneOffsets): Gaza's clocks go forward on 20 October 2040 and back six
116
- * days later, which a search a week at a time missed.
107
+ * Every change of `timezone`'s offset from `fromMs` to `toMs`, read a day at a time (ZoneOffsets):
108
+ * a weekly search misses short-lived changes such as Gaza's six-day one in October 2040.
117
109
  */
118
110
  export declare function zoneTransitions(timezone: string, fromMs: number, toMs: number): ZoneTransition[];
package/timezone.js CHANGED
@@ -1,10 +1,8 @@
1
1
  const FOURTEEN_HOURS_MS = 14 * 60 * 60 * 1000;
2
2
  /**
3
- * The instant, in ms, that reads as `wallFrameMs` (wall-clock fields framed as UTC) in a zone whose
4
- * offset at an instant `offsetAt` gives, in ms. The offsets in force within fourteen hours either
5
- * side are the candidates (no zone is further than that from UTC, so they include both sides of any
6
- * change the wall time is near); one that maps back to the wall time names it. Two do in a fold,
7
- * none in a gap, where the offset before the gap is the smaller one.
3
+ * The instant (ms) that reads as `wallFrameMs` (wall-clock fields framed as UTC) in a zone with
4
+ * offsets from `offsetAt`. Candidates are the offsets within fourteen hours either side (no zone is
5
+ * further from UTC); one that maps back names it. Two do in a fold, none in a gap.
8
6
  */
9
7
  export function wallFrameToInstant(wallFrameMs, offsetAt, choice = {}) {
10
8
  const offsets = new Set([
@@ -25,8 +23,8 @@ export function wallFrameToInstant(wallFrameMs, offsetAt, choice = {}) {
25
23
  const shifted = wallFrameMs - Math.min(...offsets);
26
24
  if (choice.gap !== 'end')
27
25
  return shifted;
28
- // The gap ends at the change: the first whole second after the instant read with the offset
29
- // after the gap (which is still before it) whose offset differs from that one's.
26
+ // The gap ends at the change: the first whole second after the instant read with the after-gap
27
+ // offset whose offset differs from it.
30
28
  let before = wallFrameMs - Math.max(...offsets);
31
29
  const offsetBefore = offsetAt(before);
32
30
  let after = shifted;
@@ -40,8 +38,8 @@ export function wallFrameToInstant(wallFrameMs, offsetAt, choice = {}) {
40
38
  return after;
41
39
  }
42
40
  /**
43
- * Date.UTC remaps years 0..99 to 1900..1999. Slotlock accepts ISO calendar years down to 1,
44
- * so construct UTC frames through setUTCFullYear instead of inheriting that legacy quirk.
41
+ * Date.UTC remaps years 0..99 to 1900..1999 and Slotlock accepts years down to 1, so frames are
42
+ * built with setUTCFullYear.
45
43
  */
46
44
  export function utcFrame(year, month, day, hour, minute, second, millisecond) {
47
45
  const frame = new Date(0);
@@ -50,10 +48,8 @@ export function utcFrame(year, month, day, hour, minute, second, millisecond) {
50
48
  return frame;
51
49
  }
52
50
  /**
53
- * Formatters already built, by zone name. Building one costs far more than using it, and every
54
- * conversion needs one: a recurring series reads about three times an occurrence. A formatter never
55
- * changes, so one per zone serves every caller. Intl accepts every capitalisation of each zone's
56
- * name, so the map is emptied when it fills rather than growing with whatever names arrive.
51
+ * Formatters by zone name: building one costs far more than using it. Intl accepts any
52
+ * capitalisation of a name, so the map is emptied when full rather than growing.
57
53
  */
58
54
  const formatters = new Map();
59
55
  const MAX_CACHED_FORMATTERS = 256;
@@ -75,21 +71,19 @@ export function zoneOffsetDay(utcMs) {
75
71
  return Math.floor(utcMs / DAY_MS);
76
72
  }
77
73
  /**
78
- * How far either side of a wall time converting it to its instant reads the zone's offsets: no zone
79
- * is further than this from UTC. The days from one end to the other hold every offset it reads.
74
+ * How far either side of a wall time its conversion reads offsets: no zone is further than this
75
+ * from UTC.
80
76
  */
81
77
  export const ZONE_OFFSET_REACH_MS = FOURTEEN_HOURS_MS;
82
78
  /** How many midnights one block of a zone's offsets holds. */
83
79
  const BLOCK_DAYS = 512;
84
80
  const NO_CHANGES = [];
85
81
  /**
86
- * A zone's offsets, read from Intl a UTC day at a time and kept. Intl's formatToParts takes about
87
- * two microseconds and a conversion asks for four to six offsets, so a series that converts a time
88
- * at each of thousands of occurrences spent most of its time there (forty daily London series from
89
- * 2016, 35 seconds). Each UTC midnight's offset is read once; a day whose two midnights agree has
90
- * that offset throughout, and one whose midnights differ has each change in it narrowed to the
91
- * second (scanTransitions). A change and its reversal within one day would be missed; the tests
92
- * compare the zones whose clocks change most with an hourly search.
82
+ * A zone's offsets, read from Intl a UTC day at a time and kept: formatToParts costs about two
83
+ * microseconds and a conversion needs four to six offsets. Each midnight is read once; a day whose
84
+ * midnights agree has that offset throughout, and a day whose midnights differ has each change
85
+ * narrowed to the second (scanTransitions). A change and its reversal within one day would be
86
+ * missed; tests check the most volatile zones against an hourly search.
93
87
  */
94
88
  class ZoneOffsets {
95
89
  /** The offset at each UTC midnight read (NaN where none is), BLOCK_DAYS days to a block. */
@@ -102,7 +96,7 @@ class ZoneOffsets {
102
96
  }
103
97
  offsetAt(utcMs) {
104
98
  const day = zoneOffsetDay(utcMs);
105
- // Not a time value: Intl refuses it, as it always has.
99
+ // Not a time value: let Intl refuse it.
106
100
  if (!Number.isFinite(day))
107
101
  return offsetAt(utcMs, this.formatter);
108
102
  let offset = this.midnight(day);
@@ -170,10 +164,8 @@ function dayStart(day) {
170
164
  return Math.min(Math.max(day * DAY_MS, -MAX_TIME_MS), MAX_TIME_MS);
171
165
  }
172
166
  /**
173
- * Each zone's offsets, by name, as createFormatter keeps formatters. A block of midnights takes 4
174
- * KB; once there are this many (16 MB, about 57 zone-centuries), every zone's are emptied, and so
175
- * are the days with changes once there are this many of those (zones change twice a year at most
176
- * but for a few).
167
+ * Each zone's offsets by name. A block of midnights is 4 KB; past this many blocks (16 MB) every
168
+ * zone's are emptied, and likewise for days with changes.
177
169
  */
178
170
  const zoneOffsetTables = new Map();
179
171
  const MAX_CACHED_BLOCKS = 4_096;
@@ -194,10 +186,9 @@ function zoneOffsets(timezone) {
194
186
  return offsets;
195
187
  }
196
188
  /**
197
- * How many instants reading `timezone`'s offsets on UTC day `day` asks Intl for with none of them
198
- * kept, beyond the day's two midnights (one each): about twenty to find each change between them.
199
- * A caller that must charge the same for an input however much the process has kept charges each
200
- * midnight and this the first time it reads them.
189
+ * How many Intl reads finding the changes on UTC day `day` takes beyond its two midnights (about
190
+ * twenty per change). Callers that must charge the same whatever the process has cached charge this
191
+ * on first read.
201
192
  */
202
193
  export function zoneOffsetChangeReads(timezone, day) {
203
194
  if (timezone === 'UTC')
@@ -222,8 +213,8 @@ function buildFormatter(timezone) {
222
213
  }
223
214
  }
224
215
  function offsetAt(utcMs, formatter) {
225
- // Intl parts expose seconds but not milliseconds. Compute the zone offset against the same
226
- // whole-second instant so sub-second input does not leak into the offset and get rounded away.
216
+ // Intl parts expose seconds, not milliseconds: compute the offset at the whole second so
217
+ // sub-second input is not rounded into it.
227
218
  const wholeSecondMs = Math.floor(utcMs / 1000) * 1000;
228
219
  const values = {};
229
220
  for (const part of formatter.formatToParts(new Date(wholeSecondMs))) {
@@ -246,17 +237,21 @@ export function createTimeZoneConverter(timezone) {
246
237
  };
247
238
  }
248
239
  /**
249
- * The spelling Slotlock keeps of an IANA zone name: Intl's, when Intl reads the name as itself in
250
- * another case (`europe/london` is `Europe/London`); otherwise the name as given (`US/Eastern` stays,
251
- * although this Node's ICU may call it `America/New_York`, so a stored name never depends on the
252
- * ICU version that wrote it). A UTC offset (`+05:00`, which Intl accepts as a zone) is not an IANA
253
- * name and is refused, as is anything Intl does not know: both with `code: 'invalid_timezone'`.
240
+ * The spelling Slotlock keeps of an IANA zone: Intl's when it reads the name as itself in another
241
+ * case (`europe/london` is `Europe/London`), else the name as given (`US/Eastern` stays, so a
242
+ * stored name never depends on the ICU version). A UTC offset such as `+05:00` and unknown names
243
+ * are refused with `code: 'invalid_timezone'`.
254
244
  */
255
245
  export function canonicalTimeZoneName(timezone) {
256
246
  const name = timezone.trim();
257
247
  const invalid = () => Object.assign(new Error(`Invalid timezone: ${timezone}`), { code: 'invalid_timezone' });
258
248
  if (!name || /^[+-]/.test(name))
259
249
  throw invalid();
250
+ // Each name resolves the same for the process's life (its time zone data does not change under
251
+ // it), and building a formatter to ask is the costly part: remembered, valid names only.
252
+ const known = canonicalNames.get(name);
253
+ if (known !== undefined)
254
+ return known;
260
255
  let resolved;
261
256
  try {
262
257
  resolved = new Intl.DateTimeFormat('en-US', { timeZone: name }).resolvedOptions().timeZone;
@@ -266,12 +261,18 @@ export function canonicalTimeZoneName(timezone) {
266
261
  }
267
262
  if (/^[+-]/.test(resolved))
268
263
  throw invalid();
269
- return resolved.toLowerCase() === name.toLowerCase() ? resolved : name;
264
+ const canonical = resolved.toLowerCase() === name.toLowerCase() ? resolved : name;
265
+ if (canonicalNames.size >= MAX_CANONICAL_NAMES)
266
+ canonicalNames.clear();
267
+ canonicalNames.set(name, canonical);
268
+ return canonical;
270
269
  }
270
+ /** Names canonicalTimeZoneName resolved; a few hundred zones exist, so this rarely fills. */
271
+ const canonicalNames = new Map();
272
+ const MAX_CANONICAL_NAMES = 2_000;
271
273
  /**
272
- * The civil date and ISO weekday an instant falls on in `timezone` — the inverse direction of
273
- * {@link zonedDateTimeToUtc}. Pricing reads "which day is this night" through here so a pickup at
274
- * 00:30 BST on a Friday is a Friday, not the Thursday its UTC timestamp reads as.
274
+ * The civil date and ISO weekday an instant falls on in `timezone` (the inverse of {@link
275
+ * zonedDateTimeToUtc}): a pickup at 00:30 BST on a Friday is a Friday, whatever UTC says.
275
276
  */
276
277
  export function zonedCalendarDate(instant, timezone) {
277
278
  const wall = wallFrame(instant, timezone);
@@ -279,9 +280,8 @@ export function zonedCalendarDate(instant, timezone) {
279
280
  return { date: civilDate(wall), isoWeekday: weekday === 0 ? 7 : weekday };
280
281
  }
281
282
  /**
282
- * The civil date and wall-clock time an instant reads as in `timezone`. Agreements and receipts
283
- * state a booking's start and end as a date and a time on the resource's own clock, not the
284
- * server's and not UTC.
283
+ * The civil date and wall-clock time an instant reads as in `timezone`, as agreements and receipts
284
+ * state a booking: on the resource's own clock.
285
285
  */
286
286
  export function zonedWallTime(instant, timezone) {
287
287
  const wall = wallFrame(instant, timezone);
@@ -295,7 +295,7 @@ export function zonedOffsetMinutes(instant, timezone) {
295
295
  }
296
296
  /**
297
297
  * The wall-clock fields an instant reads as in `timezone`, to the second: what a calendar writes
298
- * beside a TZID. Reads through the cached formatters, as every conversion here does.
298
+ * beside a TZID.
299
299
  */
300
300
  export function zonedDateTimeFields(instant, timezone) {
301
301
  const wall = wallFrame(instant, timezone);
@@ -356,9 +356,8 @@ export function zonedDateTimeToUtc(value, timezone, choice) {
356
356
  return createTimeZoneConverter(timezone).wallFrameToUtc(wallFrame, choice);
357
357
  }
358
358
  /**
359
- * Every change of `timezone`'s offset from `fromMs` to `toMs`, as Intl reads the zone, from its
360
- * offsets a day at a time (ZoneOffsets): Gaza's clocks go forward on 20 October 2040 and back six
361
- * days later, which a search a week at a time missed.
359
+ * Every change of `timezone`'s offset from `fromMs` to `toMs`, read a day at a time (ZoneOffsets):
360
+ * a weekly search misses short-lived changes such as Gaza's six-day one in October 2040.
362
361
  */
363
362
  export function zoneTransitions(timezone, fromMs, toMs) {
364
363
  if (timezone === 'UTC')
@@ -366,9 +365,8 @@ export function zoneTransitions(timezone, fromMs, toMs) {
366
365
  return zoneOffsets(timezone).transitions(Math.floor(fromMs / 1000) * 1000, Math.ceil(toMs / 1000) * 1000);
367
366
  }
368
367
  /**
369
- * The changes of the offset `offsetIn` reads from `fromMs` to `toMs`, found by comparing offsets
370
- * `stepMs` apart and narrowing each change to the second: a change and its reversal both within
371
- * one step are missed.
368
+ * Offset changes found by comparing `offsetIn` `stepMs` apart and narrowing each to the second; a
369
+ * change and its reversal within one step are missed.
372
370
  */
373
371
  function scanTransitions(offsetIn, fromMs, toMs, stepMs) {
374
372
  const found = [];
@@ -380,7 +378,6 @@ function scanTransitions(offsetIn, fromMs, toMs, stepMs) {
380
378
  at = next;
381
379
  continue;
382
380
  }
383
- // The last whole second at `offset` and the first at another.
384
381
  let low = at;
385
382
  let high = next;
386
383
  while (high - low > 1000) {