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.
279 lines
8.9 KiB
279 lines
8.9 KiB
import {
|
|
resolvePrefs,
|
|
sanitizeIntent,
|
|
type PrefsChangeCause,
|
|
type PrefsChangeEvent,
|
|
type PrefsChangeHandler,
|
|
type PrefsEffectiveOf,
|
|
type PrefsEnvironment,
|
|
type PrefsIntentOf,
|
|
type PrefsSchema,
|
|
type PrefsSnapshot,
|
|
type PrefsUnsubscribe
|
|
} from '$libs/prefs';
|
|
import {
|
|
PREFS_ENGINE_METHOD_CLEAR_INTENT,
|
|
PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT,
|
|
PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT,
|
|
PREFS_ENGINE_METHOD_RESET_INTENT,
|
|
PREFS_ENGINE_METHOD_SET_INTENT,
|
|
PREFS_KIND
|
|
} from './consts.ts';
|
|
import {
|
|
PrefsDisposedError,
|
|
PrefsIntentInvalidError,
|
|
PrefsUnknownDimensionError,
|
|
disposedErrorMessage,
|
|
intentInvalidErrorMessage,
|
|
unknownDimensionErrorMessage
|
|
} from './errors.ts';
|
|
import type { EnginePrefs, EnginePrefsOptions } from './types.ts';
|
|
|
|
/**
|
|
* Build a fresh `EnginePrefs<S>` for the given schema. Runes-free —
|
|
* safe to import from server-only modules. The Active wrapper layers
|
|
* reactive state and the per-dimension surface on top.
|
|
*
|
|
* Internally maintains:
|
|
* - The frozen `schema` reference (immutable for the engine's lifetime).
|
|
* - Two layer cells (`environment`, `intent`) replaced by reference on
|
|
* every commit.
|
|
* - A frozen `PrefsSnapshot<S>` cell with the resolved `effective`
|
|
* view; rebuilt only on commits so reads return structurally-shared
|
|
* references.
|
|
* - A monotonic `version` counter bumped per commit. Stable across
|
|
* no-op writes (see `commit()` short-circuit).
|
|
* - A listener set notified with `{previous, next, effectiveDiff,
|
|
* cause}` only when at least one layer changed by reference.
|
|
*/
|
|
export function createEnginePrefs<S extends PrefsSchema>(
|
|
options: EnginePrefsOptions<S>
|
|
): EnginePrefs<S> {
|
|
const schema = options.schema;
|
|
let environment: PrefsEnvironment = options.environment ?? {};
|
|
let intent: PrefsIntentOf<S> = sanitiseInitialIntent(schema, options.intent);
|
|
let version = 0;
|
|
let snapshot = buildSnapshot(schema, environment, intent, version);
|
|
let disposed = false;
|
|
const listeners = new Set<PrefsChangeHandler<S>>();
|
|
|
|
function ensureLive(method: string): void {
|
|
if (disposed) {
|
|
throw new PrefsDisposedError(disposedErrorMessage(method));
|
|
}
|
|
}
|
|
|
|
function commit(
|
|
nextEnvironment: PrefsEnvironment,
|
|
nextIntent: PrefsIntentOf<S>,
|
|
cause: PrefsChangeCause
|
|
): PrefsSnapshot<S> {
|
|
// No-op short-circuit: if every mutable layer is the same
|
|
// reference, the resolved view is by definition unchanged. Skip
|
|
// the recompute, the version bump and the listener walk.
|
|
if (nextEnvironment === environment && nextIntent === intent) {
|
|
return snapshot;
|
|
}
|
|
|
|
const previous = snapshot;
|
|
environment = nextEnvironment;
|
|
intent = nextIntent;
|
|
version += 1;
|
|
snapshot = buildSnapshot(schema, environment, intent, version);
|
|
|
|
const event: PrefsChangeEvent<S> = {
|
|
previous,
|
|
next: snapshot,
|
|
effectiveDiff: diffEffective(previous.effective, snapshot.effective),
|
|
cause
|
|
};
|
|
// Snapshot the listener set so a handler that mutates the engine
|
|
// (e.g. unsubscribes itself) doesn't skip its peers.
|
|
for (const listener of [...listeners]) {
|
|
listener(event);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
const engine: EnginePrefs<S> = {
|
|
kind: PREFS_KIND,
|
|
schema,
|
|
|
|
snapshot() {
|
|
return snapshot;
|
|
},
|
|
environment() {
|
|
return environment;
|
|
},
|
|
intent() {
|
|
return intent;
|
|
},
|
|
effective() {
|
|
return snapshot.effective;
|
|
},
|
|
|
|
setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S> {
|
|
ensureLive(PREFS_ENGINE_METHOD_SET_INTENT);
|
|
|
|
const stringKey = key as string;
|
|
const dim = schema[stringKey];
|
|
if (dim === undefined) {
|
|
throw new PrefsUnknownDimensionError(stringKey, unknownDimensionErrorMessage(stringKey));
|
|
}
|
|
const result = dim.validate(value);
|
|
if (!result.ok) {
|
|
throw new PrefsIntentInvalidError(
|
|
stringKey,
|
|
result.reason,
|
|
intentInvalidErrorMessage(stringKey, result.reason)
|
|
);
|
|
}
|
|
const current = (intent as Record<string, unknown>)[stringKey];
|
|
if (current === result.value) return snapshot;
|
|
const next = { ...(intent as Record<string, unknown>), [stringKey]: result.value };
|
|
return commit(environment, next as PrefsIntentOf<S>, 'intent:set');
|
|
},
|
|
|
|
clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S> {
|
|
ensureLive(PREFS_ENGINE_METHOD_CLEAR_INTENT);
|
|
const stringKey = key as string;
|
|
if (schema[stringKey] === undefined) {
|
|
throw new PrefsUnknownDimensionError(stringKey, unknownDimensionErrorMessage(stringKey));
|
|
}
|
|
if ((intent as Record<string, unknown>)[stringKey] === undefined) return snapshot;
|
|
const next = { ...(intent as Record<string, unknown>) };
|
|
delete next[stringKey];
|
|
return commit(environment, next as PrefsIntentOf<S>, 'intent:clear');
|
|
},
|
|
|
|
resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S> {
|
|
ensureLive(PREFS_ENGINE_METHOD_RESET_INTENT);
|
|
const sanitised = sanitizeIntent(schema, (next ?? {}) as Record<string, unknown>);
|
|
if (intentEqual(intent, sanitised)) return snapshot;
|
|
return commit(environment, sanitised as PrefsIntentOf<S>, 'intent:reset');
|
|
},
|
|
|
|
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S> {
|
|
ensureLive(PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT);
|
|
if (environmentEqual(environment, next)) return snapshot;
|
|
return commit(next, intent, 'environment:refresh');
|
|
},
|
|
|
|
patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot<S> {
|
|
ensureLive(PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT);
|
|
const merged: PrefsEnvironment = { ...environment, ...patch };
|
|
if (environmentEqual(environment, merged)) return snapshot;
|
|
return commit(merged, intent, 'environment:refresh');
|
|
},
|
|
|
|
subscribe(handler) {
|
|
if (disposed) return () => {};
|
|
listeners.add(handler);
|
|
const unsubscribe: PrefsUnsubscribe = () => {
|
|
listeners.delete(handler);
|
|
};
|
|
return unsubscribe;
|
|
},
|
|
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
listeners.clear();
|
|
}
|
|
};
|
|
|
|
return engine;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
function sanitiseInitialIntent<S extends PrefsSchema>(
|
|
schema: S,
|
|
intent: PrefsIntentOf<S> | undefined
|
|
): PrefsIntentOf<S> {
|
|
if (intent === undefined) return {} as PrefsIntentOf<S>;
|
|
return sanitizeIntent(schema, intent as Record<string, unknown>) as PrefsIntentOf<S>;
|
|
}
|
|
|
|
function buildSnapshot<S extends PrefsSchema>(
|
|
schema: S,
|
|
environment: PrefsEnvironment,
|
|
intent: PrefsIntentOf<S>,
|
|
version: number
|
|
): PrefsSnapshot<S> {
|
|
const effective = resolvePrefs({ schema, environment, intent }) as PrefsEffectiveOf<S>;
|
|
return Object.freeze({
|
|
environment,
|
|
intent,
|
|
effective,
|
|
version
|
|
}) as PrefsSnapshot<S>;
|
|
}
|
|
|
|
/**
|
|
* Shallow per-key diff over `effective`. Returns only keys whose value
|
|
* changed — `Object.keys(diff).length === 0` is the "effective
|
|
* unchanged" signal subscribers can branch on.
|
|
*/
|
|
function diffEffective<S extends PrefsSchema>(
|
|
previous: PrefsEffectiveOf<S>,
|
|
next: PrefsEffectiveOf<S>
|
|
): Partial<PrefsEffectiveOf<S>> {
|
|
const diff: Record<string, unknown> = {};
|
|
const previousMap = previous as unknown as Record<string, unknown>;
|
|
const nextMap = next as unknown as Record<string, unknown>;
|
|
for (const key of Object.keys(nextMap)) {
|
|
if (previousMap[key] !== nextMap[key]) {
|
|
diff[key] = nextMap[key];
|
|
}
|
|
}
|
|
return diff as Partial<PrefsEffectiveOf<S>>;
|
|
}
|
|
|
|
/**
|
|
* Sparse-map equality. Keys with `undefined` values are treated as
|
|
* absent so callers can write `{ locale: undefined }` without
|
|
* spuriously committing — the canonical "clear" path is still
|
|
* `clearIntent(key)`.
|
|
*/
|
|
function intentEqual(a: object, b: object): boolean {
|
|
const aMap = a as Record<string, unknown>;
|
|
const bMap = b as Record<string, unknown>;
|
|
const aKeys = Object.keys(aMap).filter((k) => aMap[k] !== undefined);
|
|
const bKeys = Object.keys(bMap).filter((k) => bMap[k] !== undefined);
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
for (const key of aKeys) {
|
|
if (aMap[key] !== bMap[key]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function environmentEqual(a: PrefsEnvironment, b: PrefsEnvironment): boolean {
|
|
if (a === b) return true;
|
|
const aMap = a as Record<string, unknown>;
|
|
const bMap = b as Record<string, unknown>;
|
|
const aKeys = Object.keys(aMap);
|
|
const bKeys = Object.keys(bMap);
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
for (const key of aKeys) {
|
|
if (key === 'locales') {
|
|
if (!arrayEqual(a.locales, b.locales)) return false;
|
|
} else if (aMap[key] !== bMap[key]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function arrayEqual<T>(a: readonly T[] | undefined, b: readonly T[] | undefined): boolean {
|
|
if (a === b) return true;
|
|
if (a === undefined || b === undefined) return false;
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i += 1) {
|
|
if (a[i] !== b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|