app-settings-js 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,962 @@
1
+ // src/errors.ts
2
+ class AppSettingsError extends Error {
3
+ name = "AppSettingsError";
4
+ code;
5
+ status;
6
+ requestId;
7
+ request;
8
+ body;
9
+ constructor(message, options) {
10
+ super(message, { cause: options.cause });
11
+ this.code = options.code;
12
+ this.status = options.status;
13
+ this.requestId = options.requestId;
14
+ this.request = options.request;
15
+ this.body = options.body;
16
+ }
17
+ get retryable() {
18
+ if (this.code === "network_error" || this.code === "timeout" || this.code === "unavailable") {
19
+ return true;
20
+ }
21
+ return this.status === 429 || this.status !== undefined && this.status >= 500;
22
+ }
23
+ static is(error) {
24
+ return error instanceof AppSettingsError;
25
+ }
26
+ }
27
+ var isNotFound = (error) => codeIs(error, "not_found");
28
+ var isUnauthorized = (error) => codeIs(error, "unauthorized");
29
+ var isForbidden = (error) => codeIs(error, "forbidden");
30
+ var isConflict = (error) => codeIs(error, "conflict");
31
+ var isInvalidRequest = (error) => codeIs(error, "invalid_request") || codeIs(error, "invalid_value");
32
+ function codeIs(error, code) {
33
+ return AppSettingsError.is(error) && error.code === code;
34
+ }
35
+
36
+ // src/http.ts
37
+ function createTransport(options) {
38
+ const fetchImpl = options.fetch ?? globalThis.fetch;
39
+ if (typeof fetchImpl !== "function") {
40
+ throw new AppSettingsError("No `fetch` is available. Pass one as `fetch` in the client options, or run on Node 18+, Bun, Deno or a browser.", { code: "invalid_request" });
41
+ }
42
+ if (!options.baseUrl) {
43
+ throw new AppSettingsError("`baseUrl` is required, for example http://localhost:8080", {
44
+ code: "invalid_request"
45
+ });
46
+ }
47
+ if (!options.apiKey) {
48
+ throw new AppSettingsError("`apiKey` is required; every route below /api/v1 needs one", {
49
+ code: "invalid_request"
50
+ });
51
+ }
52
+ return {
53
+ baseUrl: options.baseUrl.replace(/\/+$/, ""),
54
+ apiKey: options.apiKey,
55
+ fetch: (input, init) => fetchImpl(input, init),
56
+ timeoutMs: options.timeoutMs ?? 1e4,
57
+ retries: Math.max(0, options.retries ?? 2),
58
+ retryDelayMs: Math.max(0, options.retryDelayMs ?? 200),
59
+ headers: { ...options.headers }
60
+ };
61
+ }
62
+ async function request(config, spec) {
63
+ const url = config.baseUrl + spec.path + buildQuery(spec.query);
64
+ const label = `${spec.method} ${spec.path}`;
65
+ const timeoutMs = spec.options?.timeoutMs ?? config.timeoutMs;
66
+ const headers = {
67
+ accept: "application/json",
68
+ authorization: `Bearer ${config.apiKey}`,
69
+ ...config.headers,
70
+ ...lowercaseKeys(spec.options?.headers)
71
+ };
72
+ let payload;
73
+ if (spec.body !== undefined) {
74
+ payload = JSON.stringify(spec.body);
75
+ headers["content-type"] = "application/json";
76
+ }
77
+ const attempts = spec.method === "POST" ? 1 : config.retries + 1;
78
+ let lastError;
79
+ for (let attempt = 0;attempt < attempts; attempt++) {
80
+ if (attempt > 0) {
81
+ await delay(backoffFor(attempt, config.retryDelayMs, lastError), spec.options?.signal);
82
+ }
83
+ let response;
84
+ try {
85
+ response = await config.fetch(url, {
86
+ method: spec.method,
87
+ headers,
88
+ body: payload,
89
+ signal: timeoutSignal(timeoutMs, spec.options?.signal)
90
+ });
91
+ } catch (cause) {
92
+ lastError = fromThrown(cause, label, spec.options?.signal, timeoutMs);
93
+ if (lastError.code === "aborted" || lastError.code === "timeout")
94
+ throw lastError;
95
+ continue;
96
+ }
97
+ if (response.ok)
98
+ return await decode(response, label);
99
+ lastError = await errorFromResponse(response, label);
100
+ if (!lastError.retryable || attempt === attempts - 1)
101
+ throw lastError;
102
+ }
103
+ throw lastError ?? new AppSettingsError(`${label} failed`, { code: "network_error", request: label });
104
+ }
105
+ function buildQuery(query) {
106
+ if (!query)
107
+ return "";
108
+ const params = new URLSearchParams;
109
+ for (const [key, value] of Object.entries(query)) {
110
+ if (value === undefined || value === null || value === "")
111
+ continue;
112
+ if (Array.isArray(value)) {
113
+ for (const item of value)
114
+ if (item !== "")
115
+ params.append(key, item);
116
+ } else {
117
+ params.append(key, String(value));
118
+ }
119
+ }
120
+ const rendered = params.toString();
121
+ return rendered ? `?${rendered}` : "";
122
+ }
123
+ async function decode(response, label) {
124
+ if (response.status === 204)
125
+ return;
126
+ const text = await response.text();
127
+ if (text === "")
128
+ return;
129
+ try {
130
+ return JSON.parse(text);
131
+ } catch (cause) {
132
+ throw new AppSettingsError(`${label} returned a body that is not JSON`, {
133
+ code: "invalid_response",
134
+ status: response.status,
135
+ requestId: response.headers.get("x-request-id") ?? undefined,
136
+ request: label,
137
+ body: text.slice(0, 512),
138
+ cause
139
+ });
140
+ }
141
+ }
142
+ async function errorFromResponse(response, label) {
143
+ const requestId = response.headers.get("x-request-id") ?? undefined;
144
+ const text = await response.text().catch(() => "");
145
+ let body;
146
+ try {
147
+ body = text ? JSON.parse(text) : undefined;
148
+ } catch {
149
+ body = text;
150
+ }
151
+ const detail = body?.error;
152
+ const message = detail?.message ?? (text ? text.slice(0, 512) : response.statusText) ?? "request failed";
153
+ return new AppSettingsError(`${label} failed with ${response.status}: ${message}`, {
154
+ code: detail?.code ?? statusToCode(response.status),
155
+ status: response.status,
156
+ requestId,
157
+ request: label,
158
+ body
159
+ });
160
+ }
161
+ function statusToCode(status) {
162
+ switch (status) {
163
+ case 400:
164
+ return "invalid_request";
165
+ case 401:
166
+ return "unauthorized";
167
+ case 403:
168
+ return "forbidden";
169
+ case 404:
170
+ return "not_found";
171
+ case 409:
172
+ return "conflict";
173
+ case 503:
174
+ return "unavailable";
175
+ default:
176
+ return status >= 500 ? "internal_error" : "invalid_request";
177
+ }
178
+ }
179
+ function fromThrown(cause, label, signal, timeoutMs) {
180
+ const aborted = cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError");
181
+ if (aborted && signal?.aborted) {
182
+ return new AppSettingsError(`${label} was aborted`, { code: "aborted", request: label, cause });
183
+ }
184
+ if (aborted) {
185
+ return new AppSettingsError(`${label} timed out after ${timeoutMs}ms`, {
186
+ code: "timeout",
187
+ request: label,
188
+ cause
189
+ });
190
+ }
191
+ return new AppSettingsError(`${label} could not reach the server: ${errorText(cause)}`, {
192
+ code: "network_error",
193
+ request: label,
194
+ cause
195
+ });
196
+ }
197
+ function timeoutSignal(timeoutMs, signal) {
198
+ const timeout = timeoutMs > 0 && typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(timeoutMs) : undefined;
199
+ if (!timeout)
200
+ return signal;
201
+ if (!signal)
202
+ return timeout;
203
+ if (typeof AbortSignal.any === "function")
204
+ return AbortSignal.any([signal, timeout]);
205
+ return signal;
206
+ }
207
+ function backoffFor(attempt, base, previous) {
208
+ const retryAfter = retryAfterMs(previous);
209
+ if (retryAfter !== undefined)
210
+ return retryAfter;
211
+ const exponential = base * 2 ** (attempt - 1);
212
+ return exponential + Math.random() * base;
213
+ }
214
+ function retryAfterMs(error) {
215
+ if (error?.status !== 429 && error?.status !== 503)
216
+ return;
217
+ const header = error.body?.retry_after;
218
+ return typeof header === "number" && header >= 0 ? header * 1000 : undefined;
219
+ }
220
+ function delay(ms, signal) {
221
+ if (ms <= 0)
222
+ return Promise.resolve();
223
+ return new Promise((resolve, reject) => {
224
+ const timer = setTimeout(finish, ms);
225
+ signal?.addEventListener("abort", onAbort, { once: true });
226
+ function finish() {
227
+ signal?.removeEventListener("abort", onAbort);
228
+ resolve();
229
+ }
230
+ function onAbort() {
231
+ clearTimeout(timer);
232
+ reject(new AppSettingsError("the request was aborted", { code: "aborted" }));
233
+ }
234
+ });
235
+ }
236
+ function lowercaseKeys(headers) {
237
+ if (!headers)
238
+ return {};
239
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
240
+ }
241
+ function errorText(cause) {
242
+ return cause instanceof Error ? cause.message : String(cause);
243
+ }
244
+
245
+ // src/datetime.ts
246
+ var RFC_3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
247
+ var DATETIME_LOCAL = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
248
+ function toInstant(value) {
249
+ if (value instanceof Date) {
250
+ if (Number.isNaN(value.getTime())) {
251
+ throw invalid("an Invalid Date cannot be converted to an instant");
252
+ }
253
+ return value.toISOString();
254
+ }
255
+ if (typeof value === "number") {
256
+ if (!Number.isFinite(value)) {
257
+ throw invalid(`${value} is not a valid epoch milliseconds value`);
258
+ }
259
+ return new Date(value).toISOString();
260
+ }
261
+ if (DATETIME_LOCAL.test(value)) {
262
+ throw invalid(`"${value}" has no UTC offset, so it is a wall-clock reading rather than an instant. ` + "Pass it through localToInstant() to read it in a specific timezone, " + "or through toInstant(new Date(value)) to accept the runtime's own zone.");
263
+ }
264
+ if (!RFC_3339.test(value)) {
265
+ throw invalid(`"${value}" is not an RFC 3339 date-time. The expected shape is ` + "2026-01-02T15:04:05Z or 2026-01-02T15:04:05-05:00.");
266
+ }
267
+ const parsed = new Date(value);
268
+ if (Number.isNaN(parsed.getTime())) {
269
+ throw invalid(`"${value}" is shaped like an RFC 3339 date-time but is not a real moment`);
270
+ }
271
+ return parsed.toISOString();
272
+ }
273
+ function localToInstant(local, timeZone) {
274
+ if (!DATETIME_LOCAL.test(local) && !RFC_3339.test(local)) {
275
+ throw invalid(`"${local}" is not a datetime-local value such as 2026-01-02T15:04`);
276
+ }
277
+ if (RFC_3339.test(local))
278
+ return toInstant(local);
279
+ if (!timeZone) {
280
+ const parsed = new Date(local);
281
+ if (Number.isNaN(parsed.getTime()))
282
+ throw invalid(`"${local}" is not a real moment`);
283
+ return parsed.toISOString();
284
+ }
285
+ const naive = Date.parse(`${withSeconds(local)}Z`);
286
+ if (Number.isNaN(naive))
287
+ throw invalid(`"${local}" is not a real moment`);
288
+ let instant = naive;
289
+ for (let pass = 0;pass < 2; pass++) {
290
+ instant = naive + zoneOffsetMs(instant, timeZone);
291
+ }
292
+ return new Date(instant).toISOString();
293
+ }
294
+ function parseInstant(value) {
295
+ if (value === null || value === undefined)
296
+ return;
297
+ if (value instanceof Date)
298
+ return value;
299
+ if (typeof value !== "string") {
300
+ throw invalid(`expected an RFC 3339 string, got ${typeof value}`);
301
+ }
302
+ const parsed = new Date(value);
303
+ if (Number.isNaN(parsed.getTime())) {
304
+ throw invalid(`"${value}" is not a parseable date-time`);
305
+ }
306
+ return parsed;
307
+ }
308
+ function isInstant(value) {
309
+ return typeof value === "string" && RFC_3339.test(value) && !Number.isNaN(Date.parse(value));
310
+ }
311
+ function toDateTimeLocal(value, timeZone) {
312
+ const date = value instanceof Date ? value : new Date(typeof value === "string" ? value : Number(value));
313
+ if (Number.isNaN(date.getTime()))
314
+ throw invalid("cannot render an Invalid Date");
315
+ const parts = zoneParts(date, timeZone);
316
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
317
+ }
318
+ function zoneOffsetMs(instant, timeZone) {
319
+ const parts = zoneParts(new Date(instant), timeZone);
320
+ const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second), new Date(instant).getUTCMilliseconds());
321
+ return instant - asUtc;
322
+ }
323
+ function zoneParts(date, timeZone) {
324
+ const formatter = new Intl.DateTimeFormat("en-US", {
325
+ timeZone,
326
+ hourCycle: "h23",
327
+ year: "numeric",
328
+ month: "2-digit",
329
+ day: "2-digit",
330
+ hour: "2-digit",
331
+ minute: "2-digit",
332
+ second: "2-digit"
333
+ });
334
+ const parts = {};
335
+ for (const part of formatter.formatToParts(date)) {
336
+ if (part.type !== "literal")
337
+ parts[part.type] = part.value;
338
+ }
339
+ return parts;
340
+ }
341
+ function withSeconds(local) {
342
+ return /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}$/.test(local) ? `${local}:00` : local;
343
+ }
344
+ function invalid(message) {
345
+ return new AppSettingsError(message, { code: "invalid_value" });
346
+ }
347
+
348
+ // src/snapshot.ts
349
+ class SettingsSnapshot {
350
+ settings;
351
+ environment;
352
+ platforms;
353
+ userId;
354
+ role;
355
+ resolvedAt;
356
+ #byName;
357
+ constructor(response) {
358
+ this.settings = Object.freeze([...response.settings ?? []]);
359
+ this.environment = response.environment;
360
+ this.platforms = Object.freeze([...response.platforms ?? []]);
361
+ this.userId = response.user_id;
362
+ this.role = response.role;
363
+ this.resolvedAt = parseInstant(response.resolved_at) ?? new Date;
364
+ this.#byName = new Map(this.settings.map((setting) => [setting.name, setting]));
365
+ Object.freeze(this);
366
+ }
367
+ get names() {
368
+ return [...this.#byName.keys()];
369
+ }
370
+ get size() {
371
+ return this.settings.length;
372
+ }
373
+ has(name) {
374
+ return this.#byName.has(name);
375
+ }
376
+ get(name) {
377
+ return this.#byName.get(name);
378
+ }
379
+ value(name) {
380
+ return this.#byName.get(name)?.value ?? null;
381
+ }
382
+ isSet(name) {
383
+ const setting = this.#byName.get(name);
384
+ return setting !== undefined && setting.source !== "UNSET";
385
+ }
386
+ source(name) {
387
+ return this.#byName.get(name)?.source;
388
+ }
389
+ boolean(name, fallback = false) {
390
+ return this.#typed(name, fallback, "boolean", (value) => typeof value === "boolean");
391
+ }
392
+ number(name, fallback = 0) {
393
+ return this.#typed(name, fallback, "number", (value) => typeof value === "number" && Number.isFinite(value));
394
+ }
395
+ string(name, fallback = "") {
396
+ return this.#typed(name, fallback, "string", (value) => typeof value === "string");
397
+ }
398
+ date(name, fallback) {
399
+ const value = this.#present(name);
400
+ if (value === undefined || value === null)
401
+ return fallback;
402
+ if (typeof value !== "string")
403
+ throw this.#mismatch(name, "a date-time string", value);
404
+ return parseInstant(value);
405
+ }
406
+ list(name, fallback = []) {
407
+ const value = this.#present(name);
408
+ if (value === undefined || value === null)
409
+ return fallback;
410
+ return Array.isArray(value) ? value : [value];
411
+ }
412
+ json(name, fallback) {
413
+ const value = this.#present(name);
414
+ return value === undefined || value === null ? fallback : value;
415
+ }
416
+ options(name) {
417
+ return this.#byName.get(name)?.type_config?.options ?? [];
418
+ }
419
+ override(name) {
420
+ return this.#byName.get(name)?.override;
421
+ }
422
+ visibleOverride(name) {
423
+ const override = this.override(name);
424
+ return override?.visible ? override : undefined;
425
+ }
426
+ isEnforced(name) {
427
+ return this.override(name)?.enforced === true;
428
+ }
429
+ editable() {
430
+ return this.settings.filter((setting) => setting.scope === "PERSONAL" && setting.override?.enforced !== true);
431
+ }
432
+ filter(predicate) {
433
+ return this.settings.filter(predicate);
434
+ }
435
+ toObject() {
436
+ return Object.fromEntries(this.settings.map((setting) => [setting.name, setting.value]));
437
+ }
438
+ toJSON() {
439
+ return {
440
+ environment: this.environment,
441
+ platforms: [...this.platforms],
442
+ user_id: this.userId,
443
+ role: this.role,
444
+ settings: [...this.settings],
445
+ resolved_at: this.resolvedAt.toISOString()
446
+ };
447
+ }
448
+ with(name, value, source = "PERSONAL") {
449
+ if (!this.#byName.has(name))
450
+ return this;
451
+ return new SettingsSnapshot({
452
+ ...this.toJSON(),
453
+ settings: this.settings.map((setting) => setting.name === name ? { ...setting, value, source } : setting)
454
+ });
455
+ }
456
+ [Symbol.iterator]() {
457
+ return this.settings[Symbol.iterator]();
458
+ }
459
+ #present(name) {
460
+ const setting = this.#byName.get(name);
461
+ if (setting === undefined || setting.source === "UNSET")
462
+ return;
463
+ return setting.value ?? undefined;
464
+ }
465
+ #typed(name, fallback, expected, matches) {
466
+ const value = this.#present(name);
467
+ if (value === undefined)
468
+ return fallback;
469
+ if (!matches(value))
470
+ throw this.#mismatch(name, `a ${expected}`, value);
471
+ return value;
472
+ }
473
+ #mismatch(name, expected, value) {
474
+ const declared = this.#byName.get(name)?.type;
475
+ return new AppSettingsError(`setting "${name}" is declared ${declared} and holds ${describe(value)}, not ${expected}`, { code: "type_mismatch" });
476
+ }
477
+ }
478
+ function snapshotFrom(response) {
479
+ return new SettingsSnapshot(response);
480
+ }
481
+ function describe(value) {
482
+ if (value === null)
483
+ return "null";
484
+ if (Array.isArray(value))
485
+ return "an array";
486
+ return `a ${typeof value}`;
487
+ }
488
+
489
+ // src/client.ts
490
+ class AppSettingsClient {
491
+ #transport;
492
+ #environment;
493
+ #platform;
494
+ constructor(options) {
495
+ this.#transport = createTransport(options);
496
+ this.#environment = options.environment;
497
+ this.#platform = options.platform === undefined ? undefined : toArray(options.platform);
498
+ }
499
+ get environment() {
500
+ return this.#environment;
501
+ }
502
+ withEnvironment(environment) {
503
+ return new AppSettingsClient({
504
+ ...this.#transport,
505
+ environment,
506
+ platform: this.#platform
507
+ });
508
+ }
509
+ async resolveUser(userId, options = {}) {
510
+ const response = await this.#request({
511
+ method: "GET",
512
+ path: `/api/v1/resolve/user/${encode(userId)}`,
513
+ query: {
514
+ ...this.#resolveQuery(options),
515
+ group_id: options.groupId === undefined ? undefined : toArray(options.groupId)
516
+ },
517
+ options
518
+ });
519
+ return new SettingsSnapshot(response);
520
+ }
521
+ async resolveServer(options = {}) {
522
+ const response = await this.#request({
523
+ method: "GET",
524
+ path: "/api/v1/resolve/server",
525
+ query: this.#resolveQuery(options),
526
+ options
527
+ });
528
+ return new SettingsSnapshot(response);
529
+ }
530
+ whoami(options) {
531
+ return this.#request({ method: "GET", path: "/api/v1/whoami", options });
532
+ }
533
+ health(options) {
534
+ return this.#request({ method: "GET", path: "/healthz", options });
535
+ }
536
+ ready(options) {
537
+ return this.#request({ method: "GET", path: "/readyz", options });
538
+ }
539
+ settings = {
540
+ list: async (options = {}) => {
541
+ const body = await this.#request({
542
+ method: "GET",
543
+ path: "/api/v1/settings",
544
+ query: {
545
+ environment: options.environment ?? this.#environment,
546
+ platform: options.platform ?? this.#platform?.[0],
547
+ scope: options.scope
548
+ },
549
+ options
550
+ });
551
+ return body.settings ?? [];
552
+ },
553
+ get: (id, options) => this.#request({ method: "GET", path: `/api/v1/settings/${encode(id)}`, options }),
554
+ create: async (input, options) => {
555
+ const environment = input.environment ?? this.#environment;
556
+ if (!environment)
557
+ throw missingEnvironment("settings.create");
558
+ return this.#request({
559
+ method: "POST",
560
+ path: "/api/v1/settings",
561
+ body: {
562
+ name: input.name,
563
+ description: input.description ?? "",
564
+ type: input.type,
565
+ type_config: input.typeConfig ?? {},
566
+ role: input.role,
567
+ scope: input.scope,
568
+ platform: input.platform,
569
+ environment,
570
+ default_value: input.defaultValue ?? null
571
+ },
572
+ options
573
+ });
574
+ },
575
+ update: (id, input, options) => this.#request({
576
+ method: "PATCH",
577
+ path: `/api/v1/settings/${encode(id)}`,
578
+ body: {
579
+ description: input.description,
580
+ type_config: input.typeConfig,
581
+ role: input.role,
582
+ default_value: input.defaultValue
583
+ },
584
+ options
585
+ }),
586
+ delete: (id, options = {}) => this.#request({
587
+ method: "DELETE",
588
+ path: `/api/v1/settings/${encode(id)}`,
589
+ query: { cascade: options.cascade ? "true" : undefined },
590
+ options
591
+ })
592
+ };
593
+ values = {
594
+ server: {
595
+ get: (settingId, options) => this.#request({ method: "GET", path: `/api/v1/settings/${encode(settingId)}/server`, options }),
596
+ set: async (settingId, value, options) => this.#request({
597
+ method: "PUT",
598
+ path: `/api/v1/settings/${encode(settingId)}/server`,
599
+ body: { value: requireValue(value) },
600
+ options
601
+ }),
602
+ clear: (settingId, options) => this.#request({ method: "DELETE", path: `/api/v1/settings/${encode(settingId)}/server`, options })
603
+ },
604
+ personal: {
605
+ get: (settingId, userId, options) => this.#request({
606
+ method: "GET",
607
+ path: `/api/v1/settings/${encode(settingId)}/personal/${encode(userId)}`,
608
+ options
609
+ }),
610
+ set: async (settingId, userId, value, options) => this.#request({
611
+ method: "PUT",
612
+ path: `/api/v1/settings/${encode(settingId)}/personal/${encode(userId)}`,
613
+ body: { value: requireValue(value) },
614
+ options
615
+ }),
616
+ clear: (settingId, userId, options) => this.#request({
617
+ method: "DELETE",
618
+ path: `/api/v1/settings/${encode(settingId)}/personal/${encode(userId)}`,
619
+ options
620
+ })
621
+ },
622
+ group: {
623
+ get: (settingId, groupId, options) => this.#request({
624
+ method: "GET",
625
+ path: `/api/v1/settings/${encode(settingId)}/intermediate/${encode(groupId)}`,
626
+ options
627
+ }),
628
+ set: async (settingId, groupId, value, options = {}) => this.#request({
629
+ method: "PUT",
630
+ path: `/api/v1/settings/${encode(settingId)}/intermediate/${encode(groupId)}`,
631
+ body: { value: requireValue(value), visible: options.visible, enforced: options.enforced },
632
+ options
633
+ }),
634
+ clear: (settingId, groupId, options) => this.#request({
635
+ method: "DELETE",
636
+ path: `/api/v1/settings/${encode(settingId)}/intermediate/${encode(groupId)}`,
637
+ options
638
+ })
639
+ }
640
+ };
641
+ groups = {
642
+ list: async (options = {}) => {
643
+ const body = await this.#request({
644
+ method: "GET",
645
+ path: "/api/v1/groups",
646
+ query: {
647
+ environment: options.environment ?? this.#environment,
648
+ include_ephemeral: options.includeEphemeral === false ? "false" : undefined
649
+ },
650
+ options
651
+ });
652
+ return body.groups ?? [];
653
+ },
654
+ get: (id, options) => this.#request({ method: "GET", path: `/api/v1/groups/${encode(id)}`, options }),
655
+ create: (input, options) => this.#request({
656
+ method: "POST",
657
+ path: "/api/v1/groups",
658
+ body: {
659
+ name: input.name,
660
+ description: input.description ?? "",
661
+ environment: input.environment === undefined ? this.#environment ?? null : input.environment,
662
+ priority: input.priority ?? 0,
663
+ ephemeral: input.ephemeral ?? false,
664
+ members: input.members
665
+ },
666
+ options
667
+ }),
668
+ update: (id, input, options) => this.#request({ method: "PATCH", path: `/api/v1/groups/${encode(id)}`, body: input, options }),
669
+ delete: (id, options) => this.#request({ method: "DELETE", path: `/api/v1/groups/${encode(id)}`, options }),
670
+ members: async (id, options) => {
671
+ const body = await this.#request({
672
+ method: "GET",
673
+ path: `/api/v1/groups/${encode(id)}/members`,
674
+ options
675
+ });
676
+ return body.members ?? [];
677
+ },
678
+ addMembers: async (id, userIds, options) => {
679
+ const body = await this.#request({
680
+ method: "POST",
681
+ path: `/api/v1/groups/${encode(id)}/members`,
682
+ body: { members: userIds },
683
+ options
684
+ });
685
+ return body.added ?? 0;
686
+ },
687
+ removeMembers: async (id, userIds, options) => {
688
+ const body = await this.#request({
689
+ method: "DELETE",
690
+ path: `/api/v1/groups/${encode(id)}/members`,
691
+ body: { members: userIds },
692
+ options
693
+ });
694
+ return body.removed ?? 0;
695
+ }
696
+ };
697
+ taxonomy = {
698
+ roles: async (options) => (await this.#request({ method: "GET", path: "/api/v1/roles", options })).roles ?? [],
699
+ upsertRole: (name, input, options) => this.#request({
700
+ method: "PUT",
701
+ path: `/api/v1/roles/${encode(name)}`,
702
+ body: { rank: input.rank, description: input.description ?? "" },
703
+ options
704
+ }),
705
+ deleteRole: (name, options) => this.#request({ method: "DELETE", path: `/api/v1/roles/${encode(name)}`, options }),
706
+ platforms: async (options) => (await this.#request({ method: "GET", path: "/api/v1/platforms", options })).platforms ?? [],
707
+ upsertPlatform: (name, description = "", options) => this.#request({ method: "PUT", path: `/api/v1/platforms/${encode(name)}`, body: { description }, options }),
708
+ deletePlatform: (name, options) => this.#request({ method: "DELETE", path: `/api/v1/platforms/${encode(name)}`, options }),
709
+ environments: async (options) => (await this.#request({
710
+ method: "GET",
711
+ path: "/api/v1/environments",
712
+ options
713
+ })).environments ?? [],
714
+ upsertEnvironment: (name, description = "", options) => this.#request({ method: "PUT", path: `/api/v1/environments/${encode(name)}`, body: { description }, options }),
715
+ deleteEnvironment: (name, options) => this.#request({ method: "DELETE", path: `/api/v1/environments/${encode(name)}`, options })
716
+ };
717
+ keys = {
718
+ list: async (options = {}) => {
719
+ const body = await this.#request({
720
+ method: "GET",
721
+ path: "/api/v1/keys",
722
+ query: { include_revoked: options.includeRevoked ? "true" : undefined },
723
+ options
724
+ });
725
+ return body.keys ?? [];
726
+ },
727
+ create: (input, options) => this.#request({
728
+ method: "POST",
729
+ path: "/api/v1/keys",
730
+ body: {
731
+ name: input.name,
732
+ scopes: input.scopes,
733
+ environments: input.environments,
734
+ platforms: input.platforms,
735
+ role: input.role,
736
+ expires_at: input.expiresAt instanceof Date ? input.expiresAt.toISOString() : input.expiresAt,
737
+ expires_in: input.expiresIn
738
+ },
739
+ options
740
+ }),
741
+ revoke: (id, options) => this.#request({ method: "DELETE", path: `/api/v1/keys/${encode(id)}`, options })
742
+ };
743
+ #request(spec) {
744
+ return request(this.#transport, spec);
745
+ }
746
+ #resolveQuery(options) {
747
+ const environment = options.environment ?? this.#environment;
748
+ if (!environment)
749
+ throw missingEnvironment("resolve");
750
+ const platform = options.platform === undefined ? this.#platform : toArray(options.platform);
751
+ return { environment, platform, role: options.role };
752
+ }
753
+ }
754
+ function toArray(value) {
755
+ return Array.isArray(value) ? value : [value];
756
+ }
757
+ function encode(segment) {
758
+ return encodeURIComponent(segment);
759
+ }
760
+ function requireValue(value) {
761
+ if (value === undefined) {
762
+ throw new AppSettingsError("`value` is required; use clear() to remove a value", {
763
+ code: "invalid_value"
764
+ });
765
+ }
766
+ return value;
767
+ }
768
+ function missingEnvironment(operation) {
769
+ return new AppSettingsError(`${operation} needs an environment. Pass one as \`environment\` in the call, ` + "or set it once on the client.", { code: "invalid_request" });
770
+ }
771
+ // src/store.ts
772
+ function createSettingsStore(client, options = {}) {
773
+ const listeners = new Set;
774
+ const initial = initialSnapshot(options.initialData);
775
+ let userId = options.userId;
776
+ let state = {
777
+ status: initial ? "ready" : "idle",
778
+ snapshot: initial,
779
+ error: null,
780
+ isValidating: false,
781
+ updatedAt: initial ? Date.now() : null
782
+ };
783
+ const serverState = state;
784
+ let generation = 0;
785
+ let inFlight = null;
786
+ let interval;
787
+ let disposed = false;
788
+ function emit(next) {
789
+ state = { ...state, ...next };
790
+ for (const listener of listeners)
791
+ listener();
792
+ }
793
+ async function load() {
794
+ if (disposed)
795
+ return state;
796
+ if (inFlight)
797
+ return inFlight;
798
+ const ticket = ++generation;
799
+ emit({ isValidating: true, status: state.snapshot ? state.status : "loading" });
800
+ inFlight = (async () => {
801
+ try {
802
+ const resolveOptions = {
803
+ environment: options.environment,
804
+ platform: options.platform,
805
+ role: options.role,
806
+ groupId: options.groupId
807
+ };
808
+ const snapshot = userId ? await client.resolveUser(userId, resolveOptions) : await client.resolveServer(resolveOptions);
809
+ if (ticket !== generation || disposed)
810
+ return state;
811
+ emit({ status: "ready", snapshot, error: null, isValidating: false, updatedAt: Date.now() });
812
+ } catch (caught) {
813
+ if (ticket !== generation || disposed)
814
+ return state;
815
+ const error = asError(caught);
816
+ options.onError?.(error);
817
+ emit({ status: state.snapshot ? "ready" : "error", error, isValidating: false });
818
+ } finally {
819
+ if (ticket === generation)
820
+ inFlight = null;
821
+ }
822
+ return state;
823
+ })();
824
+ return inFlight;
825
+ }
826
+ function writeTarget(name) {
827
+ const snapshot = state.snapshot;
828
+ if (!snapshot) {
829
+ throw new AppSettingsError(`cannot write "${name}" before the first resolution has loaded`, {
830
+ code: "invalid_request"
831
+ });
832
+ }
833
+ if (!userId) {
834
+ throw new AppSettingsError(`cannot write "${name}": this store resolves the server layer, which has no personal value. ` + "Give the store a `userId`, or use client.values.server.set().", { code: "invalid_request" });
835
+ }
836
+ const setting = snapshot.get(name);
837
+ if (!setting) {
838
+ throw new AppSettingsError(`no setting named "${name}" is visible to this key and role`, {
839
+ code: "not_found"
840
+ });
841
+ }
842
+ return { snapshot, setting, userId };
843
+ }
844
+ async function writePersonal(name, value) {
845
+ const target = writeTarget(name);
846
+ const previous = state.snapshot;
847
+ const optimistic = value !== undefined && !target.snapshot.isEnforced(name) ? target.snapshot.with(name, value) : target.snapshot;
848
+ if (optimistic !== previous)
849
+ emit({ snapshot: optimistic });
850
+ try {
851
+ if (value === undefined) {
852
+ await client.values.personal.clear(target.setting.id, target.userId);
853
+ } else {
854
+ await client.values.personal.set(target.setting.id, target.userId, value);
855
+ }
856
+ } catch (caught) {
857
+ if (state.snapshot === optimistic)
858
+ emit({ snapshot: previous });
859
+ throw asError(caught);
860
+ }
861
+ await load();
862
+ }
863
+ function startTimers() {
864
+ const target = eventTarget();
865
+ if (!target)
866
+ return;
867
+ if (options.revalidateOnFocus)
868
+ target.addEventListener("focus", onWake);
869
+ if (options.revalidateOnReconnect)
870
+ target.addEventListener("online", onWake);
871
+ }
872
+ function stopTimers() {
873
+ if (interval !== undefined) {
874
+ clearInterval(interval);
875
+ interval = undefined;
876
+ }
877
+ const target = eventTarget();
878
+ target?.removeEventListener("focus", onWake);
879
+ target?.removeEventListener("online", onWake);
880
+ }
881
+ function onWake() {
882
+ load();
883
+ }
884
+ return {
885
+ subscribe(listener) {
886
+ listeners.add(listener);
887
+ if (listeners.size === 1 && !disposed) {
888
+ if (!state.snapshot)
889
+ load();
890
+ if (options.refreshIntervalMs && options.refreshIntervalMs > 0) {
891
+ interval = setInterval(onWake, options.refreshIntervalMs);
892
+ }
893
+ startTimers();
894
+ }
895
+ return () => {
896
+ listeners.delete(listener);
897
+ if (listeners.size === 0)
898
+ stopTimers();
899
+ };
900
+ },
901
+ getSnapshot: () => state,
902
+ getServerSnapshot: () => serverState,
903
+ refresh: load,
904
+ set: (name, value) => writePersonal(name, normalise(value)),
905
+ clear: (name) => writePersonal(name, undefined),
906
+ setUser(next) {
907
+ if (next === userId)
908
+ return;
909
+ userId = next;
910
+ generation++;
911
+ inFlight = null;
912
+ emit({ status: "loading", snapshot: null, error: null, updatedAt: null });
913
+ load();
914
+ },
915
+ dispose() {
916
+ disposed = true;
917
+ generation++;
918
+ stopTimers();
919
+ listeners.clear();
920
+ }
921
+ };
922
+ }
923
+ function eventTarget() {
924
+ const candidate = globalThis.window;
925
+ return typeof candidate?.addEventListener === "function" ? candidate : undefined;
926
+ }
927
+ function initialSnapshot(data) {
928
+ if (!data)
929
+ return null;
930
+ return data instanceof SettingsSnapshot ? data : new SettingsSnapshot(data);
931
+ }
932
+ function normalise(value) {
933
+ return value instanceof Date ? toInstant(value) : value;
934
+ }
935
+ function asError(caught) {
936
+ if (AppSettingsError.is(caught))
937
+ return caught;
938
+ return new AppSettingsError(caught instanceof Error ? caught.message : String(caught), {
939
+ code: "internal_error",
940
+ cause: caught
941
+ });
942
+ }
943
+ export {
944
+ AppSettingsClient,
945
+ AppSettingsError,
946
+ SettingsSnapshot,
947
+ createSettingsStore,
948
+ isConflict,
949
+ isForbidden,
950
+ isInstant,
951
+ isInvalidRequest,
952
+ isNotFound,
953
+ isUnauthorized,
954
+ localToInstant,
955
+ parseInstant,
956
+ snapshotFrom,
957
+ toDateTimeLocal,
958
+ toInstant
959
+ };
960
+
961
+ //# debugId=944BEA4D442E434464756E2164756E21
962
+ //# sourceMappingURL=index.js.map