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

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?.();
}
};
}

Powered by TurnKey Linux.