You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
247 lines
7.9 KiB
247 lines
7.9 KiB
import type {
|
|
PrefsChangeHandler,
|
|
PrefsDimension,
|
|
PrefsEffectiveOf,
|
|
PrefsEnvironment,
|
|
PrefsIntentOf,
|
|
PrefsSchema,
|
|
PrefsSnapshot,
|
|
PrefsUnsubscribe
|
|
} from '$libs/prefs';
|
|
import { createEnginePrefs } from './engine-prefs.ts';
|
|
import { PREFS_KIND } from './consts.ts';
|
|
import { PrefsReservedKeyError, reservedKeyErrorMessage } from './errors.ts';
|
|
import type { EnginePrefsOptions } from './types.ts';
|
|
|
|
/**
|
|
* Per-dimension active surface. Every key in the schema becomes one of
|
|
* these on the parent `ActivePrefs<S>`, addressable as
|
|
* `App.prefs.<key>`. The verbs are uniform across every dimension: get
|
|
* the effective value, set / clear user intent, listen for changes.
|
|
*/
|
|
export interface ActivePrefsDimension<TIntent, TEffective = TIntent> {
|
|
/** Current effective value (intent → environment → default). */
|
|
get(): TEffective;
|
|
/**
|
|
* Set explicit user intent for this dimension. Validates first;
|
|
* throws `PrefsIntentInvalidError` on rejection.
|
|
*/
|
|
set(value: TIntent): void;
|
|
/** Drop user intent. Falls back to environment / default. */
|
|
clear(): void;
|
|
/**
|
|
* Subscribe to commits where this dimension's effective value
|
|
* changed. Returns an unsubscribe function. Best-effort: a handler
|
|
* that throws does not block its peers.
|
|
*/
|
|
onChange(handler: (value: TEffective) => void): PrefsUnsubscribe;
|
|
/** Optional capability catalog when the dimension exposes one. */
|
|
catalog(): readonly TIntent[] | undefined;
|
|
}
|
|
|
|
/**
|
|
* Reactive view of `EnginePrefs.state`. Backed by `$state` cells inside
|
|
* `createActivePrefs` — accessing the getters inside a Svelte template
|
|
* or `$derived` tracks dependencies the way you'd expect.
|
|
*
|
|
* `pending` and `lastError` are placeholders for async adapters (the
|
|
* storage bridge in particular). With a sync-only engine they stay
|
|
* `false` / `null`.
|
|
*/
|
|
export interface ActivePrefsState<S extends PrefsSchema> {
|
|
readonly snapshot: PrefsSnapshot<S>;
|
|
readonly effective: PrefsEffectiveOf<S>;
|
|
readonly environment: PrefsEnvironment;
|
|
readonly intent: PrefsIntentOf<S>;
|
|
readonly version: number;
|
|
readonly pending: boolean;
|
|
readonly lastError: unknown;
|
|
}
|
|
|
|
/**
|
|
* Reserved members of `ActivePrefs<S>`. A schema key that matches one
|
|
* of these would shadow the active surface — the constructor throws
|
|
* `PrefsReservedKeyError` so the misconfiguration fails fast.
|
|
*/
|
|
export const ACTIVE_PREFS_RESERVED_KEYS: readonly string[] = [
|
|
'kind',
|
|
'schema',
|
|
'state',
|
|
'snapshot',
|
|
'environment',
|
|
'intent',
|
|
'effective',
|
|
'resetIntent',
|
|
'refreshEnvironment',
|
|
'patchEnvironment',
|
|
'subscribe',
|
|
'dispose'
|
|
];
|
|
|
|
const RESERVED_SET = new Set(ACTIVE_PREFS_RESERVED_KEYS);
|
|
|
|
/**
|
|
* Reactive Svelte adapter over `EnginePrefs<S>`. Adds the
|
|
* `ActivePrefsState` block and exposes one `ActivePrefsDimension` per
|
|
* schema key as a property — i.e. for `schema = { locale, theme }` the
|
|
* returned object has `.locale.get()` / `.locale.set(...)` /
|
|
* `.theme.get()` / etc., type-checked against each dimension's
|
|
* `TIntent` and `TEffective`.
|
|
*/
|
|
/**
|
|
* Top-level (non-dimension) surface. Always present regardless of the
|
|
* schema. Service factories see this shape via `core.prefs` and read
|
|
* specific dimensions defensively at runtime through their string key.
|
|
*/
|
|
export interface ActivePrefsBase<S extends PrefsSchema = PrefsSchema> {
|
|
readonly kind: typeof PREFS_KIND;
|
|
readonly schema: S;
|
|
readonly state: ActivePrefsState<S>;
|
|
|
|
snapshot(): PrefsSnapshot<S>;
|
|
environment(): PrefsEnvironment;
|
|
intent(): PrefsIntentOf<S>;
|
|
effective(): PrefsEffectiveOf<S>;
|
|
|
|
/**
|
|
* Low-level mutator. The recommended public API is
|
|
* `App.prefs.<dim>.set(value)` — this stays exposed only for
|
|
* adapters (storage bridge, devtools) that operate generically over
|
|
* dimension keys.
|
|
*/
|
|
setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S>;
|
|
/** Low-level mutator; prefer `App.prefs.<dim>.clear()`. */
|
|
clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S>;
|
|
resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S>;
|
|
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S>;
|
|
patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot<S>;
|
|
subscribe(handler: PrefsChangeHandler<S>): PrefsUnsubscribe;
|
|
dispose(): void;
|
|
}
|
|
|
|
/**
|
|
* Dimension surface — one slot per schema key. Only meaningful when
|
|
* `S` is a concrete schema literal. With the open `PrefsSchema`
|
|
* (`Record<string, PrefsDimension>`) the mapped type would collide
|
|
* with the index signature on the base, so we degrade to `unknown`
|
|
* for the open case (`X & unknown = X`).
|
|
*
|
|
* Service factories that need typed dimensions cast through their own
|
|
* schema generic; consumers that read `App.prefs.<key>` get the typed
|
|
* surface because `App` flows the concrete `TPrefsSchema`.
|
|
*/
|
|
export type ActivePrefsDimensions<S extends PrefsSchema> = string extends keyof S
|
|
? unknown
|
|
: {
|
|
readonly [K in keyof S]: S[K] extends PrefsDimension<infer TIntent, infer TEffective>
|
|
? ActivePrefsDimension<TIntent, TEffective>
|
|
: never;
|
|
};
|
|
|
|
export type ActivePrefs<S extends PrefsSchema = PrefsSchema> = ActivePrefsBase<S> &
|
|
ActivePrefsDimensions<S>;
|
|
|
|
export function createActivePrefs<S extends PrefsSchema>(
|
|
options: EnginePrefsOptions<S>
|
|
): ActivePrefs<S> {
|
|
for (const key of Object.keys(options.schema)) {
|
|
if (RESERVED_SET.has(key)) {
|
|
throw new PrefsReservedKeyError(key, reservedKeyErrorMessage(key));
|
|
}
|
|
}
|
|
|
|
const engine = createEnginePrefs(options);
|
|
|
|
let snapshotCell = $state<PrefsSnapshot<S>>(engine.snapshot());
|
|
// `pending` / `lastError` are placeholders today (sync engine has
|
|
// nothing async to track). Declared as `let` so async adapters
|
|
// (storage bridge in particular) can flip them when wired in
|
|
// without restructuring the rune layout.
|
|
const pendingCell = $state(false);
|
|
const lastErrorCell = $state<unknown>(null);
|
|
|
|
const detachCommit = engine.subscribe((event) => {
|
|
snapshotCell = event.next;
|
|
});
|
|
|
|
const state: ActivePrefsState<S> = {
|
|
get snapshot() {
|
|
return snapshotCell;
|
|
},
|
|
get effective() {
|
|
return snapshotCell.effective;
|
|
},
|
|
get environment() {
|
|
return snapshotCell.environment;
|
|
},
|
|
get intent() {
|
|
return snapshotCell.intent;
|
|
},
|
|
get version() {
|
|
return snapshotCell.version;
|
|
},
|
|
get pending() {
|
|
return pendingCell;
|
|
},
|
|
get lastError() {
|
|
return lastErrorCell;
|
|
}
|
|
};
|
|
|
|
const dimensionMembers: Record<string, ActivePrefsDimension<unknown, unknown>> = {};
|
|
for (const key of Object.keys(options.schema)) {
|
|
dimensionMembers[key] = makeDimension(engine, key);
|
|
}
|
|
|
|
const base = {
|
|
kind: PREFS_KIND as typeof PREFS_KIND,
|
|
schema: options.schema,
|
|
state,
|
|
snapshot: () => engine.snapshot(),
|
|
environment: () => engine.environment(),
|
|
intent: () => engine.intent(),
|
|
effective: () => engine.effective(),
|
|
setIntent: <K extends keyof S>(key: K, value: unknown) => engine.setIntent(key, value),
|
|
clearIntent: <K extends keyof S>(key: K) => engine.clearIntent(key),
|
|
resetIntent: (next?: PrefsIntentOf<S>) => engine.resetIntent(next),
|
|
refreshEnvironment: (next: PrefsEnvironment) => engine.refreshEnvironment(next),
|
|
patchEnvironment: (patch: Partial<PrefsEnvironment>) => engine.patchEnvironment(patch),
|
|
subscribe: (handler: PrefsChangeHandler<S>) => engine.subscribe(handler),
|
|
dispose: () => {
|
|
detachCommit();
|
|
engine.dispose();
|
|
}
|
|
};
|
|
|
|
return Object.assign(base, dimensionMembers) as unknown as ActivePrefs<S>;
|
|
}
|
|
|
|
function makeDimension(
|
|
engine: ReturnType<typeof createEnginePrefs>,
|
|
key: string
|
|
): ActivePrefsDimension<unknown, unknown> {
|
|
return {
|
|
get() {
|
|
return (engine.snapshot().effective as Record<string, unknown>)[key];
|
|
},
|
|
set(value: unknown) {
|
|
engine.setIntent(key, value);
|
|
},
|
|
clear() {
|
|
engine.clearIntent(key);
|
|
},
|
|
onChange(handler: (value: unknown) => void) {
|
|
return engine.subscribe((event) => {
|
|
const diff = event.effectiveDiff as Record<string, unknown>;
|
|
if (Object.prototype.hasOwnProperty.call(diff, key)) {
|
|
handler(diff[key]);
|
|
}
|
|
});
|
|
},
|
|
catalog() {
|
|
const dim = engine.schema[key];
|
|
return dim?.catalog?.();
|
|
}
|
|
};
|
|
}
|