@instantdb/core
Version:
Instant's core local abstraction
205 lines (178 loc) • 6.16 kB
text/typescript
import { TransactionChunk, Op, isLookup } from './instatx.ts';
import { IContainEntitiesAndLinks, DataAttrDef } from './schemaTypes.ts';
import { validate as validateUUID } from 'uuid';
export const isValidEntityId = (value: unknown): boolean => {
if (typeof value !== 'string') {
return false;
}
if (isLookup(value)) {
return true;
}
return validateUUID(value);
};
export class TransactionValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'TransactionValidationError';
}
}
const formatAvailableOptions = (items: string[]) =>
items.length > 0 ? items.join(', ') : 'none';
const createEntityNotFoundError = (
entityName: string,
availableEntities: string[],
) =>
new TransactionValidationError(
`Entity '${entityName}' does not exist in schema. Available entities: ${formatAvailableOptions(availableEntities)}`,
);
const TYPE_VALIDATORS = {
string: (value: unknown) => typeof value === 'string',
number: (value: unknown) => typeof value === 'number' && !isNaN(value),
boolean: (value: unknown) => typeof value === 'boolean',
date: (value: unknown) =>
value instanceof Date ||
typeof value === 'string' ||
typeof value === 'number',
json: () => true,
} as const;
const isValidValueForAttr = (
value: unknown,
attrDef: DataAttrDef<any, any, any, boolean>,
): boolean => {
if (value === null || value === undefined) return true;
return TYPE_VALIDATORS[attrDef.valueType]?.(value) ?? false;
};
const validateEntityExists = (
entityName: string,
schema: IContainEntitiesAndLinks<any, any>,
) => {
const entityDef = schema.entities[entityName];
if (!entityDef) {
throw createEntityNotFoundError(entityName, Object.keys(schema.entities));
}
return entityDef;
};
const validateDataOperation = (
entityName: string,
data: any,
schema: IContainEntitiesAndLinks<any, any>,
) => {
const entityDef = validateEntityExists(entityName, schema);
if (typeof data !== 'object' || data === null) {
throw new TransactionValidationError(
`Arguments for data operation on entity '${entityName}' must be an object, but received: ${typeof data}`,
);
}
for (const [attrName, value] of Object.entries(data)) {
if (attrName === 'id') continue; // id is handled specially
const attrDef = entityDef.attrs[attrName];
if (attrDef) {
if (!isValidValueForAttr(value, attrDef)) {
throw new TransactionValidationError(
`Invalid value for attribute '${attrName}' in entity '${entityName}'. Expected ${attrDef.valueType}, but received: ${typeof value}`,
);
}
}
}
};
const validateLinkOperation = (
entityName: string,
links: any,
schema: IContainEntitiesAndLinks<any, any>,
) => {
const entityDef = validateEntityExists(entityName, schema);
if (typeof links !== 'object' || links === null) {
throw new TransactionValidationError(
`Arguments for link operation on entity '${entityName}' must be an object, but received: ${typeof links}`,
);
}
for (const [linkName, linkValue] of Object.entries(links)) {
const link = entityDef.links[linkName];
if (!link) {
const availableLinks = Object.keys(entityDef.links);
throw new TransactionValidationError(
`Link '${linkName}' does not exist on entity '${entityName}'. Available links: ${formatAvailableOptions(availableLinks)}`,
);
}
// Validate UUID format for link values
if (linkValue !== null && linkValue !== undefined) {
if (Array.isArray(linkValue)) {
// Handle array of UUIDs
for (const linkReference of linkValue) {
if (!isValidEntityId(linkReference)) {
throw new TransactionValidationError(
`Invalid entity ID in link '${linkName}' for entity '${entityName}'. Expected a UUID or a lookup, but received: ${linkReference}`,
);
}
}
} else {
// Handle single UUID
if (!isValidEntityId(linkValue)) {
throw new TransactionValidationError(
`Invalid UUID in link '${linkName}' for entity '${entityName}'. Expected a UUID, but received: ${linkValue}`,
);
}
}
}
}
};
const VALIDATION_STRATEGIES = {
create: validateDataOperation,
update: validateDataOperation,
merge: validateDataOperation,
link: validateLinkOperation,
unlink: validateLinkOperation,
delete: () => {},
} as const;
const validateOp = (
op: Op,
schema?: IContainEntitiesAndLinks<any, any>,
): void => {
if (!schema) return;
const [action, entityName, _id, args] = op;
// _id should be a uuid
if (!Array.isArray(_id)) {
const isUuid = validateUUID(_id);
if (!isUuid) {
throw new TransactionValidationError(
`Invalid id for entity '${entityName}'. Expected a UUID, but received: ${_id}`,
);
}
}
if (typeof entityName !== 'string') {
throw new TransactionValidationError(
`Entity name must be a string, but received: ${typeof entityName}`,
);
}
const validator =
VALIDATION_STRATEGIES[action as keyof typeof VALIDATION_STRATEGIES];
if (validator && args !== undefined) {
validator(entityName, args, schema);
}
};
export const validateTransactions = (
inputChunks: TransactionChunk<any, any> | TransactionChunk<any, any>[],
schema?: IContainEntitiesAndLinks<any, any>,
): void => {
const chunks = Array.isArray(inputChunks) ? inputChunks : [inputChunks];
for (const txStep of chunks) {
if (!txStep || typeof txStep !== 'object') {
throw new TransactionValidationError(
`Transaction chunk must be an object, but received: ${typeof txStep}`,
);
}
if (!Array.isArray(txStep.__ops)) {
throw new TransactionValidationError(
`Transaction chunk must have __ops array, but received: ${typeof txStep.__ops}`,
);
}
for (const op of txStep.__ops) {
if (!Array.isArray(op)) {
throw new TransactionValidationError(
`Transaction operation must be an array, but received: ${typeof op}`,
);
}
validateOp(op, schema);
}
}
};