@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.
- package/README.md +2 -0
- package/auth-types.ts +5296 -0
- package/client.ts +320 -0
- package/index.ts +110 -0
- package/models.ts +232 -0
- package/package.json +28 -0
- package/procedures.ts +451 -0
- package/resources/ControlAgentRelease.ts +40 -0
- package/resources/ControlAlert.ts +39 -0
- package/resources/ControlCfAccount.ts +82 -0
- package/resources/ControlDcimAcceptance.ts +126 -0
- package/resources/ControlDcimCable.ts +70 -0
- package/resources/ControlDcimComponent.ts +62 -0
- package/resources/ControlDcimDevice.ts +71 -0
- package/resources/ControlDcimFeed.ts +61 -0
- package/resources/ControlDcimLocation.ts +55 -0
- package/resources/ControlDcimOutlet.ts +61 -0
- package/resources/ControlDcimPdu.ts +58 -0
- package/resources/ControlDcimPort.ts +61 -0
- package/resources/ControlDcimPowerLink.ts +34 -0
- package/resources/ControlDcimRack.ts +61 -0
- package/resources/ControlEdge.ts +43 -0
- package/resources/ControlEnrollToken.ts +45 -0
- package/resources/ControlEnvGroup.ts +60 -0
- package/resources/ControlHost.ts +184 -0
- package/resources/ControlNode.ts +48 -0
- package/resources/ControlProject.ts +36 -0
- package/resources/ControlRule.ts +56 -0
- package/resources/ControlSchedule.ts +56 -0
- package/resources/ControlService.ts +101 -0
- package/runtime/cache-policy.ts +371 -0
- package/runtime/cache.ts +129 -0
- package/runtime/credentials.ts +139 -0
- package/runtime/device.ts +199 -0
- package/runtime/errors.ts +225 -0
- package/runtime/etag-store.ts +252 -0
- package/runtime/json.ts +25 -0
- package/runtime/oauth2.ts +150 -0
- package/runtime/page.ts +81 -0
- package/runtime/realtime-client.ts +339 -0
- package/runtime/realtime.ts +257 -0
- package/runtime/realtime_pb/leavepulse/realtime/v1/ws_pb.ts +464 -0
- package/runtime/resource.ts +84 -0
- package/runtime/snowflake.ts +7 -0
- package/runtime/transport.ts +404 -0
- package/types.ts +7279 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// LeavePulse SDK — CachePolicy.
|
|
2
|
+
//
|
|
3
|
+
// An OPTIONAL layer that sits ABOVE the identity map (cache.ts) and answers a
|
|
4
|
+
// different question than the ETag store does. The ETag store (etag-store.ts)
|
|
5
|
+
// answers "did the bytes change?" (HTTP 304). CachePolicy answers "is my entity
|
|
6
|
+
// fresh enough, at this completeness level, that I can skip the network
|
|
7
|
+
// entirely?" (TTL + levels). The two stack:
|
|
8
|
+
//
|
|
9
|
+
// TTL-hit → no request at all (zero RTT)
|
|
10
|
+
// TTL-miss → the caller's fetcher runs; a generated SDK GET already routes
|
|
11
|
+
// through fetchCached, so a 304 reuses the body — and CachePolicy
|
|
12
|
+
// then stamps a fresh TTL. So an unchanged entity is "fresh" again
|
|
13
|
+
// after one cheap 304, with no re-download.
|
|
14
|
+
//
|
|
15
|
+
// CachePolicy holds NO module-level state: it is handed a `PolicyState` whose
|
|
16
|
+
// Records are owned by the consumer (e.g. Pinia `ref()`s), which keeps it
|
|
17
|
+
// SSR-safe. It NEVER touches the network itself except inside `ensure()`, and
|
|
18
|
+
// even there only via the caller-supplied fetcher — so sync getters stay
|
|
19
|
+
// network-free (RFC 0001 §5).
|
|
20
|
+
|
|
21
|
+
/** Caller-defined completeness levels, ranked by number (higher = more complete). */
|
|
22
|
+
export interface PolicyConfig<TData, TLevel extends string> {
|
|
23
|
+
/** Identity-map type key, e.g. "Server". Informational; the policy keys by id. */
|
|
24
|
+
type: string;
|
|
25
|
+
/** Level → rank. e.g. `{ list: 1, profile: 2, manage: 3 }`. */
|
|
26
|
+
levels: Record<TLevel, number>;
|
|
27
|
+
/** Level → TTL in ms. e.g. `{ list: 60_000, profile: 300_000, manage: 180_000 }`. */
|
|
28
|
+
ttlMs: Record<TLevel, number>;
|
|
29
|
+
/** Extract the alias refs an entity can be addressed by (id/slug/domain/...). */
|
|
30
|
+
refs?: (data: TData) => string[];
|
|
31
|
+
/** Normalize a ref before indexing/lookup. Default: trim + lowercase. */
|
|
32
|
+
normalizeRef?: (ref: string) => string;
|
|
33
|
+
/** Shallow-merge incoming onto base, skipping `undefined`. Default below. */
|
|
34
|
+
merge?: (base: TData, incoming: Partial<TData>) => TData;
|
|
35
|
+
/** Default TTL for query/page entries when `setQuery` gets no override. */
|
|
36
|
+
queryTtlMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Optional readiness gate BEYOND level+TTL: whether a cached entry's payload
|
|
39
|
+
* is rich enough to satisfy `level`. Lets a consumer demand specific fields
|
|
40
|
+
* (e.g. a "profile" entry without `display_server` is incomplete, so re-fetch
|
|
41
|
+
* even though its level/TTL pass). Returns `true` when ready. Default: always
|
|
42
|
+
* ready (level+TTL alone decide).
|
|
43
|
+
*/
|
|
44
|
+
ready?: (data: TData, level: TLevel) => boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Fields to PRESERVE when a lower-level upsert lands on a higher-level entry,
|
|
47
|
+
* keyed by the level that owns them. When merging e.g. a "list" payload onto
|
|
48
|
+
* an entry already at "manage", the manage-owned fields are not clobbered by
|
|
49
|
+
* the thinner payload. Returns the protected field names for a given level.
|
|
50
|
+
*/
|
|
51
|
+
protectedFields?: (level: TLevel) => readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PolicyEntry<TData, TLevel extends string> {
|
|
55
|
+
data: TData;
|
|
56
|
+
level: TLevel;
|
|
57
|
+
/** Per-level last-updated timestamp (ms). 0 = never. */
|
|
58
|
+
freshness: Record<TLevel, number>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface QueryEntry {
|
|
62
|
+
ids: string[];
|
|
63
|
+
total: number;
|
|
64
|
+
page: number;
|
|
65
|
+
limit: number;
|
|
66
|
+
fetchedAt: number;
|
|
67
|
+
staleAt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The backing state. The consumer owns these Records (e.g. as Pinia refs) so
|
|
72
|
+
* the policy stays SSR-safe and reactive — CachePolicy only reads/writes them.
|
|
73
|
+
*/
|
|
74
|
+
export interface PolicyState<TData, TLevel extends string> {
|
|
75
|
+
entities: Record<string, PolicyEntry<TData, TLevel>>;
|
|
76
|
+
refToId: Record<string, string>;
|
|
77
|
+
queries: Record<string, QueryEntry>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface EnsureOptions<TLevel extends string> {
|
|
81
|
+
/** Minimum level the cached entry must satisfy to count as a hit. */
|
|
82
|
+
minLevel?: TLevel;
|
|
83
|
+
/** Level to stamp on the fetched result. Defaults to `minLevel`. */
|
|
84
|
+
level?: TLevel;
|
|
85
|
+
/** Force a fetch even if the cache is fresh. */
|
|
86
|
+
force?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_QUERY_TTL_MS = 45_000;
|
|
90
|
+
|
|
91
|
+
function defaultNormalizeRef(ref: string): string {
|
|
92
|
+
return String(ref ?? "")
|
|
93
|
+
.trim()
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultMerge<T extends Record<string, unknown>>(
|
|
98
|
+
base: T,
|
|
99
|
+
incoming: Partial<T>,
|
|
100
|
+
): T {
|
|
101
|
+
const merged: Record<string, unknown> = { ...base };
|
|
102
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
103
|
+
if (value !== undefined) merged[key] = value;
|
|
104
|
+
}
|
|
105
|
+
return merged as T;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class CachePolicy<
|
|
109
|
+
TData extends Record<string, unknown> & { id: string | number },
|
|
110
|
+
TLevel extends string,
|
|
111
|
+
> {
|
|
112
|
+
private readonly config: PolicyConfig<TData, TLevel>;
|
|
113
|
+
private readonly state: PolicyState<TData, TLevel>;
|
|
114
|
+
private readonly normalizeRef: (ref: string) => string;
|
|
115
|
+
private readonly merge: (base: TData, incoming: Partial<TData>) => TData;
|
|
116
|
+
private readonly levelKeys: TLevel[];
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
config: PolicyConfig<TData, TLevel>,
|
|
120
|
+
state: PolicyState<TData, TLevel>,
|
|
121
|
+
) {
|
|
122
|
+
this.config = config;
|
|
123
|
+
this.state = state;
|
|
124
|
+
this.normalizeRef = config.normalizeRef ?? defaultNormalizeRef;
|
|
125
|
+
this.merge = config.merge ?? defaultMerge;
|
|
126
|
+
this.levelKeys = Object.keys(config.levels) as TLevel[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- level helpers ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
private rank(level: TLevel): number {
|
|
132
|
+
return this.config.levels[level] ?? 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether `current` rank is at least `expected`'s. */
|
|
136
|
+
private levelAtLeast(current: TLevel, expected: TLevel): boolean {
|
|
137
|
+
return this.rank(current) >= this.rank(expected);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Return the higher-ranked of two levels. */
|
|
141
|
+
private maxLevel(current: TLevel, incoming: TLevel): TLevel {
|
|
142
|
+
return this.rank(current) >= this.rank(incoming) ? current : incoming;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private emptyFreshness(): Record<TLevel, number> {
|
|
146
|
+
const record = {} as Record<TLevel, number>;
|
|
147
|
+
for (const level of this.levelKeys) record[level] = 0;
|
|
148
|
+
return record;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Stamp every level up to and including `level` with `timestamp`. */
|
|
152
|
+
private touchFreshness(
|
|
153
|
+
freshness: Record<TLevel, number>,
|
|
154
|
+
level: TLevel,
|
|
155
|
+
timestamp: number,
|
|
156
|
+
): Record<TLevel, number> {
|
|
157
|
+
const targetRank = this.rank(level);
|
|
158
|
+
const next = { ...freshness };
|
|
159
|
+
for (const key of this.levelKeys) {
|
|
160
|
+
if (this.rank(key) <= targetRank) {
|
|
161
|
+
next[key] = Math.max(next[key] ?? 0, timestamp);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- ref index -------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
private indexRefs(data: TData, id: string): void {
|
|
170
|
+
if (!this.config.refs) return;
|
|
171
|
+
for (const ref of this.config.refs(data)) {
|
|
172
|
+
const key = this.normalizeRef(ref);
|
|
173
|
+
if (key) this.state.refToId[key] = id;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private idForRef(ref: string): string | null {
|
|
178
|
+
const key = this.normalizeRef(ref);
|
|
179
|
+
if (!key) return null;
|
|
180
|
+
return this.state.refToId[key] ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- sync reads (NEVER hit the network; RFC §5) ----------------------------
|
|
184
|
+
|
|
185
|
+
getById(id: string | number): TData | null {
|
|
186
|
+
const key = String(id ?? "").trim();
|
|
187
|
+
if (!key) return null;
|
|
188
|
+
return this.state.entities[key]?.data ?? null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getByRef(ref: string): TData | null {
|
|
192
|
+
const id = this.idForRef(ref);
|
|
193
|
+
if (!id) return null;
|
|
194
|
+
return this.getById(id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getByIds(ids: Array<string | number>): TData[] {
|
|
198
|
+
const items: TData[] = [];
|
|
199
|
+
for (const id of ids) {
|
|
200
|
+
const entity = this.getById(id);
|
|
201
|
+
if (entity) items.push(entity);
|
|
202
|
+
}
|
|
203
|
+
return items;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
hasLevel(ref: string, level: TLevel): boolean {
|
|
207
|
+
const entry = this.entryForRef(ref);
|
|
208
|
+
if (!entry) return false;
|
|
209
|
+
if (!this.levelAtLeast(entry.level, level)) return false;
|
|
210
|
+
// A higher-or-equal level still fails the readiness gate if the payload
|
|
211
|
+
// lacks the fields that level requires.
|
|
212
|
+
return this.config.ready?.(entry.data, level) ?? true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
isFresh(ref: string, level: TLevel): boolean {
|
|
216
|
+
const entry = this.entryForRef(ref);
|
|
217
|
+
if (!entry) return false;
|
|
218
|
+
const lastUpdated = Number(entry.freshness[level] ?? 0);
|
|
219
|
+
if (lastUpdated <= 0) return false;
|
|
220
|
+
return Date.now() - lastUpdated <= (this.config.ttlMs[level] ?? 0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private entryForRef(ref: string): PolicyEntry<TData, TLevel> | null {
|
|
224
|
+
// Accept a raw id directly as well as an indexed ref.
|
|
225
|
+
const direct = this.state.entities[String(ref ?? "").trim()];
|
|
226
|
+
if (direct) return direct;
|
|
227
|
+
const id = this.idForRef(ref);
|
|
228
|
+
if (!id) return null;
|
|
229
|
+
return this.state.entities[id] ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- mutations -------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/** Identity-map upsert + ref index + level/freshness bump. */
|
|
235
|
+
upsert(data: TData, level: TLevel): TData {
|
|
236
|
+
const id = String(data.id ?? "").trim();
|
|
237
|
+
if (!id) return data;
|
|
238
|
+
|
|
239
|
+
const existing = this.state.entities[id];
|
|
240
|
+
const nextLevel = existing ? this.maxLevel(existing.level, level) : level;
|
|
241
|
+
const baseData = existing?.data ?? ({} as TData);
|
|
242
|
+
const mergedData = this.merge(baseData, data);
|
|
243
|
+
// When a thinner (lower-level) payload lands on a richer entry, keep the
|
|
244
|
+
// higher level's owned fields from being clobbered by absent/blank values.
|
|
245
|
+
if (
|
|
246
|
+
existing &&
|
|
247
|
+
this.config.protectedFields &&
|
|
248
|
+
this.levelAtLeast(existing.level, level) &&
|
|
249
|
+
existing.level !== level
|
|
250
|
+
) {
|
|
251
|
+
for (const field of this.config.protectedFields(existing.level)) {
|
|
252
|
+
if (baseData[field] !== undefined) {
|
|
253
|
+
(mergedData as Record<string, unknown>)[field] = baseData[field];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const mergedFreshness = this.touchFreshness(
|
|
258
|
+
existing?.freshness ?? this.emptyFreshness(),
|
|
259
|
+
level,
|
|
260
|
+
Date.now(),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
this.state.entities[id] = {
|
|
264
|
+
data: mergedData,
|
|
265
|
+
level: nextLevel,
|
|
266
|
+
freshness: mergedFreshness,
|
|
267
|
+
};
|
|
268
|
+
this.indexRefs(mergedData, id);
|
|
269
|
+
return mergedData;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
upsertMany(items: TData[], level: TLevel): void {
|
|
273
|
+
for (const item of items) this.upsert(item, level);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Shallow patch an existing entry's data (e.g. a realtime live overlay). */
|
|
277
|
+
patch(id: string | number, patch: Partial<TData>): void {
|
|
278
|
+
const key = String(id ?? "").trim();
|
|
279
|
+
if (!key) return;
|
|
280
|
+
const existing = this.state.entities[key];
|
|
281
|
+
if (!existing) return;
|
|
282
|
+
const data = this.merge(existing.data, patch);
|
|
283
|
+
this.state.entities[key] = { ...existing, data };
|
|
284
|
+
this.indexRefs(data, key);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
invalidate(id: string | number): void {
|
|
288
|
+
const key = String(id ?? "").trim();
|
|
289
|
+
if (key) delete this.state.entities[key];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- query / page cache ----------------------------------------------------
|
|
293
|
+
|
|
294
|
+
setQuery(
|
|
295
|
+
queryKey: string,
|
|
296
|
+
payload: {
|
|
297
|
+
ids: Array<string | number>;
|
|
298
|
+
total: number;
|
|
299
|
+
page: number;
|
|
300
|
+
limit: number;
|
|
301
|
+
},
|
|
302
|
+
opts: { ttlMs?: number } = {},
|
|
303
|
+
): void {
|
|
304
|
+
const key = String(queryKey ?? "").trim();
|
|
305
|
+
if (!key) return;
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
const ttl =
|
|
308
|
+
Number(opts.ttlMs) > 0
|
|
309
|
+
? Number(opts.ttlMs)
|
|
310
|
+
: (this.config.queryTtlMs ?? DEFAULT_QUERY_TTL_MS);
|
|
311
|
+
this.state.queries[key] = {
|
|
312
|
+
ids: payload.ids.map((id) => String(id)).filter(Boolean),
|
|
313
|
+
total: Number(payload.total || 0),
|
|
314
|
+
page: Number(payload.page || 1),
|
|
315
|
+
limit: Number(payload.limit || 20),
|
|
316
|
+
fetchedAt: now,
|
|
317
|
+
staleAt: now + ttl,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getQuery(queryKey: string): QueryEntry | null {
|
|
322
|
+
const key = String(queryKey ?? "").trim();
|
|
323
|
+
if (!key) return null;
|
|
324
|
+
return this.state.queries[key] ?? null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
isQueryFresh(queryKey: string): boolean {
|
|
328
|
+
const entry = this.getQuery(queryKey);
|
|
329
|
+
if (!entry) return false;
|
|
330
|
+
return Date.now() <= entry.staleAt;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- the ONLY network-touching method --------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Return a fresh-enough cached entry, or fetch via `fetcher` and cache it.
|
|
337
|
+
*
|
|
338
|
+
* The `fetcher` is a thunk the caller builds from a generated SDK GET (which
|
|
339
|
+
* already routes through the ETag layer). So on a TTL-miss the round-trip may
|
|
340
|
+
* still resolve to a 304 inside the fetcher, reusing the body; `ensure` then
|
|
341
|
+
* stamps a fresh TTL. ETag is never touched here — the two layers compose.
|
|
342
|
+
*/
|
|
343
|
+
async ensure(
|
|
344
|
+
ref: string,
|
|
345
|
+
fetcher: () => Promise<TData | null>,
|
|
346
|
+
opts: EnsureOptions<TLevel> = {},
|
|
347
|
+
): Promise<TData | null> {
|
|
348
|
+
// levelKeys is derived from the required `config.levels` map, so it is
|
|
349
|
+
// always non-empty — the `[0]` fallback exists.
|
|
350
|
+
const minLevel: TLevel = opts.minLevel ?? (this.levelKeys[0] as TLevel);
|
|
351
|
+
const stampLevel = opts.level ?? minLevel;
|
|
352
|
+
const force = Boolean(opts.force);
|
|
353
|
+
|
|
354
|
+
const cached = this.entryForRef(ref);
|
|
355
|
+
// A hit must clear all three gates: level reached, readiness payload
|
|
356
|
+
// present (config.ready), and TTL still fresh. `hasLevel` covers the
|
|
357
|
+
// first two; `isFresh` the third.
|
|
358
|
+
if (
|
|
359
|
+
cached &&
|
|
360
|
+
!force &&
|
|
361
|
+
this.hasLevel(ref, minLevel) &&
|
|
362
|
+
this.isFresh(ref, minLevel)
|
|
363
|
+
) {
|
|
364
|
+
return cached.data;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const fetched = await fetcher();
|
|
368
|
+
if (!fetched) return cached?.data ?? null;
|
|
369
|
+
return this.upsert(fetched, stampLevel);
|
|
370
|
+
}
|
|
371
|
+
}
|
package/runtime/cache.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// LeavePulse SDK — identity map.
|
|
2
|
+
//
|
|
3
|
+
// Guarantees one live object per (resource, id): `client.project(1)` always
|
|
4
|
+
// returns the same instance, so `p1 === p2`. Resource instances hold their
|
|
5
|
+
// data; the cache hands back the existing instance and refreshes its data in
|
|
6
|
+
// place rather than minting a new object.
|
|
7
|
+
//
|
|
8
|
+
// Per RFC 0001 §5 the cache is NOT a TTL auto-refetcher. Getters never touch
|
|
9
|
+
// the network; data only changes on an explicit `refresh()` / `invalidate()`.
|
|
10
|
+
|
|
11
|
+
export interface Identified {
|
|
12
|
+
/** Stable identity within its resource type. */
|
|
13
|
+
readonly id: string | number;
|
|
14
|
+
/** Replace this instance's backing data in place (used on refresh). */
|
|
15
|
+
_hydrate(data: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class IdentityMap {
|
|
19
|
+
private readonly byType = new Map<string, Map<string, Identified>>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Return the canonical instance for (type, id), creating it via `factory`
|
|
23
|
+
* on first sight and hydrating the existing one with fresh data otherwise.
|
|
24
|
+
*/
|
|
25
|
+
upsert<T extends Identified>(
|
|
26
|
+
type: string,
|
|
27
|
+
id: string | number,
|
|
28
|
+
data: unknown,
|
|
29
|
+
factory: () => T,
|
|
30
|
+
): T {
|
|
31
|
+
const key = String(id);
|
|
32
|
+
let bucket = this.byType.get(type);
|
|
33
|
+
if (!bucket) {
|
|
34
|
+
bucket = new Map();
|
|
35
|
+
this.byType.set(type, bucket);
|
|
36
|
+
}
|
|
37
|
+
const existing = bucket.get(key);
|
|
38
|
+
if (existing) {
|
|
39
|
+
existing._hydrate(data);
|
|
40
|
+
return existing as T;
|
|
41
|
+
}
|
|
42
|
+
const created = factory();
|
|
43
|
+
bucket.set(key, created);
|
|
44
|
+
return created;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hydrate a resource that may have been addressed by an alias, e.g. a public
|
|
49
|
+
* user profile loaded by slug whose canonical identity is `user_id`.
|
|
50
|
+
*/
|
|
51
|
+
upsertAlias<T extends Identified>(
|
|
52
|
+
type: string,
|
|
53
|
+
aliasId: string | number,
|
|
54
|
+
canonicalId: string | number,
|
|
55
|
+
data: unknown,
|
|
56
|
+
factory: () => T,
|
|
57
|
+
): T {
|
|
58
|
+
const aliasKey = String(aliasId);
|
|
59
|
+
const canonicalKey = String(canonicalId);
|
|
60
|
+
let bucket = this.byType.get(type);
|
|
61
|
+
if (!bucket) {
|
|
62
|
+
bucket = new Map();
|
|
63
|
+
this.byType.set(type, bucket);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const canonical = bucket.get(canonicalKey);
|
|
67
|
+
const alias = bucket.get(aliasKey);
|
|
68
|
+
if (canonical) {
|
|
69
|
+
canonical._hydrate(data);
|
|
70
|
+
if (aliasKey !== canonicalKey) bucket.set(aliasKey, canonical);
|
|
71
|
+
if (alias && alias !== canonical) alias._hydrate(data);
|
|
72
|
+
return canonical as T;
|
|
73
|
+
}
|
|
74
|
+
if (alias) {
|
|
75
|
+
alias._hydrate(data);
|
|
76
|
+
bucket.set(canonicalKey, alias);
|
|
77
|
+
return alias as T;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const created = factory();
|
|
81
|
+
created._hydrate(data);
|
|
82
|
+
bucket.set(canonicalKey, created);
|
|
83
|
+
if (aliasKey !== canonicalKey) bucket.set(aliasKey, created);
|
|
84
|
+
return created;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Return the canonical instance for (type, id) WITHOUT touching its data:
|
|
89
|
+
* the existing one as-is, or a freshly constructed one (typically seeded
|
|
90
|
+
* with just `{ id }`) on first sight. Used by sync id-accessors
|
|
91
|
+
* (`client.project(1)`) that hand back a resource without a GET; data is
|
|
92
|
+
* loaded on demand via `refresh()`. Unlike `upsert`, never overwrites an
|
|
93
|
+
* already-hydrated instance.
|
|
94
|
+
*/
|
|
95
|
+
ensure<T extends Identified>(
|
|
96
|
+
type: string,
|
|
97
|
+
id: string | number,
|
|
98
|
+
factory: () => T,
|
|
99
|
+
): T {
|
|
100
|
+
const key = String(id);
|
|
101
|
+
let bucket = this.byType.get(type);
|
|
102
|
+
if (!bucket) {
|
|
103
|
+
bucket = new Map();
|
|
104
|
+
this.byType.set(type, bucket);
|
|
105
|
+
}
|
|
106
|
+
const existing = bucket.get(key);
|
|
107
|
+
if (existing) return existing as T;
|
|
108
|
+
const created = factory();
|
|
109
|
+
bucket.set(key, created);
|
|
110
|
+
return created;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get<T extends Identified>(type: string, id: string | number): T | undefined {
|
|
114
|
+
return this.byType.get(type)?.get(String(id)) as T | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Drop one instance (or a whole type) from the cache. */
|
|
118
|
+
invalidate(type: string, id?: string | number): void {
|
|
119
|
+
if (id === undefined) {
|
|
120
|
+
this.byType.delete(type);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.byType.get(type)?.delete(String(id));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clear(): void {
|
|
127
|
+
this.byType.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// LeavePulse SDK — credential providers.
|
|
2
|
+
//
|
|
3
|
+
// A `CredentialProvider` is the seam between *acquiring* a token (PAT, device
|
|
4
|
+
// flow, OAuth2, service token) and *sending* it: `AuthenticatedTransport` asks
|
|
5
|
+
// the provider for a bearer before each request and, on a `401`, for a refresh.
|
|
6
|
+
// Providers are transport-agnostic — anything that performs a network call
|
|
7
|
+
// (e.g. a token refresh) is injected, never hardcoded — so the same provider
|
|
8
|
+
// works from a browser, a CLI, Node, or the launcher.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Supplies the bearer token a transport sends, and optionally rotates it.
|
|
12
|
+
*
|
|
13
|
+
* - `token()` returns the **current** access token to send.
|
|
14
|
+
* - `refresh()` (optional) exchanges a refresh token for a new pair; the
|
|
15
|
+
* transport calls it once on a `401` and retries. Omit it for non-rotating
|
|
16
|
+
* credentials (PAT, service token).
|
|
17
|
+
*/
|
|
18
|
+
export interface CredentialProvider {
|
|
19
|
+
/** The current bearer token to send on the next request. */
|
|
20
|
+
token(): Promise<string>;
|
|
21
|
+
/** Rotate the credential (e.g. exchange a refresh token). Optional. */
|
|
22
|
+
refresh?(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fixed, non-rotating credential: `token()` always returns the same value
|
|
27
|
+
* and there is no `refresh`. Use for Personal Access Tokens and out-of-band
|
|
28
|
+
* service tokens — `BearerTransport` is exactly this.
|
|
29
|
+
*/
|
|
30
|
+
export function StaticCredential(token: string): CredentialProvider {
|
|
31
|
+
return {
|
|
32
|
+
token: () => Promise.resolve(token),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The token pair a refresh call yields (wire shape: snake_case). */
|
|
37
|
+
export interface TokenPair {
|
|
38
|
+
access_token: string;
|
|
39
|
+
refresh_token?: string | null;
|
|
40
|
+
/** Access-token lifetime in seconds, used to compute the local expiry. */
|
|
41
|
+
expires_in?: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Performs the actual refresh exchange. Injected so the credential stays
|
|
45
|
+
* transport-agnostic (no hardcoded fetch/URL): the caller decides whether it
|
|
46
|
+
* hits the transport, a raw fetch, or a mock. */
|
|
47
|
+
export type RefreshFn = (refreshToken: string) => Promise<TokenPair>;
|
|
48
|
+
|
|
49
|
+
/** Seed tokens for a {@link RefreshingCredential}. */
|
|
50
|
+
export interface RefreshingCredentialInit {
|
|
51
|
+
accessToken: string;
|
|
52
|
+
refreshToken: string;
|
|
53
|
+
/** Access-token lifetime in seconds, from the issuing response. */
|
|
54
|
+
expiresIn?: number | null;
|
|
55
|
+
/** Performs the refresh-token exchange. */
|
|
56
|
+
refreshFn: RefreshFn;
|
|
57
|
+
/** Refresh this many seconds before the computed expiry (default 30). */
|
|
58
|
+
leewaySeconds?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Holds an access + refresh token locally and rotates them on demand.
|
|
63
|
+
* `token()` returns the access token, transparently refreshing first if it is
|
|
64
|
+
* known to be expired; `refresh()` forces an exchange (called by the transport
|
|
65
|
+
* on a `401`). The network exchange is delegated to the injected `refreshFn`,
|
|
66
|
+
* so this works for the launcher (refresh-in-body), device flow, and any other
|
|
67
|
+
* rotating credential without knowing the URL or transport.
|
|
68
|
+
*/
|
|
69
|
+
export class RefreshingCredential implements CredentialProvider {
|
|
70
|
+
private accessToken: string;
|
|
71
|
+
private refreshToken: string;
|
|
72
|
+
private readonly refreshFn: RefreshFn;
|
|
73
|
+
private readonly leewayMs: number;
|
|
74
|
+
/** Epoch ms when the access token expires, or `undefined` if unknown. */
|
|
75
|
+
private expiresAt?: number;
|
|
76
|
+
/** Coalesces concurrent refreshes into one in-flight exchange. */
|
|
77
|
+
private inflight?: Promise<void>;
|
|
78
|
+
|
|
79
|
+
constructor(init: RefreshingCredentialInit) {
|
|
80
|
+
this.accessToken = init.accessToken;
|
|
81
|
+
this.refreshToken = init.refreshToken;
|
|
82
|
+
this.refreshFn = init.refreshFn;
|
|
83
|
+
this.leewayMs = (init.leewaySeconds ?? 30) * 1000;
|
|
84
|
+
this.expiresAt = expiryFromSeconds(init.expiresIn);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The current refresh token (for persisting to a local store). */
|
|
88
|
+
get currentRefreshToken(): string {
|
|
89
|
+
return this.refreshToken;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async token(): Promise<string> {
|
|
93
|
+
// Refresh `leeway` ms early so the token is still valid for the request.
|
|
94
|
+
if (
|
|
95
|
+
this.expiresAt !== undefined &&
|
|
96
|
+
Date.now() >= this.expiresAt - this.leewayMs
|
|
97
|
+
) {
|
|
98
|
+
await this.refresh();
|
|
99
|
+
}
|
|
100
|
+
return this.accessToken;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async refresh(): Promise<void> {
|
|
104
|
+
// Coalesce concurrent callers (e.g. a 401 retry racing an expiry check).
|
|
105
|
+
if (this.inflight) return this.inflight;
|
|
106
|
+
this.inflight = (async () => {
|
|
107
|
+
try {
|
|
108
|
+
const pair = await this.refreshFn(this.refreshToken);
|
|
109
|
+
this.applyTokenPair(pair);
|
|
110
|
+
} finally {
|
|
111
|
+
this.inflight = undefined;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
return this.inflight;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Adopt a freshly-issued token pair (keeps the old refresh token if the
|
|
118
|
+
* server rotated only the access token). */
|
|
119
|
+
protected applyTokenPair(pair: TokenPair): void {
|
|
120
|
+
this.accessToken = pair.access_token;
|
|
121
|
+
if (pair.refresh_token) this.refreshToken = pair.refresh_token;
|
|
122
|
+
this.expiresAt = expiryFromSeconds(pair.expires_in);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A credential seeded from an OAuth2 authorization-code token exchange that
|
|
128
|
+
* auto-refreshes via the same `/auth/oauth2/token` endpoint. Behaviour is
|
|
129
|
+
* identical to {@link RefreshingCredential}; it exists as a named type so
|
|
130
|
+
* `sdk.oauth2.exchangeCode` can return a self-describing credential. The
|
|
131
|
+
* refresh exchange is still injected (`refreshFn`) to stay transport-agnostic.
|
|
132
|
+
*/
|
|
133
|
+
export class OAuth2Credential extends RefreshingCredential {}
|
|
134
|
+
|
|
135
|
+
/** Convert a seconds-from-now lifetime into an absolute epoch-ms expiry. */
|
|
136
|
+
function expiryFromSeconds(expiresIn?: number | null): number | undefined {
|
|
137
|
+
if (expiresIn === undefined || expiresIn === null) return undefined;
|
|
138
|
+
return Date.now() + expiresIn * 1000;
|
|
139
|
+
}
|