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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] — 2026-09-12
|
|
10
|
+
|
|
11
|
+
First release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `AppSettingsClient`, covering every route the API exposes: resolution,
|
|
16
|
+
setting definitions, values in all three layers, groups and membership,
|
|
17
|
+
roles, platforms, environments, and API keys.
|
|
18
|
+
- `SettingsSnapshot`, an immutable view over a resolution with typed accessors
|
|
19
|
+
(`boolean`, `number`, `string`, `date`, `list`, `json`) and override helpers
|
|
20
|
+
(`isEnforced`, `visibleOverride`, `editable`).
|
|
21
|
+
- `createSettingsStore`, a framework-agnostic reactive store exposing the
|
|
22
|
+
`subscribe` / `getSnapshot` / `getServerSnapshot` trio that React's
|
|
23
|
+
`useSyncExternalStore` consumes directly, with optimistic writes, rollback
|
|
24
|
+
on rejection, and server-rendered hydration through `initialData`.
|
|
25
|
+
- Datetime helpers for the API's strict RFC 3339 rules: `toInstant`,
|
|
26
|
+
`localToInstant`, `parseInstant`, `toDateTimeLocal`, `isInstant`.
|
|
27
|
+
- A single `AppSettingsError` carrying the server's `code`, `status` and
|
|
28
|
+
`requestId`, with `isNotFound` / `isForbidden` / `isConflict` guards.
|
|
29
|
+
- Automatic retries with jittered exponential backoff on network errors, 5xx
|
|
30
|
+
and 429, honouring `Retry-After` and never retrying a `POST`.
|
|
31
|
+
|
|
32
|
+
[Unreleased]: https://github.com/connordoman/app-settings/compare/v0.1.0...HEAD
|
|
33
|
+
[0.1.0]: https://github.com/connordoman/app-settings/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Connor Doman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# app-settings-js
|
|
2
|
+
|
|
3
|
+
A TypeScript SDK for the [App Settings](../README.md) API.
|
|
4
|
+
|
|
5
|
+
- **No dependencies.** Nothing but the platform's own `fetch`.
|
|
6
|
+
- **Runs anywhere.** Browsers, Node 18+, Bun, Deno, and edge runtimes. ESM and CJS.
|
|
7
|
+
- **React-ready, React-free.** No React import anywhere in this package. The
|
|
8
|
+
store exposes the `subscribe` / `getSnapshot` pair `useSyncExternalStore`
|
|
9
|
+
wants, so React is three lines away — and so are Vue, Svelte and Solid.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bun add app-settings-js # or npm / pnpm / yarn
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { AppSettingsClient } from "app-settings-js";
|
|
19
|
+
|
|
20
|
+
const client = new AppSettingsClient({
|
|
21
|
+
baseUrl: "https://settings.example.com",
|
|
22
|
+
apiKey: process.env.SETTINGS_API_KEY!,
|
|
23
|
+
environment: "production",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const settings = await client.resolveUser("alice");
|
|
27
|
+
|
|
28
|
+
settings.boolean("dark_mode"); // false
|
|
29
|
+
settings.number("page_size", 25); // 25 if unset
|
|
30
|
+
settings.string("theme");
|
|
31
|
+
settings.date("digest_at"); // a Date
|
|
32
|
+
settings.json<Limits>("rate_limits");
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`resolveUser` returns a [`SettingsSnapshot`](#the-snapshot): every setting the
|
|
36
|
+
user can see, already collapsed to one effective value each. That is the call a
|
|
37
|
+
product backend makes, and usually the only one it needs.
|
|
38
|
+
|
|
39
|
+
> **Where this runs.** The API key is a server credential. Settings are meant to
|
|
40
|
+
> be read through your own backend, which authenticates the user first. Putting
|
|
41
|
+
> a key in a browser bundle hands every visitor whatever that key can reach.
|
|
42
|
+
> Front ends should call your server, and hydrate a store from what it returns —
|
|
43
|
+
> see [Server rendering](#server-rendering).
|
|
44
|
+
|
|
45
|
+
## The snapshot
|
|
46
|
+
|
|
47
|
+
A snapshot is immutable, and its identity changes only when the data does, which
|
|
48
|
+
is what lets a UI framework skip a re-render.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
settings.has("dark_mode"); // is it visible to this role at all?
|
|
52
|
+
settings.isSet("dark_mode"); // did any layer, or the default, supply a value?
|
|
53
|
+
settings.source("dark_mode"); // "PERSONAL" | "SERVER" | "DEFAULT" | ...
|
|
54
|
+
settings.get("dark_mode"); // the whole ResolvedSetting
|
|
55
|
+
settings.toObject(); // { dark_mode: false, page_size: 25, ... }
|
|
56
|
+
|
|
57
|
+
for (const setting of settings) { /* iterable, in the server's order */ }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Typed accessors return the fallback when a setting is unset or invisible to the
|
|
61
|
+
role, and **throw** when the setting exists but holds another type — which can
|
|
62
|
+
only mean the wrong name was asked for:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
settings.string("dark_mode");
|
|
66
|
+
// AppSettingsError: setting "dark_mode" is declared BOOLEAN and holds a boolean, not a string
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`list()` covers `SELECT`, returning a single choice as a one-element array so no
|
|
70
|
+
caller has to branch on `multiple`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
settings.list<string>("tags"); // ["a", "b"]
|
|
74
|
+
settings.options("tags"); // [["A", "a"], ["B", "b"]] for a picker
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Overrides
|
|
78
|
+
|
|
79
|
+
A group override is either **advisory** (a group-wide default the user's own
|
|
80
|
+
value beats) or **enforced** (a policy that beats it). Orthogonally, an override
|
|
81
|
+
may be marked invisible, meaning the user is not meant to observe it.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
settings.isEnforced("retention_days"); // disable the control
|
|
85
|
+
settings.visibleOverride("retention_days"); // the override you may tell them about
|
|
86
|
+
settings.override("retention_days"); // the raw one, visible or not
|
|
87
|
+
settings.editable(); // personal scope, not enforced
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`visibleOverride` is the accessor a UI should reach for: it withholds an
|
|
91
|
+
override the server marked `visible: false`, so a snapshot cannot leak one by
|
|
92
|
+
accident.
|
|
93
|
+
|
|
94
|
+
## The store
|
|
95
|
+
|
|
96
|
+
`createSettingsStore` keeps one resolution current and tells subscribers when it
|
|
97
|
+
changes. It handles the parts that are tedious to get right: no fetch until
|
|
98
|
+
something is listening, one request shared between concurrent refreshes, stale
|
|
99
|
+
responses dropped, and the last good snapshot kept when a refresh fails.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createSettingsStore } from "app-settings-js";
|
|
103
|
+
|
|
104
|
+
const store = createSettingsStore(client, {
|
|
105
|
+
userId: "alice",
|
|
106
|
+
refreshIntervalMs: 60_000,
|
|
107
|
+
revalidateOnFocus: true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
store.subscribe(() => console.log(store.getSnapshot().snapshot?.toObject()));
|
|
111
|
+
await store.set("dark_mode", true); // optimistic, rolled back if rejected
|
|
112
|
+
await store.clear("dark_mode"); // fall back to the layer beneath
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`getSnapshot()` returns a `SettingsState`:
|
|
116
|
+
|
|
117
|
+
| Field | Meaning |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `status` | `idle` before the first load, then `loading`, `ready` or `error` |
|
|
120
|
+
| `snapshot` | the current `SettingsSnapshot`, or `null` before the first one |
|
|
121
|
+
| `error` | why the last load failed; cleared by a successful one |
|
|
122
|
+
| `isValidating` | a request is in flight, including a background refresh |
|
|
123
|
+
| `updatedAt` | when the current snapshot arrived |
|
|
124
|
+
|
|
125
|
+
`refresh()` never rejects — the failure lands in `error` — so a caller needs no
|
|
126
|
+
`try`/`catch`. `set()` and `clear()` do reject, because a rejected write is
|
|
127
|
+
something the caller has to react to.
|
|
128
|
+
|
|
129
|
+
### React
|
|
130
|
+
|
|
131
|
+
There is no React module here, and there does not need to be one. Paste this
|
|
132
|
+
into your app:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { useSyncExternalStore } from "react";
|
|
136
|
+
import { createSettingsStore, type SettingsStore } from "app-settings-js";
|
|
137
|
+
|
|
138
|
+
const store = createSettingsStore(client, { userId: currentUserId });
|
|
139
|
+
|
|
140
|
+
function useSettings(store: SettingsStore) {
|
|
141
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function DarkModeToggle() {
|
|
145
|
+
const { snapshot, status } = useSettings(store);
|
|
146
|
+
if (!snapshot) return status === "error" ? <Failed /> : <Spinner />;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<label>
|
|
150
|
+
<input
|
|
151
|
+
type="checkbox"
|
|
152
|
+
checked={snapshot.boolean("dark_mode")}
|
|
153
|
+
disabled={snapshot.isEnforced("dark_mode")}
|
|
154
|
+
onChange={(event) => store.set("dark_mode", event.target.checked)}
|
|
155
|
+
/>
|
|
156
|
+
Dark mode
|
|
157
|
+
{snapshot.visibleOverride("dark_mode") && <Badge>Set by your team</Badge>}
|
|
158
|
+
</label>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`getSnapshot` returns the same object until something actually changes, which is
|
|
164
|
+
exactly the contract `useSyncExternalStore` enforces, and `getServerSnapshot`
|
|
165
|
+
makes the store safe to render on a server.
|
|
166
|
+
|
|
167
|
+
The same two functions adapt anywhere: Svelte's `readable`, Vue's
|
|
168
|
+
`shallowRef` plus `onScopeDispose`, or a Solid signal.
|
|
169
|
+
|
|
170
|
+
### Server rendering
|
|
171
|
+
|
|
172
|
+
Resolve on the server, serialise the response into the page, and hand it to the
|
|
173
|
+
store so the first render already has data and no request is made:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// server
|
|
177
|
+
const settings = await client.resolveUser(userId);
|
|
178
|
+
return { props: { settings: settings.toJSON() } };
|
|
179
|
+
|
|
180
|
+
// client
|
|
181
|
+
const store = createSettingsStore(client, { userId, initialData: props.settings });
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Writing values
|
|
185
|
+
|
|
186
|
+
Each layer has its own namespace, mirroring the API's routes:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
await client.values.server.set(settingId, true);
|
|
190
|
+
await client.values.personal.set(settingId, "alice", "solarized");
|
|
191
|
+
await client.values.group.set(settingId, groupId, false, {
|
|
192
|
+
enforced: true, // beats the user's own value
|
|
193
|
+
visible: false, // the user is not meant to observe it
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await client.values.personal.clear(settingId, "alice");
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`undefined` is refused before a request is made — clearing a value is `clear()`,
|
|
200
|
+
which is a `DELETE`. `null` goes through, because it is a legitimate JSON value.
|
|
201
|
+
|
|
202
|
+
## Definitions, groups, taxonomy and keys
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
await client.settings.list({ scope: "PERSONAL" });
|
|
206
|
+
await client.settings.create({
|
|
207
|
+
name: "page_size",
|
|
208
|
+
type: "NUMBER",
|
|
209
|
+
scope: "PERSONAL",
|
|
210
|
+
platform: "web",
|
|
211
|
+
typeConfig: { min: 10, max: 100, integer: true },
|
|
212
|
+
defaultValue: 25,
|
|
213
|
+
});
|
|
214
|
+
await client.settings.delete(id, { cascade: true }); // required if values exist
|
|
215
|
+
|
|
216
|
+
await client.groups.create({ name: "beta", members: ["alice"], priority: 10 });
|
|
217
|
+
await client.groups.addMembers(groupId, ["bob"]);
|
|
218
|
+
|
|
219
|
+
await client.taxonomy.roles();
|
|
220
|
+
await client.taxonomy.upsertRole("auditor", { rank: 25 });
|
|
221
|
+
|
|
222
|
+
const minted = await client.keys.create({ name: "web backend", scopes: ["resolve"] });
|
|
223
|
+
minted.token; // the only time it is ever available
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Datetimes
|
|
227
|
+
|
|
228
|
+
`DATETIME` accepts only a strict RFC 3339 value with an offset, because an
|
|
229
|
+
offset is what makes a value an instant rather than a wall-clock reading. A
|
|
230
|
+
`Date` satisfies that automatically; `<input type="datetime-local">` does not.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { localToInstant, parseInstant, toDateTimeLocal, toInstant } from "app-settings-js";
|
|
234
|
+
|
|
235
|
+
toInstant(new Date()); // "2026-01-02T20:04:05.250Z"
|
|
236
|
+
toInstant("2026-01-02T15:04:05-05:00"); // "2026-01-02T20:04:05.000Z"
|
|
237
|
+
toInstant("2026-01-02T15:04"); // throws, naming the problem
|
|
238
|
+
|
|
239
|
+
localToInstant("2026-01-02T15:04", "America/New_York"); // "2026-01-02T20:04:00.000Z"
|
|
240
|
+
toDateTimeLocal(value, "America/New_York"); // back into the input
|
|
241
|
+
parseInstant(settings.value("digest_at")); // a Date
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Passing the timezone to `localToInstant` is the point: the same reading is a
|
|
245
|
+
different moment in every zone, so the SDK makes you say which one rather than
|
|
246
|
+
guessing. `store.set()` converts a `Date` for you.
|
|
247
|
+
|
|
248
|
+
## Errors
|
|
249
|
+
|
|
250
|
+
Everything throws one class. Branch on `code`, which is stable, not on the
|
|
251
|
+
message, which is written for a human.
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { AppSettingsError, isNotFound, isForbidden } from "app-settings-js";
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await client.settings.get(id);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (isNotFound(error)) return null;
|
|
260
|
+
if (isForbidden(error)) throw new Error("this key is fenced out of that environment");
|
|
261
|
+
if (AppSettingsError.is(error)) {
|
|
262
|
+
error.code; // "not_found" | "forbidden" | "network_error" | "timeout" | ...
|
|
263
|
+
error.status; // 404
|
|
264
|
+
error.requestId; // the server's X-Request-ID, worth quoting in a bug report
|
|
265
|
+
error.retryable;
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A key fenced out of an environment gets `not_found` rather than `forbidden` for
|
|
272
|
+
a setting inside it — the server declines to reveal that it exists.
|
|
273
|
+
|
|
274
|
+
Failed requests are retried automatically on network errors, 5xx and 429, with
|
|
275
|
+
exponential backoff and jitter, honouring `Retry-After`. `POST` is never
|
|
276
|
+
retried, being the only non-idempotent method in the API. Tune it per client:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
new AppSettingsClient({ baseUrl, apiKey, retries: 5, retryDelayMs: 100, timeoutMs: 5_000 });
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Every call also takes `{ signal, timeoutMs, headers }` for a one-off override.
|
|
283
|
+
|
|
284
|
+
## Testing against it
|
|
285
|
+
|
|
286
|
+
The client takes any `fetch`, so tests need no network and no mocking library:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
const client = new AppSettingsClient({
|
|
290
|
+
baseUrl: "https://settings.test",
|
|
291
|
+
apiKey: "as_test",
|
|
292
|
+
environment: "test",
|
|
293
|
+
fetch: async () => new Response(JSON.stringify(fixture), { status: 200 }),
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Requirements
|
|
298
|
+
|
|
299
|
+
Any runtime with a global `fetch`: **Node 18+**, Bun, Deno, or a browser. Pass
|
|
300
|
+
your own `fetch` for anything older. TypeScript is optional; when used, the
|
|
301
|
+
declarations are checked against **TypeScript 5.9 and 7.x**.
|
|
302
|
+
|
|
303
|
+
## Development
|
|
304
|
+
|
|
305
|
+
```sh
|
|
306
|
+
just # list every recipe
|
|
307
|
+
just check # typecheck + test
|
|
308
|
+
just build # ESM, CJS and declarations into dist/
|
|
309
|
+
just test # bun test
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
From the repository root, `just sdk <recipe>` reaches these, and `just
|
|
313
|
+
check-all` runs the server's checks and the SDK's together.
|
|
314
|
+
|
|
315
|
+
### Releasing
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
just version 0.2.0 # bump, then write the CHANGELOG entry
|
|
319
|
+
just preflight # tests, build, and the packaging checks below
|
|
320
|
+
just publish # bun publish; prepublishOnly re-runs the gate
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`just verify` runs [`@arethetypeswrong/cli`](https://arethetypeswrong.github.io)
|
|
324
|
+
and [`publint`](https://publint.dev) against the real tarball, which is what
|
|
325
|
+
catches the two failure modes this package is shaped around:
|
|
326
|
+
|
|
327
|
+
- **The `require` path needs its own declarations.** `package.json` is
|
|
328
|
+
`type: module`, so a lone `index.d.ts` describes the CommonJS build as ESM.
|
|
329
|
+
The build emits a `.d.cts` beside every `.d.ts` and the `exports` map points
|
|
330
|
+
each condition at the matching one.
|
|
331
|
+
- **`sideEffects` is deliberately absent.** Bun 1.4.0's bundler tree-shakes
|
|
332
|
+
every module out of a pure re-export barrel when that field is present,
|
|
333
|
+
emitting `export { … }` with nothing bound — valid-looking output, a zero
|
|
334
|
+
exit code, and a package that throws `SyntaxError` on import. `bun run build`
|
|
335
|
+
asserts the bundle contains an implementation so this cannot ship again.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { type FetchLike, type RequestOptions, type TransportOptions } from "./http.cjs";
|
|
2
|
+
import { SettingsSnapshot } from "./snapshot.cjs";
|
|
3
|
+
import type { ApiKey, CreatedApiKey, Environment, Group, GroupMember, Health, IntermediateValue, PersonalValue, Platform, Role, Scope, ServerValue, Setting, SettingScope, SettingType, SettingValue, TypeConfig, WhoAmI } from "./types.cjs";
|
|
4
|
+
/** How to reach the server, and what to assume when a call does not say. */
|
|
5
|
+
export interface ClientOptions extends TransportOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The environment used by calls that require one. Resolution always needs an
|
|
8
|
+
* environment, so setting it here means most calls take no options at all.
|
|
9
|
+
*/
|
|
10
|
+
environment?: string;
|
|
11
|
+
/** The platform filter applied to resolution and to setting lookups. */
|
|
12
|
+
platform?: string | string[];
|
|
13
|
+
}
|
|
14
|
+
/** Options shared by both resolution calls. */
|
|
15
|
+
export interface ResolveOptions extends RequestOptions {
|
|
16
|
+
/** Overrides the client's environment. Required if the client has none. */
|
|
17
|
+
environment?: string;
|
|
18
|
+
/** Restricts the resolution to these platforms. Capped by the key's own fence. */
|
|
19
|
+
platform?: string | string[];
|
|
20
|
+
/** Resolves as this role instead of the key's. It may not outrank the key. */
|
|
21
|
+
role?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Resolution for one user. */
|
|
24
|
+
export interface ResolveUserOptions extends ResolveOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Groups to apply without storing membership, which is how an ad-hoc group
|
|
27
|
+
* is used. Saved memberships apply regardless.
|
|
28
|
+
*/
|
|
29
|
+
groupId?: string | string[];
|
|
30
|
+
}
|
|
31
|
+
/** Filters for listing setting definitions. */
|
|
32
|
+
export interface ListSettingsOptions extends RequestOptions {
|
|
33
|
+
environment?: string;
|
|
34
|
+
platform?: string;
|
|
35
|
+
scope?: SettingScope;
|
|
36
|
+
}
|
|
37
|
+
/** A new setting definition. Type, scope, platform and environment are fixed at creation. */
|
|
38
|
+
export interface CreateSettingInput {
|
|
39
|
+
name: string;
|
|
40
|
+
type: SettingType;
|
|
41
|
+
scope: SettingScope;
|
|
42
|
+
platform: string;
|
|
43
|
+
/** Defaults to the client's environment. */
|
|
44
|
+
environment?: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
/** The rules for this type. See {@link TypeConfig}. */
|
|
47
|
+
typeConfig?: TypeConfig;
|
|
48
|
+
/** Defaults to the calling key's own role. */
|
|
49
|
+
role?: string;
|
|
50
|
+
/** Used when no layer supplies a value. */
|
|
51
|
+
defaultValue?: SettingValue;
|
|
52
|
+
}
|
|
53
|
+
/** The parts of a definition that are safe to change after creation. */
|
|
54
|
+
export interface UpdateSettingInput {
|
|
55
|
+
description?: string;
|
|
56
|
+
typeConfig?: TypeConfig;
|
|
57
|
+
role?: string;
|
|
58
|
+
defaultValue?: SettingValue;
|
|
59
|
+
}
|
|
60
|
+
/** Options for writing a group override. */
|
|
61
|
+
export interface GroupValueOptions extends RequestOptions {
|
|
62
|
+
/** Whether the user is meant to observe the override. Defaults to true. */
|
|
63
|
+
visible?: boolean;
|
|
64
|
+
/** Whether the override beats the user's own value. Defaults to false. */
|
|
65
|
+
enforced?: boolean;
|
|
66
|
+
}
|
|
67
|
+
/** A new group. */
|
|
68
|
+
export interface CreateGroupInput {
|
|
69
|
+
name: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
/** Omit to span every environment, which a fenced key may not do. */
|
|
72
|
+
environment?: string | null;
|
|
73
|
+
/** Higher priority wins when two groups override the same setting. */
|
|
74
|
+
priority?: number;
|
|
75
|
+
/** Marks an ad-hoc group so operators can prune it later. */
|
|
76
|
+
ephemeral?: boolean;
|
|
77
|
+
/** Seeds membership in the same request. */
|
|
78
|
+
members?: string[];
|
|
79
|
+
}
|
|
80
|
+
/** A new API key. It can never reach further than the key that mints it. */
|
|
81
|
+
export interface CreateKeyInput {
|
|
82
|
+
name: string;
|
|
83
|
+
scopes: Scope[];
|
|
84
|
+
/** Empty inherits the creating key's fence rather than granting everything. */
|
|
85
|
+
environments?: string[];
|
|
86
|
+
platforms?: string[];
|
|
87
|
+
/** Defaults to the lowest-ranked role. May not outrank the creating key. */
|
|
88
|
+
role?: string;
|
|
89
|
+
/** Supply at most one of these. */
|
|
90
|
+
expiresAt?: Date | string;
|
|
91
|
+
/** A Go duration such as `"720h"`. */
|
|
92
|
+
expiresIn?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A client for the App Settings API.
|
|
96
|
+
*
|
|
97
|
+
* One instance is cheap and holds no connection state, so it is safe to build
|
|
98
|
+
* once at module scope and share it.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const client = new AppSettingsClient({
|
|
102
|
+
* baseUrl: "https://settings.example.com",
|
|
103
|
+
* apiKey: process.env.SETTINGS_API_KEY!,
|
|
104
|
+
* environment: "production",
|
|
105
|
+
* });
|
|
106
|
+
*
|
|
107
|
+
* const settings = await client.resolveUser("alice");
|
|
108
|
+
* if (settings.boolean("dark_mode")) { ... }
|
|
109
|
+
*/
|
|
110
|
+
export declare class AppSettingsClient {
|
|
111
|
+
#private;
|
|
112
|
+
constructor(options: ClientOptions);
|
|
113
|
+
/** The environment this client defaults to, if it has one. */
|
|
114
|
+
get environment(): string | undefined;
|
|
115
|
+
/** A copy of this client bound to a different environment. */
|
|
116
|
+
withEnvironment(environment: string): AppSettingsClient;
|
|
117
|
+
/**
|
|
118
|
+
* Every setting a user can see, collapsed to one effective value each.
|
|
119
|
+
*
|
|
120
|
+
* This is the call a product backend makes. Precedence, lowest to highest, is
|
|
121
|
+
* `default < server < advisory group < personal < enforced group`.
|
|
122
|
+
*/
|
|
123
|
+
resolveUser(userId: string, options?: ResolveUserOptions): Promise<SettingsSnapshot>;
|
|
124
|
+
/** The server's own settings, with no user layer applied. */
|
|
125
|
+
resolveServer(options?: ResolveOptions): Promise<SettingsSnapshot>;
|
|
126
|
+
/** Describes the calling key, so a deployment can confirm what it can do. */
|
|
127
|
+
whoami(options?: RequestOptions): Promise<WhoAmI>;
|
|
128
|
+
/** Whether the process is up. Needs no API key on the server, but sends one. */
|
|
129
|
+
health(options?: RequestOptions): Promise<Health>;
|
|
130
|
+
/** Whether the server's dependencies are reachable. */
|
|
131
|
+
ready(options?: RequestOptions): Promise<Health>;
|
|
132
|
+
readonly settings: {
|
|
133
|
+
/** Every definition this key may see, narrowed by the given filters. */
|
|
134
|
+
list: (options?: ListSettingsOptions) => Promise<Setting[]>;
|
|
135
|
+
/** One definition by id. */
|
|
136
|
+
get: (id: string, options?: RequestOptions) => Promise<Setting>;
|
|
137
|
+
/** Defines a new setting. */
|
|
138
|
+
create: (input: CreateSettingInput, options?: RequestOptions) => Promise<Setting>;
|
|
139
|
+
/** Changes a definition. Omitted fields are left as they are. */
|
|
140
|
+
update: (id: string, input: UpdateSettingInput, options?: RequestOptions) => Promise<Setting>;
|
|
141
|
+
/**
|
|
142
|
+
* Removes a definition.
|
|
143
|
+
*
|
|
144
|
+
* A delete that would destroy stored values is refused with a `conflict`
|
|
145
|
+
* naming how many, unless `cascade` says to go ahead.
|
|
146
|
+
*/
|
|
147
|
+
delete: (id: string, options?: RequestOptions & {
|
|
148
|
+
cascade?: boolean;
|
|
149
|
+
}) => Promise<void>;
|
|
150
|
+
};
|
|
151
|
+
readonly values: {
|
|
152
|
+
/** The server-wide layer, beneath every group and user value. */
|
|
153
|
+
server: {
|
|
154
|
+
get: (settingId: string, options?: RequestOptions) => Promise<ServerValue>;
|
|
155
|
+
set: (settingId: string, value: SettingValue, options?: RequestOptions) => Promise<ServerValue>;
|
|
156
|
+
/** Removes the value, falling back to the definition's default. */
|
|
157
|
+
clear: (settingId: string, options?: RequestOptions) => Promise<void>;
|
|
158
|
+
};
|
|
159
|
+
/** One user's own choice. */
|
|
160
|
+
personal: {
|
|
161
|
+
get: (settingId: string, userId: string, options?: RequestOptions) => Promise<PersonalValue>;
|
|
162
|
+
set: (settingId: string, userId: string, value: SettingValue, options?: RequestOptions) => Promise<PersonalValue>;
|
|
163
|
+
clear: (settingId: string, userId: string, options?: RequestOptions) => Promise<void>;
|
|
164
|
+
};
|
|
165
|
+
/** A group override, in either direction. */
|
|
166
|
+
group: {
|
|
167
|
+
get: (settingId: string, groupId: string, options?: RequestOptions) => Promise<IntermediateValue>;
|
|
168
|
+
/**
|
|
169
|
+
* Writes an override. `enforced` decides its direction: an enforced
|
|
170
|
+
* override beats the user's own value, an advisory one yields to it.
|
|
171
|
+
*/
|
|
172
|
+
set: (settingId: string, groupId: string, value: SettingValue, options?: GroupValueOptions) => Promise<IntermediateValue>;
|
|
173
|
+
clear: (settingId: string, groupId: string, options?: RequestOptions) => Promise<void>;
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
readonly groups: {
|
|
177
|
+
list: (options?: RequestOptions & {
|
|
178
|
+
environment?: string;
|
|
179
|
+
includeEphemeral?: boolean;
|
|
180
|
+
}) => Promise<Group[]>;
|
|
181
|
+
get: (id: string, options?: RequestOptions) => Promise<Group>;
|
|
182
|
+
create: (input: CreateGroupInput, options?: RequestOptions) => Promise<Group>;
|
|
183
|
+
update: (id: string, input: {
|
|
184
|
+
description?: string;
|
|
185
|
+
priority?: number;
|
|
186
|
+
}, options?: RequestOptions) => Promise<Group>;
|
|
187
|
+
delete: (id: string, options?: RequestOptions) => Promise<void>;
|
|
188
|
+
/** Everyone whose membership is saved. Ad-hoc application does not appear here. */
|
|
189
|
+
members: (id: string, options?: RequestOptions) => Promise<GroupMember[]>;
|
|
190
|
+
addMembers: (id: string, userIds: string[], options?: RequestOptions) => Promise<number>;
|
|
191
|
+
removeMembers: (id: string, userIds: string[], options?: RequestOptions) => Promise<number>;
|
|
192
|
+
};
|
|
193
|
+
readonly taxonomy: {
|
|
194
|
+
roles: (options?: RequestOptions) => Promise<Role[]>;
|
|
195
|
+
/** Creates or updates a role. Rank orders roles and may not exceed the key's. */
|
|
196
|
+
upsertRole: (name: string, input: {
|
|
197
|
+
rank: number;
|
|
198
|
+
description?: string;
|
|
199
|
+
}, options?: RequestOptions) => Promise<Role>;
|
|
200
|
+
deleteRole: (name: string, options?: RequestOptions) => Promise<void>;
|
|
201
|
+
platforms: (options?: RequestOptions) => Promise<Platform[]>;
|
|
202
|
+
upsertPlatform: (name: string, description?: string, options?: RequestOptions) => Promise<Platform>;
|
|
203
|
+
deletePlatform: (name: string, options?: RequestOptions) => Promise<void>;
|
|
204
|
+
environments: (options?: RequestOptions) => Promise<Environment[]>;
|
|
205
|
+
upsertEnvironment: (name: string, description?: string, options?: RequestOptions) => Promise<Environment>;
|
|
206
|
+
deleteEnvironment: (name: string, options?: RequestOptions) => Promise<void>;
|
|
207
|
+
};
|
|
208
|
+
readonly keys: {
|
|
209
|
+
list: (options?: RequestOptions & {
|
|
210
|
+
includeRevoked?: boolean;
|
|
211
|
+
}) => Promise<ApiKey[]>;
|
|
212
|
+
/**
|
|
213
|
+
* Mints a key. The returned `token` is the only time it is ever available:
|
|
214
|
+
* only its hash is stored.
|
|
215
|
+
*/
|
|
216
|
+
create: (input: CreateKeyInput, options?: RequestOptions) => Promise<CreatedApiKey>;
|
|
217
|
+
/** Revokes a key. This is permanent and takes effect immediately. */
|
|
218
|
+
revoke: (id: string, options?: RequestOptions) => Promise<void>;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export type { FetchLike, RequestOptions };
|