All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
A minor release: the additions below are backwards
compatible at runtime. One is not quite so at the type level: HistorySpan
gains a required field, finalThrough, so code that builds a HistorySpan
object by hand (a test double, say) needs to add it. Code that only reads the
spans span() returns is unaffected.
-
themeparks-backfill --since YYYY-MM-DDand--until YYYY-MM-DD. There was no way to ask for less than everything:--sincewas an unknown option, so a key that reaches the whole archive downloaded all of it, every time. Both days are inclusive and must be real calendar days writtenYYYY-MM-DD(2025-02-30,2025-1-1and20250101are refused), and--sinceafter--untilis refused before anything is requested.--sinceapplies when a file is started. A later run accepts the same--sinceor a later one, so a fixed or a rolling cron line both work, and refuses one earlier than the day the file was started from, or one that would leave a gap, with what to do instead of quietly handing back a file that is not what was asked for. -
history.changeRows()exposes theopeningstate. The raw history response carries, per entity, the state in force at the start of the range, andchangeRows()threw it away. Without it the time between midnight and an entity's first change had no known status, so a day rebuilt from raw history disagreed with the daily summary whenever a ride was still running from the night before. The result is still the same async generator and yields exactly what it always did; it now also hasopening, an object ofHistoryOpeningkeyed by entity id, covering every entity in the response, including one that did not change all day. It is readable once the response has arrived: iterate first, orawait changes.load(). Either way it is one request. It is not enumerable, so spreading or logging the result is safe, and after a failed request it says so. TheHistoryChangesandHistoryOpeningtypes are exported, and the README explainsopening.degraded. -
HistorySpan.finalThrough: the newest day the archive has recorded that the key may read, the earlier ofrecordedToandretrievableThrough: the place to stop if you fetch each day once. -
An interrupted
themeparks-backfillnever appends a day twice. The state file is written before the first request and after every page, atomically, with the size of the data file at that moment; the next run first cuts the file back to that size. Ctrl-C, SIGTERM (now exit 143, as well as Ctrl-C's 130) and a kill at any point cost at most the page in flight. Two runs on the same park and--outat once are refused. -
days()awaitsonPage. A hook that returns a promise holds the next page until it settles, so a checkpoint written there is on disk before the next request. The hook's type is widened to returnunknown, so existing callbacks still compile.
-
A finished park now updates on the next run. A rerun printed
already completeand exited 0 without fetching a single new day, so a nightly cron looked healthy and never updated; the only way to get yesterday was--overwrite, which downloads the whole archive again. A finished file is now carried forward from the day after its last one, appending only the new days. An interrupted run still resumes at its page boundary, including a rerun interrupted before its first page, which would otherwise have started again from the top of the file's range. -
The newest rows of a backfill were partial days, and stayed that way. A run ended on
retrievableThrough, which is usually today: today's row is the day so far, and the archive records days 2 to 3 behind live data, so the last few days of every file were still changing when they were written. A run now ends atfinalThrough, says so when it holds days back, and the next run adds them once they are final. Each day is fetched once, as the archive recorded it; the README says how to fetch a range again if the archive later re-records it.Files written by 8.3.x are corrected once. Their state file does not say which of their newest days were final, so the first run of this version removes the rows dated within seven days of that run's end and fetches those days again. Every other row is left byte for byte as it was, and a file whose newest row is older than that is not rewritten at all. A file that lies wholly inside those seven days, as an anonymous run's does, is downloaded again. The state file format moves to version 2 for this; version 1 files from this SDK are upgraded, not refused.
-
A continued file no longer jumps forward to the key's first day. When the day a file continues from is older than the key may read (a cron that did not run for longer than the key's window, or a key that lost its plan), the run used to carry on from the key's first day and leave a gap the file then did not record. It is refused now, the file is left alone, and the message says how to start again.
-
A finished file written to a different column layout was appended to. Only an unfinished one was refused. A finished one fell through to a fresh start, which opened the existing file in append mode and wrote the whole archive into it a second time under a second header, exit 0. It is refused now, the same way.
-
An interrupted nightly extension appended the same days twice. The state was written only at the end of a run or on an error the command caught, so Ctrl-C, SIGTERM or a kill during an extension left it saying finished through the old day, and the rerun appended those days again. See the checkpointing above.
-
A run that stopped part-way through its first page lost days. It resumed from the newest day any entity had reached; rows arrive entity by entity, so the entities behind it lost the days in between. Checkpoints make this impossible for new files, and an older state with only that day goes back a whole page (31 days) instead.
-
The state recorded the start asked for, not the first day written.
startis now the first day the file covers, after the key's window, and a newsincefield keeps the start that was asked for, so the same--sincekeeps working and a different one before the file is refused. -
The anonymous-access notice promised 7 days. Only final days are written, so an anonymous run writes usually 4 or 5 days per park, and the notice now says so.
-
A state file whose data file had been deleted was continued, producing a file that started part-way through its range and was then recorded as complete. The park is downloaded again from the start instead.
-
A run with no API key now says so when it finishes, not only when it starts. Without a key the command SUCCEEDS: it reads the 7 days anonymous access allows, writes 433 rows of Magic Kingdom instead of about 94,000, and exits 0. The notice was printed before a run that takes minutes, so it scrolled away, and the last thing on screen was
done: 433 rows— which for someone who has just paid for 400 days is indistinguishable from success. There is a file, there is no error, and the number means nothing unless you already know what it should have been.The README and
--helpnowexport THEMEPARKS_API_KEYbefore the example that needs it, and still say--listdoes not: finding a park before you have paid is the point of that flag.
- A committed mutant list (
test/mutation/mutants.json) and a nightly, non-gating job that runs it. An author-written mutant list contains the mutations that author's tests already catch — one scored 18/18 on this package while an independent sweep found ten survivors. The list is committed so a reviewer can see what is checked and, more usefully, what is not. One mutant targets the generated column list, so the sharedcsv_contract.jsonfixture is exercised as a mutation as well as a test: renaming a column there must turn the suite red, or the two SDKs can drift apart again.
-
themeparks-backfill: the archive download as a command. The Python SDK shipped this first; this is the same tool, and the two write byte-for-byte identical CSVs. Magic Kingdom's full five-year archive: 94,223 rows, 41 columns, identical from both, the only differences being today's row, which grows as the day elapses.npm install themeparks npx themeparks-backfill "magic kingdom"- Takes a park or a destination, by name or id, and a name that identifies one park unambiguously is enough. A destination back fills every park in it, one file each. An ambiguous name lists the ids that match, sorted by park name.
--list [text]prints destinations with their parks underneath and needs no key, so you can find your park before deciding whether to pay.- Runs without a key, reading the 7 days anonymous access allows, and says what a key would add.
- NDJSON by default,
--format csvfor one wide row per entity per day. Every row carriesparkId,parkName,entityId,entityNameandentityType, so two files load into one table and(entityId, date)is the natural key. The entity name is the one the history response gave for those rows, not the park's current children list: rides get renamed, and today's name on a row from three years ago rewrites the record. Files are named for the park's id, because names change. - The CSV carries a UTF-8 BOM so Excel on Windows does not mangle
®and accents, and a cell a spreadsheet would execute as a formula is prefixed with an apostrophe. Numeric cells are untouched, so a negative number stays a number. - Resumable. It checkpoints against the hourly history budget and exits 75
(
EX_TEMPFAIL), so a cron or systemd timer retries rather than alerting, and running the same command again continues. The checkpoint is the day the server's ownnextURL starts on, never the newest row written -- an entity that stopped reporting has no rows for the tail days of its page, so resuming from a row re-fetches days already in the file. - The state file is
<parkId>.<format>.backfill-state.jsonand records the SDK, its version, a state version and a fingerprint of the exact header. Anything that does not match is refused with a message saying why, never resumed -- including a state file written by the Python SDK, whose keys differ. - One park's failure does not abandon the rest of a destination; what did not finish is named at the end. A network failure or timeout exits 75, anything the API rejected exits 1, and neither is a traceback.
- An earlier run's rows are never deleted. A failure or a closed window on a resumed run keeps the file and says the run did not finish.
-
onPageondays(), called once every row of a page has been yielded, with aHistoryPage(from,to,next). The page boundary is the server's own answer to "where do I carry on", and the rows cannot tell you -- so it is the only safe checkpoint for a resumable download.HistoryPageandPageOptionsare exported. -
DailyEntrycarriesnameandentityType, taken from the history response itself. Already in the payload, so nothing has to ask what an id refers to. Both are required fields, so a hand-builtDailyEntryin a test double needs them. -
test/fixtures/csv_contract.json, an identical copy of which lives in the Python SDK. Both suites assert their column list against it, because this is one command with two implementations and a customer using both should get one file format. Before it existed, this SDK wrote 32 columns and Python wrote 41. -
npm run test:package, in CI andprepublishOnly: it packs the tarball, installs it elsewhere and runs the binary. See below for why.
-
The vendored OpenAPI schema was stale.
unknownMinutes,inParkHours(the day's numbers limited to the park's published hours) andextremeWaits(how many readings of 480+ minutes are folded into the statistics, which is how you spot a feed error) are on the rows the API returns and were in none of the types. The CSV column list is now generated from the spec, the nightly drift job commits it alongside the schema, and the generator refuses a duplicate column name or a missing nested block. -
A failed write was reported as success. Node hands
end's callback the stream's error; the callback took no arguments and resolved regardless, so on ENOSPC or EDQUOT mid-download the command printeddone: N rows, recordedcomplete: trueand exited 0 with a truncated file that no rerun would continue. The stream also had no'error'listener until the flush, so an earlier failure became an unhandled'error'event that killed the whole run. -
A bare
\rin an entity name was written unquoted, so one row parsed as two with every later column shifted. -
--listwith no value exited 2 although the help advertises--list [TEXT];-hwas not accepted; a query that folds to nothing (東京) listed all 127 parks instead of none; an empty--api-keyorTHEMEPARKS_API_KEY=""counted as a key; running with no arguments fetched/destinationsbefore saying so, which exited 75 with no network;--versionprinted a bare number; and the 404 hint for a mistyped id sat where nothing could reach it.
-
The client reads the rate-limit headers, and acts on them. Both meters, the per-minute REST one and the separate hourly history budget, on
client.rateLimit:tp.rateLimit.rest.remaining; tp.rateLimit.history.remaining; secondsUntilReset(tp.rateLimit.rest);
Every field can be null, and null means the server did not say rather than "nothing left". Use
isExhausted, true only when the server said zero. The per-minute figures ride most responses; the hourly history ones are withheld from anything a shared cache may store, because they are per-caller; an unmetered plan advertises nothing. A response served from a cache is ignored entirely, because its figures belong to whoever populated the entry.resetis a relative countdown frozen when it was read, sosecondsUntilResetages it rather than returning a stale number.A window the server says is spent is now waited out instead of walked into, since that request is a certain 429 that also spends budget being refused.
retry: { respectRemaining: false }opts out.The hourly history budget is new on the wire; before it there was nothing to read.
- Calls may now block before sending. When the server has said your window
is spent, or has issued a 429 that is still in force, the client waits rather
than sending a request certain to be refused. A call that used to return in
200ms can now take up to
retry.maxRetryAfterMs(120000) first. That is a TOTAL across the call, not per wait: the shared 429 gate and the spent-window wait stack, and before the budget existed a 429 carrying both aRetry-Afterand a spent window blocked for 180 seconds under a 120 second cap. Turn the two halves off withretry: { respectRemaining: false }andretry: { on429: false }.
-
A paged history call crashed on the default configuration.
#privatefields on the transport failed their brand check through the caching Proxy, sohistory.days()threwTypeError: Receiver must be an instance of class Transporton page two for anyone who had not passedcache: false. Every test passedcache: false, so none of them saw it. -
on429: falsedid not opt out. It threw the error the caller asked for and then held their NEXT call for the fullRetry-Afteranyway, because the shared gate was closed regardless of the setting. -
The gate timed off the wall clock. A backward NTP step turned a five-second wait into however far the clock moved, unbounded, because the cap is applied when the gate is armed and not when it is served. It uses a monotonic clock now, as the Python sibling always did.
-
A 429 was waited out once per in-flight request. The wait belongs to the caller, not to whichever request met it, so ten concurrent requests each slept their own
Retry-Afterand then retried at the same instant, re-tripping the limit together. It is taken once now, on a gate shared by the whole client, with a little jitter so waiters do not wake in unison. A shorter wait arriving while a longer one is in force no longer brings the gate forward.
-
entity(id).history.span(),.days()and.changeRows()— the loop above the three history calls.const history = tp.entity(DISNEYLAND).history; const span = await history.span(); for await (const { entityId, row } of history.days({ from: span.archiveFrom, to: span.retrievableThrough, })) { // ... }
span()returnsarchiveFrom,recordedToandretrievableThroughin one shape. The underlying coverage documents do not: a park nests them undersummary, an entity carries them at the top level under different names, so without this every caller writes that branch first.retrievableThroughis the end date to bound a backfill by, because it is what the key may read rather than what the archive holds.days()pages until the server stops offering anext, following that URL verbatim, and yields{ entityId, row }as rows arrive rather than collecting them. A park's daily call is the one paged call in the family, so without this a park backfill silently stopped at the first 31 days.- Both flatten a park envelope and an entity envelope to the same stream, so
a caller writes one loop and does not branch on
'entities' in res. BudgetExhaustedError(aRateLimitError) is thrown when the history budget is spent and the server asks for longer thanmaxWaitMs(120000 by default). It carriesretryAfterMs, so a backfill can checkpoint and resume rather than hold a process open for most of an hour.
-
examples/backfill.mjs— a complete backfill with resume and NDJSON or CSV output. It pulled Disneyland Resort's whole daily archive, 98,452 rows, in one run.
-
A 429 could park the client for hours. The transport honoured any
Retry-Afterup toretry.maxtimes. That is right for a REST 429, which asks for seconds, and wrong for a history 429: that budget is hourly, so a spent one can ask for most of an hour, and three of those is roughly two and a half hours of a silent process.RetryConfiggainsmaxRetryAfterMs(120000 by default): past it the client does not sleep at all and throwsRateLimitErrorwithretryAfterMsset. -
EntityHistoryCoveragewas missing the park shape./entity/{id}/history/coverageanswers a PARK withHistoryParkCoverageDocument, the same way/historyand/history/dailydo, and the type named onlyHistoryCoverageDocument. The two do not overlap where it counts: a park carriessummaryandfields, an entity carriesfirstRecordedAt,lastRecordedAtandkinds. A TypeScript user read.kindsoff a park's coverage, gotundefinedat runtime, and the compiler said nothing. The fixture that covered this 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 park shape it actually gets. -
The user agent announced the wrong version.
PACKAGE_VERSIONwas still7.0.0-alpha.0in a package at8.0.0, so every request announced a version a major old and nothing failed. A gate test now asserts theUser-Agentthe server actually receives carries the versionpackage.jsondeclares, so forgetting the bump is a red test rather than a quiet lie in a header. -
apiKeyclient option. Sent as theX-API-Keyheader 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).const tp = new ThemeParks({ apiKey: 'your-api-key' });
-
History endpoints.
tp.entity(id).history.changes(query),.daily(query)and.coverage()overGET /entity/{id}/history,/history/dailyand/history/coverage, withtp.raw.getEntityHistory,getEntityHistoryDailyandgetEntityHistoryCoverageunderneath. The query is{ date }or{ from, to }, park-local days or RFC 3339 instants, and is sent throughURLSearchParams, so an instant's+02:00offset survives the trip. APARKanswers withentities[]for every entity in it; every other type answers for itself. New exported types:EntityHistory,EntityHistoryDaily,EntityHistoryCoverage,HistoryQuery.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
coveragefor an hour and leaveschangesanddailyuncached, since a range that holds today is not final.
-
Schedule entries now expose
purchases, andtypeis a union again. The upstream spec described a park's schedule two different ways: precisely when nested under a destination, loosely when fetched directly. The direct path is the one this client uses, sopurchaseswas invisible andtypewas a barestring.Magic Kingdom served 26 of 79 upcoming entries with
purchaseson the day this shipped. If you reached them before, you did it with a cast. You no longer need to:const sched = await tp.entity(parkId).schedule.upcoming(); for (const day of sched.schedule ?? []) { for (const p of day.purchases ?? []) { console.log(day.date, p.name, p.price.amount, p.price.currency); // 2026-09-08 Lightning Lane for Seven Dwarfs Mine Train 1100 USD } }
Purchases are not limited to
TICKETED_EVENTdays — Lightning Lane entries attach to ordinaryOPERATINGdays, so do not filter ontypeto find them. -
purchases[].price.amountis nullable, matchingPriceData. 7.1.0 madePriceData.amountnullable but the schedule path carried a second, inline copy of the price shape that keptamountnon-nullable. Both now resolve to onePriceData. Tokyo Disneyland serves six Premier Access rows with a null amount right now, so this was a type that disagreed with production. -
Schedule entries gained the
descriptionfield the API has always sent.
-
BREAKING —
tags[].valueis nowunknown. The spec declares no type for it, only a prose description, so the previousstring | number | Record<string, never>was an invention. Narrow before use:const v = entity.tags?.[0]?.value; if (typeof v === 'string') { /* ... */ }
-
BREAKING — nullability tightened where the API never sends null.
locationon entities and children is no longer| null, andpurchases[].typeis no longer| null. Verified against production: 412 sampled children all carried a location, 255 sampled purchases all carried a type. Comparisons againstnullon these will now fail to compile. -
destinationson the destinations response is required rather than optional, and a destination'sparksare typed as their own shape rather than recursively as a schedule response. -
BREAKING — minimum supported Node is now 20. Node 18 reached end of life on 2025-04-30 and is no longer tested.
enginesmoves from>=18to>=20, and CI runs Node 20, 22 and 24.Nothing in the shipped bundle needed Node 18 specifically; the constraint arrives from the dev toolchain, where eslint 10 and vitest 4 both require Node 20 or newer. Rather than keep claiming support for a runtime nothing verifies, the claim is withdrawn. If you are still on Node 18, stay on 7.1.x.
-
Dev dependencies: eslint 9 to 10, vitest 1 to 4.
TypeScript stays on 5.x.
openapi-typescript@7.13.0still declarespeer typescript@"^5.x", so TypeScript 6 cannot be installed here until that range widens upstream.
-
PriceData.amountis nownumber | null, matching the API spec, which has declared this field nullable for some time. The API returnsnullwhen a paid queue exists but the provider does not publish a price;0is reserved for a queue that is genuinely free. The two were previously conflated as0.This is a compile break for strict TypeScript consumers. If you read
price.amountdirectly you will now getTS18047: 'amount' is possibly 'null'orTS2322. Narrow it first:const amount = queue.PAID_RETURN_TIME?.price.amount; const label = amount === null ? 'price not published' : formatCents(amount);
Runtime output is unchanged — the emitted JS is byte-identical, only the type declarations move. Plain-JavaScript and non-strict consumers are unaffected. See MIGRATION.md.
First stable v7 release. After two alpha iterations (alpha.0/alpha.1 blocked
by CI release-pipeline issues, alpha.2 published to next dist-tag) the
public surface is unchanged. Also landed post-alpha.2:
- Docs site deploys the hand-written cookbook alongside the generated API ref.
- README and cookbook examples are plain JavaScript (previously mixed TypeScript syntax into blocks labeled runnable).
- Dependabot action bumps merged (
actions/checkout,deploy-pages,upload-pages-artifact,create-pull-request,action-gh-release).
- Full TypeScript rewrite; dual ESM + CJS output.
- Sync-by-default API built on platform
fetch(Node 18+, browsers, Deno, Bun, Workers). - Ergonomic
tp.entity(id)navigation withwalk(),schedule.range(), discriminated-unionnarrowQueues()andcurrentWaitTime()helpers. - Default-on per-endpoint caching with pluggable adapter.
- 429
Retry-Afterhandling. - Types generated from the upstream OpenAPI spec; post-gen patches not needed (openapi-typescript handles nullability correctly).
- Legacy
Themeparks.DestinationsApi/EntitiesApigenerated surface. See MIGRATION.md. - Babel 7 toolchain,
superagent,mocha.