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/src/snapshot.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { parseInstant } from "./datetime.ts";
|
|
2
|
+
import { AppSettingsError } from "./errors.ts";
|
|
3
|
+
import type { Override, ResolveResponse, ResolvedSetting, SelectOption, SettingValue } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A resolution, wrapped so reading a value is a one-liner.
|
|
7
|
+
*
|
|
8
|
+
* The instance is immutable and its identity only changes when the underlying
|
|
9
|
+
* data does, which is exactly what a UI framework needs to decide whether to
|
|
10
|
+
* re-render. {@link SettingsSnapshot.with} produces a new snapshot rather than
|
|
11
|
+
* mutating this one.
|
|
12
|
+
*/
|
|
13
|
+
export class SettingsSnapshot {
|
|
14
|
+
/** Every setting the resolution returned, in the server's order. */
|
|
15
|
+
readonly settings: readonly ResolvedSetting[];
|
|
16
|
+
/** The environment this was resolved in. */
|
|
17
|
+
readonly environment: string;
|
|
18
|
+
/** The platforms the resolution was filtered to, if any. */
|
|
19
|
+
readonly platforms: readonly string[];
|
|
20
|
+
/** The user this was resolved for, absent for a server resolution. */
|
|
21
|
+
readonly userId?: string;
|
|
22
|
+
/** The role the resolution ran as. */
|
|
23
|
+
readonly role: string;
|
|
24
|
+
/** When the server produced this. */
|
|
25
|
+
readonly resolvedAt: Date;
|
|
26
|
+
|
|
27
|
+
readonly #byName: ReadonlyMap<string, ResolvedSetting>;
|
|
28
|
+
|
|
29
|
+
constructor(response: ResolveResponse) {
|
|
30
|
+
this.settings = Object.freeze([...(response.settings ?? [])]);
|
|
31
|
+
this.environment = response.environment;
|
|
32
|
+
this.platforms = Object.freeze([...(response.platforms ?? [])]);
|
|
33
|
+
this.userId = response.user_id;
|
|
34
|
+
this.role = response.role;
|
|
35
|
+
this.resolvedAt = parseInstant(response.resolved_at) ?? new Date();
|
|
36
|
+
this.#byName = new Map(this.settings.map((setting) => [setting.name, setting]));
|
|
37
|
+
Object.freeze(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The names of every setting present, useful for iterating a settings page. */
|
|
41
|
+
get names(): string[] {
|
|
42
|
+
return [...this.#byName.keys()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How many settings the resolution returned. */
|
|
46
|
+
get size(): number {
|
|
47
|
+
return this.settings.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether a setting is present at all. Absent means the role cannot see it. */
|
|
51
|
+
has(name: string): boolean {
|
|
52
|
+
return this.#byName.has(name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The whole resolved setting, or `undefined` if this role cannot see it. */
|
|
56
|
+
get(name: string): ResolvedSetting | undefined {
|
|
57
|
+
return this.#byName.get(name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The effective value, untyped.
|
|
62
|
+
*
|
|
63
|
+
* `null` is returned both for a setting whose source is `UNSET` and for one
|
|
64
|
+
* genuinely set to null; {@link SettingsSnapshot.isSet} tells them apart.
|
|
65
|
+
*/
|
|
66
|
+
value(name: string): SettingValue {
|
|
67
|
+
return this.#byName.get(name)?.value ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether any layer, including the definition's default, supplied a value. */
|
|
71
|
+
isSet(name: string): boolean {
|
|
72
|
+
const setting = this.#byName.get(name);
|
|
73
|
+
return setting !== undefined && setting.source !== "UNSET";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Which layer the effective value came from. */
|
|
77
|
+
source(name: string): ResolvedSetting["source"] | undefined {
|
|
78
|
+
return this.#byName.get(name)?.source;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A `BOOLEAN` value.
|
|
83
|
+
*
|
|
84
|
+
* @param fallback Returned when the setting is invisible to this role or unset.
|
|
85
|
+
* @throws {AppSettingsError} with code `type_mismatch` if the setting holds
|
|
86
|
+
* something other than a boolean, which means the wrong name was asked for.
|
|
87
|
+
*/
|
|
88
|
+
boolean(name: string, fallback = false): boolean {
|
|
89
|
+
return this.#typed(name, fallback, "boolean", (value) => typeof value === "boolean");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A `NUMBER` value. See {@link SettingsSnapshot.boolean} for the rules. */
|
|
93
|
+
number(name: string, fallback = 0): number {
|
|
94
|
+
return this.#typed(name, fallback, "number", (value) => typeof value === "number" && Number.isFinite(value));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A `STRING` or single-choice `SELECT` value. */
|
|
98
|
+
string(name: string, fallback = ""): string {
|
|
99
|
+
return this.#typed(name, fallback, "string", (value) => typeof value === "string");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A `DATETIME` value, already parsed. */
|
|
103
|
+
date(name: string, fallback?: Date): Date | undefined {
|
|
104
|
+
const value = this.#present(name);
|
|
105
|
+
if (value === undefined || value === null) return fallback;
|
|
106
|
+
if (typeof value !== "string") throw this.#mismatch(name, "a date-time string", value);
|
|
107
|
+
return parseInstant(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A multi-choice `SELECT` value. A single-choice value is returned as a
|
|
112
|
+
* one-element array, so a caller need not branch on `multiple`.
|
|
113
|
+
*/
|
|
114
|
+
list<T = SettingValue>(name: string, fallback: T[] = []): T[] {
|
|
115
|
+
const value = this.#present(name);
|
|
116
|
+
if (value === undefined || value === null) return fallback;
|
|
117
|
+
return (Array.isArray(value) ? value : [value]) as T[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A `JSON` value, or any value at all, cast to the caller's type.
|
|
122
|
+
*
|
|
123
|
+
* Nothing is validated here: the server has already checked the value against
|
|
124
|
+
* the setting's own rules, and a `JSON` setting has none beyond being JSON.
|
|
125
|
+
*/
|
|
126
|
+
json<T = SettingValue>(name: string, fallback?: T): T {
|
|
127
|
+
const value = this.#present(name);
|
|
128
|
+
return (value === undefined || value === null ? fallback : value) as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The choices a `SELECT` offers, for rendering a picker. */
|
|
132
|
+
options(name: string): SelectOption[] {
|
|
133
|
+
return this.#byName.get(name)?.type_config?.options ?? [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The group override that shaped this value, if one did. */
|
|
137
|
+
override(name: string): Override | undefined {
|
|
138
|
+
return this.#byName.get(name)?.override;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The override to tell the user about.
|
|
143
|
+
*
|
|
144
|
+
* An override marked `visible: false` is deliberately withheld: the user is
|
|
145
|
+
* not meant to observe that anything was overridden, so a UI should read this
|
|
146
|
+
* rather than {@link SettingsSnapshot.override}.
|
|
147
|
+
*/
|
|
148
|
+
visibleOverride(name: string): Override | undefined {
|
|
149
|
+
const override = this.override(name);
|
|
150
|
+
return override?.visible ? override : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a group policy is holding this value in place.
|
|
155
|
+
*
|
|
156
|
+
* This is the check for disabling a control: an enforced override beats
|
|
157
|
+
* whatever the user chooses, so writing to it would appear to do nothing.
|
|
158
|
+
*/
|
|
159
|
+
isEnforced(name: string): boolean {
|
|
160
|
+
return this.override(name)?.enforced === true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Settings the user may actually change: personal scope, not enforced. */
|
|
164
|
+
editable(): ResolvedSetting[] {
|
|
165
|
+
return this.settings.filter(
|
|
166
|
+
(setting) => setting.scope === "PERSONAL" && setting.override?.enforced !== true,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Every setting matching a predicate, for grouping a settings page. */
|
|
171
|
+
filter(predicate: (setting: ResolvedSetting) => boolean): ResolvedSetting[] {
|
|
172
|
+
return this.settings.filter(predicate);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A plain `{ name: value }` object, handy for logging or a feature-flag map. */
|
|
176
|
+
toObject(): Record<string, SettingValue> {
|
|
177
|
+
return Object.fromEntries(this.settings.map((setting) => [setting.name, setting.value]));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The underlying response, for anything this class does not cover. */
|
|
181
|
+
toJSON(): ResolveResponse {
|
|
182
|
+
return {
|
|
183
|
+
environment: this.environment,
|
|
184
|
+
platforms: [...this.platforms],
|
|
185
|
+
user_id: this.userId,
|
|
186
|
+
role: this.role,
|
|
187
|
+
settings: [...this.settings],
|
|
188
|
+
resolved_at: this.resolvedAt.toISOString(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A new snapshot with one value replaced, leaving this one untouched.
|
|
194
|
+
*
|
|
195
|
+
* Used for an optimistic update: show the new value at once, then reconcile
|
|
196
|
+
* with whatever the next resolution says.
|
|
197
|
+
*/
|
|
198
|
+
with(name: string, value: SettingValue, source: ResolvedSetting["source"] = "PERSONAL"): SettingsSnapshot {
|
|
199
|
+
if (!this.#byName.has(name)) return this;
|
|
200
|
+
|
|
201
|
+
return new SettingsSnapshot({
|
|
202
|
+
...this.toJSON(),
|
|
203
|
+
settings: this.settings.map((setting) =>
|
|
204
|
+
setting.name === name ? { ...setting, value, source } : setting,
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
[Symbol.iterator](): IterableIterator<ResolvedSetting> {
|
|
210
|
+
return this.settings[Symbol.iterator]();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The value when one is present, else undefined. Null reads as absent. */
|
|
214
|
+
#present(name: string): SettingValue | undefined {
|
|
215
|
+
const setting = this.#byName.get(name);
|
|
216
|
+
if (setting === undefined || setting.source === "UNSET") return undefined;
|
|
217
|
+
return setting.value ?? undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#typed<T>(name: string, fallback: T, expected: string, matches: (value: SettingValue) => boolean): T {
|
|
221
|
+
const value = this.#present(name);
|
|
222
|
+
if (value === undefined) return fallback;
|
|
223
|
+
if (!matches(value)) throw this.#mismatch(name, `a ${expected}`, value);
|
|
224
|
+
return value as T;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#mismatch(name: string, expected: string, value: SettingValue): AppSettingsError {
|
|
228
|
+
const declared = this.#byName.get(name)?.type;
|
|
229
|
+
return new AppSettingsError(
|
|
230
|
+
`setting "${name}" is declared ${declared} and holds ${describe(value)}, not ${expected}`,
|
|
231
|
+
{ code: "type_mismatch" },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Builds a snapshot from a raw resolution response. */
|
|
237
|
+
export function snapshotFrom(response: ResolveResponse): SettingsSnapshot {
|
|
238
|
+
return new SettingsSnapshot(response);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function describe(value: SettingValue): string {
|
|
242
|
+
if (value === null) return "null";
|
|
243
|
+
if (Array.isArray(value)) return "an array";
|
|
244
|
+
return `a ${typeof value}`;
|
|
245
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { AppSettingsClient, ResolveUserOptions } from "./client.ts";
|
|
2
|
+
import { toInstant } from "./datetime.ts";
|
|
3
|
+
import { AppSettingsError } from "./errors.ts";
|
|
4
|
+
import { SettingsSnapshot } from "./snapshot.ts";
|
|
5
|
+
import type { ResolveResponse, SettingValue } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A reactive store over one resolution.
|
|
9
|
+
*
|
|
10
|
+
* It deliberately knows nothing about any UI framework. What it exposes is the
|
|
11
|
+
* `subscribe` / `getSnapshot` pair that React's `useSyncExternalStore` wants,
|
|
12
|
+
* which is also the shape Vue, Svelte and Solid adapt to in a line or two.
|
|
13
|
+
*
|
|
14
|
+
* @example React, with no React-specific code in this package:
|
|
15
|
+
* const store = createSettingsStore(client, { userId: "alice" });
|
|
16
|
+
*
|
|
17
|
+
* function useSettings() {
|
|
18
|
+
* return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What a subscriber reads. This object's identity changes only when something did. */
|
|
23
|
+
export interface SettingsState {
|
|
24
|
+
/**
|
|
25
|
+
* `idle` before the first load, `loading` during it, then `ready` or `error`.
|
|
26
|
+
* A background refresh leaves the status alone and raises `isValidating`.
|
|
27
|
+
*/
|
|
28
|
+
status: "idle" | "loading" | "ready" | "error";
|
|
29
|
+
/** The current resolution, or null before the first one arrives. */
|
|
30
|
+
snapshot: SettingsSnapshot | null;
|
|
31
|
+
/** Why the last load failed. Cleared by a successful one. */
|
|
32
|
+
error: AppSettingsError | null;
|
|
33
|
+
/** Whether a request is in flight, including a background refresh. */
|
|
34
|
+
isValidating: boolean;
|
|
35
|
+
/** When the current snapshot arrived, as epoch milliseconds. */
|
|
36
|
+
updatedAt: number | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How the store should resolve, and when it should do so again. */
|
|
40
|
+
export interface SettingsStoreOptions {
|
|
41
|
+
/** The user to resolve for. Omit for a server resolution. */
|
|
42
|
+
userId?: string;
|
|
43
|
+
/** Overrides the client's environment. */
|
|
44
|
+
environment?: string;
|
|
45
|
+
/** Restricts the resolution to these platforms. */
|
|
46
|
+
platform?: string | string[];
|
|
47
|
+
/** Resolves as this role instead of the key's own. */
|
|
48
|
+
role?: string;
|
|
49
|
+
/** Groups applied without stored membership. */
|
|
50
|
+
groupId?: string | string[];
|
|
51
|
+
/** Re-resolves on this interval. 0, the default, disables it. */
|
|
52
|
+
refreshIntervalMs?: number;
|
|
53
|
+
/** Re-resolves when the tab is focused again. Browsers only. */
|
|
54
|
+
revalidateOnFocus?: boolean;
|
|
55
|
+
/** Re-resolves when the network comes back. Browsers only. */
|
|
56
|
+
revalidateOnReconnect?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* A resolution already in hand, so the first render has data.
|
|
59
|
+
*
|
|
60
|
+
* This is the server-rendering path: resolve on the server, serialise the
|
|
61
|
+
* response into the page, and pass it here.
|
|
62
|
+
*/
|
|
63
|
+
initialData?: ResolveResponse | SettingsSnapshot;
|
|
64
|
+
/** Called on every failed load, for logging. Failures also land in the state. */
|
|
65
|
+
onError?: (error: AppSettingsError) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The store returned by {@link createSettingsStore}. */
|
|
69
|
+
export interface SettingsStore {
|
|
70
|
+
/** Registers a listener and returns its unsubscribe function. */
|
|
71
|
+
subscribe: (listener: () => void) => () => void;
|
|
72
|
+
/** The current state. Stable between changes, as `useSyncExternalStore` requires. */
|
|
73
|
+
getSnapshot: () => SettingsState;
|
|
74
|
+
/** The state to render on a server, where nothing is ever fetched. */
|
|
75
|
+
getServerSnapshot: () => SettingsState;
|
|
76
|
+
/** Re-resolves now. Never rejects; the failure lands in the state. */
|
|
77
|
+
refresh: () => Promise<SettingsState>;
|
|
78
|
+
/**
|
|
79
|
+
* Writes a personal value and shows it immediately.
|
|
80
|
+
*
|
|
81
|
+
* The change is applied to the local snapshot before the request goes out and
|
|
82
|
+
* rolled back if it fails, so a control feels instant but never lies.
|
|
83
|
+
*
|
|
84
|
+
* @throws {AppSettingsError} if the write is rejected.
|
|
85
|
+
*/
|
|
86
|
+
set: (name: string, value: SettingValue) => Promise<void>;
|
|
87
|
+
/** Clears the user's own value, falling back to whatever lies beneath it. */
|
|
88
|
+
clear: (name: string) => Promise<void>;
|
|
89
|
+
/** Points the store at a different user, discarding the current snapshot. */
|
|
90
|
+
setUser: (userId: string | undefined) => void;
|
|
91
|
+
/** Stops timers and listeners. Safe to call more than once. */
|
|
92
|
+
dispose: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Builds a store. Nothing is fetched until something subscribes or refreshes. */
|
|
96
|
+
export function createSettingsStore(
|
|
97
|
+
client: AppSettingsClient,
|
|
98
|
+
options: SettingsStoreOptions = {},
|
|
99
|
+
): SettingsStore {
|
|
100
|
+
const listeners = new Set<() => void>();
|
|
101
|
+
const initial = initialSnapshot(options.initialData);
|
|
102
|
+
|
|
103
|
+
let userId = options.userId;
|
|
104
|
+
let state: SettingsState = {
|
|
105
|
+
status: initial ? "ready" : "idle",
|
|
106
|
+
snapshot: initial,
|
|
107
|
+
error: null,
|
|
108
|
+
isValidating: false,
|
|
109
|
+
updatedAt: initial ? Date.now() : null,
|
|
110
|
+
};
|
|
111
|
+
// A server render must be deterministic, so it always sees the initial state.
|
|
112
|
+
const serverState: SettingsState = state;
|
|
113
|
+
|
|
114
|
+
let generation = 0;
|
|
115
|
+
let inFlight: Promise<SettingsState> | null = null;
|
|
116
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
117
|
+
let disposed = false;
|
|
118
|
+
|
|
119
|
+
function emit(next: Partial<SettingsState>): void {
|
|
120
|
+
state = { ...state, ...next };
|
|
121
|
+
for (const listener of listeners) listener();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function load(): Promise<SettingsState> {
|
|
125
|
+
if (disposed) return state;
|
|
126
|
+
// One request at a time: a second caller joins the first rather than
|
|
127
|
+
// racing it, which is what makes focus and interval refreshes cheap.
|
|
128
|
+
if (inFlight) return inFlight;
|
|
129
|
+
|
|
130
|
+
const ticket = ++generation;
|
|
131
|
+
emit({ isValidating: true, status: state.snapshot ? state.status : "loading" });
|
|
132
|
+
|
|
133
|
+
inFlight = (async () => {
|
|
134
|
+
try {
|
|
135
|
+
const resolveOptions: ResolveUserOptions = {
|
|
136
|
+
environment: options.environment,
|
|
137
|
+
platform: options.platform,
|
|
138
|
+
role: options.role,
|
|
139
|
+
groupId: options.groupId,
|
|
140
|
+
};
|
|
141
|
+
const snapshot = userId
|
|
142
|
+
? await client.resolveUser(userId, resolveOptions)
|
|
143
|
+
: await client.resolveServer(resolveOptions);
|
|
144
|
+
|
|
145
|
+
// A newer load, or a setUser, has superseded this one.
|
|
146
|
+
if (ticket !== generation || disposed) return state;
|
|
147
|
+
emit({ status: "ready", snapshot, error: null, isValidating: false, updatedAt: Date.now() });
|
|
148
|
+
} catch (caught) {
|
|
149
|
+
if (ticket !== generation || disposed) return state;
|
|
150
|
+
const error = asError(caught);
|
|
151
|
+
options.onError?.(error);
|
|
152
|
+
// Keep the last good snapshot: stale settings beat none at all.
|
|
153
|
+
emit({ status: state.snapshot ? "ready" : "error", error, isValidating: false });
|
|
154
|
+
} finally {
|
|
155
|
+
if (ticket === generation) inFlight = null;
|
|
156
|
+
}
|
|
157
|
+
return state;
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
return inFlight;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Everything a write needs, with the reasons it cannot proceed spelled out. */
|
|
164
|
+
function writeTarget(name: string) {
|
|
165
|
+
const snapshot = state.snapshot;
|
|
166
|
+
if (!snapshot) {
|
|
167
|
+
throw new AppSettingsError(`cannot write "${name}" before the first resolution has loaded`, {
|
|
168
|
+
code: "invalid_request",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!userId) {
|
|
172
|
+
throw new AppSettingsError(
|
|
173
|
+
`cannot write "${name}": this store resolves the server layer, which has no personal value. ` +
|
|
174
|
+
"Give the store a `userId`, or use client.values.server.set().",
|
|
175
|
+
{ code: "invalid_request" },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const setting = snapshot.get(name);
|
|
179
|
+
if (!setting) {
|
|
180
|
+
throw new AppSettingsError(`no setting named "${name}" is visible to this key and role`, {
|
|
181
|
+
code: "not_found",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return { snapshot, setting, userId };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writePersonal(name: string, value: SettingValue | undefined): Promise<void> {
|
|
188
|
+
const target = writeTarget(name);
|
|
189
|
+
const previous = state.snapshot;
|
|
190
|
+
|
|
191
|
+
// An enforced override beats whatever is stored underneath it, so the
|
|
192
|
+
// effective value will not move. Store the write, but do not pretend.
|
|
193
|
+
const optimistic =
|
|
194
|
+
value !== undefined && !target.snapshot.isEnforced(name)
|
|
195
|
+
? target.snapshot.with(name, value)
|
|
196
|
+
: target.snapshot;
|
|
197
|
+
|
|
198
|
+
if (optimistic !== previous) emit({ snapshot: optimistic });
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
if (value === undefined) {
|
|
202
|
+
await client.values.personal.clear(target.setting.id, target.userId);
|
|
203
|
+
} else {
|
|
204
|
+
await client.values.personal.set(target.setting.id, target.userId, value);
|
|
205
|
+
}
|
|
206
|
+
} catch (caught) {
|
|
207
|
+
if (state.snapshot === optimistic) emit({ snapshot: previous });
|
|
208
|
+
throw asError(caught);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The server canonicalises values — a DATETIME comes back in UTC — and a
|
|
212
|
+
// clear falls through to a layer only it knows about, so re-resolve.
|
|
213
|
+
await load();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function startTimers(): void {
|
|
217
|
+
const target = eventTarget();
|
|
218
|
+
if (!target) return;
|
|
219
|
+
|
|
220
|
+
if (options.revalidateOnFocus) target.addEventListener("focus", onWake);
|
|
221
|
+
if (options.revalidateOnReconnect) target.addEventListener("online", onWake);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stopTimers(): void {
|
|
225
|
+
if (interval !== undefined) {
|
|
226
|
+
clearInterval(interval);
|
|
227
|
+
interval = undefined;
|
|
228
|
+
}
|
|
229
|
+
const target = eventTarget();
|
|
230
|
+
target?.removeEventListener("focus", onWake);
|
|
231
|
+
target?.removeEventListener("online", onWake);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function onWake(): void {
|
|
235
|
+
void load();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
subscribe(listener) {
|
|
240
|
+
listeners.add(listener);
|
|
241
|
+
|
|
242
|
+
// The first subscriber starts the store; nothing polls an empty room.
|
|
243
|
+
if (listeners.size === 1 && !disposed) {
|
|
244
|
+
if (!state.snapshot) void load();
|
|
245
|
+
if (options.refreshIntervalMs && options.refreshIntervalMs > 0) {
|
|
246
|
+
interval = setInterval(onWake, options.refreshIntervalMs);
|
|
247
|
+
}
|
|
248
|
+
startTimers();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return () => {
|
|
252
|
+
listeners.delete(listener);
|
|
253
|
+
if (listeners.size === 0) stopTimers();
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
getSnapshot: () => state,
|
|
258
|
+
getServerSnapshot: () => serverState,
|
|
259
|
+
refresh: load,
|
|
260
|
+
|
|
261
|
+
set: (name, value) => writePersonal(name, normalise(value)),
|
|
262
|
+
clear: (name) => writePersonal(name, undefined),
|
|
263
|
+
|
|
264
|
+
setUser(next) {
|
|
265
|
+
if (next === userId) return;
|
|
266
|
+
userId = next;
|
|
267
|
+
// Invalidate anything in flight: it is about the previous user.
|
|
268
|
+
generation++;
|
|
269
|
+
inFlight = null;
|
|
270
|
+
emit({ status: "loading", snapshot: null, error: null, updatedAt: null });
|
|
271
|
+
void load();
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
dispose() {
|
|
275
|
+
disposed = true;
|
|
276
|
+
generation++;
|
|
277
|
+
stopTimers();
|
|
278
|
+
listeners.clear();
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The `window` object, when there is one.
|
|
285
|
+
*
|
|
286
|
+
* Typed structurally rather than through the DOM lib, so this package compiles
|
|
287
|
+
* in a project that does not include DOM types and still works in a browser.
|
|
288
|
+
*/
|
|
289
|
+
interface WakeTarget {
|
|
290
|
+
addEventListener: (type: string, listener: () => void) => void;
|
|
291
|
+
removeEventListener: (type: string, listener: () => void) => void;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function eventTarget(): WakeTarget | undefined {
|
|
295
|
+
const candidate = (globalThis as { window?: WakeTarget }).window;
|
|
296
|
+
return typeof candidate?.addEventListener === "function" ? candidate : undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Accepts either form of seed data, so an SSR payload needs no unwrapping. */
|
|
300
|
+
function initialSnapshot(data: SettingsStoreOptions["initialData"]): SettingsSnapshot | null {
|
|
301
|
+
if (!data) return null;
|
|
302
|
+
return data instanceof SettingsSnapshot ? data : new SettingsSnapshot(data);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A `Date` is the natural thing to hand a DATETIME setting, so accept one. */
|
|
306
|
+
function normalise(value: SettingValue): SettingValue {
|
|
307
|
+
return value instanceof Date ? toInstant(value) : value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function asError(caught: unknown): AppSettingsError {
|
|
311
|
+
if (AppSettingsError.is(caught)) return caught;
|
|
312
|
+
return new AppSettingsError(caught instanceof Error ? caught.message : String(caught), {
|
|
313
|
+
code: "internal_error",
|
|
314
|
+
cause: caught,
|
|
315
|
+
});
|
|
316
|
+
}
|