sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
324 lines (273 loc) • 9.15 kB
text/typescript
import {
type ArraySchemaType,
type InitialValueProperty,
type InitialValueResolver,
type InitialValueResolverContext,
isArraySchemaType,
isObjectSchemaType,
type ObjectSchemaType,
type Schema,
type SchemaType,
} from '@sanity/types'
import {isDeepEmpty, randomKey, resolveTypeName} from '@sanity/util/content'
import {type Template} from './types'
import deepAssign from './util/deepAssign'
import {isRecord} from './util/isRecord'
import {validateInitialObjectValue} from './validate'
/** @internal */
export type Serializeable<T> = {
serialize(): T
}
interface Options {
useCache?: boolean
}
/** @internal */
export function isBuilder(template: unknown): template is Serializeable<Template> {
return isRecord(template) && typeof template.serialize === 'function'
}
const cache = new WeakMap<
InitialValueResolver<unknown, unknown>,
Record<string, unknown | Promise<unknown>>
>()
/** @internal */
// returns the "resolved" value from an initial value property (e.g. type.initialValue)
// eslint-disable-next-line require-await
export async function resolveValue<Params, InitialValue>(
initialValueOpt: InitialValueProperty<Params, InitialValue>,
params: Params | undefined,
context: InitialValueResolverContext,
options?: Options,
): Promise<InitialValue | undefined> {
const useCache = options?.useCache
if (typeof initialValueOpt === 'function') {
const cached = cache.get(initialValueOpt as InitialValueResolver<unknown, unknown>)
const key = JSON.stringify([
params,
context.projectId,
context.dataset,
context.currentUser?.id,
])
if (useCache && cached?.[key]) {
return cached[key] as InitialValue | Promise<InitialValue>
}
const value = (initialValueOpt as InitialValueResolver<Params, InitialValue>)(params, context)
if (useCache) {
cache.set(initialValueOpt as InitialValueResolver<unknown, unknown>, {
...cached,
[key]: value,
})
}
return value
}
return initialValueOpt
}
/** @internal */
export async function resolveInitialValue(
schema: Schema,
template: Template,
params: {[key: string]: any} = {},
context: InitialValueResolverContext,
options?: Options,
): Promise<{[key: string]: any}> {
// Template builder?
if (isBuilder(template)) {
return resolveInitialValue(schema, template.serialize(), params, context, options)
}
const {id, schemaType: schemaTypeName, value} = template
if (!value) {
throw new Error(`Template "${id}" has invalid "value" property`)
}
let resolvedValue = await resolveValue(value, params, context, options)
if (!isRecord(resolvedValue)) {
throw new Error(
`Template "${id}" has invalid "value" property - must be a plain object or a resolver function returning a plain object`,
)
}
// Ensure _type is set on empty objects
if (isRecord(resolvedValue) && !Object.keys(resolvedValue).length) {
resolvedValue = {_type: schemaTypeName}
}
// validate default document initial values
resolvedValue = validateInitialObjectValue(resolvedValue, template)
// Get deep initial values from schema types (note: the initial value from template overrides the types)
const schemaType = schema.get(schemaTypeName)
if (!schemaType) {
throw new Error(`Could not find schema type with name "${schemaTypeName}".`)
}
const newValue = deepAssign(
(await resolveInitialValueForType(
schemaType,
params,
DEFAULT_MAX_RECURSION_DEPTH,
context,
options,
)) || {},
resolvedValue as Record<string, unknown>,
)
// revalidate and return new initial values
// todo: would be better to do validation as part of type resolution
return validateInitialObjectValue(newValue, template)
}
/** @internal */
export function getItemType(arrayType: ArraySchemaType, item: unknown): SchemaType | undefined {
const itemTypeName = resolveTypeName(item)
return itemTypeName === 'object' && arrayType.of.length === 1
? arrayType.of[0]
: arrayType.of.find((memberType) => memberType.name === itemTypeName)
}
/** @internal */
export const DEFAULT_MAX_RECURSION_DEPTH = 10
type ResolveInitialValueForType = <TParams extends Record<string, unknown>>(
/**
* This is the name of the document.
*/ type: SchemaType,
/**
* Params is a sanity context object passed to every initial value function.
*/
params: TParams,
/**
* Maximum recursion depth (default 9).
*/
maxDepth: number,
context: InitialValueResolverContext,
options?: Options,
) => Promise<any>
const memoizeResolveInitialValueForType: (
fn: ResolveInitialValueForType,
) => ResolveInitialValueForType = (fn) => {
const resolveInitialValueForTypeCache = new WeakMap<SchemaType, Map<string, Promise<any>>>()
const stableStringify = (obj: any): string => {
if (obj !== null && typeof obj === 'object') {
if (Array.isArray(obj)) {
return `[${obj.map(stableStringify).join(',')}]`
}
const keys = Object.keys(obj).sort()
return `{${keys
.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
.join(',')}}`
}
return JSON.stringify(obj)
}
const hashParameters = (
params: Record<string, unknown>,
context: InitialValueResolverContext,
): string => {
return stableStringify({
params,
context: {
schemaName: context.schema.name,
projectId: context.projectId,
dataset: context.dataset,
currentUser: context.currentUser?.id,
},
})
}
return async function resolveInitialValueForType(type, params, maxDepth, context, options) {
if (!options?.useCache) return fn(type, params, maxDepth, context, options)
let typeCache = resolveInitialValueForTypeCache.get(type)
if (!typeCache) {
typeCache = new Map<string, Promise<any>>()
resolveInitialValueForTypeCache.set(type, typeCache)
}
const hash = hashParameters(params, context)
const cachedResult = typeCache.get(hash)
if (cachedResult) return cachedResult
const result = await fn(type, params, maxDepth, context, options)
// double check after the await
if (!typeCache.has(hash)) {
typeCache.set(hash, result)
}
return result
}
}
/**
* Resolve initial value for the given schema type (recursively)
*
* @internal
*/
export const resolveInitialValueForType = memoizeResolveInitialValueForType(
(type, params, maxDepth = DEFAULT_MAX_RECURSION_DEPTH, context, options): Promise<any> => {
if (maxDepth <= 0) {
return Promise.resolve(undefined)
}
if (isObjectSchemaType(type)) {
return postTask(() => resolveInitialObjectValue(type, params, maxDepth, context, options))
}
if (isArraySchemaType(type)) {
return postTask(() => resolveInitialArrayValue(type, params, maxDepth, context, options))
}
return resolveValue(type.initialValue, params, context, options)
},
)
async function resolveInitialArrayValue<Params extends Record<string, unknown>>(
type: SchemaType,
params: Params,
maxDepth: number,
context: InitialValueResolverContext,
options?: Options,
): Promise<any> {
const initialArray = await resolveValue(type.initialValue, undefined, context, options)
if (!Array.isArray(initialArray)) {
return undefined
}
return Promise.all(
initialArray.map(async (initialItem) => {
const itemType = getItemType(type as ArraySchemaType, initialItem)!
return isObjectSchemaType(itemType)
? {
...initialItem,
...(await resolveInitialValueForType(itemType, params, maxDepth - 1, context, options)),
_key: randomKey(),
}
: initialItem
}),
)
}
/** @internal */
export async function resolveInitialObjectValue<Params extends Record<string, unknown>>(
type: ObjectSchemaType,
params: Params,
maxDepth: number,
context: InitialValueResolverContext,
options?: Options,
): Promise<any> {
const initialObject: Record<string, unknown> = {
...((await resolveValue(type.initialValue, params, context, options)) || {}),
}
const fieldValues: Record<string, any> = {}
await Promise.all(
type.fields.map(async (field) => {
const initialFieldValue = await resolveInitialValueForType(
field.type,
params,
maxDepth - 1,
context,
options,
)
if (initialFieldValue !== undefined && initialFieldValue !== null) {
fieldValues[field.name] = initialFieldValue
}
}),
)
const merged = deepAssign(fieldValues, initialObject)
if (isDeepEmpty(merged)) {
return undefined
}
if (type.name !== 'object') {
merged._type = type.name
}
return merged
}
/**
* Schedule the provided callback using `scheduler.postTask`, if it's available.
* Otherwise, call it immediately.
*/
function postTask<Result>(
callback: () => Result,
options: PostTaskOptions = {},
): Result | Promise<Result> {
if ('scheduler' in window && typeof window.scheduler?.postTask === 'function') {
return window.scheduler.postTask(callback, options)
}
return callback()
}