Skip to content

feat: history endpoints and an API key option - #45

Merged
cubehouse merged 3 commits into
ThemeParks:mainfrom
BeyondVertical:feat/history-and-api-key
Sep 23, 2026
Merged

cubehouse merged 3 commits into
ThemeParks:mainfrom
BeyondVertical:feat/history-and-api-key

Conversation

@BeyondVertical

Copy link
Copy Markdown
Contributor

Summary

Three commits:

  1. chore: regenerate schema from upstream spec. Same three paths as chore: regenerate schema from upstream spec #41, with the spec's current wording, which has moved on since that branch was cut. If chore: regenerate schema from upstream spec #41 merges first this commit becomes empty on rebase.
  2. feat: accept an API key and send it as X-API-Key.
  3. feat: expose the history endpoints.

API key

The spec's ApiKeyAuth scheme is the X-API-Key header, and the client had no way to send one short of wrapping fetch. Every endpoint still answers without a key, but the history endpoints size their window and hourly budget by it: 7 days and 60 requests an hour anonymously, 30 days and 600 with a free key.

const tp = new ThemeParks({ apiKey: 'your-api-key' });

The header is sent when the key is a non-empty string and not at all otherwise; a client without a key sends the same two headers as before. The cache key is still the path.

History

call path answers
tp.entity(id).history.changes(query) GET /entity/{id}/history one row per change: the live-data object plus time, changed
tp.entity(id).history.daily(query) GET /entity/{id}/history/daily one row per park-local day: operating minutes, standby stats
tp.entity(id).history.coverage() GET /entity/{id}/history/coverage which days and which live-data fields are held

tp.raw.getEntityHistory, getEntityHistoryDaily and getEntityHistoryCoverage sit underneath. The query type is the generated operation's own ({ date } or { from, to }), so it cannot drift from the spec, and it is sent through URLSearchParams:

{ from: '2026-09-17T10:00:00+02:00', to: '2026-09-17T12:00:00+02:00' }
→ /entity/{id}/history?from=2026-09-17T10%3A00%3A00%2B02%3A00&to=…

A bare + in a query string is a space, so concatenation would have shifted every instant with a positive offset by its own offset.

The response types are the spec's unions. A PARK answers with entities[] for every entity in it; every other entity type, a DESTINATION included, answers for itself with history[] / days[]. 'entities' in res narrows them, and the README shows that.

Cache policy: coverage for an hour, as its values are whole days; changes and daily uncached, since a range that holds today is not final. Documented in the TTL table.

Errors surface as they do elsewhere: a day outside the window is a 403 ApiError whose body.error.earliestAllowedDate names the first day allowed; over the hourly budget is a 429 RateLimitError. The README notes that the budget's Retry-After can be most of an hour and that the default retry.on429 honours it, and shows retry: { on429: false } for a poller that would rather fail fast.

Fixtures

Real responses from 2026-09-17, trimmed to a few rows and entities: The Barnstormer's day (history[]), Magic Kingdom's day (entities[], including a restaurant with history: [], present but unchanged), Magic Kingdom's daily statistics, Magic Kingdom's coverage, and the real 403 body for a day outside the anonymous window.

