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