136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
import '@/lib/persistence/bootstrap';
|
|
|
|
/**
|
|
* Lazy app-wide RuntimeStore singleton (#869). One `maic-runtime` IndexedDB
|
|
* per origin, shared by every runtime kind (pbl, chat, quizAttempt, playback)
|
|
* as they migrate onto the runtime layer. PBL events, chat sessions, and quiz
|
|
* attempts use it today; the stage-deletion cascade clears every kind together.
|
|
*
|
|
* Client-only: the store lazily opens IndexedDB. Server code must not import
|
|
* this module without injecting its own `RuntimeStore`.
|
|
*/
|
|
import { BrowserRuntimeStore, type RuntimeStore } from '@openmaic/storage';
|
|
|
|
import { registerRuntimeStorageResetHook, resolveConfiguredRuntimeStore } from './config';
|
|
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from './payload-validators';
|
|
|
|
export {
|
|
configureRuntimeStorage,
|
|
isRuntimeStorageConfigured,
|
|
resetRuntimeStorageForTests,
|
|
} from './config';
|
|
export type { RuntimeStorageOptions } from './config';
|
|
|
|
// BrowserRuntimeStore's default dbName; passed explicitly below so the probe
|
|
// in deleteStageRuntimeSafely and the store itself can never drift apart.
|
|
const RUNTIME_DB_NAME = 'maic-runtime';
|
|
|
|
let store: RuntimeStore | undefined;
|
|
|
|
registerRuntimeStorageResetHook(() => {
|
|
store = undefined;
|
|
});
|
|
let usesDefaultBrowserStore = false;
|
|
|
|
function createRuntimeStore(): RuntimeStore {
|
|
const configured = resolveConfiguredRuntimeStore();
|
|
usesDefaultBrowserStore = configured === undefined;
|
|
return (
|
|
configured ??
|
|
new BrowserRuntimeStore({
|
|
dbName: RUNTIME_DB_NAME,
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
})
|
|
);
|
|
}
|
|
|
|
export function getRuntimeStore(): RuntimeStore {
|
|
// `??=` assigns only after resolution succeeds: if a configured factory
|
|
// throws, the next call retries it rather than caching the failure.
|
|
return (store ??= createRuntimeStore());
|
|
}
|
|
|
|
/** How long the deletion cascade may run before the caller moves on. */
|
|
const STAGE_RUNTIME_DELETE_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* True unless the probe API positively says the runtime DB was never created.
|
|
* Opening the store would CREATE the database, so a deletion on a device that
|
|
* never wrote runtime data must not touch it. Where `indexedDB.databases` is
|
|
* unavailable (older Firefox), assume it exists — the timeout already bounds
|
|
* the degraded case, and skipping would strand real cleanup.
|
|
*/
|
|
async function runtimeDbExists(): Promise<boolean> {
|
|
if (typeof indexedDB === 'undefined' || typeof indexedDB.databases !== 'function') {
|
|
return true;
|
|
}
|
|
const databases = await indexedDB.databases();
|
|
return databases.some((db) => db.name === RUNTIME_DB_NAME);
|
|
}
|
|
|
|
/** Reject after `ms`, clearing the timer once the raced promise settles. */
|
|
async function withTimeout(work: Promise<void>, ms: number): Promise<void> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
await Promise.race([
|
|
work,
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cascade a stage deletion into the runtime store without ever throwing or
|
|
* hanging. The runtime layer lives in a separate IndexedDB database
|
|
* (`maic-runtime`), so a broken or hung runtime DB must not brick stage
|
|
* deletion in the main app DB — the cascade is bounded by a timeout, and any
|
|
* failure warns and moves on. A failed or timed-out cascade leaves orphaned
|
|
* runtime rows; they are not reachable through normal navigation once the
|
|
* stage is gone, and a future startup sweep can reclaim them.
|
|
*/
|
|
export async function deleteStageRuntimeSafely(
|
|
stageId: string,
|
|
runtimeStore?: RuntimeStore,
|
|
): Promise<void> {
|
|
await beginStageRuntimeDeletionSafely(stageId, runtimeStore).completion;
|
|
}
|
|
|
|
export interface StageRuntimeDeletion {
|
|
/** Bounded, fail-soft caller-visible completion. */
|
|
completion: Promise<void>;
|
|
/** Fail-soft actual settlement, used to retain destructive maintenance locks. */
|
|
settlement: Promise<void>;
|
|
}
|
|
|
|
/** Start one bounded deletion while keeping a handle to its real settlement. */
|
|
export function beginStageRuntimeDeletionSafely(
|
|
stageId: string,
|
|
runtimeStore?: RuntimeStore,
|
|
): StageRuntimeDeletion {
|
|
// Probe + cascade share the same underlying work: a hanging databases()
|
|
// probe is just as important to retain behind maintenance as a hanging delete.
|
|
const work = (async () => {
|
|
if (runtimeStore) {
|
|
await runtimeStore.deleteStageRuntime(stageId);
|
|
return;
|
|
}
|
|
|
|
const resolvedStore = getRuntimeStore();
|
|
if (usesDefaultBrowserStore && !(await runtimeDbExists())) return;
|
|
await resolvedStore.deleteStageRuntime(stageId);
|
|
})();
|
|
let reported = false;
|
|
const report = (error: unknown): void => {
|
|
if (reported) return;
|
|
reported = true;
|
|
console.warn(`Failed to delete runtime data for stage ${stageId}:`, error);
|
|
};
|
|
const settlement = work.catch(report);
|
|
const completion = withTimeout(work, STAGE_RUNTIME_DELETE_TIMEOUT_MS).catch(report);
|
|
return { completion, settlement };
|
|
}
|