Files
openmaic/OpenMAIC/lib/document-store/validators.ts
2026-08-16 14:58:47 +08:00

121 lines
5.2 KiB
TypeScript

import { validateScene, validateStage, type ValidationIssue } from '@openmaic/dsl';
import type { SceneValidator, StageValidator } from '@openmaic/storage';
import { hasPBLProjectV2Containers } from '@/lib/pbl/v2/types';
import { isEmptyLegacyPBLConfig, type PBLProjectConfig } from '@/lib/pbl/legacy/read';
function objectValue(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function requiredString(
value: Record<string, unknown>,
key: string,
errors: ValidationIssue[],
): void {
if (typeof value[key] !== 'string' || value[key] === '') {
errors.push({ path: `/${key}`, message: `expected non-empty string \`${key}\`` });
}
}
/** Validate the app's four-way scene union at the document write boundary. */
export const validateAppScene: SceneValidator = (scene) => {
const value = objectValue(scene);
if (!value) {
return { valid: false, errors: [{ path: '/', message: 'scene must be an object' }] };
}
if (value.type === 'slide' || value.type === 'quiz') return validateScene(scene);
const errors: ValidationIssue[] = [];
requiredString(value, 'id', errors);
requiredString(value, 'stageId', errors);
requiredString(value, 'title', errors);
if (typeof value.order !== 'number' || !Number.isFinite(value.order)) {
errors.push({ path: '/order', message: 'expected finite number `order`' });
}
const content = objectValue(value.content);
if (value.type !== 'interactive' && value.type !== 'pbl') {
errors.push({
path: '/type',
message: `unknown app scene type: ${JSON.stringify(value.type)}`,
});
} else if (!content) {
errors.push({ path: '/content', message: 'scene `content` must be an object' });
} else if (content.type !== value.type) {
errors.push({
path: '/content/type',
message: `content type ${JSON.stringify(content.type)} does not match scene type ${JSON.stringify(value.type)}`,
});
} else if (value.type === 'interactive') {
if (typeof content.html !== 'string' && typeof content.url !== 'string') {
errors.push({
path: '/content',
message: 'interactive content requires `html` or `url` as a string',
});
}
if (content.url !== undefined && typeof content.url !== 'string') {
errors.push({ path: '/content/url', message: '`url` must be a string when present' });
}
if (content.html !== undefined && typeof content.html !== 'string') {
errors.push({ path: '/content/html', message: '`html` must be a string when present' });
}
if (content.widgetConfig !== undefined && objectValue(content.widgetConfig) === null) {
// Primitive widgetConfig values crash hydration ('in' throws on non-objects
// in migrateInteractiveContent), so the write barrier rejects exactly that
// class. Arrays and type-less objects stay tolerated as historical shapes.
errors.push({
path: '/content/widgetConfig',
message: '`widgetConfig` must be an object when present',
});
}
// The contract validator stays strict for external consumers. The app write
// path remains lenient over historical widget shapes until stored configs
// are canonicalized in a follow-up.
} else if (
value.type === 'pbl' &&
content.projectConfig !== undefined &&
(!objectValue(content.projectConfig) || Array.isArray(content.projectConfig))
) {
errors.push({ path: '/content/projectConfig', message: '`projectConfig` must be an object' });
} else if (
value.type === 'pbl' &&
// null is treated like absent so documents stored before projectV2
// validation existed keep saving; the renderer applies the same rule.
content.projectV2 != null &&
// Every scene accepted by the old write barrier carried projectConfig, so
// stored scenes with both fields are the pre-cutover hybrid cohort. Preserve
// a damaged projectV2 there as inert bytes — but only when the legacy config
// is structurally sound and non-empty (real stored v1 data, the renderer's
// actual fallback); an empty stub like `{}` must not disable v2 validation.
// V2-only scenes are new planner writes, where strict container validation
// enforces planner output quality.
!(
objectValue(content.projectConfig) &&
!Array.isArray(content.projectConfig) &&
!isEmptyLegacyPBLConfig(content.projectConfig as PBLProjectConfig)
) &&
!hasPBLProjectV2Containers(content.projectV2)
) {
errors.push({
path: '/content/projectV2',
message: '`projectV2` must contain milestones, roles and threads arrays',
});
}
return errors.length === 0 ? { valid: true } : { valid: false, errors };
};
/** Validate canonical app stage metadata and exclude device playback position. */
export const validateAppStage: StageValidator = (stage) => {
const base = validateStage(stage);
const value = objectValue(stage);
if (!value || !Object.prototype.hasOwnProperty.call(value, 'currentSceneId')) return base;
const issue = {
path: '/currentSceneId',
message: '`currentSceneId` is device playback state and is not allowed on AppStage',
};
return base.valid
? { valid: false, errors: [issue] }
: { valid: false, errors: [...base.errors, issue] };
};