@leavepulse/control-sdk 0.3.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/auth-types.ts +5296 -0
  3. package/client.ts +320 -0
  4. package/index.ts +110 -0
  5. package/models.ts +232 -0
  6. package/package.json +28 -0
  7. package/procedures.ts +451 -0
  8. package/resources/ControlAgentRelease.ts +40 -0
  9. package/resources/ControlAlert.ts +39 -0
  10. package/resources/ControlCfAccount.ts +82 -0
  11. package/resources/ControlDcimAcceptance.ts +126 -0
  12. package/resources/ControlDcimCable.ts +70 -0
  13. package/resources/ControlDcimComponent.ts +62 -0
  14. package/resources/ControlDcimDevice.ts +71 -0
  15. package/resources/ControlDcimFeed.ts +61 -0
  16. package/resources/ControlDcimLocation.ts +55 -0
  17. package/resources/ControlDcimOutlet.ts +61 -0
  18. package/resources/ControlDcimPdu.ts +58 -0
  19. package/resources/ControlDcimPort.ts +61 -0
  20. package/resources/ControlDcimPowerLink.ts +34 -0
  21. package/resources/ControlDcimRack.ts +61 -0
  22. package/resources/ControlEdge.ts +43 -0
  23. package/resources/ControlEnrollToken.ts +45 -0
  24. package/resources/ControlEnvGroup.ts +60 -0
  25. package/resources/ControlHost.ts +184 -0
  26. package/resources/ControlNode.ts +48 -0
  27. package/resources/ControlProject.ts +36 -0
  28. package/resources/ControlRule.ts +56 -0
  29. package/resources/ControlSchedule.ts +56 -0
  30. package/resources/ControlService.ts +101 -0
  31. package/runtime/cache-policy.ts +371 -0
  32. package/runtime/cache.ts +129 -0
  33. package/runtime/credentials.ts +139 -0
  34. package/runtime/device.ts +199 -0
  35. package/runtime/errors.ts +225 -0
  36. package/runtime/etag-store.ts +252 -0
  37. package/runtime/json.ts +25 -0
  38. package/runtime/oauth2.ts +150 -0
  39. package/runtime/page.ts +81 -0
  40. package/runtime/realtime-client.ts +339 -0
  41. package/runtime/realtime.ts +257 -0
  42. package/runtime/realtime_pb/leavepulse/realtime/v1/ws_pb.ts +464 -0
  43. package/runtime/resource.ts +84 -0
  44. package/runtime/snowflake.ts +7 -0
  45. package/runtime/transport.ts +404 -0
  46. package/types.ts +7279 -0
