From 9035711acb1eff3c2e00c125542c47c2eeb30f48 Mon Sep 17 00:00:00 2001 From: BeyondVertical <17316492+BeyondVertical@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:57:22 +0200 Subject: [PATCH 1/3] chore: regenerate schema from upstream spec Regenerated from https://api.themeparks.wiki/docs/v1.yaml. The spec gained three paths: /v1/entity/{id}/history, .../history/coverage and .../history/daily, with their envelope, row and error schemas. Same paths as the open drift PR; the descriptions are the spec's current wording, which has moved on since that branch was cut. Co-Authored-By: Claude Fable 5.1 --- src/_generated/schema.ts | 532 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 520 insertions(+), 12 deletions(-) diff --git a/src/_generated/schema.ts b/src/_generated/schema.ts index 9fce650..6f3ce82 100644 --- a/src/_generated/schema.ts +++ b/src/_generated/schema.ts @@ -55,6 +55,66 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/entity/{id}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * GET /v1/entity/{id}/history + * @description History of an entity as one row per change, every row the complete live-data object at that instant (same keys as /live) plus `time` and `changed`. Ask for a park-local day (`date=YYYY-MM-DD`), a range of days (`from`/`to`, inclusive), or RFC 3339 instants with an offset (half-open). No parameters means today. At most 31 days per call. `opening` is the state effective at the start of the range; `coverage.firstRecordedAt` is the first day this entity has any history. Anonymous callers see 7 days, a free API key 30; deeper ranges return 403 HISTORY_WINDOW_EXCEEDED with the earliest allowed date. History requests have their own hourly budget, separate from the per-minute REST limit: 60 an hour without a key, 600 with a free key, 1200 on Pro, 3000 on Business, unmetered on Enterprise (published per tier in GET /tiers as limits.historyRequestsPerHour); over it returns 429 HISTORY_RATE_LIMITED with retryAfter. The per-minute REST limit still applies first and answers with the API-wide 429 body. A range that includes TODAY may be up to an hour old for callers without an API key, so a poller can see a response up to an hour behind the live feed; completed days are cached for longer because they cannot change. For the current state of an entity rather than its history, GET /v1/entity/{id}/live is not cached that way. FOR A PARK, this path answers the WHOLE PARK and the 200 is a different schema: `HistoryParkRawEnvelope`, carrying an `entities[]` array — one entry per entity of the park that has history, ascending by name, the park itself included when it has history of its own — each entry holding the same `coverage`, `opening` and `history` block a single entity gets. An entity with no history is absent from that array rather than present and empty. A park range is 1 park-local day, not 31: a day of a park is every recorded change for every entity in it, so a wider range is 400 RANGE_TOO_LONG and the call is never paged. Every other entityType — a DESTINATION included — returns the single-entity envelope described above, so the 200 is a union of two schemas and the entity's `entityType` is what selects between them. Either way the call costs ONE unit of the hourly history budget, whatever the park's size. + */ + get: operations["getHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/entity/{id}/history/coverage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * GET /v1/entity/{id}/history/coverage + * @description What history we hold for this entity, broken down per live-data field: the first and last park-local day each field (`status`, `queue.STANDBY`, `showtimes`, and so on) was reported, keyed by the same live-data path GET /v1/entity/{id}/live and GET /v1/entity/{id}/history and .../history/daily use, so a key matches straight across all four. A field the entity never reported is ABSENT from `kinds`. Read `last` as "newest day in the ARCHIVE", not "last day reported": the archive is written two to three days behind live data, so a field being published right now still has a `last` a few days old, and an active field is indistinguishable from a withdrawn one here. To ask whether a field is still live, call GET /v1/entity/{id}/live. For how recent a day you can actually ASK for, read `retrievableThrough`: history calls serve recent readings as well as the archive, so it is normally TODAY for an entity still reporting and therefore sits two to three days AHEAD of `lastRecordedAt` — that gap is expected, not a fault. For an entity that stopped reporting long ago it equals `lastRecordedAt`, so it never promises data that is not there. `firstRecordedAt` and `lastRecordedAt` are the entity-wide ARCHIVE bounds; an entity with nothing recorded returns the document with both null and `kinds: {}`, never a 404 — "we hold nothing for this entity" is a real, actionable answer, and a different claim from "this entity does not exist". No parameters, no paging and no tier window: the document is the same handful of day strings whatever the entity's history depth, so this call is not entitlement-gated. It still shares the hourly history budget with GET /v1/entity/{id}/history and .../history/daily, separate from the per-minute REST limit: 60 an hour without a key, 600 with a free key, 1200 on Pro, 3000 on Business, unmetered on Enterprise (published per tier in GET /tiers as limits.historyRequestsPerHour); over it returns 429 HISTORY_RATE_LIMITED with retryAfter. The per-minute REST limit still applies first and answers with the API-wide 429 body. The response is the same bytes for every caller, so it is publicly cacheable for an hour — every value in it is a whole day, so a cached copy can only differ from a fresh one in the first hour after park-local midnight, when `retrievableThrough` may still name the previous day. + */ + get: operations["getHistoryCoverage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/entity/{id}/history/daily": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * GET /v1/entity/{id}/history/daily + * @description One summary row per park-local day: how long the entity was OPERATING and DOWN, when it first opened and last closed, minute-weighted standby and single-rider wait statistics, a show count where the entity publishes showtimes, and how many changes were recorded. Ask for a park-local day (`date=YYYY-MM-DD`), a range of days (`from`/`to`, inclusive), or RFC 3339 instants with an offset; `range` always comes back as park-local days, because a row summarises a whole day. No parameters means today. At most 3660 days per call, and the response is never paged. A day with no data is ABSENT from `days` — there is no row of zeroes, because "we have nothing for this day" is a different claim from "observed, closed all day" — and `standby`, `singleRider` and `showCount` are omitted rather than null when the entity published nothing of that kind. `coverage.firstRecordedAt` is the first day this entity has any history. Anonymous callers see 7 days, a free API key 30; deeper ranges return 403 HISTORY_WINDOW_EXCEEDED with the earliest allowed date. These calls share the hourly history budget with GET /v1/entity/{id}/history, separate from the per-minute REST limit: 60 an hour without a key, 600 with a free key, 1200 on Pro, 3000 on Business, unmetered on Enterprise (published per tier in GET /tiers as limits.historyRequestsPerHour); over it returns 429 HISTORY_RATE_LIMITED with retryAfter. The per-minute REST limit still applies first and answers with the API-wide 429 body. FOR A PARK, this path answers the WHOLE PARK and the 200 is a different schema: `HistoryParkDailyEnvelope`, carrying an `entities[]` array — one entry per entity of the park that has history, ascending by name, the park itself included when it has history of its own — instead of this envelope's `coverage` and `days`. An entity with no history is absent from that array rather than present and empty. A park call is also the one PAGED call in this family: it serves at most 31 park-local days and `next` is an absolute URL for the rest, with your other parameters preserved. Every other entityType — a DESTINATION included — returns the single-entity envelope described above, so the 200 is a union of two schemas and the entity's `entityType` is what selects between them. Either way the call costs ONE unit of the hourly history budget, whatever the park's size. + */ + get: operations["getHistoryDaily"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/entity/{id}/live": { parameters: { query?: never; @@ -148,6 +208,8 @@ export interface components { /** @description Parent entity identifier */ parentId?: string; location?: components["schemas"]["EntityLocation"]; + /** @description URL-friendly slug */ + slug?: string | null; }; EntityChildrenResponse: { /** @description Parent entity identifier */ @@ -159,20 +221,33 @@ export interface components { timezone?: string; children?: components["schemas"]["EntityChild"][]; }; + /** @description A single entity. Beyond the properties listed here, an entity may carry additional tag-derived properties named after the tag's slug, for example `minimumHeight` (integer, centimetres) or `mayGetWet` (boolean). The set is open-ended and driven by data rather than fixed by this contract, so clients should read them defensively rather than assume any particular tag is present. */ EntityData: { /** @description Unique entity identifier */ id: string; /** @description Entity name */ name: string; entityType: components["schemas"]["EntityType"]; + /** + * @description Kind of attraction. Present on ATTRACTION entities. + * @enum {string} + */ + attractionType?: "UNKNOWN" | "RIDE" | "SHOW" | "TRANSPORT" | "PARADE" | "MEET_AND_GREET" | "OTHER"; /** @description Parent entity identifier */ parentId?: string | null; /** @description Destination identifier */ destinationId?: string | null; + /** @description Identifier of the park this entity belongs to. Absent on destinations and on parks themselves. */ + parkId?: string | null; /** @description Entity timezone */ timezone: string; location?: components["schemas"]["EntityLocation"]; - tags?: components["schemas"]["TagData"][]; + /** @description Identifier used by the source data provider. */ + externalId?: string; + /** @description URL-friendly slug. Served for destinations. */ + slug?: string | null; + } & { + [key: string]: unknown; }; EntityLiveData: { /** @description Entity identifier */ @@ -225,10 +300,280 @@ export interface components { * @enum {string} */ EntityType: "DESTINATION" | "PARK" | "ATTRACTION" | "RESTAURANT" | "HOTEL" | "SHOW"; + HistoryCoverage: { + /** + * Format: date + * @description First park-local day with recorded history for this entity, or null when nothing has been archived yet. Per-kind detail and gaps: GET /v1/entity/{id}/history/coverage. + */ + firstRecordedAt: string | null; + }; + /** @description What history is actually held for one entity, broken down per live-data field. Two different questions, answered separately: `firstRecordedAt`/`lastRecordedAt` and the per-field spans describe the ARCHIVE, while `retrievableThrough` is the newest day a history call could return for this entity — which is normally today, and normally two to three days AHEAD of `lastRecordedAt`. An entity with nothing recorded is a 200 with kinds: {} and every day null, never a 404: "we hold nothing for this entity" is a real, actionable answer and a different claim from "this entity does not exist". */ + HistoryCoverageDocument: { + id: string; + name: string; + entityType: string; + parentId: string | null; + destinationId: string | null; + /** @description IANA timezone the park-local days are resolved in. */ + timezone: string; + /** + * Format: date + * @description First park-local day with any recorded history for this entity, or null when nothing has been archived yet. May legitimately be earlier than any individual kind's first: the two are recorded independently and answer slightly different questions. + */ + firstRecordedAt: string | null; + /** + * Format: date + * @description The newest `last` across `kinds` — the most recent park-local day we hold anything at all for this entity — or null when nothing is recorded. Like the per-field `last`, this tracks the ARCHIVE and lags live data by two to three days, so it sits in the past for an entity reporting normally. + */ + lastRecordedAt: string | null; + /** + * Format: date + * @description The newest park-local day GET /v1/entity/{id}/history and .../history/daily could return data for this entity — what you can ASK FOR, as opposed to what has been filed. Normally TODAY for an entity still reporting, because those endpoints answer from recent readings as well as the archive, and it therefore sits AHEAD of `lastRecordedAt` by two to three days for a healthy entity. That gap is the whole point of this field: `lastRecordedAt` and every per-field `last` describe the ARCHIVE only, and reading them as capability is what makes coverage look as though it has stopped a couple of days short. It is the LATER of `lastRecordedAt` and the newest day recent readings can still answer for — so an entity that stopped reporting long ago reports its archive day here, NOT today, and the field never promises data that is not there. Recent readings do not go back indefinitely, so a day is reported here only if one of those endpoints can actually return it: an entity whose last reading is old enough falls back to its archive day rather than naming the day that reading was taken. Null only when we hold nothing for this entity in either place. A request for a range up to this day can still be narrowed by your tier's history window, which bounds how far BACK you may ask, never how recent. + */ + retrievableThrough: string | null; + /** @description Keyed by LIVE-DATA PATH (status, queue.STANDBY, showtimes, ...), not by the internal kind name, so a key matches straight against what GET /v1/entity/{id}/live and GET /v1/entity/{id}/history return. A kind the entity never reported is ABSENT here and absent from every /history row — there is no zero-span entry for it. See `last` for why a span ending in the past does NOT mean the field stopped being reported: the archive lags live data by two to three days, so an actively published field ends in the past too. Every span here is archive-only; `retrievableThrough` is the entity-wide answer to how recent a day you can actually ask for. */ + kinds: { + [key: string]: components["schemas"]["HistoryCoverageKindSpan"]; + }; + }; + /** @description One live-data field's recorded span for an entity, both ends inclusive. */ + HistoryCoverageKindSpan: { + /** + * Format: date + * @description First park-local day this field was reported. + */ + first: string; + /** + * Format: date + * @description Newest park-local day this field appears in the ARCHIVE. This is not the same as the last day the field was reported, and it does NOT mean the field has stopped: the archive is written two to three days behind live data, so a field being published right now still has a `last` a few days in the past. Every active field looks the same as a withdrawn one here. Use this to know how far back the archive goes and how current it is, not to decide whether a field is still live — GET /v1/entity/{id}/live answers that directly. + */ + last: string; + }; + /** @description A day-by-day summary of one entity's history. GET /v1/entity/{id}/history/daily returns this shape for every entityType EXCEPT PARK; for a PARK the same path returns HistoryParkDailyEnvelope, which carries an entities[] array instead of this envelope's coverage/days block. range.from and range.to ALWAYS echo park-local calendar days (YYYY-MM-DD), even when the call supplied RFC 3339 instants, because this endpoint summarises whole park-local days and an instant echo would claim a precision the rows do not have. TODAY's row is the day so far — its counters cover only elapsed minutes and grow as the day does — and a response to a request without an API key may be up to an hour old, so a poller can see a value that far behind the live feed. If you need the current state of an entity rather than its day so far, GET /v1/entity/{id}/live is not cached that way. */ + HistoryDailyEnvelope: { + id: string; + name: string; + entityType: string; + parentId: string | null; + destinationId: string | null; + /** @description IANA timezone the park-local days are resolved in. */ + timezone: string; + range: components["schemas"]["HistoryRange"]; + coverage: components["schemas"]["HistoryCoverage"]; + /** @description One row per park-local day, ascending by date. A day with no data is ABSENT: there is no row of zeroes, because "we have nothing for this day" is a different claim from "observed, closed all day". */ + days: components["schemas"]["HistoryDailyRow"][]; + /** @description URL of the next page, or null. Always null for a single entity: a daily call is unpaged. Park calls page; see HistoryParkDailyEnvelope. */ + next: string | null; + }; + /** @description One park-local day of an entity's history reduced to the numbers a crowd calendar needs. standby, singleRider and showCount are ABSENT rather than null when there is nothing to report: an absent standby means no numeric standby wait was in force while OPERATING for a whole sampled minute that day — the statistics are sampled at minute resolution, so a wait published only inside a sub-minute window produces no block at all, even though /history records it and the day's operatingMinutes count it. The same applies to singleRider. An absent showCount means the entity published no showtimes. showCount counts distinct performance start times in the local day. */ + HistoryDailyRow: { + /** + * Format: date + * @description The park-local calendar day this row summarises, YYYY-MM-DD, in the entity's timezone. + */ + date: string; + /** + * Format: date-time + * @description UTC instant (whole seconds) the entity first became OPERATING on this park-local day, or null if it never did. A day that opened already OPERATING reports the start of the local day. + */ + firstOperatingAt: string | null; + /** + * Format: date-time + * @description UTC instant (whole seconds) of the last transition out of OPERATING or DOWN into CLOSED or REFURBISHMENT after firstOperatingAt, or null if there was none. For a park closing after local midnight this instant falls on the following UTC day. + */ + lastClosedAt: string | null; + /** @description Minutes of this park-local day the entity was OPERATING. Minutes with no observed state count as neither operating nor down, so the two counters need not add up to the length of the day. For TODAY the count covers only the minutes that have already elapsed, so it grows through the day and is final once the day ends: a caller polling today's row sees it rise, which is the day filling in rather than the answer changing. */ + operatingMinutes: number; + /** @description Minutes of this park-local day the entity was DOWN. As with operatingMinutes, today's count covers only the elapsed part of the day. */ + downMinutes: number; + standby?: components["schemas"]["HistoryDailyStats"]; + singleRider?: components["schemas"]["HistoryDailyStats"]; + /** @description Distinct performance start times whose park-local day is this day. Present only for entities that published showtimes on the day. */ + showCount?: number; + /** @description Number of history rows recorded on this day, i.e. instants at which any kind changed. Short flaps are counted as they happened; this is not a cleaned figure. */ + changes: number; + }; + /** @description Wait statistics for one park-local day. The percentiles and the mean are weighted by the MINUTES the wait was posted rather than by the number of readings, so a wait that stood for three hours counts three hours and a brief flap does not drag the median; they are sampled at minute resolution and the percentiles are nearest-rank, never interpolated. `min` and `max` are TRUE extremes over every value posted, so a spike too short to be sampled still shows there. Only periods where the entity was OPERATING and published a numeric wait count at all. The block is absent when it never did. */ + HistoryDailyStats: { + /** @description Lowest wait, in minutes, the entity published while OPERATING that day. A true extreme over every value posted, including one that stood for less than a minute — so unlike the percentiles below it is not minute-weighted. */ + min: number; + /** @description Median wait, nearest-rank over the minute weights (a value actually posted, never interpolated). */ + p50: number; + /** @description Minute-weighted average wait, rounded to the nearest whole minute. */ + mean: number; + /** @description 90th-percentile wait, nearest-rank over the minute weights. */ + p90: number; + /** @description Highest wait, in minutes, the entity published while OPERATING that day. A true extreme, as min is: a spike that lasted forty seconds counts here and is invisible to the percentiles, which is the intended difference between the two halves of this block — extremes answer "what did it ever reach", percentiles answer "what was it usually like". */ + max: number; + }; + /** @description One entity's history as full-state change rows. GET /v1/entity/{id}/history returns this shape for every entityType EXCEPT PARK; for a PARK the same path returns HistoryParkRawEnvelope, which carries an entities[] array instead of this envelope's coverage/opening/history block. */ + HistoryEnvelope: { + id: string; + name: string; + entityType: string; + parentId: string | null; + destinationId: string | null; + /** @description IANA timezone the park-local days are resolved in. */ + timezone: string; + range: components["schemas"]["HistoryRange"]; + coverage: components["schemas"]["HistoryCoverage"]; + opening: components["schemas"]["HistoryOpening"]; + /** @description Ascending by time. */ + history: components["schemas"]["HistoryRow"][]; + /** @description URL of the next page, or null. Always null for a single entity (a call covers up to 31 days). Park calls page; see HistoryParkRawEnvelope. */ + next: string | null; + }; + /** @description 502: the range needs archived history and that backend is temporarily unavailable. The request is retryable. */ + HistoryErrorBackendUnavailable: { + error: { + /** @enum {string} */ + type: "HISTORY_BACKEND_UNAVAILABLE"; + message: string; + }; + }; + /** @description 400: a date parameter is not a calendar day or an RFC 3339 instant with an explicit offset, or date was combined with from/to, or from and to mix the two forms, or to was given without from. */ + HistoryErrorInvalidDate: { + error: { + /** @enum {string} */ + type: "INVALID_DATE"; + /** @description e.g. "from must be a calendar day (YYYY-MM-DD) or an RFC 3339 instant with an offset (e.g. 2026-09-13T14:00:00Z)." */ + message: string; + }; + }; + /** @description 400: both ends parsed, but the range runs backwards (days: to before from; instants: to not after from). */ + HistoryErrorInvalidRange: { + error: { + /** @enum {string} */ + type: "INVALID_RANGE"; + /** @description e.g. "to must not be before from." */ + message: string; + }; + }; + /** @description 404: no entity with that id. */ + HistoryErrorNotFound: { + error: { + /** @enum {string} */ + type: "NOT_FOUND"; + message: string; + }; + }; + /** @description 400: the range spans more than 31 park-local days. Split it into consecutive calls. */ + HistoryErrorRangeTooLong: { + error: { + /** @enum {string} */ + type: "RANGE_TOO_LONG"; + /** @description e.g. "A history call covers at most 31 park-local days (2026-01-01 to 2026-03-01 is 60). Ask for a shorter range." */ + message: string; + }; + }; + /** @description 429: the caller's hourly history request budget is spent. The budget is separate from the per-minute REST limit and is published per tier in GET /tiers as limits.historyRequestsPerHour (anonymous.historyRequestsPerHour for keyless calls). */ + HistoryErrorRateLimited: { + error: { + /** @enum {string} */ + type: "HISTORY_RATE_LIMITED"; + /** @description e.g. "This key can make 600 history requests an hour." */ + message: string; + /** @description Seconds until the hourly history budget admits another request. Also sent as the Retry-After header. */ + retryAfter: number; + }; + }; + HistoryErrorWindowExceeded: { + error: { + /** @enum {string} */ + type: "HISTORY_WINDOW_EXCEEDED"; + /** @description A fact and a date, naming no plan and selling nothing. e.g. "This key can see history back to 2026-09-08 (7 days).", or "Requests without an API key can see history back to 2026-09-08 (7 days)." when you sent no key. */ + message: string; + /** + * Format: date + * @description First park-local day this credential may query. + */ + earliestAllowedDate: string; + }; + }; + /** @description The full live-data state effective at the start of the range, in the same shape as a row. A key is present only when the entity has that kind. When the value is unknown at that instant (typically an older range, answered from the archive rather than from recent readings) the kind carries its EMPTY live value rather than a null container — an unknown standby is {"waitTime": null}, an unknown showtimes list is [] — and status, which has no empty value, is null. */ + HistoryOpening: { + /** + * Format: date-time + * @description Start of the range (UTC, whole seconds). The state below is effective from this instant. + */ + time: string; + /** @description Live status at the start of the range; null when unknown. */ + status?: string | null; + queue?: components["schemas"]["LiveQueue"]; + showtimes?: components["schemas"]["LiveShowTime"][] | null; + }; + /** @description A day-by-day summary of a whole PARK: every entity of the park that has history, in one call. GET /v1/entity/{id}/history/daily returns THIS shape when the entity is a PARK (entityType: "PARK") and HistoryDailyEnvelope for every other entityType, so a client should branch on the presence of entities[] or on the entity's type. range.from and range.to are always park-local calendar days, and range.to is THIS PAGE's last day rather than the whole range you asked for: a call serves at most 31 park-local days and next carries the rest. */ + HistoryParkDailyEnvelope: { + id: string; + name: string; + entityType: string; + parentId: string | null; + destinationId: string | null; + /** @description IANA timezone the park-local days are resolved in. The park is the authority on where its day boundaries fall, so every entity below is summarised in THIS zone. */ + timezone: string; + range: components["schemas"]["HistoryRange"]; + /** @description One entry per entity of the park that has history, ascending by name. An entity with no history is ABSENT — never an entry of nulls or an empty days[] — because "we hold nothing for this entity" is a different claim from "we hold nothing for these days". The park itself is included when it has history of its own. */ + entities: components["schemas"]["HistoryParkEntityDaily"][]; + /** @description Absolute URL of the next page, or null on the last one. A park daily call serves at most 31 park-local days and pages by DAY: the next URL repeats your other parameters with from advanced past this page's last day. Entity order never affects paging. */ + next: string | null; + }; + /** @description One entity of a park in a park DAILY response: its identity, the first day it has any history, and its day rows. The park itself appears as an entry too when it has history of its own (match it by id against the envelope's id). An entity with no history at all is ABSENT from entities[]. */ + HistoryParkEntityDaily: { + id: string; + name: string; + entityType: string; + coverage: components["schemas"]["HistoryCoverage"]; + /** @description One row per park-local day, ascending by date, exactly as GET /v1/entity/{id}/history/daily returns for this entity on its own. A day with no data is ABSENT, and an entity with history but nothing in the requested days has an empty array rather than vanishing from entities[] — so the entity list keeps its shape from one page to the next. */ + days: components["schemas"]["HistoryDailyRow"][]; + }; + /** @description One entity of a park in a park RAW response: the same coverage, opening and history block GET /v1/entity/{id}/history returns for that entity on its own, so one client type reads both. The park itself appears as an entry too when it has history of its own (match it by id against the envelope's id). */ + HistoryParkEntityRaw: { + id: string; + name: string; + entityType: string; + coverage: components["schemas"]["HistoryCoverage"]; + opening: components["schemas"]["HistoryOpening"]; + /** @description Ascending by time. Empty when this entity recorded no change in the requested range, which is a different claim from having no history at all — an entity with no history is absent from entities[]. */ + history: components["schemas"]["HistoryRow"][]; + }; + /** @description Full-state change rows for a whole PARK: every entity of the park that has history, in one call, for exactly 1 park-local day. GET /v1/entity/{id}/history returns THIS shape when the entity is a PARK (entityType: "PARK") and HistoryEnvelope for every other entityType, so a client should branch on the presence of entities[] or on the entity's type. A range spanning more than one day is 400 RANGE_TOO_LONG: a day of a park is every recorded change for every entity in it, and the one-day limit is what bounds that. Ask day by day. */ + HistoryParkRawEnvelope: { + id: string; + name: string; + entityType: string; + parentId: string | null; + destinationId: string | null; + /** @description IANA timezone the park-local day is resolved in. The park is the authority on where its day boundaries fall, so every entity below is resolved in THIS zone. */ + timezone: string; + range: components["schemas"]["HistoryRange"]; + /** @description One entry per entity of the park that has history, ascending by name. An entity with no history is ABSENT — never an entry of nulls — because "we hold nothing for this entity" is a different claim from "this entity recorded no change today". The park itself is included when it has history of its own. */ + entities: components["schemas"]["HistoryParkEntityRaw"][]; + /** @description Always null: a park history call covers one park-local day, so there is never a next page. */ + next: string | null; + }; + HistoryRange: { + /** @description The requested start. A park-local day comes back verbatim (YYYY-MM-DD); an instant comes back NORMALISED to UTC whole seconds (2026-09-13T14:00:00Z), so an offset or sub-second precision you sent is not echoed back. On a day-granular endpoint such as /history/daily this is ALWAYS a park-local day, even when you asked with an instant: that endpoint's rows are whole days and cannot be sliced finer, so echoing your instant back would claim a precision the data does not have. */ + from: string; + /** @description The requested end, in the same form as from, and normalised the same way. Omitted instants default to now; omitted days default to today, park-local. The same day-granular rule as from applies on /history/daily. */ + to: string; + }; + /** @description One row per instant at which any kind changed. Every present kind is carried forward, so a row is the complete live-data object at that instant (same keys, nesting and enum values as GET /v1/entity/{id}/live). */ + HistoryRow: { + /** + * Format: date-time + * @description UTC instant (whole seconds) from which this state is effective, until the next row's time. + */ + time: string; + /** @description Leaf paths that differ from the previous row (or from opening for the first row), e.g. queue.STANDBY.waitTime, status, showtimes. */ + changed: string[]; + status?: string | null; + queue?: components["schemas"]["LiveQueue"]; + showtimes?: components["schemas"]["LiveShowTime"][] | null; + }; LiveQueue: { STANDBY?: { /** @description Current standby wait time in minutes */ - waitTime?: number; + waitTime?: number | null; }; SINGLE_RIDER?: { /** @description Current single rider wait time in minutes */ @@ -366,16 +711,6 @@ export interface components { * @enum {string} */ SchedulePriceType: "ADMISSION" | "PACKAGE" | "ATTRACTION"; - TagData: { - /** @description Tag identifier */ - tag: string; - /** @description Human readable tag name */ - tagName: string; - /** @description Unique identifier */ - id?: string; - /** @description Tag value - can be string, number or object */ - value?: unknown; - }; }; responses: never; parameters: never; @@ -506,6 +841,179 @@ export interface operations { }; }; }; + getHistory: { + parameters: { + query?: { + date?: string; + from?: string; + to?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryEnvelope"] | components["schemas"]["HistoryParkRawEnvelope"]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorInvalidDate"] | components["schemas"]["HistoryErrorInvalidRange"] | components["schemas"]["HistoryErrorRangeTooLong"]; + }; + }; + /** @description Access forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorWindowExceeded"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorNotFound"]; + }; + }; + /** @description Too many requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorRateLimited"]; + }; + }; + /** @description Upstream unavailable */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorBackendUnavailable"]; + }; + }; + }; + }; + getHistoryCoverage: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryCoverageDocument"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorNotFound"]; + }; + }; + /** @description Too many requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorRateLimited"]; + }; + }; + }; + }; + getHistoryDaily: { + parameters: { + query?: { + date?: string; + from?: string; + to?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryDailyEnvelope"] | components["schemas"]["HistoryParkDailyEnvelope"]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorInvalidDate"] | components["schemas"]["HistoryErrorInvalidRange"] | components["schemas"]["HistoryErrorRangeTooLong"]; + }; + }; + /** @description Access forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorWindowExceeded"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorNotFound"]; + }; + }; + /** @description Too many requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HistoryErrorRateLimited"]; + }; + }; + }; + }; getEntityLiveData: { parameters: { query?: never; From 071c945c05bfbff110b0e4cf7db11ccc60c99312 Mon Sep 17 00:00:00 2001 From: BeyondVertical <17316492+BeyondVertical@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:58:32 +0200 Subject: [PATCH 2/3] feat: accept an API key and send it as X-API-Key The API takes a key in the `X-API-Key` header (the spec's ApiKeyAuth scheme) and the client had no way to send one, so a consumer holding a key had to wrap `fetch` to add the header by hand. Every endpoint still answers without a key, but the history endpoints size their window and their hourly budget by it: 7 days and 60 requests an hour anonymously, 30 days and 600 with a free key. `new ThemeParks({ apiKey })` passes the key to the transport, which sends the header on every request when the key is a non-empty string and no header at all otherwise. Nothing else changes: the cache key is still the path, and a client without a key sends the same two headers as before. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 13 +++++++++++++ README.md | 2 ++ src/client.ts | 6 ++++++ src/transport.ts | 5 +++++ test/unit/client.test.ts | 16 ++++++++++++++++ test/unit/transport.test.ts | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 74 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d16d53..6f0bfff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`apiKey` client option.** Sent as the `X-API-Key` header on every + request. Every endpoint still answers without one; a key raises the limits, + which matters for the history endpoints (30 days of history and 600 + requests an hour with a free key, against 7 days and 60 without). + + ```js + const tp = new ThemeParks({ apiKey: 'your-api-key' }); + ``` + ## [8.0.0] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 80e21be..1859946 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Jungle Cruise 40 min | ----------- | ----------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------- | | `baseUrl` | `string` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). | | `userAgent` | `string` | `themeparks-sdk-js/` | Sent as the `User-Agent` header. Set this to identify your app. | +| `apiKey` | `string` | none | API key from api.themeparks.wiki, sent as `X-API-Key`. Optional; a key raises the limits. | | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. Useful for logging, mocking, or older runtimes. | | `timeoutMs` | `number` | `10000` | Per-request timeout in milliseconds. | | `retry` | `Partial` | `{ max: 3, on429: true }` | Retry/backoff behavior. `max` counts retries **beyond** the initial attempt (so `3` = up to 4 total). | @@ -63,6 +64,7 @@ Example: ```js const tp = new ThemeParks({ userAgent: 'my-app/1.2.3 (+https://example.com)', + apiKey: 'your-api-key', timeoutMs: 15_000, retry: { max: 5, on429: true }, }); diff --git a/src/client.ts b/src/client.ts index 4c1b977..cc4d866 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,6 +11,11 @@ const DEFAULT_USER_AGENT = `themeparks-sdk-js/${PACKAGE_VERSION}`; export interface ThemeParksOptions { baseUrl?: string; userAgent?: string; + /** + * API key from https://api.themeparks.wiki, sent as the `X-API-Key` header. + * Optional: every endpoint answers without one; a key raises the limits. + */ + apiKey?: string; fetch?: FetchLike; timeoutMs?: number; retry?: Partial; @@ -35,6 +40,7 @@ export class ThemeParks { this.transport = new Transport({ baseUrl: this.#baseUrl, userAgent: options.userAgent ?? DEFAULT_USER_AGENT, + ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}), timeoutMs: options.timeoutMs ?? 10_000, retry: { max: options.retry?.max ?? 3, on429: options.retry?.on429 ?? true }, fetch: fetchFn, diff --git a/src/transport.ts b/src/transport.ts index 5ca7f29..49389c3 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -54,6 +54,8 @@ export interface RetryConfig { export interface TransportOptions { baseUrl: string; userAgent: string; + /** Sent as the `X-API-Key` header on every request when set. */ + apiKey?: string; timeoutMs: number; /** * Retry policy. See {@link RetryConfig} — `max` counts retries beyond the @@ -140,6 +142,9 @@ export class Transport { headers: { accept: 'application/json', 'user-agent': this.opts.userAgent, + ...(this.opts.apiKey !== undefined && this.opts.apiKey !== '' + ? { 'x-api-key': this.opts.apiKey } + : {}), }, signal: controller.signal, }); diff --git a/test/unit/client.test.ts b/test/unit/client.test.ts index d81d293..6690b22 100644 --- a/test/unit/client.test.ts +++ b/test/unit/client.test.ts @@ -31,6 +31,22 @@ describe('ThemeParks client', () => { ); }); + it('passes apiKey through to the transport', async () => { + const fetchFn = mockFetch({ destinations: [] }); + const tp = new ThemeParks({ fetch: fetchFn, apiKey: 'tpw_secret' }); + await tp.raw.getDestinations(); + const init = fetchFn.mock.calls[0]![1] as RequestInit; + expect((init.headers as Record)['x-api-key']).toBe('tpw_secret'); + }); + + it('sends no x-api-key header without apiKey', async () => { + const fetchFn = mockFetch({ destinations: [] }); + const tp = new ThemeParks({ fetch: fetchFn }); + await tp.raw.getDestinations(); + const init = fetchFn.mock.calls[0]![1] as RequestInit; + expect(init.headers).not.toHaveProperty('x-api-key'); + }); + it('caches /destinations by default for 1 hour', async () => { const fetchFn = mockFetch({ destinations: [] }); const tp = new ThemeParks({ fetch: fetchFn }); diff --git a/test/unit/transport.test.ts b/test/unit/transport.test.ts index c239af2..b5ed9bb 100644 --- a/test/unit/transport.test.ts +++ b/test/unit/transport.test.ts @@ -31,6 +31,38 @@ describe('Transport', () => { }); }); + it('sends the API key as x-api-key when set', async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + const t = new Transport({ + baseUrl: 'https://api.example/v1', + userAgent: 'test/1', + apiKey: 'tpw_secret', + timeoutMs: 1000, + retry: { max: 0, on429: true }, + fetch: fetchFn, + }); + await t.get('/destinations'); + const [, init] = fetchFn.mock.calls[0]!; + expect((init as RequestInit).headers).toMatchObject({ 'x-api-key': 'tpw_secret' }); + }); + + it('sends no x-api-key header when the key is unset or empty', async () => { + for (const apiKey of [undefined, '']) { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + const t = new Transport({ + baseUrl: 'https://api.example/v1', + userAgent: 'test/1', + ...(apiKey !== undefined ? { apiKey } : {}), + timeoutMs: 1000, + retry: { max: 0, on429: true }, + fetch: fetchFn, + }); + await t.get('/destinations'); + const [, init] = fetchFn.mock.calls[0]!; + expect(Object.keys((init as RequestInit).headers as object)).not.toContain('x-api-key'); + } + }); + it('throws ApiError on 4xx with body', async () => { const fetchFn = vi .fn() From 3a6eb0a25e310c88b872ec270b5902dde1f5c5fd Mon Sep 17 00:00:00 2001 From: BeyondVertical <17316492+BeyondVertical@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:02:16 +0200 Subject: [PATCH 3/3] feat: expose the history endpoints The API records every live-data change and serves it back on three paths the client did not cover: /entity/{id}/history (one row per change, the full live-data object at that instant plus `time` and `changed`), /history/daily (one row per park-local day with operating minutes and standby min/p50/p90/max/mean) and /history/coverage (which days and which fields are held). A consumer had to build the URLs and carry the types itself. `RawClient` gains `getEntityHistory`, `getEntityHistoryDaily` and `getEntityHistoryCoverage`; `tp.entity(id).history` mirrors them as `changes`, `daily` and `coverage`. The query type comes straight from the generated operation (`{ date }` or `{ from, to }`) and is sent through `URLSearchParams`: an instant such as `2026-09-17T10:00:00+02:00` travels as `%2B02%3A00`, where string concatenation would have turned the `+` into a space. The response types are the spec's unions: a PARK answers with `entities[]` for every entity in it, every other type for itself, and `'entities' in res` tells them apart. Cache: `coverage` for an hour, as its values are whole days; `changes` and `daily` uncached, since a range that holds today is not final. Tests use trimmed real responses for Magic Kingdom and The Barnstormer from 2026-09-17 and the real 403 body for a day outside the anonymous window. The live smoke suite gains coverage and today's history for Magic Kingdom. The README documents the park envelope, the key-dependent window and budget, and that a history 429's Retry-After can be most of an hour, which the default retry policy honours. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 21 +++ README.md | 78 +++++++- src/cache/ttls.ts | 2 + src/ergonomic/entity.ts | 27 ++- src/index.ts | 4 + src/raw.ts | 50 ++++- test/fixtures/barnstormer_history.json | 131 +++++++++++++ test/fixtures/history_window_exceeded.json | 7 + test/fixtures/mk_history_coverage.json | 21 +++ test/fixtures/mk_history_daily.json | 95 ++++++++++ test/fixtures/mk_history_park.json | 155 +++++++++++++++ test/live/smoke.test.ts | 11 ++ test/unit/cache.test.ts | 9 + test/unit/history.test.ts | 208 +++++++++++++++++++++ 14 files changed, 810 insertions(+), 9 deletions(-) create mode 100644 test/fixtures/barnstormer_history.json create mode 100644 test/fixtures/history_window_exceeded.json create mode 100644 test/fixtures/mk_history_coverage.json create mode 100644 test/fixtures/mk_history_daily.json create mode 100644 test/fixtures/mk_history_park.json create mode 100644 test/unit/history.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0bfff..11ccfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 const tp = new ThemeParks({ apiKey: 'your-api-key' }); ``` +- **History endpoints.** `tp.entity(id).history.changes(query)`, + `.daily(query)` and `.coverage()` over `GET /entity/{id}/history`, + `/history/daily` and `/history/coverage`, with `tp.raw.getEntityHistory`, + `getEntityHistoryDaily` and `getEntityHistoryCoverage` underneath. The + query is `{ date }` or `{ from, to }`, park-local days or RFC 3339 + instants, and is sent through `URLSearchParams`, so an instant's `+02:00` + offset survives the trip. A `PARK` answers with `entities[]` for every + entity in it; every other type answers for itself. New exported types: + `EntityHistory`, `EntityHistoryDaily`, `EntityHistoryCoverage`, + `HistoryQuery`. + + ```js + const day = await tp.entity(barnstormerId).history.changes({ date: '2026-09-17' }); + if (!('entities' in day)) { + for (const row of day.history) console.log(row.time, row.queue?.STANDBY?.waitTime); + } + ``` + + The default cache keeps `coverage` for an hour and leaves `changes` and + `daily` uncached, since a range that holds today is not final. + ## [8.0.0] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 1859946..0119a67 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,68 @@ const entries = await tp.entity(mk).schedule.range(new Date('2026-05-01'), new D console.log(`${entries.length} schedule entries`); ``` +## History + +Three endpoints answer what an entity did in the past. Days are park-local; the +range is one day (`date`) or `from`/`to` (days inclusive, or RFC 3339 instants +with `to` exclusive). Without a range the call means today. + +```js +import { ThemeParks } from 'themeparks'; + +const tp = new ThemeParks({ apiKey: 'your-api-key' }); +const barnstormer = tp.entity('924a3b2c-6b4b-49e5-99d3-e9dc3f2e8a48'); + +// Every recorded change of one day: the full live-data object per change, plus +// `time` and the `changed` paths. +const day = await barnstormer.history.changes({ date: '2026-09-17' }); +if (!('entities' in day)) { + console.log(`opened ${day.opening.status}, ${day.history.length} changes`); + for (const row of day.history) { + console.log(row.time, row.status, row.queue?.STANDBY?.waitTime ?? '--', row.changed.join(',')); + } +} + +// One row per day: operating and down minutes, standby min/p50/p90/max/mean. +const week = await barnstormer.history.daily({ from: '2026-09-11', to: '2026-09-17' }); +if (!('entities' in week)) { + for (const d of week.days) console.log(d.date, d.operatingMinutes, d.standby?.p50); +} + +// Which days, and which live-data fields, are held at all. +const coverage = await barnstormer.history.coverage(); +console.log(coverage.firstRecordedAt, coverage.retrievableThrough, Object.keys(coverage.kinds)); +``` + +Sample output of the first loop: + +``` +2026-09-17T12:30:25Z OPERATING 5 status,queue.STANDBY.waitTime,queue.RETURN_TIME.returnStart,queue.RETURN_TIME.returnEnd +2026-09-17T12:43:48Z OPERATING 10 queue.STANDBY.waitTime +``` + +Three things to know before polling these: + +- **A park answers for the whole park.** `changes` and `daily` on a `PARK` + return an `entities[]` array, one entry per entity with history, instead of + `history[]` / `days[]`; the range of a park is limited to one day for + `changes`. Every other entity type, a `DESTINATION` included, answers for + itself. Narrow with `'entities' in res`, as above. +- **The window and the budget depend on the key.** Anonymous callers see 7 + days back and 60 history requests an hour; a free key sees 30 days and 600. + A day outside the window is a 403 `ApiError` whose + `body.error.earliestAllowedDate` names the first day you may ask for. Over + the budget is a 429 whose `Retry-After` can be most of an hour; with the + default `retry.on429` the client sleeps that long before trying again, so a + poller that would rather fail fast passes `retry: { on429: false }` and reads + `err.retryAfterMs`. +- **Today is not final.** The default cache leaves `changes` and `daily` + uncached and keeps `coverage` for an hour. A completed day never changes, so + cache it yourself for as long as you like. + +`tp.raw.getEntityHistory(id, query)`, `getEntityHistoryDaily(id, query)` and +`getEntityHistoryCoverage(id)` are the underlying calls. + ## Low-level escape hatch Every ergonomic helper is built on top of `tp.raw`, which is a thin, typed 1:1 wrapper over the OpenAPI operations. Use it directly when you want the raw response shape: @@ -222,13 +284,15 @@ const tp = new ThemeParks({ The default client caches `GET` responses in-memory (LRU) with sensible per-endpoint TTLs: -| Endpoint | TTL | Rationale | -| ------------------------------------- | ---------- | --------------------------------- | -| `GET /destinations` | 1 hour | Directory rarely changes. | -| `GET /entity/{id}` | 1 hour | Entity metadata is static. | -| `GET /entity/{id}/children` | 1 hour | Park topology is stable. | -| `GET /entity/{id}/schedule[/yyyy/mm]` | 5 minutes | Schedules update but not rapidly. | -| `GET /entity/{id}/live` | 0 (bypass) | Live data is always fetched. | +| Endpoint | TTL | Rationale | +| ------------------------------------- | ---------- | ----------------------------------- | +| `GET /destinations` | 1 hour | Directory rarely changes. | +| `GET /entity/{id}` | 1 hour | Entity metadata is static. | +| `GET /entity/{id}/children` | 1 hour | Park topology is stable. | +| `GET /entity/{id}/schedule[/yyyy/mm]` | 5 minutes | Schedules update but not rapidly. | +| `GET /entity/{id}/live` | 0 (bypass) | Live data is always fetched. | +| `GET /entity/{id}/history/coverage` | 1 hour | Whole days; changes once a day. | +| `GET /entity/{id}/history[/daily]` | 0 (bypass) | A range holding today is not final. | ### Disable caching diff --git a/src/cache/ttls.ts b/src/cache/ttls.ts index d4fa1a2..46f445c 100644 --- a/src/cache/ttls.ts +++ b/src/cache/ttls.ts @@ -7,6 +7,8 @@ const FIVE_MIN = 5 * 60 * 1000; */ export function ttlForPath(path: string): number { if (/^\/entity\/[^/]+\/live$/.test(path)) return 0; + if (/^\/entity\/[^/]+\/history\/coverage$/.test(path)) return HOUR; + if (/^\/entity\/[^/]+\/history(\/daily)?(\?.*)?$/.test(path)) return 0; if (/^\/entity\/[^/]+\/schedule(\/\d+\/\d+)?$/.test(path)) return FIVE_MIN; if (/^\/entity\/[^/]+\/children$/.test(path)) return HOUR; if (/^\/entity\/[^/]+$/.test(path)) return HOUR; diff --git a/src/ergonomic/entity.ts b/src/ergonomic/entity.ts index 84f004d..4da2d2e 100644 --- a/src/ergonomic/entity.ts +++ b/src/ergonomic/entity.ts @@ -1,5 +1,15 @@ import type { components } from '../_generated/schema'; -import type { Entity, EntityChildren, EntityLive, EntitySchedule, RawClient } from '../raw'; +import type { + Entity, + EntityChildren, + EntityHistory, + EntityHistoryCoverage, + EntityHistoryDaily, + EntityLive, + EntitySchedule, + HistoryQuery, + RawClient, +} from '../raw'; export type EntityChild = components['schemas']['EntityChild']; @@ -11,8 +21,18 @@ export interface ScheduleApi { range(start: Date, end: Date): Promise; } +export interface HistoryApi { + /** Every recorded change in the range, one row per change: `GET /entity/{id}/history`. */ + changes(query?: HistoryQuery): Promise; + /** One row per park-local day with operating minutes and wait-time statistics: `GET /entity/{id}/history/daily`. */ + daily(query?: HistoryQuery): Promise; + /** Which days and which live-data fields are held: `GET /entity/{id}/history/coverage`. */ + coverage(): Promise; +} + export class EntityHandle { readonly schedule: ScheduleApi; + readonly history: HistoryApi; constructor( private readonly raw: RawClient, @@ -23,6 +43,11 @@ export class EntityHandle { month: (year, month) => this.raw.getEntityScheduleMonth(this.id, year, month), range: (start, end) => this.scheduleRange(start, end), }; + this.history = { + changes: (query) => this.raw.getEntityHistory(this.id, query), + daily: (query) => this.raw.getEntityHistoryDaily(this.id, query), + coverage: () => this.raw.getEntityHistoryCoverage(this.id), + }; } get(): Promise { diff --git a/src/index.ts b/src/index.ts index 0324aeb..698cbd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,12 @@ export { type Destinations, type Entity, type EntityChildren, + type EntityHistory, + type EntityHistoryCoverage, + type EntityHistoryDaily, type EntityLive, type EntitySchedule, + type HistoryQuery, } from './raw'; export { EntityHandle } from './ergonomic/entity'; export { DestinationsApi } from './ergonomic/destinations'; diff --git a/src/raw.ts b/src/raw.ts index a7437a1..9f8197b 100644 --- a/src/raw.ts +++ b/src/raw.ts @@ -1,4 +1,4 @@ -import type { components } from './_generated/schema'; +import type { components, operations } from './_generated/schema'; import type { Transport } from './transport'; export type Destinations = components['schemas']['DestinationsResponse']; @@ -7,6 +7,36 @@ export type EntityChildren = components['schemas']['EntityChildrenResponse']; export type EntityLive = components['schemas']['EntityLiveDataResponse']; export type EntitySchedule = components['schemas']['EntityScheduleResponse']; +/** + * Response of `/entity/{id}/history`. A PARK answers for every entity in it + * (`HistoryParkRawEnvelope`, with `entities[]`); any other entity type answers + * for itself (`HistoryEnvelope`, with `history[]`). Narrow on `'entities' in res`. + */ +export type EntityHistory = + | components['schemas']['HistoryEnvelope'] + | components['schemas']['HistoryParkRawEnvelope']; +export type EntityHistoryCoverage = components['schemas']['HistoryCoverageDocument']; +/** Response of `/entity/{id}/history/daily`; a PARK answers with `entities[]`, see {@link EntityHistory}. */ +export type EntityHistoryDaily = + | components['schemas']['HistoryDailyEnvelope'] + | components['schemas']['HistoryParkDailyEnvelope']; + +/** + * Range of a history request: one park-local day (`date`), or `from`/`to` + * as park-local days (both inclusive) or RFC 3339 instants (`to` exclusive). + * Empty means today. The same shape serves `/history` and `/history/daily`. + */ +export type HistoryQuery = NonNullable; + +function queryString(query: HistoryQuery): string { + const params = new URLSearchParams(); + for (const [name, value] of Object.entries(query)) { + if (value !== undefined) params.set(name, value); + } + const encoded = params.toString(); + return encoded === '' ? '' : `?${encoded}`; +} + export class RawClient { constructor(private readonly transport: Transport) {} @@ -36,4 +66,22 @@ export class RawClient { `/entity/${encodeURIComponent(entityId)}/schedule/${String(year)}/${paddedMonth}`, ); } + + getEntityHistory(entityId: string, query: HistoryQuery = {}): Promise { + return this.transport.get( + `/entity/${encodeURIComponent(entityId)}/history${queryString(query)}`, + ); + } + + getEntityHistoryCoverage(entityId: string): Promise { + return this.transport.get( + `/entity/${encodeURIComponent(entityId)}/history/coverage`, + ); + } + + getEntityHistoryDaily(entityId: string, query: HistoryQuery = {}): Promise { + return this.transport.get( + `/entity/${encodeURIComponent(entityId)}/history/daily${queryString(query)}`, + ); + } } diff --git a/test/fixtures/barnstormer_history.json b/test/fixtures/barnstormer_history.json new file mode 100644 index 0000000..b6071ab --- /dev/null +++ b/test/fixtures/barnstormer_history.json @@ -0,0 +1,131 @@ +{ + "id": "924a3b2c-6b4b-49e5-99d3-e9dc3f2e8a48", + "name": "The Barnstormer", + "entityType": "ATTRACTION", + "parentId": "75ea578a-adc8-4116-a54d-dccb60765ef9", + "destinationId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "timezone": "America/New_York", + "range": { + "from": "2026-09-17", + "to": "2026-09-17" + }, + "coverage": { + "firstRecordedAt": "2021-07-03" + }, + "opening": { + "time": "2026-09-17T04:00:00Z", + "status": "CLOSED", + "queue": { + "STANDBY": { + "waitTime": null + }, + "RETURN_TIME": { + "state": "FINISHED", + "returnStart": null, + "returnEnd": null + } + } + }, + "history": [ + { + "time": "2026-09-17T04:29:28Z", + "changed": [ + "queue.RETURN_TIME.state", + "queue.RETURN_TIME.returnStart", + "queue.RETURN_TIME.returnEnd" + ], + "status": "CLOSED", + "queue": { + "STANDBY": { + "waitTime": null + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T10:55:00-04:00", + "returnEnd": "2026-09-17T11:55:00-04:00" + } + } + }, + { + "time": "2026-09-17T12:30:25Z", + "changed": [ + "status", + "queue.STANDBY.waitTime", + "queue.RETURN_TIME.returnStart", + "queue.RETURN_TIME.returnEnd" + ], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T11:05:00-04:00", + "returnEnd": "2026-09-17T12:05:00-04:00" + } + } + }, + { + "time": "2026-09-17T12:34:53Z", + "changed": ["queue.RETURN_TIME.returnStart", "queue.RETURN_TIME.returnEnd"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T11:00:00-04:00", + "returnEnd": "2026-09-17T12:00:00-04:00" + } + } + }, + { + "time": "2026-09-17T12:39:58Z", + "changed": ["queue.RETURN_TIME.returnStart", "queue.RETURN_TIME.returnEnd"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T09:55:00-04:00", + "returnEnd": "2026-09-17T10:55:00-04:00" + } + } + }, + { + "time": "2026-09-17T12:45:04Z", + "changed": ["queue.RETURN_TIME.returnStart", "queue.RETURN_TIME.returnEnd"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T10:15:00-04:00", + "returnEnd": "2026-09-17T11:15:00-04:00" + } + } + }, + { + "time": "2026-09-17T12:50:23Z", + "changed": ["queue.RETURN_TIME.returnStart", "queue.RETURN_TIME.returnEnd"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T09:40:00-04:00", + "returnEnd": "2026-09-17T10:40:00-04:00" + } + } + } + ], + "next": null +} diff --git a/test/fixtures/history_window_exceeded.json b/test/fixtures/history_window_exceeded.json new file mode 100644 index 0000000..2a4970e --- /dev/null +++ b/test/fixtures/history_window_exceeded.json @@ -0,0 +1,7 @@ +{ + "error": { + "type": "HISTORY_WINDOW_EXCEEDED", + "message": "Requests without an API key can see history back to 2026-09-12 (7 days).", + "earliestAllowedDate": "2026-09-12" + } +} diff --git a/test/fixtures/mk_history_coverage.json b/test/fixtures/mk_history_coverage.json new file mode 100644 index 0000000..f202dbb --- /dev/null +++ b/test/fixtures/mk_history_coverage.json @@ -0,0 +1,21 @@ +{ + "id": "75ea578a-adc8-4116-a54d-dccb60765ef9", + "name": "Magic Kingdom Park", + "entityType": "PARK", + "parentId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "destinationId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "timezone": "America/New_York", + "firstRecordedAt": "2024-05-14", + "lastRecordedAt": "2024-05-14", + "retrievableThrough": "2024-05-14", + "kinds": { + "status": { + "first": "2024-05-14", + "last": "2024-05-14" + }, + "queue.STANDBY": { + "first": "2024-05-14", + "last": "2024-05-14" + } + } +} diff --git a/test/fixtures/mk_history_daily.json b/test/fixtures/mk_history_daily.json new file mode 100644 index 0000000..f64fe73 --- /dev/null +++ b/test/fixtures/mk_history_daily.json @@ -0,0 +1,95 @@ +{ + "id": "75ea578a-adc8-4116-a54d-dccb60765ef9", + "name": "Magic Kingdom Park", + "entityType": "PARK", + "parentId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "destinationId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "timezone": "America/New_York", + "range": { + "from": "2026-09-12", + "to": "2026-09-17" + }, + "entities": [ + { + "id": "d9d12438-d999-4482-894b-8955fdb20ccf", + "name": "Astro Orbiter", + "entityType": "ATTRACTION", + "coverage": { + "firstRecordedAt": "2021-07-03" + }, + "days": [ + { + "date": "2026-09-12", + "firstOperatingAt": "2026-09-12T04:00:00Z", + "lastClosedAt": "2026-09-13T03:00:22Z", + "operatingMinutes": 931, + "downMinutes": 0, + "standby": { + "max": 25, + "min": 5, + "p50": 15, + "p90": 20, + "mean": 13 + }, + "changes": 53 + }, + { + "date": "2026-09-13", + "firstOperatingAt": "2026-09-13T11:31:35Z", + "lastClosedAt": "2026-09-13T22:00:36Z", + "operatingMinutes": 867, + "downMinutes": 91, + "standby": { + "max": 20, + "min": 5, + "p50": 5, + "p90": 10, + "mean": 6 + }, + "changes": 21 + } + ] + }, + { + "id": "de3309ca-97d5-4211-bffe-739fed47e92f", + "name": "Big Thunder Mountain Railroad", + "entityType": "ATTRACTION", + "coverage": { + "firstRecordedAt": "2021-07-03" + }, + "days": [ + { + "date": "2026-09-12", + "firstOperatingAt": "2026-09-12T04:00:00Z", + "lastClosedAt": "2026-09-13T03:00:22Z", + "operatingMinutes": 723, + "downMinutes": 177, + "standby": { + "max": 70, + "min": 5, + "p50": 40, + "p90": 60, + "mean": 39 + }, + "changes": 101 + }, + { + "date": "2026-09-13", + "firstOperatingAt": "2026-09-13T12:01:34Z", + "lastClosedAt": "2026-09-13T22:00:36Z", + "operatingMinutes": 787, + "downMinutes": 141, + "standby": { + "max": 50, + "min": 5, + "p50": 25, + "p90": 35, + "mean": 23 + }, + "changes": 92 + } + ] + } + ], + "next": null +} diff --git a/test/fixtures/mk_history_park.json b/test/fixtures/mk_history_park.json new file mode 100644 index 0000000..0fb545b --- /dev/null +++ b/test/fixtures/mk_history_park.json @@ -0,0 +1,155 @@ +{ + "id": "75ea578a-adc8-4116-a54d-dccb60765ef9", + "name": "Magic Kingdom Park", + "entityType": "PARK", + "parentId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "destinationId": "e957da41-3552-4cf6-b636-5babc5cbc4e5", + "timezone": "America/New_York", + "range": { + "from": "2026-09-17", + "to": "2026-09-17" + }, + "entities": [ + { + "id": "d9d12438-d999-4482-894b-8955fdb20ccf", + "name": "Astro Orbiter", + "entityType": "ATTRACTION", + "coverage": { + "firstRecordedAt": "2021-07-03" + }, + "opening": { + "time": "2026-09-17T04:00:00Z", + "status": "CLOSED", + "queue": { + "STANDBY": { + "waitTime": null + } + } + }, + "history": [ + { + "time": "2026-09-17T12:30:25Z", + "changed": ["status", "queue.STANDBY.waitTime"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + } + } + }, + { + "time": "2026-09-17T12:58:22Z", + "changed": ["queue.STANDBY.waitTime"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 10 + } + } + }, + { + "time": "2026-09-17T12:59:33Z", + "changed": ["queue.STANDBY.waitTime"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + } + } + } + ] + }, + { + "id": "e847a8bd-7d21-432b-a7a1-f483517a22b5", + "name": "Be Our Guest Restaurant", + "entityType": "RESTAURANT", + "coverage": { + "firstRecordedAt": "2021-07-03" + }, + "opening": { + "time": "2026-09-17T04:00:00Z", + "status": "OPERATING" + }, + "history": [] + }, + { + "id": "de3309ca-97d5-4211-bffe-739fed47e92f", + "name": "Big Thunder Mountain Railroad", + "entityType": "ATTRACTION", + "coverage": { + "firstRecordedAt": "2021-07-03" + }, + "opening": { + "time": "2026-09-17T04:00:00Z", + "status": "CLOSED", + "queue": { + "STANDBY": { + "waitTime": null + }, + "RETURN_TIME": { + "state": "FINISHED", + "returnStart": null, + "returnEnd": null + } + } + }, + "history": [ + { + "time": "2026-09-17T04:29:29Z", + "changed": [ + "queue.RETURN_TIME.state", + "queue.RETURN_TIME.returnStart", + "queue.RETURN_TIME.returnEnd" + ], + "status": "CLOSED", + "queue": { + "STANDBY": { + "waitTime": null + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T15:35:00-04:00", + "returnEnd": "2026-09-17T16:35:00-04:00" + } + } + }, + { + "time": "2026-09-17T13:01:29Z", + "changed": [ + "status", + "queue.STANDBY.waitTime", + "queue.RETURN_TIME.returnStart", + "queue.RETURN_TIME.returnEnd" + ], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T11:10:00-04:00", + "returnEnd": "2026-09-17T12:10:00-04:00" + } + } + }, + { + "time": "2026-09-17T13:05:54Z", + "changed": ["queue.RETURN_TIME.returnStart", "queue.RETURN_TIME.returnEnd"], + "status": "OPERATING", + "queue": { + "STANDBY": { + "waitTime": 5 + }, + "RETURN_TIME": { + "state": "AVAILABLE", + "returnStart": "2026-09-17T11:20:00-04:00", + "returnEnd": "2026-09-17T12:20:00-04:00" + } + } + } + ] + } + ], + "next": null +} diff --git a/test/live/smoke.test.ts b/test/live/smoke.test.ts index 6fd032f..267c5ee 100644 --- a/test/live/smoke.test.ts +++ b/test/live/smoke.test.ts @@ -27,4 +27,15 @@ describe('live smoke tests against api.themeparks.wiki', () => { const s = await tp.entity(DLP_ID).schedule.upcoming(); expect(Array.isArray(s.schedule)).toBe(true); }); + + it('Magic Kingdom history coverage parses', async () => { + const c = await tp.entity(MK_ID).history.coverage(); + expect(c.timezone).toBe('America/New_York'); + expect(typeof c.kinds).toBe('object'); + }); + + it("Magic Kingdom today's history answers for the whole park", async () => { + const h = await tp.entity(MK_ID).history.changes(); + expect('entities' in h && Array.isArray(h.entities)).toBe(true); + }); }); diff --git a/test/unit/cache.test.ts b/test/unit/cache.test.ts index e1e1d56..797bc04 100644 --- a/test/unit/cache.test.ts +++ b/test/unit/cache.test.ts @@ -20,6 +20,15 @@ describe('ttlForPath', () => { it('returns 0 (bypass) for /entity/{id}/live', () => { expect(ttlForPath('/entity/abc-123/live')).toBe(0); }); + it('returns 1h for /entity/{id}/history/coverage', () => { + expect(ttlForPath('/entity/abc-123/history/coverage')).toBe(3_600_000); + }); + it('returns 0 (bypass) for /entity/{id}/history with or without a query', () => { + expect(ttlForPath('/entity/abc-123/history')).toBe(0); + expect(ttlForPath('/entity/abc-123/history?date=2026-09-17')).toBe(0); + expect(ttlForPath('/entity/abc-123/history/daily')).toBe(0); + expect(ttlForPath('/entity/abc-123/history/daily?from=2026-09-12&to=2026-09-17')).toBe(0); + }); }); describe('InMemoryLruCache', () => { diff --git a/test/unit/history.test.ts b/test/unit/history.test.ts new file mode 100644 index 0000000..2fbe38d --- /dev/null +++ b/test/unit/history.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { ThemeParks } from '../../src/client'; +import { ApiError } from '../../src/errors'; +import { RawClient } from '../../src/raw'; +import { Transport } from '../../src/transport'; + +// Fixtures are real responses from api.themeparks.wiki for Magic Kingdom and +// The Barnstormer, cut down to a few rows and entities. +async function loadFixture(name: string): Promise { + return JSON.parse(await readFile(resolve(__dirname, '../fixtures', name), 'utf8')); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function transportReturning(body: unknown, status = 200): Transport { + return new Transport({ + baseUrl: 'https://api.example/v1', + userAgent: 't/1', + timeoutMs: 1000, + retry: { max: 0, on429: false }, + fetch: vi.fn().mockImplementation(() => Promise.resolve(jsonResponse(body, status))), + }); +} + +describe('RawClient history paths', () => { + it('getEntityHistory without a query calls /entity/{id}/history', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + await new RawClient(transport).getEntityHistory('abc-123'); + expect(spy).toHaveBeenCalledWith('/entity/abc-123/history'); + }); + + it('getEntityHistory passes a day as ?date=', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + await new RawClient(transport).getEntityHistory('abc-123', { date: '2026-09-17' }); + expect(spy).toHaveBeenCalledWith('/entity/abc-123/history?date=2026-09-17'); + }); + + it('getEntityHistory passes a range as ?from=&to=', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + await new RawClient(transport).getEntityHistory('abc-123', { + from: '2026-09-01', + to: '2026-09-17', + }); + expect(spy).toHaveBeenCalledWith('/entity/abc-123/history?from=2026-09-01&to=2026-09-17'); + }); + + it('getEntityHistory encodes the offset of an RFC 3339 instant', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + await new RawClient(transport).getEntityHistory('abc-123', { + from: '2026-09-17T10:00:00+02:00', + to: '2026-09-17T12:00:00+02:00', + }); + // A bare '+' in a query string is a space; it has to travel as %2B. + expect(spy).toHaveBeenCalledWith( + '/entity/abc-123/history?from=2026-09-17T10%3A00%3A00%2B02%3A00&to=2026-09-17T12%3A00%3A00%2B02%3A00', + ); + }); + + it('getEntityHistoryCoverage calls /entity/{id}/history/coverage', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + await new RawClient(transport).getEntityHistoryCoverage('abc-123'); + expect(spy).toHaveBeenCalledWith('/entity/abc-123/history/coverage'); + }); + + it('getEntityHistoryDaily calls /entity/{id}/history/daily with the same query shape', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + const raw = new RawClient(transport); + await raw.getEntityHistoryDaily('abc-123'); + await raw.getEntityHistoryDaily('abc-123', { from: '2026-09-12', to: '2026-09-17' }); + expect(spy).toHaveBeenNthCalledWith(1, '/entity/abc-123/history/daily'); + expect(spy).toHaveBeenNthCalledWith( + 2, + '/entity/abc-123/history/daily?from=2026-09-12&to=2026-09-17', + ); + }); + + it('url-encodes the entity id ahead of the query', async () => { + const transport = transportReturning({}); + const spy = vi.spyOn(transport, 'get'); + await new RawClient(transport).getEntityHistory('a/b', { date: '2026-09-17' }); + expect(spy).toHaveBeenCalledWith('/entity/a%2Fb/history?date=2026-09-17'); + }); +}); + +describe('history responses', () => { + it('an attraction answers with its own rows', async () => { + const fixture = await loadFixture('barnstormer_history.json'); + const raw = new RawClient(transportReturning(fixture)); + const res = await raw.getEntityHistory('924a3b2c-6b4b-49e5-99d3-e9dc3f2e8a48', { + date: '2026-09-17', + }); + expect(res).toEqual(fixture); + if ('entities' in res) throw new Error('expected a single-entity envelope'); + expect(res.entityType).toBe('ATTRACTION'); + expect(res.range).toEqual({ from: '2026-09-17', to: '2026-09-17' }); + expect(res.opening.status).toBe('CLOSED'); + expect(res.history[1]?.changed).toContain('queue.STANDBY.waitTime'); + expect(res.history[1]?.queue?.STANDBY?.waitTime).toBe(5); + }); + + it('a park answers for every entity in it', async () => { + const fixture = await loadFixture('mk_history_park.json'); + const raw = new RawClient(transportReturning(fixture)); + const res = await raw.getEntityHistory('75ea578a-adc8-4116-a54d-dccb60765ef9', { + date: '2026-09-17', + }); + if (!('entities' in res)) throw new Error('expected a park envelope'); + expect(res.entityType).toBe('PARK'); + expect(res.entities.map((e) => e.name)).toEqual([ + 'Astro Orbiter', + 'Be Our Guest Restaurant', + 'Big Thunder Mountain Railroad', + ]); + // Present with no change in the range, which is not the same as absent. + expect(res.entities[1]?.history).toEqual([]); + }); + + it('daily statistics of a park', async () => { + const fixture = await loadFixture('mk_history_daily.json'); + const raw = new RawClient(transportReturning(fixture)); + const res = await raw.getEntityHistoryDaily('75ea578a-adc8-4116-a54d-dccb60765ef9', { + from: '2026-09-12', + to: '2026-09-17', + }); + if (!('entities' in res)) throw new Error('expected a park envelope'); + const thunder = res.entities.find((e) => e.name === 'Big Thunder Mountain Railroad'); + expect(thunder?.days[0]).toMatchObject({ + date: '2026-09-12', + operatingMinutes: expect.any(Number), + standby: { min: 5, p50: 40, p90: 60, max: 70, mean: 39 }, + }); + }); + + it('coverage names the recorded days per field', async () => { + const fixture = await loadFixture('mk_history_coverage.json'); + const raw = new RawClient(transportReturning(fixture)); + const res = await raw.getEntityHistoryCoverage('75ea578a-adc8-4116-a54d-dccb60765ef9'); + expect(res.timezone).toBe('America/New_York'); + expect(res.kinds['status']).toEqual({ first: '2024-05-14', last: '2024-05-14' }); + }); + + it('a day outside the window is an ApiError carrying the earliest allowed date', async () => { + const fixture = await loadFixture('history_window_exceeded.json'); + const raw = new RawClient(transportReturning(fixture, 403)); + const err = await raw + .getEntityHistory('75ea578a-adc8-4116-a54d-dccb60765ef9', { from: '2026-09-10' }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(403); + expect((err as ApiError).body).toMatchObject({ + error: { type: 'HISTORY_WINDOW_EXCEEDED', earliestAllowedDate: '2026-09-12' }, + }); + }); +}); + +describe('EntityHandle.history', () => { + function client(body: unknown) { + const fetchFn = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse(body))); + return { tp: new ThemeParks({ fetch: fetchFn, cache: false }), fetchFn }; + } + + it('.changes() calls /entity/{id}/history with the query', async () => { + const { tp, fetchFn } = client({ history: [] }); + await tp.entity('abc').history.changes({ date: '2026-09-17' }); + expect(fetchFn.mock.calls[0]![0]).toBe( + 'https://api.themeparks.wiki/v1/entity/abc/history?date=2026-09-17', + ); + }); + + it('.daily() calls /entity/{id}/history/daily', async () => { + const { tp, fetchFn } = client({ days: [] }); + await tp.entity('abc').history.daily(); + expect(fetchFn.mock.calls[0]![0]).toBe( + 'https://api.themeparks.wiki/v1/entity/abc/history/daily', + ); + }); + + it('.coverage() calls /entity/{id}/history/coverage', async () => { + const { tp, fetchFn } = client({ kinds: {} }); + await tp.entity('abc').history.coverage(); + expect(fetchFn.mock.calls[0]![0]).toBe( + 'https://api.themeparks.wiki/v1/entity/abc/history/coverage', + ); + }); + + it('coverage is cached for an hour, changes are not cached', async () => { + const fetchFn = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({}))); + const tp = new ThemeParks({ fetch: fetchFn }); + await tp.entity('abc').history.coverage(); + await tp.entity('abc').history.coverage(); + await tp.entity('abc').history.changes({ date: '2026-09-17' }); + await tp.entity('abc').history.changes({ date: '2026-09-17' }); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); +});