Releases: OlivierZal/api-core
Release list
v1.7.1
What
Copilot's second pass on #26 landed after that PR's threads were checked and before it was merged, and its one point is right: SyncManager.release() set a fresh quiet window even when no hold was open — an unbalanced release() (more releases than holds) settled nothing yet still pushed the next tick out. The core's own use is balanced (request() pairs the two in try/finally), so nothing observable changed for the SDKs, but SyncManager is exported and its contract should not depend on the caller's discipline.
A release with no hold open is now ignored: it closes nothing, so it settles nothing either. One clause pins it — a stray release(10 min) a millisecond before the deadline leaves the tick where it was. 1.7.1; the two SDK adoptions in flight (heatzy-api #1250, melcloud-api #1781) move to it before their releases.
Verification
format, lint, typecheck, test:coverage, lint:package and docs all green by exit code; 414 tests, 100 % thresholds held.
v1.7.0
What
Two mechanisms heatzy-api needs to return to its June behaviour, both in the core because both SDKs share the seat. 1.7.0, additive: a new optional HttpClientConfig field and a new SyncManager hold, no existing surface moves.
The auto-sync tick is parked around a mutation
heatzy-api refreshed its registry every 5 seconds in June (DEFAULT_SYNC_INTERVAL = 5 in seconds); after the extraction it refreshes every 5 minutes (syncIntervalMinutes). Returning to the June cadence makes an overlap between a write and the refresh the common case, not the edge: a refresh that overlaps a write reads the pre-write state back into the registry (and through onSync into the Homey capability, which flips back for one cycle), and one that follows the write too closely reads a device that has not applied it yet — a Classic unit keeps only the flagged fields a few seconds after the POST, a Gizwits device answers its control over MQTT.
SessionAPI.request() now opens a SyncManager hold around every non-GET call and releases it with a 3-second settle window (SYNC_SETTLE_MS). The hold:
- only delays, never advances — the deadline stays where
planNext()put it unless the settle window reaches past it, so a write costs no extra refresh and MELCloud's 5-minute cadence never becomes "3 seconds after every write" (its rate limits are the reason this is not a post-write sync); - nests, one per mutation in flight, and arms nothing when no tick was planned;
- holds nothing for a read — the heartbeat's own traffic must not defer itself.
SyncManager gains hold() / release(quietMs), a remembered deadline and a quiet-until mark; clear() forgets a held tick. Six clauses pin the manager and three pin the session path (a mutation parks the due tick until it lands and the window runs; a read parks nothing; a write early in the interval leaves the deadline where it was), the request held in flight by a gated fetch across a fake-timer deadline.
A dialect can describe a failed response
HttpClient threw every non-2xx as Request failed with status code N. heatzy-api's June logging/error.ts surfaced Gizwits' detail_message ?? error_message instead — the reason the wire gives for a refusal — and the extraction dropped it. HttpClientConfig.describeFailure?: (status, data) => string is the seam: the core hands the status and the parsed body, the dialect returns the message; the class, the snapshot and the redaction stay the core's. Default unchanged. A config option rather than a protected method: the family's class-methods-use-this rule refuses a default body that reads no this, and a host-prebuilt transport can carry the reader too.
Verification
format, lint, typecheck, test:coverage, lint:package and docs all green by exit code; 412 tests, 100 % thresholds held. CLAUDE.md carries both mechanisms — the hold next to the planNext() paragraph, the reader next to the redaction seats — with the rule that the reader must never copy the (pre-redaction) body into the message.
v1.6.0
Two gaps in 1.5.0's session epilogue, surfaced by an independent re-verification of that release's fixes and both reproduced before they were fixed. Behaviour only — no API surface changes, so adoption in melcloud-api and heatzy-api is a pin bump.
A definitive refusal was still retried, forever (#24)
#reportResumeFailure recorded #isCredentialRefused on a plain AuthenticationError and then deferred a retry anyway. The window for a refusal is LOGIN_BACKOFF_FAILURE_MS, 15 minutes, without growth — so a client whose stored password had died replayed the same dead pair about 96 times a day for the life of the process, each with a Session resume failed line: precisely the hammering the disarm-on-refusal verdict exists to prevent.
A definitive refusal now parks the pair — the record is set, no retry is deferred, and only the next accepted sign-in lifts it. A throttle keeps its retry, because a throttle says nothing about the pair and a throttled retry can itself be throttled. A transport blip arms no window, so the deferral stays a no-op.
A losing flight could leave a session standing behind a sign-out (#24)
#settleAcceptedSignIn set the post-sign-out claimant flag for every accepted flight, before testing the epoch — so a stale flight set it on its way to discarding. With two sign-ins in the air when the sign-out landed (a background resume plus an explicit sign-in) and answered one after the other, the first discarded and set the flag, and the second read the flag, skipped the discard, and left the session its own doAuthenticate had just re-established standing behind an explicit sign-out, with no pair behind it.
Only an acceptance that began after the last sign-out claims now. A stale flight clears its own material whatever landed before it. The clause that pins it needs two gates, released one after the other — a single shared gate lets both session writes land before either epilogue and hides the gap.
The value surface is pinned both ways (#25)
api-surface.test.ts is one code-unit-sorted list of the 44 value exports against Object.keys of the root barrel: a drop and an addition now fail, where the former per-name toBeDefined clauses could never fail on their own. CLAUDE.md also records that isKeyOf, clampToRange and omitUndefined were weighed for the core and declined — omitUndefined is typed differently per SDK for a protocol reason.
v1.5.0
Three session-lifecycle defects, each reproduced before it was fixed. Behaviour only — no API surface changes, so adoption in melcloud-api and heatzy-api is a pin bump.
The login-backoff gate owed itself a retry (#21)
#attemptResumeSession refused an automatic resume without a wire call, so no sync cycle ran, so planNext() — the only thing that ever arms the auto-sync timer — was never reached. A boot landing inside the window armed nothing at all and stayed dormant for the life of the process, having emitted onAuthenticationLost it could never retract, with not one line in the log to explain the silence. Neither consuming app has a heartbeat of its own, so after a throttle episode a restart left every reading frozen until a user gesture happened to issue a request. The window runs up to two hours.
The refusal now schedules one DisposableTimeout at the deadline it already knows, and says so at log level. Idempotent per window, rearmed after a rejected retry, cleared by logOut, by an accepted sign-in and by disposal, and bounded by LOGIN_BACKOFF_THROTTLE_MS — setTimeout clamps anything above 2^31-1 ms to one tick, so a corrupt persisted deadline would otherwise fire immediately and re-schedule in a hot loop.
ensureSession could await itself (#22)
performSessionRefresh signs in, and the enforced post-auth registry sync that follows issues requests — each of which passes back through ensureSession. The nested call joined #refreshPromise unconditionally, awaiting the promise that was waiting on it. Every request on the client hung forever, with nothing logged and no timeout to end it. What kept it alive was only that needsSessionRefresh() usually reads false by then; an expiry that cannot be parsed keeps it true and closes the loop.
#refresh now runs the hook inside a per-instance AsyncLocalStorage, and ensureSession returns early when it finds itself inside that scope. A genuinely concurrent caller, outside it, still shares the single flight. Per instance, never per module: two clients share a process, and one's refresh must not excuse the other's requests from their own gate.
The clause that pins it hung to the suite timeout before the fix.
A raced sign-in's epilogue could destroy a newer session (#23)
authenticate captured the logOut epoch and #finishLogin tested it alone — which answers "did a sign-out land after me?" and was being used to answer "is what I stored still current?". Two shapes followed, both after authenticate had reported success: an account switch silently reverted to the previous pair, and with a sign-out in between, the stale flight's epilogue deleted the session and both credentials the newer sign-in had just established.
The verdict now lives in one method, on two independent questions in this order:
- a sign-out landed → clear what this flight re-established, unless a sign-in has claimed the session since that sign-out;
- otherwise a later sign-in started → return without writing this pair.
Start order, never acceptance order: a background resume that began first can be answered last, and counting acceptances suppresses the explicit sign-in's epilogue instead of the resume's. The order of the two questions is load-bearing too — checking supersession first leaves a session standing behind an explicit sign-out when the later sign-in is refused. Both orderings and both shapes are pinned.
What no epilogue can undo is the session store: doAuthenticate replaces it wholesale. The next request settles that, serving or answering 401 and re-authenticating over the stored pair, which these guards keep as the newer one.
v1.3.0
Added
./testing subpath — the SDK test helpers have an owner. @olivierzal/api-core/testing ships cast, defined, mock, createLogger, createSettingStore, createMockHttpClient(clientClass, baseURL), mockFetchResponse, createHttpError / createServerError / createUnauthorizedError, mockTemporalNowInstant / mockTemporalNowZoned, plus the SettingStore and MockHttpClient result types. It imports vitest from YOUR devDependencies and declares nothing — no dependency, no peer, optional or not: the SDKs are production dependencies of the Homey apps, and a recorded peer would put the test framework on the device. The root barrel never re-exports it. mockFetchResponse nulls the body on 204, 205 and 304 — the Fetch null-body statuses the Response constructor can build at all (it refuses 101/103 outright, outside its 200–599 range). createMockHttpClient takes the transport CLASS, so each SDK passes its own redaction-seated HttpClient subclass and gets that type back.
ValidationError moved into the core (root barrel and errors barrel): the context field, the validator's error on cause, name === 'ValidationError'. It imports nothing from zod — the zod bar that keeps parseOrThrow in each SDK never applied to the class.
SessionAPI.toAuthFailure(error, message) (protected): narrows an HttpError whose status is in the instance's authFailureStatuses into AuthenticationError (cause preserved), null otherwise — the sign-in normalization both SDKs carried as module-level twins (normalizeUnauthorized, toAuthFailure). The vocabulary is spelled once per protocol: AuthRetryPolicy owns it and gains a public isAuthFailure(error) type guard the helper consults. Spell the null branch as a bare rethrow, in two statements — const authError = this.toAuthFailure(error, '<Vendor> rejected the credentials'), if (authError !== null) throw authError, throw error. The one-liner throw this.toAuthFailure(…) ?? error does not lint in a consumer: the family's @typescript-eslint/only-throw-error (unknown disallowed) admits a catch-clause variable thrown bare and refuses the ?? expression, which is typed unknown (reported by melcloud-api's dry adoption, reproduced in the core under its own overlay). No disable is the answer — the family forbids new ones — and the README snippet, the helper's JSDoc and the core's own suite fixture all carry the two-statement form.
syncDevices(params?) decorator factory, generic over the sync params, beside setting: awaits the method, then this.notifySync?.(params) — forwarded verbatim. The returned method's this names the structural host contract ({ notifySync?: (params?: TParams) => Promise<void> }); a TC39 application site does not check it (probed against the native compiler — the method's own type carries no this), so the contract is documented, not enforced, as in both SDK copies. The default TParams is never, so a suite driving a bare syncDevices() through .call(host) fits a typed hook.
Adoption
melcloud-api (56.0.0 in the wave)
- Pin
@olivierzal/api-core1.3.0. tests/helpers.tskeepsokValue,matchObject,mockResponse; every other helper imports from@olivierzal/api-core/testing.createMockHttpClient(HttpClient, url)now takes the SDK'sHttpClientclass first.src/errors/validation.tsbecomesexport { ValidationError } from '@olivierzal/api-core'(theregistry-sync.tspattern); theerrors.test.tsclauses that re-test the class move out.- Delete
normalizeUnauthorized(src/api/base.ts) and itsdescribeinbase-api.test.ts;home.ts'sdoAuthenticatecatch readsconst authError = this.toAuthFailure(error, 'MELCloud rejected the credentials')/if (authError !== null) throw authError/throw error— the two-statement form, never?? error. src/decorators/sync-devices.tsbecomesexport { syncDevices } from '@olivierzal/api-core'; call sites are unchanged (@syncDevices(),@syncDevices({ type })). One thing to check: a bare@syncDevices()now forwardsundefinedwhere the local copy forwarded{ type: undefined }— reword a kernel clause asserting the latter (or apply@syncDevices({})).
heatzy-api (17.0.0 in the wave)
- Pin 1.3.0.
tests/helpers.tskeepscreateMockAdapter,mockResponse; the rest comes from./testing(itsmockFetchResponseregains the null-body statuses its #1240 copy dropped).src/errors/validation.tsbecomes a re-export.- Delete the module-level
toAuthFailureinsrc/api/heatzy.tsand itsdescribeinheatzy-api-auth.test.ts;doAuthenticate's catch readsconst authError = this.toAuthFailure(error, 'Heatzy rejected the credentials')/if (authError !== null) throw authError/throw error— the two-statement form, never?? error(it failsonly-throw-errorin a consumer). The[401, 400]set is read fromauthFailureStatuses, no longer spelled twice. src/decorators/sync-devices.tsbecomes a re-export; the three call sites (entities/device.ts×2,api/heatzy.ts) become@syncDevices()— the decorator this SDK re-exports changes shape: its MAJOR.
Not breaking for the core
Every change is additive: a new subpath, new exports, a new protected template helper, a new public method on AuthRetryPolicy. No published signature changed. MINOR — 1.3.0. The family's .nvmrc rule (install floor 22.22.2, engines unchanged) rides in this library's configs-5.0.0 adoption PR, not here.
Recorded verdicts
The 2026-09-06 "test helpers stay local" deferral is closed — the cost is accepted and CLAUDE.md's "The ./testing subpath" section carries the four rules of the seat. ValidationError leaves the zod bar; normalizeUnauthorized leaves the "zod/Result boundary" list. The toAuthFailure seam paragraph records the two-statement prescription with its measurement (2026-09-07) and why no helper shape answers the lint rule. The root-barrel ledger re-counts to 69 names (44 values, 25 types) with the same 22 consumer-less names; api-surface.test.ts pins the 44.
v1.2.0
Changed
SyncManager receives the labelled logger, like every other seat. The deferred asymmetry expired: a host running two labelled clients (melcloud's Classic + Home) could not tell which one emitted an auto-sync line in a diagnostic report. Adoption effect: melcloud-api's SyncManager lines gain their [Classic]/[Home] prefix; a no-label consumer (heatzy-api) is byte-identical — the no-op wrap is pinned on the auto-sync line itself.
Also: the AuthenticationError doc's hard link to its throttle subclass softened to a code-font name, so a consumer re-exporting the class without the subclass resolves its d.ts docs cleanly.
Recorded verdict
The policy toolkit (AuthRetryPolicy, CompositePolicy, RateLimitPolicy, TransientRetryPolicy, the retry-backoff surface, DisposableTimeout) and BASE_SENSITIVE_KEYS/baseRedaction currently have no external consumer — SessionAPI constructs them internally. They STAY exported: an unconstructed export costs a consumer nothing, and a host composing its own client outside SessionAPI wants exactly these pieces.
v1.1.0
Added — the session mechanism crosses the boundary
SessionAPI — the session lifecycle and request pipeline both SDKs carried as ~450-line twins: authenticate/resumeSession/initialize/logOut/start, the single-flight ensureSession and resumeSession, request/dispatch/policy composition, the sync-cycle trio (strict, best-effort, epilogue), the login backoff with its persisted deadline, the auth-lost/restored episode machinery, the #acceptedSignIns round-trip verdict, the #isCredentialRefused record with its protected isSessionServable() read, and RegistrySyncError. Twelve protected abstract hooks form the seam; everything that differs between consumers by DATA is a constructor option (authFailureStatuses, logLabel, rateLimitHours, redaction, sync cadence).
Session prerequisites — AuthenticationError, AuthenticationThrottledError, LoginCredentials, and the setting accessor decorator (storage keys derive from accessor NAMES; expiry/loginBackoffUntil/password/username are pinned literals).
What an adopting SDK must do
- Subclass
SessionAPI, implement the twelve hooks, and pass an ALREADY-BUILTHttpClient— the transport resolution stays yours (a core-sideinstanceofwould accept a client carrying only base redaction). - Pass your bound
redactionengine. The dispatch request/response log lines, the error seat and the transient-retry URL all serialize through it; omitting it degrades to base-vocabulary coverage — your SDK-specific credential keys (x-gizwits-user-token,contextkey, …) would print in clear. Pin the wiring with a masked-header clause at your seam. - Re-export the moved names under your existing paths (the shim pattern); your kernel must cross byte-identical.
Also fixed here
- The observability shells'
urlfield now serializes throughredactUrl: a one-pair query credential (?token=…) previously passed in clear and multi-pair queries mangled the path — a latent leak inherited from the pre-extraction twins, present in both SDKs' current logs until they adopt this release.
Proven by both consumers against this exact commit before tagging: melcloud-api (kernel byte-identical, 556=556 exports, zero public d.ts delta) and heatzy-api (kernel identical but the one recorded dispose clause, 64=64 exports, delta entirely protected).
v1.0.0
Initial release: the mechanism twins extracted from melcloud-api and heatzy-api — HTTP core with constructor-seated whole-snapshot credential redaction (vocabulary injected via createRedaction, the base being the consumers' historical intersection so adoption can only redact more), the observability shells, and the resilience primitives (auth statuses, retry guard and session-expiry zone parameterized). Born from the 2026-08-21 incident where a security fix took four days to reach its hand-mirrored twin.
Adoption: each SDK re-exports its existing public names from this package and seats its own redaction vocabulary in a thin HttpClient subclass — zero public-surface change (proven by export-set diff: 283 = 283 and 62 = 62).