@@ -0,0 +1,199 @@
1
+ // LeavePulse SDK — OAuth device-flow (RFC 8628) polling helper.
2
+ //
3
+ // `auth.device.start` / `approve` / `token` are the raw generated calls. This
4
+ // wraps the token poll loop: call `token` every `interval` seconds, back off on
5
+ // `slow_down`, and resolve once the user approves (or reject on expiry/denial).
6
+ // Framework-agnostic — the caller passes the poll function and the device_code.
7
+ //
8
+ // `beginDeviceFlow` is the higher-level headless facade: it runs `start`,
9
+ // surfaces the user-facing URL + code, and exposes `.poll()` which honours the
10
+ // returned interval and maps the approved grant into a RefreshingCredential.
11
+
12
+ import type { RefreshFn } from "./credentials";
13
+ import { RefreshingCredential } from "./credentials";
14
+
15
+ /** The poll status the token endpoint returns (RFC 8628 §3.5). */
16
+ export type DevicePollStatus =
17
+ | "approved"
18
+ | "pending"
19
+ | "slow_down"
20
+ | "expired"
21
+ | "denied";
22
+
23
+ /** Minimal shape of a `auth.device.token` response the poller needs. */
24
+ export interface DeviceTokenResponse {
25
+ status: DevicePollStatus;
26
+ access_token?: string | null;
27
+ token_type?: string | null;
28
+ expires_in?: number | null;
29
+ refresh_token?: string | null;
30
+ refresh_token_expires_in?: number | null;
31
+ }
32
+
33
+ export interface DevicePollOptions {
34
+ /** Initial seconds between polls (the `interval` from `start`). Default 5. */
35
+ intervalSeconds?: number;
36
+ /** Seconds added to the interval on each `slow_down`. Default 5. */
37
+ slowDownStepSeconds?: number;
38
+ /** Abort the poll loop (e.g. user cancelled). */
39
+ signal?: AbortSignal;
40
+ }
41
+
42
+ /** Raised when a device authorization can't complete. */
43
+ export class DeviceFlowError extends Error {
44
+ constructor(readonly status: "expired" | "denied" | "aborted") {
45
+ super(`device authorization ${status}`);
46
+ this.name = "DeviceFlowError";
47
+ }
48
+ }
49
+
50
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
51
+ new Promise((resolve, reject) => {
52
+ if (signal?.aborted) {
53
+ reject(new DeviceFlowError("aborted"));
54
+ return;
55
+ }
56
+ const timer = setTimeout(resolve, ms);
57
+ signal?.addEventListener(
58
+ "abort",
59
+ () => {
60
+ clearTimeout(timer);
61
+ reject(new DeviceFlowError("aborted"));
62
+ },
63
+ { once: true },
64
+ );
65
+ });
66
+
67
+ /**
68
+ * Poll `token` until the user approves the device. Resolves with the approved
69
+ * token response, or throws `DeviceFlowError` on expiry/denial/abort.
70
+ *
71
+ * @param token a function that performs one `auth.device.token` call.
72
+ */
73
+ export async function pollDeviceToken(
74
+ token: () => Promise<DeviceTokenResponse>,
75
+ options: DevicePollOptions = {},
76
+ ): Promise<DeviceTokenResponse> {
77
+ const step = (options.slowDownStepSeconds ?? 5) * 1000;
78
+ let interval = (options.intervalSeconds ?? 5) * 1000;
79
+ for (;;) {
80
+ if (options.signal?.aborted) throw new DeviceFlowError("aborted");
81
+ const response = await token();
82
+ switch (response.status) {
83
+ case "approved":
84
+ return response;
85
+ case "pending":
86
+ break;
87
+ case "slow_down":
88
+ interval += step;
89
+ break;
90
+ case "expired":
91
+ throw new DeviceFlowError("expired");
92
+ case "denied":
93
+ throw new DeviceFlowError("denied");
94
+ }
95
+ await sleep(interval, options.signal);
96
+ }
97
+ }
98
+
99
+ /** Shape of a `auth.device.start` response (RFC 8628 §3.2, wire snake_case). */
100
+ export interface DeviceStartResponse {
101
+ /** Opaque code the client polls `token` with. */
102
+ device_code: string;
103
+ /** Short code the user enters in the frontend (`device.vue`). */
104
+ user_code: string;
105
+ /** URL the user opens to approve. */
106
+ verification_uri: string;
107
+ /** `verification_uri` with the `user_code` pre-filled, when provided. */
108
+ verification_uri_complete?: string | null;
109
+ /** Seconds until the device code expires. */
110
+ expires_in: number;
111
+ /** Recommended seconds between polls. */
112
+ interval?: number | null;
113
+ }
114
+
115
+ /** Options for {@link beginDeviceFlow}'s `.poll()`. */
116
+ export interface BeginDeviceFlowOptions {
117
+ /**
118
+ * Performs the refresh-token exchange for the credential returned by
119
+ * `.poll()`. Omit to leave wiring to the integration layer — the returned
120
+ * credential then throws if a refresh is attempted. Keep transport-agnostic.
121
+ */
122
+ refreshFn?: RefreshFn;
123
+ /** Refresh this many seconds before the access token expires (default 30). */
124
+ leewaySeconds?: number;
125
+ /** Abort the poll loop (e.g. user cancelled). */
126
+ signal?: AbortSignal;
127
+ /** Override the `slow_down` interval step (default 5s). */
128
+ slowDownStepSeconds?: number;
129
+ }
130
+
131
+ /** A started device authorization: user-facing fields plus a `.poll()` that
132
+ * blocks until approval and yields a refreshing credential. */
133
+ export interface DeviceFlowHandle {
134
+ /** Short code the user confirms in the frontend. */
135
+ readonly userCode: string;
136
+ /** URL the user opens to approve the device. */
137
+ readonly verificationUri: string;
138
+ /** `verificationUri` with the code pre-filled, when the server supplied it. */
139
+ readonly verificationUriComplete?: string;
140
+ /** Seconds until the device code expires. */
141
+ readonly expiresIn: number;
142
+ /** The `device_code` to poll with (exposed for advanced callers). */
143
+ readonly deviceCode: string;
144
+ /**
145
+ * Poll `token` (honouring `interval`/`slow_down`/`expires_in`) until the
146
+ * user approves, then resolve with a {@link RefreshingCredential} seeded
147
+ * from the grant. Throws {@link DeviceFlowError} on expiry/denial/abort.
148
+ */
149
+ poll(): Promise<RefreshingCredential>;
150
+ }
151
+
152
+ /**
153
+ * Begin RFC 8628 device authorization headlessly. Calls `start()`, returns the
154
+ * user-facing URL + code immediately, and exposes `.poll()` which runs the
155
+ * existing {@link pollDeviceToken} loop with the server-advised `interval` and
156
+ * maps the approved grant into a {@link RefreshingCredential}.
157
+ *
158
+ * @param start a function performing one `auth.device.start` call.
159
+ * @param poll a function performing one `auth.device.token` call for a given
160
+ * `device_code`.
161
+ */
162
+ export async function beginDeviceFlow(
163
+ start: () => Promise<DeviceStartResponse>,
164
+ poll: (deviceCode: string) => Promise<DeviceTokenResponse>,
165
+ options: BeginDeviceFlowOptions = {},
166
+ ): Promise<DeviceFlowHandle> {
167
+ const started = await start();
168
+ const interval = started.interval ?? undefined;
169
+ return {
170
+ userCode: started.user_code,
171
+ verificationUri: started.verification_uri,
172
+ verificationUriComplete: started.verification_uri_complete ?? undefined,
173
+ expiresIn: started.expires_in,
174
+ deviceCode: started.device_code,
175
+ async poll(): Promise<RefreshingCredential> {
176
+ const approved = await pollDeviceToken(() => poll(started.device_code), {
177
+ intervalSeconds: interval,
178
+ slowDownStepSeconds: options.slowDownStepSeconds,
179
+ signal: options.signal,
180
+ });
181
+ if (!approved.access_token || !approved.refresh_token) {
182
+ throw new DeviceFlowError("denied");
183
+ }
184
+ return new RefreshingCredential({
185
+ accessToken: approved.access_token,
186
+ refreshToken: approved.refresh_token,
187
+ expiresIn: approved.expires_in,
188
+ leewaySeconds: options.leewaySeconds,
189
+ refreshFn:
190
+ options.refreshFn ??
191
+ (() => {
192
+ throw new Error(
193
+ "device-flow credential has no refreshFn; provide one in beginDeviceFlow options",
194
+ );
195
+ }),
196
+ });
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,225 @@
1
+ // LeavePulse SDK — error hierarchy (discord.py-style).
2
+ //
3
+ // Every failure is a `LeavePulseError`. HTTP failures are an `HTTPException`
4
+ // subclass chosen by status code, so callers can `catch (e) { if (e instanceof
5
+ // NotFound) ... }`. The backend speaks RFC 7807 problem+json (service-toolkit /
6
+ // awesome_errors), so the parsed `problem` carries the machine-readable `code`,
7
+ // human `detail`, validation `fields`, and `requestId` for support.
8
+
9
+ import type { TransportRequest } from "./transport";
10
+
11
+ /** RFC 7807 problem details as emitted by the LeavePulse backend. */
12
+ export interface ProblemDetails {
13
+ /** URI identifying the problem type (`urn:<service>:error:<code>`). */
14
+ type?: string;
15
+ /** Short human-readable summary. */
16
+ title?: string;
17
+ /** HTTP status code. */
18
+ status?: number;
19
+ /** Human-readable explanation specific to this occurrence. */
20
+ detail?: string;
21
+ /** URI identifying this specific occurrence. */
22
+ instance?: string;
23
+ /** Stable machine-readable error code, UPPER_SNAKE (e.g. `RESOURCE_NOT_FOUND`). */
24
+ code?: string;
25
+ /** ISO-8601 timestamp. */
26
+ timestamp?: string;
27
+ /** Correlation id for support / log lookup. */
28
+ requestId?: string;
29
+ /** Originating service name. */
30
+ service?: string;
31
+ /** Extra structured context. For validation failures it holds
32
+ * `{ errors: [{ key, message, source }], path }`. */
33
+ details?: Record<string, unknown>;
34
+ }
35
+
36
+ /** A single per-field validation failure, normalized from the backend's
37
+ * `details.errors` array (Litestar's `{ key, message, source }` shape). */
38
+ export interface FieldError {
39
+ /** The offending field path (e.g. `email`, `body.address.city`). */
40
+ field: string;
41
+ /** Human-readable validation message. */
42
+ message: string;
43
+ /** Where it came from: `body` | `query` | `path` | `cookie` | … */
44
+ source?: string;
45
+ }
46
+
47
+ /** Extract normalized per-field validation errors from a problem's `details`.
48
+ * The backend (awesome_errors + Litestar) reports them as
49
+ * `details.errors = [{ key, message, source }]`; older/other shapes
50
+ * (`details.fields` object, `{ loc, msg }` pydantic-style) are tolerated. */
51
+ export function fieldErrorsOf(problem: ProblemDetails | null): FieldError[] {
52
+ const details = problem?.details;
53
+ if (!details || typeof details !== "object") return [];
54
+ const raw = (details as { errors?: unknown; fields?: unknown }).errors;
55
+
56
+ const fromEntry = (entry: unknown): FieldError | null => {
57
+ if (!entry || typeof entry !== "object") return null;
58
+ const e = entry as Record<string, unknown>;
59
+ const loc = e.key ?? e.field ?? e.path ?? e.loc;
60
+ const field = Array.isArray(loc) ? loc.join(".") : String(loc ?? "");
61
+ const message = String(e.message ?? e.msg ?? e.detail ?? "");
62
+ const source = e.source != null ? String(e.source) : undefined;
63
+ if (!field && !message) return null;
64
+ return { field, message, source };
65
+ };
66
+
67
+ if (Array.isArray(raw)) {
68
+ return raw.map(fromEntry).filter((x): x is FieldError => x !== null);
69
+ }
70
+ // Legacy `details.fields = { email: "msg" }` object shape.
71
+ const fields = (details as { fields?: unknown }).fields;
72
+ if (fields && typeof fields === "object" && !Array.isArray(fields)) {
73
+ return Object.entries(fields as Record<string, unknown>).map(
74
+ ([field, message]) => ({ field, message: String(message) }),
75
+ );
76
+ }
77
+ return [];
78
+ }
79
+
80
+ /** Base class for every error the SDK raises. */
81
+ export class LeavePulseError extends Error {
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = new.target.name;
85
+ }
86
+ }
87
+
88
+ /** The server returned a payload that doesn't match the expected shape (e.g. a
89
+ * resource with no id to identity-map on). Distinct from an HTTP error: the
90
+ * request succeeded but the body is unusable. */
91
+ export class MalformedResponse extends LeavePulseError {
92
+ constructor(
93
+ message: string,
94
+ readonly payload: unknown,
95
+ ) {
96
+ super(message);
97
+ }
98
+ }
99
+
100
+ /** Any non-2xx HTTP response. Subclasses narrow by status. */
101
+ export class HTTPException extends LeavePulseError {
102
+ readonly status: number;
103
+ readonly request: TransportRequest;
104
+ readonly problem: ProblemDetails | null;
105
+ /** Raw response body text when it wasn't valid problem+json. */
106
+ readonly raw: string;
107
+
108
+ constructor(
109
+ status: number,
110
+ request: TransportRequest,
111
+ problem: ProblemDetails | null,
112
+ raw: string,
113
+ ) {
114
+ const code = problem?.code ? ` [${problem.code}]` : "";
115
+ const detail = problem?.detail ?? problem?.title ?? raw;
116
+ super(
117
+ `${request.method} ${request.path} → ${status}${code}${detail ? `: ${detail}` : ""}`,
118
+ );
119
+ this.status = status;
120
+ this.request = request;
121
+ this.problem = problem;
122
+ this.raw = raw;
123
+ }
124
+
125
+ /** Machine-readable error code, when the server supplied one
126
+ * (UPPER_SNAKE, e.g. `RESOURCE_NOT_FOUND`). */
127
+ get code(): string | undefined {
128
+ return this.problem?.code;
129
+ }
130
+
131
+ /** Whether the server's stable error code matches `code` — a transport-
132
+ * agnostic check that survives status-code remapping (`err.is("SESSION_EXPIRED")`). */
133
+ is(code: string): boolean {
134
+ return this.problem?.code === code;
135
+ }
136
+
137
+ /** Correlation id for support, when present. */
138
+ get requestId(): string | undefined {
139
+ return this.problem?.requestId;
140
+ }
141
+
142
+ /** Normalized per-field validation errors, when the backend reported them
143
+ * (populated for 400/422 validation failures). */
144
+ get fieldErrors(): FieldError[] {
145
+ return fieldErrorsOf(this.problem);
146
+ }
147
+ }
148
+
149
+ /** 400 — malformed request / failed validation. */
150
+ export class BadRequest extends HTTPException {}
151
+
152
+ /** 422 — semantic validation failure (the backend's primary validation status,
153
+ * carrying `details.errors`). Separate class so callers can target it, but it
154
+ * shares `fieldErrors` with `BadRequest`. */
155
+ export class UnprocessableEntity extends HTTPException {}
156
+
157
+ /** 401 — authentication required or failed. */
158
+ export class Unauthorized extends HTTPException {}
159
+ /** 403 — authenticated but not permitted. */
160
+ export class Forbidden extends HTTPException {}
161
+ /** 404 — resource not found. */
162
+ export class NotFound extends HTTPException {}
163
+ /** 409 — state conflict (e.g. duplicate, already-exists). */
164
+ export class Conflict extends HTTPException {}
165
+
166
+ /** 429 — rate limited. `retryAfter` is the server-advised wait in seconds. */
167
+ export class RateLimited extends HTTPException {
168
+ constructor(
169
+ status: number,
170
+ request: TransportRequest,
171
+ problem: ProblemDetails | null,
172
+ raw: string,
173
+ readonly retryAfter: number | undefined,
174
+ ) {
175
+ super(status, request, problem, raw);
176
+ }
177
+ }
178
+
179
+ /** 5xx — the server failed to fulfil a valid request. */
180
+ export class ServerError extends HTTPException {}
181
+
182
+ /** Parse a response body as RFC 7807 problem+json; `null` if it isn't JSON. */
183
+ export function parseProblem(raw: string): ProblemDetails | null {
184
+ if (!raw) return null;
185
+ try {
186
+ const obj = JSON.parse(raw) as Record<string, unknown>;
187
+ if (!obj || typeof obj !== "object") return null;
188
+ // The wire uses snake_case (`request_id`); expose camelCase too.
189
+ return {
190
+ ...obj,
191
+ requestId: (obj.request_id ?? obj.requestId) as string | undefined,
192
+ } as ProblemDetails;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /** Build the right `HTTPException` subclass from a failed response. */
199
+ export function httpErrorFor(
200
+ status: number,
201
+ request: TransportRequest,
202
+ raw: string,
203
+ retryAfter?: number,
204
+ ): HTTPException {
205
+ const problem = parseProblem(raw);
206
+ switch (status) {
207
+ case 400:
208
+ return new BadRequest(status, request, problem, raw);
209
+ case 401:
210
+ return new Unauthorized(status, request, problem, raw);
211
+ case 403:
212
+ return new Forbidden(status, request, problem, raw);
213
+ case 404:
214
+ return new NotFound(status, request, problem, raw);
215
+ case 409:
216
+ return new Conflict(status, request, problem, raw);
217
+ case 422:
218
+ return new UnprocessableEntity(status, request, problem, raw);
219
+ case 429:
220
+ return new RateLimited(status, request, problem, raw, retryAfter);
221
+ default:
222
+ if (status >= 500) return new ServerError(status, request, problem, raw);
223
+ return new HTTPException(status, request, problem, raw);
224
+ }
225
+ }
@@ -0,0 +1,252 @@
1
+ // LeavePulse SDK — ETag cache store (RFC 7232 conditional requests).
2
+ //
3
+ // `transport.conditional()` is the raw mechanism: it sends `If-None-Match` and
4
+ // reports modified / not_modified / not_found, but doesn't remember anything.
5
+ // This module adds the *policy*: an `EtagStore` keeps `{etag, body}` per key,
6
+ // and `fetchCached` ties it to the transport — serve the cached body on `304`,
7
+ // store the new body on `200`. Two stores ship (in-memory, localStorage);
8
+ // implement `EtagStore` for anything else (SQLite, IndexedDB, …).
9
+
10
+ import { NotFound } from "./errors";
11
+ import type {
12
+ ConditionalResult,
13
+ Transport,
14
+ TransportRequest,
15
+ } from "./transport";
16
+
17
+ /** A cached conditional response: the validator and the body it belongs to. */
18
+ export interface EtagEntry<T = unknown> {
19
+ etag: string;
20
+ body: T;
21
+ }
22
+
23
+ /**
24
+ * Pluggable storage for ETag cache entries. Sync or async — `fetchCached`
25
+ * awaits every call, so a synchronous store (Map) and an async one (IndexedDB,
26
+ * SQLite) satisfy the same interface.
27
+ */
28
+ export interface EtagStore {
29
+ get<T>(
30
+ key: string,
31
+ ): EtagEntry<T> | undefined | Promise<EtagEntry<T> | undefined>;
32
+ set<T>(key: string, entry: EtagEntry<T>): void | Promise<void>;
33
+ delete(key: string): void | Promise<void>;
34
+ }
35
+
36
+ /** The default cache key for a request: method + path (+ query). */
37
+ export function defaultCacheKey(req: TransportRequest): string {
38
+ const channel = req.channel ?? "platform";
39
+ return `${channel} ${req.method} ${req.path}`;
40
+ }
41
+
42
+ /** In-process cache. Lives for the process; ideal for SSR and tests. */
43
+ export class MemoryEtagStore implements EtagStore {
44
+ private readonly map = new Map<string, EtagEntry>();
45
+
46
+ get<T>(key: string): EtagEntry<T> | undefined {
47
+ return this.map.get(key) as EtagEntry<T> | undefined;
48
+ }
49
+
50
+ set<T>(key: string, entry: EtagEntry<T>): void {
51
+ this.map.set(key, entry);
52
+ }
53
+
54
+ delete(key: string): void {
55
+ this.map.delete(key);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Browser-persistent cache backed by `localStorage`. Entries survive reloads;
61
+ * a parse/quota failure degrades to a cache miss rather than throwing, so a
62
+ * full or corrupt store never breaks a request.
63
+ */
64
+ export class LocalStorageEtagStore implements EtagStore {
65
+ /** @param prefix namespaces keys to avoid clashing with other data. */
66
+ constructor(
67
+ private readonly storage: Storage,
68
+ private readonly prefix = "lp.etag:",
69
+ ) {}
70
+
71
+ get<T>(key: string): EtagEntry<T> | undefined {
72
+ const raw = this.storage.getItem(this.prefix + key);
73
+ if (raw === null) return undefined;
74
+ try {
75
+ return JSON.parse(raw) as EtagEntry<T>;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ set<T>(key: string, entry: EtagEntry<T>): void {
82
+ try {
83
+ this.storage.setItem(this.prefix + key, JSON.stringify(entry));
84
+ } catch {
85
+ // Quota exceeded / unavailable — skip caching, the request still works.
86
+ }
87
+ }
88
+
89
+ delete(key: string): void {
90
+ try {
91
+ this.storage.removeItem(this.prefix + key);
92
+ } catch {
93
+ // ignore
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Browser-persistent cache backed by IndexedDB. Unlike `localStorage` it is
100
+ * asynchronous and not bound by the ~5 MB string quota, so it suits caching
101
+ * larger response bodies across sessions. Every operation degrades to a
102
+ * cache miss / no-op on failure (private mode, blocked storage, version
103
+ * conflicts), so a broken store never breaks a request.
104
+ */
105
+ export class IndexedDbEtagStore implements EtagStore {
106
+ private dbPromise: Promise<IDBDatabase | null> | undefined;
107
+
108
+ /**
109
+ * @param dbName database name (one per app is plenty).
110
+ * @param storeName object store holding the etag entries.
111
+ */
112
+ constructor(
113
+ private readonly dbName = "lp-etag-cache",
114
+ private readonly storeName = "entries",
115
+ ) {}
116
+
117
+ private openDb(): Promise<IDBDatabase | null> {
118
+ if (this.dbPromise) return this.dbPromise;
119
+ this.dbPromise = new Promise<IDBDatabase | null>((resolve) => {
120
+ const idb = typeof indexedDB !== "undefined" ? indexedDB : undefined;
121
+ if (!idb) {
122
+ resolve(null);
123
+ return;
124
+ }
125
+ let request: IDBOpenDBRequest;
126
+ try {
127
+ request = idb.open(this.dbName, 1);
128
+ } catch {
129
+ resolve(null);
130
+ return;
131
+ }
132
+ request.onupgradeneeded = () => {
133
+ const db = request.result;
134
+ if (!db.objectStoreNames.contains(this.storeName)) {
135
+ db.createObjectStore(this.storeName);
136
+ }
137
+ };
138
+ request.onsuccess = () => resolve(request.result);
139
+ request.onerror = () => resolve(null);
140
+ request.onblocked = () => resolve(null);
141
+ });
142
+ return this.dbPromise;
143
+ }
144
+
145
+ private async withStore<R>(
146
+ mode: IDBTransactionMode,
147
+ run: (store: IDBObjectStore) => IDBRequest,
148
+ ): Promise<R | undefined> {
149
+ const db = await this.openDb();
150
+ if (!db) return undefined;
151
+ return new Promise<R | undefined>((resolve) => {
152
+ let req: IDBRequest;
153
+ try {
154
+ req = run(
155
+ db.transaction(this.storeName, mode).objectStore(this.storeName),
156
+ );
157
+ } catch {
158
+ resolve(undefined);
159
+ return;
160
+ }
161
+ req.onsuccess = () => resolve(req.result as R | undefined);
162
+ req.onerror = () => resolve(undefined);
163
+ });
164
+ }
165
+
166
+ async get<T>(key: string): Promise<EtagEntry<T> | undefined> {
167
+ return this.withStore<EtagEntry<T>>("readonly", (store) => store.get(key));
168
+ }
169
+
170
+ async set<T>(key: string, entry: EtagEntry<T>): Promise<void> {
171
+ await this.withStore("readwrite", (store) => store.put(entry, key));
172
+ }
173
+
174
+ async delete(key: string): Promise<void> {
175
+ await this.withStore("readwrite", (store) => store.delete(key));
176
+ }
177
+ }
178
+
179
+ export interface FetchCachedOptions {
180
+ /** Override the cache key (default: channel + method + path). */
181
+ key?: string;
182
+ }
183
+
184
+ /**
185
+ * Fetch a resource through the ETag cache: send the stored validator, return
186
+ * the cached body unchanged on `304`, store and return the fresh body on `200`,
187
+ * and return `null` on `404` (evicting any stale entry).
188
+ */
189
+ export async function fetchCached<T>(
190
+ transport: Transport,
191
+ store: EtagStore,
192
+ req: TransportRequest,
193
+ options: FetchCachedOptions = {},
194
+ ): Promise<T | null> {
195
+ const key = options.key ?? defaultCacheKey(req);
196
+ const cached = await store.get<T>(key);
197
+ const result: ConditionalResult<T> = await transport.conditional<T>({
198
+ ...req,
199
+ ifNoneMatch: cached?.etag,
200
+ });
201
+
202
+ switch (result.status) {
203
+ case "not_modified": {
204
+ // Server confirmed the cached copy is current — return it.
205
+ if (cached) return cached.body;
206
+ // We held a validator but no body (e.g. the store instance differs
207
+ // between SSR and client hydration, or the entry was evicted). The
208
+ // `304` carries no body, so re-fetch unconditionally to recover the
209
+ // full payload instead of reporting a phantom miss (which would
210
+ // surface as a bogus 404 in fetchCachedOrThrow).
211
+ const fresh = await transport.conditional<T>(req);
212
+ if (fresh.status === "modified") {
213
+ if (fresh.etag) {
214
+ await store.set<T>(key, { etag: fresh.etag, body: fresh.data });
215
+ }
216
+ return fresh.data;
217
+ }
218
+ if (fresh.status === "not_found") {
219
+ await store.delete(key);
220
+ return null;
221
+ }
222
+ return null;
223
+ }
224
+ case "modified":
225
+ if (result.etag) {
226
+ await store.set<T>(key, { etag: result.etag, body: result.data });
227
+ }
228
+ return result.data;
229
+ case "not_found":
230
+ await store.delete(key);
231
+ return null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Like {@link fetchCached} but for endpoints that always yield a value: a `404`
237
+ * (null) is surfaced as a {@link NotFound} error, matching what a plain
238
+ * `transport.request` would throw. Generated GET methods route through this so
239
+ * their return type stays non-nullable.
240
+ */
241
+ export async function fetchCachedOrThrow<T>(
242
+ transport: Transport,
243
+ store: EtagStore,
244
+ req: TransportRequest,
245
+ options: FetchCachedOptions = {},
246
+ ): Promise<T> {
247
+ const body = await fetchCached<T>(transport, store, req, options);
248
+ if (body === null) {
249
+ throw new NotFound(404, req, null, "");
250
+ }
251
+ return body;
252
+ }
@@ -0,0 +1,25 @@
1
+ import { isInteger, parse } from "lossless-json";
2
+
3
+ /**
4
+ * Parse a JSON response body, keeping integers beyond `Number.MAX_SAFE_INTEGER`
5
+ * as strings so 64-bit Snowflake ids survive intact (a plain `JSON.parse` would
6
+ * round them through a lossy `number`). All other numbers stay regular numbers.
7
+ *
8
+ * The transport uses this instead of `response.json()` so every SDK consumer
9
+ * gets precise ids without any per-call handling.
10
+ */
11
+ export function parseJson(text: string): unknown {
12
+ if (text.length === 0) return undefined;
13
+ return parse(text, undefined, {
14
+ parseNumber(value: string) {
15
+ if (isInteger(value)) {
16
+ const num = Number(value);
17
+ if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) {
18
+ return value;
19
+ }
20
+ return num;
21
+ }
22
+ return Number.parseFloat(value);
23
+ },
24
+ });
25
+ }