Test plan

  • npm test: 86 unit tests across 12 files (64 on main): 4 for the key, 18 for history (paths, query encoding including the + case, the four fixtures, the 403 body, the entity handle, the cache policy)
  • npm run test:live: 6 passed, including the two new cases (Magic Kingdom coverage; today's history answers for the whole park)
  • npm run lint, npx prettier --check ., npm run typecheck, npm run build
  • The README example compiled under the repository's strict settings before it went in

Not in this PR

  • Stitching a range longer than 31 days out of several calls, the way schedule.range() stitches months.
  • Dedicated error classes for HISTORY_WINDOW_EXCEEDED / HISTORY_RATE_LIMITED; the typed bodies are on err.body.
  • Capping how long the transport sleeps on a Retry-After.
  • Unrelated observation: PACKAGE_VERSION in src/client.ts is still 7.0.0-alpha.0, so the default user agent reads themeparks-sdk-js/7.0.0-alpha.0 on 8.0.0. Happy to send a one-line PR.

🤖 Generated with Claude Code

BeyondVertical and others added 3 commits September 18, 2026 16:57
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@cubehouse

Copy link
Copy Markdown
Member

Reviewed and merging. Thank you for this, particularly the cache TTLs and the URLSearchParams point about a bare + in an offset, which is a genuinely easy one to get wrong.

Two things I found that I will fix in a follow-up rather than hold this up for.

EntityHistoryCoverage needs to be a union. /history/coverage answers a PARK with HistoryParkCoverageDocument, the same way /history and /history/daily do, and the type here names only HistoryCoverageDocument. The two shapes do not overlap where it counts: a park carries summary and fields, an entity carries firstRecordedAt, lastRecordedAt and kinds. So a TypeScript user reads .kinds off a park's coverage, gets undefined at runtime, and the compiler says nothing.

Checked against production just now for Magic Kingdom:

keys = destinationId, entities, entityType, fields, id, name, parentId, summary, timezone

The live smoke test will fail on the next drift run for the same reason:

const c = await tp.entity(MK_ID).history.coverage();
expect(typeof c.kinds).toBe('object');   // 'undefined' - MK is a PARK

CI did not catch it because the live suite only runs in the drift workflow. I will widen the type, branch the assertion, and add a park fixture alongside your entity one.

The follow-up also adds what sits above these calls: following next on a paged park daily response, flattening a park envelope to the same row stream as an entity one, and not sleeping through a history 429 (that budget is hourly, so honouring Retry-After three times can park the process for hours).

@cubehouse
cubehouse merged commit f20ad62 into ThemeParks:main Sep 23, 2026
3 checks passed
cubehouse added a commit that referenced this pull request Sep 23, 2026
The announced version was a literal and it had drifted: 7.0.0-alpha.0 in a
package at 8.0.0, so every request the SDK made named a version a major old and
nothing anywhere failed. The Python sibling had the same bug and was two majors
out, which is what made it worth fixing structurally rather than by hand.

A gate test now asserts the User-Agent the server actually receives carries the
version package.json declares. Checking the header rather than the constant
means a correct constant wired up wrongly fails too.

8.1.0 rather than 9.0.0: the only shape that changed, EntityHistoryCoverage
widening to a union, has never been published. 8.0.0 shipped before #45 merged,
so no consumer can be relying on the narrower type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cubehouse added a commit that referenced this pull request Sep 23, 2026
* feat: the loop above the history calls

#45 exposed the three history endpoints. This adds what a backfill needs on
top of them, and fixes two things in the layer underneath.

span() returns archiveFrom, recordedTo and retrievableThrough in one shape.
The coverage documents do not: a park nests them under summary, an entity
carries them at the top level under different names, so every caller writes
that branch before their first question. retrievableThrough is the end date to
bound a backfill by, because it is what the key may read rather than what the
archive holds, and those differ on every plan below the top one.

days() pages until the server stops offering a next, follows that URL verbatim
rather than re-deriving it, and yields { entityId, row } as rows arrive instead
of collecting them. A park's daily call is the one paged call in the family, so
a park backfill previously stopped at the first 31 days without saying so.
changeRows() does the same for changes(). Both flatten a park envelope and an
entity envelope to the same stream, so the caller writes one loop.

BudgetExhaustedError carries retryAfterMs, so a run that hits the hourly budget
can checkpoint and come back.

Two fixes underneath:

- A 429 could park the client for hours. The transport honoured any
  Retry-After up to retry.max times, which is right for a REST 429 asking for
  seconds and wrong for a history one: that budget is hourly, so three waits is
  about two and a half hours of a silent process. RetryConfig gains
  maxRetryAfterMs, 120000 by default; past it the client does not sleep and
  throws RateLimitError with retryAfterMs set.

- EntityHistoryCoverage was missing the park shape. /history/coverage answers a
  PARK with HistoryParkCoverageDocument, exactly as /history and /history/daily
  do. A TypeScript user read .kinds off a park's coverage, got undefined, and
  the compiler said nothing. The fixture covering it was hand-written in the
  entity shape and named after a park, so it agreed with the code for the same
  reason the code was wrong; both coverage fixtures are now captured from
  production, and the live smoke test asserts the shape it actually gets.

Also: the user agent announced 7.0.0-alpha.0 from a package at 8.0.0, and the
schema is regenerated against the current spec, which supersedes #41.

examples/backfill.mjs is the whole thing end to end, with resume and CSV. Run
against production it pulled Disneyland Resort's entire daily archive, 98,452
rows, and matched the Python SDK's output row for row on every completed day;
the only rows that differed were today's, where operatingMinutes had grown by
the elapsed time between the two runs, which is what the spec says it does.

99 unit tests, 12 of them new. tsc, eslint, prettier and the build are clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* release: 8.1.0, and stop the version drifting again

The announced version was a literal and it had drifted: 7.0.0-alpha.0 in a
package at 8.0.0, so every request the SDK made named a version a major old and
nothing anywhere failed. The Python sibling had the same bug and was two majors
out, which is what made it worth fixing structurally rather than by hand.

A gate test now asserts the User-Agent the server actually receives carries the
version package.json declares. Checking the header rather than the constant
means a correct constant wired up wrongly fails too.

8.1.0 rather than 9.0.0: the only shape that changed, EntityHistoryCoverage
widening to a union, has never been published. 8.0.0 shipped before #45 merged,
so no consumer can be relying on the narrower type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BeyondVertical
BeyondVertical deleted the feat/history-and-api-key branch September 23, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants