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`, addressable as * `App.prefs.`. The verbs are uniform across every dimension: get * the effective value, set / clear user intent, listen for changes. */ export interface ActivePrefsDimension { /** 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 { readonly snapshot: PrefsSnapshot; readonly effective: PrefsEffectiveOf; readonly environment: PrefsEnvironment; readonly intent: PrefsIntentOf; readonly version: number; readonly pending: boolean; readonly lastError: unknown; } /** * Reserved members of `ActivePrefs`. 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`. 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 { readonly kind: typeof PREFS_KIND; readonly schema: S; readonly state: ActivePrefsState; snapshot(): PrefsSnapshot; environment(): PrefsEnvironment; intent(): PrefsIntentOf; effective(): PrefsEffectiveOf; /** * Low-level mutator. The recommended public API is * `App.prefs..set(value)` — this stays exposed only for * adapters (storage bridge, devtools) that operate generically over * dimension keys. */ setIntent(key: K, value: unknown): PrefsSnapshot; /** Low-level mutator; prefer `App.prefs..clear()`. */ clearIntent(key: K): PrefsSnapshot; resetIntent(next?: PrefsIntentOf): PrefsSnapshot; refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot; patchEnvironment(patch: Partial): PrefsSnapshot; subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe; dispose(): void; } /** * Dimension surface — one slot per schema key. Only meaningful when * `S` is a concrete schema literal. With the open `PrefsSchema` * (`Record`) 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.` get the typed * surface because `App` flows the concrete `TPrefsSchema`. */ export type ActivePrefsDimensions = string extends keyof S ? unknown : { readonly [K in keyof S]: S[K] extends PrefsDimension ? ActivePrefsDimension : never; }; export type ActivePrefs = ActivePrefsBase & ActivePrefsDimensions; export function createActivePrefs( options: EnginePrefsOptions ): ActivePrefs { 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>(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(null); const detachCommit = engine.subscribe((event) => { snapshotCell = event.next; }); const state: ActivePrefsState = { 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> = {}; 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: (key: K, value: unknown) => engine.setIntent(key, value), clearIntent: (key: K) => engine.clearIntent(key), resetIntent: (next?: PrefsIntentOf) => engine.resetIntent(next), refreshEnvironment: (next: PrefsEnvironment) => engine.refreshEnvironment(next), patchEnvironment: (patch: Partial) => engine.patchEnvironment(patch), subscribe: (handler: PrefsChangeHandler) => engine.subscribe(handler), dispose: () => { detachCommit(); engine.dispose(); } }; return Object.assign(base, dimensionMembers) as unknown as ActivePrefs; } function makeDimension( engine: ReturnType, key: string ): ActivePrefsDimension { return { get() { return (engine.snapshot().effective as Record)[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; if (Object.prototype.hasOwnProperty.call(diff, key)) { handler(diff[key]); } }); }, catalog() { const dim = engine.schema[key]; return dim?.catalog?.(); } }; }