Files
openmaic/OpenMAIC/lib/runtime/config.ts
2026-08-16 14:58:47 +08:00

101 lines
4.1 KiB
TypeScript

import type { RuntimeStore } from '@openmaic/storage';
export interface RuntimeStorageOptions {
/** A RuntimeStore instance, or a factory evaluated lazily until it first succeeds. */
store?: RuntimeStore | (() => RuntimeStore);
/** Resolves the client session's learner partition key on first resolution. */
learnerKey?: () => string | Promise<string>;
}
let options: RuntimeStorageOptions | undefined;
let resolutionStarted = false;
/**
* Configure the app-wide runtime persistence backend and learner identity.
*
* This is a client-bootstrap-only, single-shot API. It is not SSR-safe and is
* not intended for request-scoped stores or identity. Call it at module-level
* bootstrap, before rendering any runtime consumer; a component effect is too
* late. A second call always throws, even if runtime storage has not been used
* yet. Once resolution has started, configuration stays sealed so a live app
* cannot split data across backends or learner partitions. Omitted fields
* retain the browser IndexedDB backend and anonymous device-key behavior.
*
* A store factory is called lazily by `getRuntimeStore()` until it first
* succeeds. The configured learner-key provider is invoked only on first
* resolution; concurrent callers share that resolution and its first resolved
* value is retained for the client session. Identity changes mid-session are
* the application layer's responsibility (reload or a `mergeLearner` flow).
*
* Dev-mode limitation: configuration and consumer caches live in separate
* modules, so partial HMR replacement can fragment their module-level state.
* Reload the page after changing bootstrap configuration.
*
* @example
* ```ts
* configureRuntimeStorage({
* store: new HttpRuntimeStore({ baseUrl: '/api' }),
* learnerKey: () => clientSessionStore.getState().userId,
* });
* ```
*/
export function configureRuntimeStorage(next: RuntimeStorageOptions): void {
assertRuntimeStorageConfigurable();
// Snapshot: a caller mutating its options object after configuring must not
// be able to swap the backend or identity behind the sealed configuration.
options = { store: next.store, learnerKey: next.learnerKey };
}
/**
* @internal Synchronous bootstrap preflight used to make multi-seam
* configuration atomic.
*/
export function assertRuntimeStorageConfigurable(): void {
if (resolutionStarted) {
throw new Error(
'configureRuntimeStorage must be called at module-level bootstrap, before any runtime consumer runs — a component effect is too late. Runtime storage resolution has already started; configuration remains sealed even if resolution failed. Retry the runtime consumer to retry resolution.',
);
}
if (options) {
throw new Error('Runtime storage has already been configured');
}
}
/** Whether client bootstrap has supplied runtime storage configuration. */
export function isRuntimeStorageConfigured(): boolean {
return options !== undefined;
}
type RuntimeStorageResetHook = () => void;
const resetHooks: RuntimeStorageResetHook[] = [];
/**
* @internal Modules that latch singleton caches derived from this
* configuration (the store singleton, the learner-key in-flight promise)
* register a clearer so the test reset below leaves no stale cache behind.
*/
export function registerRuntimeStorageResetHook(hook: RuntimeStorageResetHook): void {
resetHooks.push(hook);
}
/** @internal Test-only reset: clears configuration AND every latched consumer cache. */
export function resetRuntimeStorageForTests(): void {
options = undefined;
resolutionStarted = false;
for (const hook of resetHooks) hook();
}
/** @internal Resolve and seal the configured store override, if any. */
export function resolveConfiguredRuntimeStore(): RuntimeStore | undefined {
resolutionStarted = true;
const configured = options?.store;
return typeof configured === 'function' ? configured() : configured;
}
/** @internal Resolve and seal the configured learner-key override, if any. */
export function resolveConfiguredLearnerKey(): Promise<string> | undefined {
resolutionStarted = true;
const provider = options?.learnerKey;
return provider ? Promise.resolve().then(provider) : undefined;
}