Faster validations + record reference stability at the same time (#2848)

This PR adds a validation mode whereby previous known-to-be-valid values
can be used to speed up the validation process itself. At the same time
it enables us to do fine-grained equality checking on records much more
quickly than by using something like lodash isEqual, and using that we
can prevent triggering effects for record updates that don't actually
alter any values in the store.

Here's some preliminary perf testing of average time spent in
`store.put()` during some common interactions

| task | before (ms) | after (ms) |
| ---- | ---- | ---- |
| drawing lines | 0.0403 | 0.0214 |
| drawing boxes | 0.0408 | 0.0348 |
| translating lines | 0.0352 | 0.0042 |
| translating boxes | 0.0051 | 0.0032 |
| rotating lines | 0.0312 | 0.0065 |
| rotating boxes | 0.0053 | 0.0035 |
| brush selecting boxes | 0.0200 | 0.0232 |
| traversal with shapes | 0.0130 | 0.0108 |
| traversal without shapes | 0.0201 | 0.0173 |

**traversal** means moving the camera and pointer around the canvas

#### Discussion

At the scale of hundredths of a millisecond these .put operations are so
fast that even if they became literally instantaneous the change would
not be human perceptible. That said, there is an overall marked
improvement here. Especially for dealing with draw shapes.

These figures are also mostly in line with expectations, aside from a
couple of things:

- I don't understand why the `brush selecting boxes` task got slower
after the change.
- I don't understand why the `traversal` tasks are slower than the
`translating boxes` task, both before and after. I would expect that
.putting shape records would be much slower than .putting pointer/camera
records (since the latter have fewer and simpler properties)

### Change Type

- [x] `patch` — Bug fix

### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-02-20 12:35:25 +00:00 committed by GitHub
parent 50f77fe75c
commit 4a2040f92c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1486 additions and 293 deletions

View file

@ -11,7 +11,7 @@
"scripts": {
"dev": "cross-env NODE_ENV=development wrangler dev --log-level info --persist-to tmp-assets",
"test-ci": "lazy inherit --passWithNoTests",
"test": "yarn run -T jest",
"test": "yarn run -T jest --passWithNoTests",
"test-coverage": "lazy inherit --passWithNoTests",
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},

View file

@ -15,7 +15,7 @@ const SIZES = [
{ value: 'm', icon: 'size-medium' },
{ value: 'l', icon: 'size-large' },
{ value: 'xl', icon: 'size-extra-large' },
]
] as const
// There's a guide at the bottom of this file!

View file

@ -31,9 +31,12 @@ export const FilterStyleUi = track(function FilterStyleUi() {
onChange={(e) => {
editor.batch(() => {
if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(MyFilterStyle, e.target.value)
editor.setStyleForSelectedShapes(
MyFilterStyle,
MyFilterStyle.validate(e.target.value)
)
}
editor.setStyleForNextShapes(MyFilterStyle, e.target.value)
editor.setStyleForNextShapes(MyFilterStyle, MyFilterStyle.validate(e.target.value))
})
}}
>

View file

@ -55,7 +55,7 @@
"check-scripts": "tsx scripts/check-scripts.ts",
"api-check": "lazy api-check",
"test-ci": "lazy test-ci",
"test": "yarn run -T jest",
"test": "lazy test",
"test-coverage": "lazy test-coverage && node scripts/offer-coverage.mjs",
"e2e": "lazy e2e --filter='apps/examples'"
},

View file

@ -32,6 +32,7 @@ import { Signal } from '@tldraw/state';
import { StoreSchema } from '@tldraw/store';
import { StoreSnapshot } from '@tldraw/store';
import { StyleProp } from '@tldraw/tlschema';
import { StylePropValue } from '@tldraw/tlschema';
import { TLArrowShape } from '@tldraw/tlschema';
import { TLArrowShapeArrowheadStyle } from '@tldraw/tlschema';
import { TLAsset } from '@tldraw/tlschema';
@ -864,7 +865,7 @@ export class Editor extends EventEmitter<TLEventMap> {
setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this;
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this;
setStyleForSelectedShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this;
setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>, historyOptions?: TLCommandHistoryOptions): this;
shapeUtils: {
readonly [K in string]?: ShapeUtil<TLUnknownShape>;
};
@ -884,7 +885,7 @@ export class Editor extends EventEmitter<TLEventMap> {
stretchShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
// (undocumented)
styleProps: {
[key: string]: Map<StyleProp<unknown>, string>;
[key: string]: Map<StyleProp<any>, string>;
};
readonly textMeasure: TextManager;
toggleLock(shapes: TLShape[] | TLShapeId[]): this;
@ -1462,10 +1463,10 @@ export { react }
// @public
export class ReadonlySharedStyleMap {
// (undocumented)
[Symbol.iterator](): IterableIterator<[StyleProp<unknown>, SharedStyle<unknown>]>;
[Symbol.iterator](): IterableIterator<[StyleProp<any>, SharedStyle<unknown>]>;
constructor(entries?: Iterable<[StyleProp<unknown>, SharedStyle<unknown>]>);
// (undocumented)
entries(): IterableIterator<[StyleProp<unknown>, SharedStyle<unknown>]>;
entries(): IterableIterator<[StyleProp<any>, SharedStyle<unknown>]>;
// (undocumented)
equals(other: ReadonlySharedStyleMap): boolean;
// (undocumented)
@ -1473,9 +1474,9 @@ export class ReadonlySharedStyleMap {
// (undocumented)
getAsKnownValue<T>(prop: StyleProp<T>): T | undefined;
// (undocumented)
keys(): IterableIterator<StyleProp<unknown>>;
keys(): IterableIterator<StyleProp<any>>;
// @internal (undocumented)
protected map: Map<StyleProp<unknown>, SharedStyle<unknown>>;
protected map: Map<StyleProp<any>, SharedStyle<unknown>>;
// (undocumented)
get size(): number;
// (undocumented)

View file

@ -17658,7 +17658,7 @@
"excerptTokens": [
{
"kind": "Content",
"text": "setStyleForSelectedShapes<T>(style: "
"text": "setStyleForSelectedShapes<S extends "
},
{
"kind": "Reference",
@ -17667,15 +17667,28 @@
},
{
"kind": "Content",
"text": "<T>"
"text": "<any>"
},
{
"kind": "Content",
"text": ">(style: "
},
{
"kind": "Content",
"text": "S"
},
{
"kind": "Content",
"text": ", value: "
},
{
"kind": "Reference",
"text": "StylePropValue",
"canonicalReference": "@tldraw/tlschema!StylePropValue:type"
},
{
"kind": "Content",
"text": "T"
"text": "<S>"
},
{
"kind": "Content",
@ -17701,10 +17714,10 @@
],
"typeParameters": [
{
"typeParameterName": "T",
"typeParameterName": "S",
"constraintTokenRange": {
"startIndex": 0,
"endIndex": 0
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
@ -17714,8 +17727,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 8,
"endIndex": 9
"startIndex": 11,
"endIndex": 12
},
"releaseTag": "Public",
"isProtected": false,
@ -17723,14 +17736,6 @@
"parameters": [
{
"parameterName": "style",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "value",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
@ -17738,10 +17743,18 @@
"isOptional": false
},
{
"parameterName": "historyOptions",
"parameterName": "value",
"parameterTypeTokenRange": {
"startIndex": 6,
"endIndex": 7
"endIndex": 8
},
"isOptional": false
},
{
"parameterName": "historyOptions",
"parameterTypeTokenRange": {
"startIndex": 9,
"endIndex": 10
},
"isOptional": true
}
@ -18263,7 +18276,7 @@
},
{
"kind": "Content",
"text": "<unknown>, string>;\n }"
"text": "<any>, string>;\n }"
},
{
"kind": "Content",
@ -28523,7 +28536,7 @@
},
{
"kind": "Content",
"text": "<unknown>, "
"text": "<any>, "
},
{
"kind": "Reference",
@ -28632,7 +28645,7 @@
},
{
"kind": "Content",
"text": "<unknown>, "
"text": "<any>, "
},
{
"kind": "Reference",
@ -28872,7 +28885,7 @@
},
{
"kind": "Content",
"text": "<unknown>>"
"text": "<any>>"
},
{
"kind": "Content",

View file

@ -5,6 +5,7 @@ import {
InstancePageStateRecordType,
PageRecordType,
StyleProp,
StylePropValue,
TLArrowShape,
TLAsset,
TLAssetId,
@ -737,7 +738,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
shapeUtils: { readonly [K in string]?: ShapeUtil<TLUnknownShape> }
styleProps: { [key: string]: Map<StyleProp<unknown>, string> }
styleProps: { [key: string]: Map<StyleProp<any>, string> }
/**
* Get a shape util from a shape itself.
@ -7385,9 +7386,9 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
setStyleForSelectedShapes<T>(
style: StyleProp<T>,
value: T,
setStyleForSelectedShapes<S extends StyleProp<any>>(
style: S,
value: StylePropValue<S>,
historyOptions?: TLCommandHistoryOptions
): this {
const selectedShapes = this.getSelectedShapes()

View file

@ -35,7 +35,7 @@ function sharedStyleEquals<T>(a: SharedStyle<T>, b: SharedStyle<T> | undefined)
*/
export class ReadonlySharedStyleMap {
/** @internal */
protected map: Map<StyleProp<unknown>, SharedStyle<unknown>>
protected map: Map<StyleProp<any>, SharedStyle<unknown>>
constructor(entries?: Iterable<[StyleProp<unknown>, SharedStyle<unknown>]>) {
this.map = new Map(entries)

View file

@ -163,9 +163,7 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
typeName: R['typeName'], config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
readonly migrations: Migrations;
readonly validator?: {
validate: (r: unknown) => R;
} | StoreValidator<R>;
readonly validator?: StoreValidator<R>;
readonly scope?: RecordScope;
});
clone(record: R): R;
@ -183,11 +181,9 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
// (undocumented)
readonly scope: RecordScope;
readonly typeName: R['typeName'];
validate(record: unknown): R;
validate(record: unknown, recordBefore?: R): R;
// (undocumented)
readonly validator: {
validate: (r: unknown) => R;
} | StoreValidator<R>;
readonly validator: StoreValidator<R>;
withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>;
}
@ -344,6 +340,7 @@ export type StoreSnapshot<R extends UnknownRecord> = {
// @public (undocumented)
export type StoreValidator<R extends UnknownRecord> = {
validate: (record: unknown) => R;
validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R;
};
// @public (undocumented)

View file

@ -2062,7 +2062,7 @@
},
{
"kind": "Content",
"text": ";\n readonly validator?: {\n validate: (r: unknown) => R;\n } | "
"text": ";\n readonly validator?: "
},
{
"kind": "Reference",
@ -2650,6 +2650,14 @@
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": ", recordBefore?: "
},
{
"kind": "Content",
"text": "R"
},
{
"kind": "Content",
"text": "): "
@ -2665,8 +2673,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
@ -2679,6 +2687,14 @@
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "recordBefore",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": true
}
],
"isOptional": false,
@ -2694,10 +2710,6 @@
"kind": "Content",
"text": "readonly validator: "
},
{
"kind": "Content",
"text": "{\n validate: (r: unknown) => R;\n } | "
},
{
"kind": "Reference",
"text": "StoreValidator",
@ -2718,7 +2730,7 @@
"name": "validator",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
"endIndex": 3
},
"isStatic": false,
"isProtected": false,
@ -5535,7 +5547,7 @@
},
{
"kind": "Content",
"text": "{\n validate: (record: unknown) => R;\n}"
"text": "{\n validate: (record: unknown) => R;\n validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R;\n}"
},
{
"kind": "Content",

View file

@ -29,7 +29,7 @@ export class RecordType<
> {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations
readonly validator: StoreValidator<R> | { validate: (r: unknown) => R }
readonly validator: StoreValidator<R>
readonly scope: RecordScope
@ -44,7 +44,7 @@ export class RecordType<
config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations
readonly validator?: StoreValidator<R> | { validate: (r: unknown) => R }
readonly validator?: StoreValidator<R>
readonly scope?: RecordScope
}
) {
@ -198,7 +198,10 @@ export class RecordType<
* Check that the passed in record passes the validations for this type. Returns its input
* correctly typed if it does, but throws an error otherwise.
*/
validate(record: unknown): R {
validate(record: unknown, recordBefore?: R): R {
if (recordBefore && this.validator.validateUsingKnownGoodVersion) {
return this.validator.validateUsingKnownGoodVersion(recordBefore, record)
}
return this.validator.validate(record)
}
}

View file

@ -84,6 +84,7 @@ export type StoreSnapshot<R extends UnknownRecord> = {
/** @public */
export type StoreValidator<R extends UnknownRecord> = {
validate: (record: unknown) => R
validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R
}
/** @public */
@ -389,24 +390,19 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
if (beforeUpdate) record = beforeUpdate(initialValue, record, source)
// Validate the record
record = this.schema.validateRecord(
const validated = this.schema.validateRecord(
this,
record,
phaseOverride ?? 'updateRecord',
initialValue
)
if (validated === initialValue) continue
recordAtom.set(devFreeze(record))
// need to deref atom in case nextValue is not identical but is .equals?
const finalValue = recordAtom.__unsafe__getWithoutCapture()
// If the value has changed, assign it to updates.
// todo: is this always going to be true?
if (initialValue !== finalValue) {
didChange = true
updates[record.id] = [initialValue, finalValue]
}
didChange = true
updates[record.id] = [initialValue, recordAtom.__unsafe__getWithoutCapture()]
} else {
if (beforeCreate) record = beforeCreate(record, source)

View file

@ -85,7 +85,7 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
if (!recordType) {
throw new Error(`Missing definition for record type ${record.typeName}`)
}
return recordType.validate(record)
return recordType.validate(record, recordBefore ?? undefined)
} catch (error: unknown) {
if (this.options.onValidationFailure) {
return this.options.onValidationFailure({

View file

@ -195,9 +195,9 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
isPrecise: boolean;
}>;
point: ObjectValidator< {
type: "point";
x: number;
y: number;
type: "point";
}>;
}, never>;
end: UnionValidator<"type", {
@ -209,9 +209,9 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
isPrecise: boolean;
}>;
point: ObjectValidator< {
type: "point";
x: number;
y: number;
type: "point";
}>;
}, never>;
bend: Validator<number>;
@ -1253,7 +1253,7 @@ export function TldrawUiButtonIcon({ icon, small, invertIcon }: TLUiButtonIconPr
export function TldrawUiButtonLabel({ children }: TLUiButtonLabelProps): JSX_2.Element;
// @public (undocumented)
export const TldrawUiButtonPicker: MemoExoticComponent<(<T extends string>(props: TLUiButtonPickerProps<T>) => JSX_2.Element)>;
export const TldrawUiButtonPicker: typeof _TldrawUiButtonPicker;
// @public (undocumented)
export function TldrawUiComponentsProvider({ overrides, children, }: TLUiComponentsProviderProps): JSX_2.Element;
@ -2108,7 +2108,7 @@ export function useNativeClipboardEvents(): void;
export function useReadonly(): boolean;
// @public (undocumented)
export function useRelevantStyles(stylesToCheck?: readonly (EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow"> | EnumStyleProp<"dashed" | "dotted" | "draw" | "solid"> | EnumStyleProp<"l" | "m" | "s" | "xl"> | EnumStyleProp<"none" | "pattern" | "semi" | "solid">)[]): null | ReadonlySharedStyleMap;
export function useRelevantStyles(stylesToCheck?: readonly StyleProp<any>[]): null | ReadonlySharedStyleMap;
// @public (undocumented)
export function useTldrawUiComponents(): Partial<{

View file

@ -1387,7 +1387,7 @@
},
{
"kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n end: import(\"@tldraw/editor\")."
"text": "<{\n x: number;\n y: number;\n type: \"point\";\n }>;\n }, never>;\n end: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
@ -1432,7 +1432,7 @@
},
{
"kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n bend: import(\"@tldraw/editor\")."
"text": "<{\n x: number;\n y: number;\n type: \"point\";\n }>;\n }, never>;\n bend: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
@ -14716,34 +14716,12 @@
},
{
"kind": "Content",
"text": "import(\"react\")."
"text": "typeof "
},
{
"kind": "Reference",
"text": "MemoExoticComponent",
"canonicalReference": "@types/react!React.MemoExoticComponent:type"
},
{
"kind": "Content",
"text": "<(<T extends string>(props: "
},
{
"kind": "Reference",
"text": "TLUiButtonPickerProps",
"canonicalReference": "@tldraw/tldraw!TLUiButtonPickerProps:interface"
},
{
"kind": "Content",
"text": "<T>) => import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": ")>"
"text": "_TldrawUiButtonPicker",
"canonicalReference": "@tldraw/tldraw!~_TldrawUiButtonPicker:function"
}
],
"fileUrlPath": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx",
@ -14752,7 +14730,7 @@
"name": "TldrawUiButtonPicker",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 8
"endIndex": 3
}
},
{
@ -23506,43 +23484,16 @@
},
{
"kind": "Content",
"text": "readonly (import(\"@tldraw/editor\")."
"text": "readonly "
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
"text": "StyleProp",
"canonicalReference": "@tldraw/tlschema!StyleProp:class"
},
{
"kind": "Content",
"text": "<\"black\" | \"blue\" | \"green\" | \"grey\" | \"light-blue\" | \"light-green\" | \"light-red\" | \"light-violet\" | \"orange\" | \"red\" | \"violet\" | \"yellow\"> | import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
},
{
"kind": "Content",
"text": "<\"dashed\" | \"dotted\" | \"draw\" | \"solid\"> | import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
},
{
"kind": "Content",
"text": "<\"l\" | \"m\" | \"s\" | \"xl\"> | import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
},
{
"kind": "Content",
"text": "<\"none\" | \"pattern\" | \"semi\" | \"solid\">)[]"
"text": "<any>[]"
},
{
"kind": "Content",
@ -23564,8 +23515,8 @@
],
"fileUrlPath": "packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts",
"returnTypeTokenRange": {
"startIndex": 11,
"endIndex": 13
"startIndex": 5,
"endIndex": 7
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -23574,7 +23525,7 @@
"parameterName": "stylesToCheck",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 10
"endIndex": 4
},
"isOptional": true
}

View file

@ -12,6 +12,8 @@ import {
LineShapeSplineStyle,
ReadonlySharedStyleMap,
StyleProp,
TLArrowShapeArrowheadStyle,
TLDefaultVerticalAlignStyle,
minBy,
useEditor,
useValue,
@ -199,7 +201,7 @@ function TextStylePickerSet({ styles }: { styles: ReadonlySharedStyleMap }) {
<TldrawUiButtonIcon icon="vertical-align-center" />
</TldrawUiButton>
) : (
<DropdownPicker
<DropdownPicker<TLDefaultVerticalAlignStyle>
type="icon"
id="geo-vertical-alignment"
uiType="verticalAlign"
@ -270,7 +272,7 @@ function ArrowheadStylePickerSet({ styles }: { styles: ReadonlySharedStyleMap })
}
return (
<DoubleDropdownPicker
<DoubleDropdownPicker<TLArrowShapeArrowheadStyle>
label={'style-panel.arrowheads'}
uiTypeA="arrowheadStart"
styleA={ArrowShapeArrowheadStartStyle}

View file

@ -27,7 +27,7 @@ interface DoubleDropdownPickerProps<T extends string> {
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
}
export const DoubleDropdownPicker = React.memo(function DoubleDropdownPicker<T extends string>({
function _DoubleDropdownPicker<T extends string>({
label,
uiTypeA,
uiTypeB,
@ -137,4 +137,9 @@ export const DoubleDropdownPicker = React.memo(function DoubleDropdownPicker<T e
</div>
</div>
)
})
}
// need to memo like this to get generics
export const DoubleDropdownPicker = React.memo(
_DoubleDropdownPicker
) as typeof _DoubleDropdownPicker

View file

@ -25,7 +25,7 @@ interface DropdownPickerProps<T extends string> {
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
}
export const DropdownPicker = React.memo(function DropdownPicker<T extends string>({
function _DropdownPicker<T extends string>({
id,
label,
uiType,
@ -76,4 +76,7 @@ export const DropdownPicker = React.memo(function DropdownPicker<T extends strin
</TldrawUiDropdownMenuContent>
</TldrawUiDropdownMenuRoot>
)
})
}
// need to export like this to get generics
export const DropdownPicker = React.memo(_DropdownPicker) as typeof _DropdownPicker

View file

@ -25,10 +25,7 @@ export interface TLUiButtonPickerProps<T extends string> {
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
}
/** @public */
export const TldrawUiButtonPicker = memo(function TldrawUiButtonPicker<T extends string>(
props: TLUiButtonPickerProps<T>
) {
function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) {
const {
uiType,
items,
@ -125,4 +122,6 @@ export const TldrawUiButtonPicker = memo(function TldrawUiButtonPicker<T extends
))}
</div>
)
})
}
/** @public */
export const TldrawUiButtonPicker = memo(_TldrawUiButtonPicker) as typeof _TldrawUiButtonPicker

View file

@ -5,11 +5,12 @@ import {
DefaultSizeStyle,
ReadonlySharedStyleMap,
SharedStyleMap,
StyleProp,
useEditor,
useValue,
} from '@tldraw/editor'
const selectToolStyles = Object.freeze([
const selectToolStyles: readonly StyleProp<any>[] = Object.freeze([
DefaultColorStyle,
DefaultDashStyle,
DefaultFillStyle,

View file

@ -56,7 +56,7 @@ const tldrawFileValidator: T.Validator<TldrawFile> = T.object({
}),
records: T.arrayOf(
T.object({
id: T.string as T.Validator<RecordId<any>>,
id: T.string as any as T.Validator<RecordId<any>>,
typeName: T.string,
}).allowUnknownProperties()
),

View file

@ -45,12 +45,12 @@ export const arrowShapeProps: {
normalizedAnchor: VecModel;
isExact: boolean;
isPrecise: boolean;
}>;
} & {}>;
point: T.ObjectValidator<{
type: "point";
x: number;
y: number;
}>;
type: "point";
} & {}>;
}, never>;
end: T.UnionValidator<"type", {
binding: T.ObjectValidator<{
@ -59,12 +59,12 @@ export const arrowShapeProps: {
normalizedAnchor: VecModel;
isExact: boolean;
isPrecise: boolean;
}>;
} & {}>;
point: T.ObjectValidator<{
type: "point";
x: number;
y: number;
}>;
type: "point";
} & {}>;
}, never>;
bend: T.Validator<number>;
text: T.Validator<string>;
@ -116,13 +116,19 @@ export const CameraRecordType: RecordType<TLCamera, never>;
export const canvasUiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
// @public
export function createAssetValidator<Type extends string, Props extends JsonObject>(type: Type, props: T.Validator<Props>): T.ObjectValidator<{
id: TLAssetId;
typeName: 'asset';
type: Type;
props: Props;
meta: JsonObject;
}>;
export function createAssetValidator<Type extends string, Props extends JsonObject>(type: Type, props: T.Validator<Props>): T.ObjectValidator<{ [P in "id" | "meta" | "typeName" | (undefined extends Props ? never : "props") | (undefined extends Type ? never : "type")]: {
id: TLAssetId;
typeName: 'asset';
type: Type;
props: Props;
meta: JsonObject;
}[P]; } & { [P_1 in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: {
id: TLAssetId;
typeName: 'asset';
type: Type;
props: Props;
meta: JsonObject;
}[P_1] | undefined; }>;
// @public (undocumented)
export const createPresenceStateDerivation: ($user: Signal<{
@ -139,7 +145,7 @@ export function createShapeValidator<Type extends string, Props extends JsonObje
[K in keyof Props]: T.Validatable<Props[K]>;
}, meta?: {
[K in keyof Meta]: T.Validatable<Meta[K]>;
}): T.ObjectValidator<TLBaseShape<Type, Props>>;
}): T.ObjectValidator<{ [P in "id" | "index" | "isLocked" | "meta" | "opacity" | "parentId" | "rotation" | "typeName" | "x" | "y" | (undefined extends Props ? never : "props") | (undefined extends Type ? never : "type")]: TLBaseShape<Type, Props>[P]; } & { [P_1 in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: TLBaseShape<Type, Props>[P_1] | undefined; }>;
// @public
export function createTLSchema({ shapes }: {
@ -196,7 +202,7 @@ export const drawShapeProps: {
segments: T.ArrayOfValidator<{
type: "free" | "straight";
points: VecModel[];
}>;
} & {}>;
isComplete: T.Validator<boolean>;
isClosed: T.Validator<boolean>;
isPen: T.Validator<boolean>;
@ -515,7 +521,7 @@ export const highlightShapeProps: {
segments: T.ArrayOfValidator<{
type: "free" | "straight";
points: VecModel[];
}>;
} & {}>;
isComplete: T.Validator<boolean>;
isPen: T.Validator<boolean>;
};
@ -533,10 +539,10 @@ export const imageShapeProps: {
playing: T.Validator<boolean>;
url: T.Validator<string>;
assetId: T.Validator<TLAssetId | null>;
crop: T.Validator<{
crop: T.Validator<({
topLeft: VecModel;
bottomRight: VecModel;
} | null>;
} & {}) | null>;
};
// @public (undocumented)
@ -716,12 +722,8 @@ export const rootShapeMigrations: Migrations;
// @public (undocumented)
export type SchemaShapeInfo = {
migrations?: Migrations;
props?: Record<string, {
validate: (prop: any) => any;
}>;
meta?: Record<string, {
validate: (prop: any) => any;
}>;
props?: Record<string, AnyValidator>;
meta?: Record<string, AnyValidator>;
};
// @internal (undocumented)
@ -755,8 +757,13 @@ export class StyleProp<Type> implements T.Validatable<Type> {
readonly type: T.Validatable<Type>;
// (undocumented)
validate(value: unknown): Type;
// (undocumented)
validateUsingKnownGoodVersion(prevValue: Type, newValue: unknown): Type;
}
// @public (undocumented)
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never;
// @internal (undocumented)
export const textShapeMigrations: Migrations;

View file

@ -355,7 +355,7 @@
},
{
"kind": "Content",
"text": ";\n isExact: boolean;\n isPrecise: boolean;\n }>;\n point: "
"text": ";\n isExact: boolean;\n isPrecise: boolean;\n } & {}>;\n point: "
},
{
"kind": "Reference",
@ -364,7 +364,7 @@
},
{
"kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n end: "
"text": "<{\n x: number;\n y: number;\n type: \"point\";\n } & {}>;\n }, never>;\n end: "
},
{
"kind": "Reference",
@ -400,7 +400,7 @@
},
{
"kind": "Content",
"text": ";\n isExact: boolean;\n isPrecise: boolean;\n }>;\n point: "
"text": ";\n isExact: boolean;\n isPrecise: boolean;\n } & {}>;\n point: "
},
{
"kind": "Reference",
@ -409,7 +409,7 @@
},
{
"kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n bend: "
"text": "<{\n x: number;\n y: number;\n type: \"point\";\n } & {}>;\n }, never>;\n bend: "
},
{
"kind": "Reference",
@ -880,7 +880,7 @@
},
{
"kind": "Content",
"text": "<{\n id: "
"text": "<{ [P in \"id\" | \"meta\" | \"typeName\" | (undefined extends Props ? never : \"props\") | (undefined extends Type ? never : \"type\")]: {\n id: "
},
{
"kind": "Reference",
@ -898,7 +898,25 @@
},
{
"kind": "Content",
"text": ";\n}>"
"text": ";\n}[P]; } & { [P_1 in (undefined extends Props ? \"props\" : never) | (undefined extends Type ? \"type\" : never)]?: {\n id: "
},
{
"kind": "Reference",
"text": "TLAssetId",
"canonicalReference": "@tldraw/tlschema!TLAssetId:type"
},
{
"kind": "Content",
"text": ";\n typeName: 'asset';\n type: Type;\n props: Props;\n meta: "
},
{
"kind": "Reference",
"text": "JsonObject",
"canonicalReference": "@tldraw/utils!JsonObject:type"
},
{
"kind": "Content",
"text": ";\n}[P_1] | undefined; }>"
},
{
"kind": "Content",
@ -908,7 +926,7 @@
"fileUrlPath": "packages/tlschema/src/assets/TLBaseAsset.ts",
"returnTypeTokenRange": {
"startIndex": 10,
"endIndex": 16
"endIndex": 20
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -1154,7 +1172,7 @@
},
{
"kind": "Content",
"text": "<"
"text": "<{ [P in \"id\" | \"index\" | \"isLocked\" | \"meta\" | \"opacity\" | \"parentId\" | \"rotation\" | \"typeName\" | \"x\" | \"y\" | (undefined extends Props ? never : \"props\") | (undefined extends Type ? never : \"type\")]: "
},
{
"kind": "Reference",
@ -1163,7 +1181,16 @@
},
{
"kind": "Content",
"text": "<Type, Props>>"
"text": "<Type, Props>[P]; } & { [P_1 in (undefined extends Props ? \"props\" : never) | (undefined extends Type ? \"type\" : never)]?: "
},
{
"kind": "Reference",
"text": "TLBaseShape",
"canonicalReference": "@tldraw/tlschema!TLBaseShape:interface"
},
{
"kind": "Content",
"text": "<Type, Props>[P_1] | undefined; }>"
},
{
"kind": "Content",
@ -1173,7 +1200,7 @@
"fileUrlPath": "packages/tlschema/src/shapes/TLBaseShape.ts",
"returnTypeTokenRange": {
"startIndex": 17,
"endIndex": 21
"endIndex": 23
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -1698,7 +1725,7 @@
},
{
"kind": "Content",
"text": "[];\n }>;\n isComplete: "
"text": "[];\n } & {}>;\n isComplete: "
},
{
"kind": "Reference",
@ -2304,7 +2331,7 @@
},
{
"kind": "Content",
"text": "[];\n }>;\n isComplete: "
"text": "[];\n } & {}>;\n isComplete: "
},
{
"kind": "Reference",
@ -2408,7 +2435,7 @@
},
{
"kind": "Content",
"text": "<{\n topLeft: import(\"../misc/geometry-types\")."
"text": "<({\n topLeft: import(\"../misc/geometry-types\")."
},
{
"kind": "Reference",
@ -2426,7 +2453,7 @@
},
{
"kind": "Content",
"text": ";\n } | null>;\n}"
"text": ";\n } & {}) | null>;\n}"
}
],
"fileUrlPath": "packages/tlschema/src/shapes/TLImageShape.ts",
@ -3061,7 +3088,16 @@
},
{
"kind": "Content",
"text": "<string, {\n validate: (prop: any) => any;\n }>;\n meta?: "
"text": "<string, "
},
{
"kind": "Reference",
"text": "AnyValidator",
"canonicalReference": "@tldraw/tlschema!~AnyValidator:type"
},
{
"kind": "Content",
"text": ">;\n meta?: "
},
{
"kind": "Reference",
@ -3070,7 +3106,16 @@
},
{
"kind": "Content",
"text": "<string, {\n validate: (prop: any) => any;\n }>;\n}"
"text": "<string, "
},
{
"kind": "Reference",
"text": "AnyValidator",
"canonicalReference": "@tldraw/tlschema!~AnyValidator:type"
},
{
"kind": "Content",
"text": ">;\n}"
},
{
"kind": "Content",
@ -3082,7 +3127,7 @@
"name": "SchemaShapeInfo",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 8
"endIndex": 12
}
},
{
@ -3548,6 +3593,70 @@
"isOptional": false,
"isAbstract": false,
"name": "validate"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/tlschema!StyleProp#validateUsingKnownGoodVersion:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "validateUsingKnownGoodVersion(prevValue: "
},
{
"kind": "Content",
"text": "Type"
},
{
"kind": "Content",
"text": ", newValue: "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "Type"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "prevValue",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "newValue",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "validateUsingKnownGoodVersion"
}
],
"implementsTokenRanges": [
@ -3557,6 +3666,67 @@
}
]
},
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/tlschema!StylePropValue:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type StylePropValue<T extends "
},
{
"kind": "Reference",
"text": "StyleProp",
"canonicalReference": "@tldraw/tlschema!StyleProp:class"
},
{
"kind": "Content",
"text": "<any>"
},
{
"kind": "Content",
"text": "> = "
},
{
"kind": "Content",
"text": "T extends "
},
{
"kind": "Reference",
"text": "StyleProp",
"canonicalReference": "@tldraw/tlschema!StyleProp:class"
},
{
"kind": "Content",
"text": "<infer U> ? U : never"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tlschema/src/styles/StyleProp.ts",
"releaseTag": "Public",
"name": "StylePropValue",
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"typeTokenRange": {
"startIndex": 4,
"endIndex": 7
}
},
{
"kind": "Variable",
"canonicalReference": "@tldraw/tlschema!textShapeProps:var",

View file

@ -27,14 +27,14 @@ export const assetIdValidator = idValidator<TLAssetId>('asset')
export function createAssetValidator<Type extends string, Props extends JsonObject>(
type: Type,
props: T.Validator<Props>
): T.ObjectValidator<{
id: TLAssetId
typeName: 'asset'
type: Type
props: Props
meta: JsonObject
}> {
return T.object({
) {
return T.object<{
id: TLAssetId
typeName: 'asset'
type: Type
props: Props
meta: JsonObject
}>({
id: assetIdValidator,
typeName: T.literal('asset'),
type: T.literal(type),

View file

@ -14,11 +14,16 @@ import { createShapeRecordType, getShapePropKeysByStyle } from './records/TLShap
import { storeMigrations } from './store-migrations'
import { StyleProp } from './styles/StyleProp'
type AnyValidator = {
validate: (prop: any) => any
validateUsingKnownGoodVersion?: (prevVersion: any, newVersion: any) => any
}
/** @public */
export type SchemaShapeInfo = {
migrations?: Migrations
props?: Record<string, { validate: (prop: any) => any }>
meta?: Record<string, { validate: (prop: any) => any }>
props?: Record<string, AnyValidator>
meta?: Record<string, AnyValidator>
}
/** @public */

View file

@ -136,7 +136,7 @@ export {
type TLTextShapeProps,
} from './shapes/TLTextShape'
export { videoShapeMigrations, videoShapeProps, type TLVideoShape } from './shapes/TLVideoShape'
export { EnumStyleProp, StyleProp } from './styles/StyleProp'
export { EnumStyleProp, StyleProp, type StylePropValue } from './styles/StyleProp'
export {
DefaultColorStyle,
DefaultColorThemePalette,

View file

@ -52,8 +52,8 @@ export function createShapeValidator<
type: T.literal(type),
isLocked: T.boolean,
opacity: opacityValidator,
props: props ? T.object(props) : (T.jsonValue as T.ObjectValidator<Props>),
meta: meta ? T.object(meta) : (T.jsonValue as T.ObjectValidator<Meta>),
props: props ? T.object(props) : (T.jsonValue as any),
meta: meta ? T.object(meta) : (T.jsonValue as any),
})
}

View file

@ -90,6 +90,14 @@ export class StyleProp<Type> implements T.Validatable<Type> {
validate(value: unknown) {
return this.type.validate(value)
}
validateUsingKnownGoodVersion(prevValue: Type, newValue: unknown) {
if (this.type.validateUsingKnownGoodVersion) {
return this.type.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return this.validate(newValue)
}
}
}
/**
@ -107,3 +115,8 @@ export class EnumStyleProp<T> extends StyleProp<T> {
super(id, defaultValue, T.literalEnum(...values))
}
}
/**
* @public
*/
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never

View file

@ -86,7 +86,11 @@ const number: Validator<number>;
// @public
function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>;
}): ObjectValidator<Shape>;
}): ObjectValidator<{
[P in ExtractRequiredKeys<Shape>]: Shape[P];
} & {
[P in ExtractOptionalKeys<Shape>]?: Shape[P];
}>;
// @public (undocumented)
export class ObjectValidator<Shape extends object> extends Validator<Shape> {
@ -136,6 +140,7 @@ declare namespace T {
nullable,
literalEnum,
ValidatorFn,
ValidatorUsingKnownGoodVersionFn,
Validatable,
ValidationError,
TypeOf,
@ -166,7 +171,7 @@ declare namespace T {
export { T }
// @public (undocumented)
type TypeOf<V extends Validatable<unknown>> = V extends Validatable<infer T> ? T : never;
type TypeOf<V extends Validatable<any>> = V extends Validatable<infer T> ? T : never;
// @public
function union<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>;
@ -187,6 +192,7 @@ const unknownObject: Validator<Record<string, unknown>>;
// @public (undocumented)
type Validatable<T> = {
validate: (value: unknown) => T;
validateUsingKnownGoodVersion?: (knownGoodValue: T, newValue: unknown) => T;
};
// @public (undocumented)
@ -202,7 +208,7 @@ class ValidationError extends Error {
// @public (undocumented)
export class Validator<T> implements Validatable<T> {
constructor(validationFn: ValidatorFn<T>);
constructor(validationFn: ValidatorFn<T>, validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn<T, T>);
check(name: string, checkFn: (value: T) => void): Validator<T>;
// (undocumented)
check(checkFn: (value: T) => void): Validator<T>;
@ -212,12 +218,19 @@ export class Validator<T> implements Validatable<T> {
refine<U>(otherValidationFn: (value: T) => U): Validator<U>;
validate(value: unknown): T;
// (undocumented)
validateUsingKnownGoodVersion(knownGoodValue: T, newValue: unknown): T;
// (undocumented)
readonly validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn<T, T>;
// (undocumented)
readonly validationFn: ValidatorFn<T>;
}
// @public (undocumented)
type ValidatorFn<T> = (value: unknown) => T;
// @public (undocumented)
type ValidatorUsingKnownGoodVersionFn<In, Out = In> = (knownGoodValue: In, value: unknown) => Out;
// (No @packageDocumentation comment for this package)
```

View file

@ -2142,7 +2142,25 @@
},
{
"kind": "Content",
"text": "<Shape>"
"text": "<{\n [P in "
},
{
"kind": "Reference",
"text": "ExtractRequiredKeys",
"canonicalReference": "@tldraw/validate!~ExtractRequiredKeys:type"
},
{
"kind": "Content",
"text": "<Shape>]: Shape[P];\n} & {\n [P in "
},
{
"kind": "Reference",
"text": "ExtractOptionalKeys",
"canonicalReference": "@tldraw/validate!~ExtractOptionalKeys:type"
},
{
"kind": "Content",
"text": "<Shape>]?: Shape[P];\n}>"
},
{
"kind": "Content",
@ -2152,7 +2170,7 @@
"fileUrlPath": "packages/validate/src/lib/validation.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 9
"endIndex": 13
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -2722,7 +2740,7 @@
},
{
"kind": "Content",
"text": "<unknown>"
"text": "<any>"
},
{
"kind": "Content",
@ -3193,7 +3211,7 @@
},
{
"kind": "Content",
"text": "{\n validate: (value: unknown) => T;\n}"
"text": "{\n validate: (value: unknown) => T;\n validateUsingKnownGoodVersion?: (knownGoodValue: T, newValue: unknown) => T;\n}"
},
{
"kind": "Content",
@ -3461,6 +3479,23 @@
"kind": "Content",
"text": "<T>"
},
{
"kind": "Content",
"text": ", validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{
"kind": "Content",
"text": ");"
@ -3477,6 +3512,14 @@
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "validateUsingKnownGoodVersionFn",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 7
},
"isOptional": true
}
]
},
@ -3841,6 +3884,109 @@
"isAbstract": false,
"name": "validate"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/validate!T.Validator#validateUsingKnownGoodVersion:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "validateUsingKnownGoodVersion(knownGoodValue: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", newValue: "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "knownGoodValue",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "newValue",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "validateUsingKnownGoodVersion"
},
{
"kind": "Property",
"canonicalReference": "@tldraw/validate!T.Validator#validateUsingKnownGoodVersionFn:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": true,
"isOptional": true,
"releaseTag": "Public",
"name": "validateUsingKnownGoodVersionFn",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/validate!T.Validator#validationFn:member",
@ -3922,6 +4068,64 @@
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type ValidatorUsingKnownGoodVersionFn<In, Out = "
},
{
"kind": "Content",
"text": "In"
},
{
"kind": "Content",
"text": "> = "
},
{
"kind": "Content",
"text": "(knownGoodValue: In, value: unknown) => Out"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/validate/src/lib/validation.ts",
"releaseTag": "Public",
"name": "ValidatorUsingKnownGoodVersionFn",
"typeParameters": [
{
"typeParameterName": "In",
"constraintTokenRange": {
"startIndex": 0,
"endIndex": 0
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
},
{
"typeParameterName": "Out",
"constraintTokenRange": {
"startIndex": 0,
"endIndex": 0
},
"defaultTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
}
],
"typeTokenRange": {
"startIndex": 3,
"endIndex": 4
}
}
]
},
@ -4224,6 +4428,23 @@
"kind": "Content",
"text": "<T>"
},
{
"kind": "Content",
"text": ", validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{
"kind": "Content",
"text": ");"
@ -4240,6 +4461,14 @@
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "validateUsingKnownGoodVersionFn",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 7
},
"isOptional": true
}
]
},
@ -4604,6 +4833,109 @@
"isAbstract": false,
"name": "validate"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/validate!Validator#validateUsingKnownGoodVersion:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "validateUsingKnownGoodVersion(knownGoodValue: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", newValue: "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "knownGoodValue",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "newValue",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "validateUsingKnownGoodVersion"
},
{
"kind": "Property",
"canonicalReference": "@tldraw/validate!Validator#validateUsingKnownGoodVersionFn:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": true,
"isOptional": true,
"releaseTag": "Public",
"name": "validateUsingKnownGoodVersionFn",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/validate!Validator#validationFn:member",

View file

@ -53,6 +53,7 @@
}
},
"devDependencies": {
"lazyrepo": "0.0.0-alpha.27"
"lazyrepo": "0.0.0-alpha.27",
"lodash.isequal": "^4.5.0"
}
}

View file

@ -9,9 +9,26 @@ import {
/** @public */
export type ValidatorFn<T> = (value: unknown) => T
/** @public */
export type ValidatorUsingKnownGoodVersionFn<In, Out = In> = (
knownGoodValue: In,
value: unknown
) => Out
/** @public */
export type Validatable<T> = { validate: (value: unknown) => T }
export type Validatable<T> = {
validate: (value: unknown) => T
/**
* This is a performance optimizing version of validate that can use a previous
* version of the value to avoid revalidating every part of the new value if
* any part of it has not changed since the last validation.
*
* If the value has not changed but is not referentially equal, the function
* should return the previous value.
* @returns
*/
validateUsingKnownGoodVersion?: (knownGoodValue: T, newValue: unknown) => T
}
function formatPath(path: ReadonlyArray<number | string>): string | null {
if (!path.length) {
@ -92,11 +109,14 @@ function typeToString(value: unknown): string {
}
/** @public */
export type TypeOf<V extends Validatable<unknown>> = V extends Validatable<infer T> ? T : never
export type TypeOf<V extends Validatable<any>> = V extends Validatable<infer T> ? T : never
/** @public */
export class Validator<T> implements Validatable<T> {
constructor(readonly validationFn: ValidatorFn<T>) {}
constructor(
readonly validationFn: ValidatorFn<T>,
readonly validateUsingKnownGoodVersionFn?: ValidatorUsingKnownGoodVersionFn<T>
) {}
/**
* Asserts that the passed value is of the correct type and returns it. The returned value is
@ -110,6 +130,18 @@ export class Validator<T> implements Validatable<T> {
return validated
}
validateUsingKnownGoodVersion(knownGoodValue: T, newValue: unknown): T {
if (Object.is(knownGoodValue, newValue)) {
return knownGoodValue as T
}
if (this.validateUsingKnownGoodVersionFn) {
return this.validateUsingKnownGoodVersionFn(knownGoodValue, newValue)
}
return this.validate(newValue)
}
/** Checks that the passed value is of the correct type. */
isValid(value: unknown): value is T {
try {
@ -141,9 +173,19 @@ export class Validator<T> implements Validatable<T> {
* if the value can't be converted to the new type, or return the new type otherwise.
*/
refine<U>(otherValidationFn: (value: T) => U): Validator<U> {
return new Validator((value) => {
return otherValidationFn(this.validate(value))
})
return new Validator(
(value) => {
return otherValidationFn(this.validate(value))
},
(knownGoodValue, newValue) => {
const validated = this.validateUsingKnownGoodVersion(knownGoodValue as any, newValue)
if (Object.is(knownGoodValue, validated)) {
return knownGoodValue
}
return otherValidationFn(validated)
}
)
}
/**
@ -179,13 +221,40 @@ export class Validator<T> implements Validatable<T> {
/** @public */
export class ArrayOfValidator<T> extends Validator<T[]> {
constructor(readonly itemValidator: Validatable<T>) {
super((value) => {
const arr = array.validate(value)
for (let i = 0; i < arr.length; i++) {
prefixError(i, () => itemValidator.validate(arr[i]))
super(
(value) => {
const arr = array.validate(value)
for (let i = 0; i < arr.length; i++) {
prefixError(i, () => itemValidator.validate(arr[i]))
}
return arr as T[]
},
(knownGoodValue, newValue) => {
if (!itemValidator.validateUsingKnownGoodVersion) return this.validate(newValue)
const arr = array.validate(newValue)
let isDifferent = knownGoodValue.length !== arr.length
for (let i = 0; i < arr.length; i++) {
const item = arr[i]
if (i >= knownGoodValue.length) {
isDifferent = true
prefixError(i, () => itemValidator.validate(item))
continue
}
// sneaky quick check here to avoid the prefix + validator overhead
if (Object.is(knownGoodValue[i], item)) {
continue
}
const checkedItem = prefixError(i, () =>
itemValidator.validateUsingKnownGoodVersion!(knownGoodValue[i], item)
)
if (!Object.is(checkedItem, knownGoodValue[i])) {
isDifferent = true
}
}
return isDifferent ? (newValue as T[]) : knownGoodValue
}
return arr as T[]
})
)
}
nonEmpty() {
@ -213,27 +282,68 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
},
private readonly shouldAllowUnknownProperties = false
) {
super((object) => {
if (typeof object !== 'object' || object === null) {
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
}
super(
(object) => {
if (typeof object !== 'object' || object === null) {
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
}
for (const [key, validator] of Object.entries(config)) {
prefixError(key, () => {
;(validator as Validator<unknown>).validate(getOwnProperty(object, key))
})
}
for (const [key, validator] of Object.entries(config)) {
prefixError(key, () => {
;(validator as Validator<unknown>).validate(getOwnProperty(object, key))
})
}
if (!shouldAllowUnknownProperties) {
for (const key of Object.keys(object)) {
if (!hasOwnProperty(config, key)) {
throw new ValidationError(`Unexpected property`, [key])
if (!shouldAllowUnknownProperties) {
for (const key of Object.keys(object)) {
if (!hasOwnProperty(config, key)) {
throw new ValidationError(`Unexpected property`, [key])
}
}
}
}
return object as Shape
})
return object as Shape
},
(knownGoodValue, newValue) => {
if (typeof newValue !== 'object' || newValue === null) {
throw new ValidationError(`Expected object, got ${typeToString(newValue)}`)
}
let isDifferent = false
for (const [key, validator] of Object.entries(config)) {
const prev = getOwnProperty(knownGoodValue, key)
const next = getOwnProperty(newValue, key)
// sneaky quick check here to avoid the prefix + validator overhead
if (Object.is(prev, next)) {
continue
}
const checked = prefixError(key, () => {
return (validator as Validator<unknown>).validateUsingKnownGoodVersion(prev, next)
})
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
if (!shouldAllowUnknownProperties) {
for (const key of Object.keys(newValue)) {
if (!hasOwnProperty(config, key)) {
throw new ValidationError(`Unexpected property`, [key])
}
}
}
for (const key of Object.keys(knownGoodValue)) {
if (!hasOwnProperty(newValue, key)) {
isDifferent = true
break
}
}
return isDifferent ? (newValue as Shape) : knownGoodValue
}
)
}
allowUnknownProperties() {
@ -257,7 +367,7 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
extend<Extension extends Record<string, unknown>>(extension: {
readonly [K in keyof Extension]: Validatable<Extension[K]>
}): ObjectValidator<Shape & Extension> {
return new ObjectValidator({ ...this.config, ...extension }) as ObjectValidator<
return new ObjectValidator({ ...this.config, ...extension }) as any as ObjectValidator<
Shape & Extension
>
}
@ -280,25 +390,61 @@ export class UnionValidator<
private readonly config: Config,
private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue
) {
super((input) => {
if (typeof input !== 'object' || input === null) {
throw new ValidationError(`Expected an object, got ${typeToString(input)}`, [])
}
super(
(input) => {
this.expectObject(input)
const variant = getOwnProperty(input, key) as keyof Config | undefined
if (typeof variant !== 'string') {
throw new ValidationError(
`Expected a string for key "${key}", got ${typeToString(variant)}`
)
}
const { matchingSchema, variant } = this.getMatchingSchemaAndVariant(input)
if (matchingSchema === undefined) {
return this.unknownValueValidation(input, variant)
}
const matchingSchema = hasOwnProperty(config, variant) ? config[variant] : undefined
if (matchingSchema === undefined) {
return this.unknownValueValidation(input, variant)
}
return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input))
},
(prevValue, newValue) => {
this.expectObject(newValue)
this.expectObject(prevValue)
return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input))
})
const { matchingSchema, variant } = this.getMatchingSchemaAndVariant(newValue)
if (matchingSchema === undefined) {
return this.unknownValueValidation(newValue, variant)
}
if (getOwnProperty(prevValue, key) !== getOwnProperty(newValue, key)) {
// the type has changed so bail out and do a regular validation
return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(newValue))
}
return prefixError(`(${key} = ${variant})`, () => {
if (matchingSchema.validateUsingKnownGoodVersion) {
return matchingSchema.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return matchingSchema.validate(newValue)
}
})
}
)
}
private expectObject(value: unknown): asserts value is object {
if (typeof value !== 'object' || value === null) {
throw new ValidationError(`Expected an object, got ${typeToString(value)}`, [])
}
}
private getMatchingSchemaAndVariant(object: object): {
matchingSchema: Validatable<any> | undefined
variant: string
} {
const variant = getOwnProperty(object, this.key) as keyof Config | undefined
if (typeof variant !== 'string') {
throw new ValidationError(
`Expected a string for key "${this.key}", got ${typeToString(variant)}`
)
}
const matchingSchema = hasOwnProperty(this.config, variant) ? this.config[variant] : undefined
return { matchingSchema, variant }
}
validateUnknownVariants<Unknown>(
@ -314,20 +460,65 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
public readonly keyValidator: Validatable<Key>,
public readonly valueValidator: Validatable<Value>
) {
super((object) => {
if (typeof object !== 'object' || object === null) {
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
}
super(
(object) => {
if (typeof object !== 'object' || object === null) {
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
}
for (const [key, value] of Object.entries(object)) {
prefixError(key, () => {
keyValidator.validate(key)
valueValidator.validate(value)
})
}
for (const [key, value] of Object.entries(object)) {
prefixError(key, () => {
keyValidator.validate(key)
valueValidator.validate(value)
})
}
return object as Record<Key, Value>
})
return object as Record<Key, Value>
},
(knownGoodValue, newValue) => {
if (typeof newValue !== 'object' || newValue === null) {
throw new ValidationError(`Expected object, got ${typeToString(newValue)}`)
}
let isDifferent = false
for (const [key, value] of Object.entries(newValue)) {
if (!hasOwnProperty(knownGoodValue, key)) {
isDifferent = true
prefixError(key, () => {
keyValidator.validate(key)
valueValidator.validate(value)
})
continue
}
const prev = getOwnProperty(knownGoodValue, key)
const next = value
// sneaky quick check here to avoid the prefix + validator overhead
if (Object.is(prev, next)) {
continue
}
const checked = prefixError(key, () => {
if (valueValidator.validateUsingKnownGoodVersion) {
return valueValidator.validateUsingKnownGoodVersion(prev as any, next)
} else {
return valueValidator.validate(next)
}
})
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
for (const key of Object.keys(knownGoodValue)) {
if (!hasOwnProperty(newValue, key)) {
isDifferent = true
break
}
}
return isDifferent ? (newValue as Record<Key, Value>) : knownGoodValue
}
)
}
}
@ -477,6 +668,14 @@ export const unknownObject = new Validator<Record<string, unknown>>((value) => {
return value as Record<string, unknown>
})
type ExtractRequiredKeys<T extends object> = {
[K in keyof T]: undefined extends T[K] ? never : K
}[keyof T]
type ExtractOptionalKeys<T extends object> = {
[K in keyof T]: undefined extends T[K] ? K : never
}[keyof T]
/**
* Validate an object has a particular shape.
*
@ -484,8 +683,18 @@ export const unknownObject = new Validator<Record<string, unknown>>((value) => {
*/
export function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>
}): ObjectValidator<Shape> {
return new ObjectValidator(config)
}): ObjectValidator<
{ [P in ExtractRequiredKeys<Shape>]: Shape[P] } & { [P in ExtractOptionalKeys<Shape>]?: Shape[P] }
> {
return new ObjectValidator(config) as any
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
(value.constructor === Object || !value.constructor)
)
}
function isValidJson(value: any): value is JsonValue {
@ -502,7 +711,7 @@ function isValidJson(value: any): value is JsonValue {
return value.every(isValidJson)
}
if (typeof value === 'object') {
if (isPlainObject(value)) {
return Object.values(value).every(isValidJson)
}
@ -514,13 +723,64 @@ function isValidJson(value: any): value is JsonValue {
*
* @public
*/
export const jsonValue = new Validator<JsonValue>((value): JsonValue => {
if (isValidJson(value)) {
return value as JsonValue
}
export const jsonValue: Validator<JsonValue> = new Validator<JsonValue>(
(value): JsonValue => {
if (isValidJson(value)) {
return value as JsonValue
}
throw new ValidationError(`Expected json serializable value, got ${typeof value}`)
})
throw new ValidationError(`Expected json serializable value, got ${typeof value}`)
},
(knownGoodValue, newValue) => {
if (Array.isArray(knownGoodValue) && Array.isArray(newValue)) {
let isDifferent = knownGoodValue.length !== newValue.length
for (let i = 0; i < newValue.length; i++) {
if (i >= knownGoodValue.length) {
isDifferent = true
jsonValue.validate(newValue[i])
continue
}
const prev = knownGoodValue[i]
const next = newValue[i]
if (Object.is(prev, next)) {
continue
}
const checked = jsonValue.validateUsingKnownGoodVersion!(prev, next)
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
return isDifferent ? (newValue as JsonValue) : knownGoodValue
} else if (isPlainObject(knownGoodValue) && isPlainObject(newValue)) {
let isDifferent = false
for (const key of Object.keys(newValue)) {
if (!hasOwnProperty(knownGoodValue, key)) {
isDifferent = true
jsonValue.validate(newValue[key])
continue
}
const prev = knownGoodValue[key]
const next = newValue[key]
if (Object.is(prev, next)) {
continue
}
const checked = jsonValue.validateUsingKnownGoodVersion!(prev!, next)
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
for (const key of Object.keys(knownGoodValue)) {
if (!hasOwnProperty(newValue, key)) {
isDifferent = true
break
}
}
return isDifferent ? (newValue as JsonValue) : knownGoodValue
} else {
return jsonValue.validate(newValue)
}
}
)
/**
* Validate an object has a particular shape.
@ -581,14 +841,20 @@ export function model<T extends { readonly id: string }>(
name: string,
validator: Validatable<T>
): Validator<T> {
return new Validator((value) => {
const prefix =
value && typeof value === 'object' && 'id' in value && typeof value.id === 'string'
? `${name}(id = ${value.id})`
: name
return prefixError(prefix, () => validator.validate(value))
})
return new Validator(
(value) => {
return prefixError(name, () => validator.validate(value))
},
(prevValue, newValue) => {
return prefixError(name, () => {
if (validator.validateUsingKnownGoodVersion) {
return validator.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return validator.validate(newValue)
}
})
}
)
}
/** @public */
@ -604,18 +870,37 @@ export function setEnum<T>(values: ReadonlySet<T>): Validator<T> {
/** @public */
export function optional<T>(validator: Validatable<T>): Validator<T | undefined> {
return new Validator((value) => {
if (value === undefined) return undefined
return validator.validate(value)
})
return new Validator(
(value) => {
if (value === undefined) return undefined
return validator.validate(value)
},
(knownGoodValue, newValue) => {
if (knownGoodValue === undefined && newValue === undefined) return undefined
if (newValue === undefined) return undefined
if (validator.validateUsingKnownGoodVersion && knownGoodValue !== undefined) {
return validator.validateUsingKnownGoodVersion(knownGoodValue as T, newValue)
}
return validator.validate(newValue)
}
)
}
/** @public */
export function nullable<T>(validator: Validatable<T>): Validator<T | null> {
return new Validator((value) => {
if (value === null) return null
return validator.validate(value)
})
return new Validator(
(value) => {
if (value === null) return null
return validator.validate(value)
},
(knownGoodValue, newValue) => {
if (newValue === null) return null
if (validator.validateUsingKnownGoodVersion && knownGoodValue !== null) {
return validator.validateUsingKnownGoodVersion(knownGoodValue as T, newValue)
}
return validator.validate(newValue)
}
)
}
/** @public */

View file

@ -0,0 +1,379 @@
import { mapObjectMapValues } from '@tldraw/utils'
import isEqual from 'lodash.isequal'
import { T, Validator } from '..'
class RandomSource {
private seed: number
constructor(seed: number) {
this.seed = seed
}
nextFloat(): number {
this.seed = (this.seed * 9301 + 49297) % 233280
return this.seed / 233280
}
nextInt(max: number): number {
return Math.floor(this.nextFloat() * max)
}
nextIntInRange(min: number, max: number): number {
return this.nextInt(max - min) + min
}
nextId(): string {
return this.nextInt(Number.MAX_SAFE_INTEGER).toString(36)
}
selectOne<T>(arr: readonly T[]): T {
return arr[this.nextInt(arr.length)]
}
choice(probability: number): boolean {
return this.nextFloat() < probability
}
executeOne<Result>(
_choices: Record<string, (() => Result) | { weight?: number; do(): Result }>
): Result {
const choices = Object.values(_choices).map((choice) => {
if (typeof choice === 'function') {
return { weight: 1, do: choice }
}
return choice
})
const totalWeight = Object.values(choices).reduce(
(total, choice) => total + (choice.weight ?? 1),
0
)
const randomWeight = this.nextInt(totalWeight)
let weight = 0
for (const choice of Object.values(choices)) {
weight += choice.weight ?? 1
if (randomWeight < weight) {
return choice.do()
}
}
throw new Error('unreachable')
}
nextPropertyName(): string {
return this.selectOne(['foo', 'bar', 'baz', 'qux', 'mux', 'bah'])
}
nextJsonValue(): any {
return this.executeOne<any>({
string: { weight: 1, do: () => this.nextId() },
number: { weight: 1, do: () => this.nextFloat() },
integer: { weight: 1, do: () => this.nextInt(100) },
boolean: { weight: 1, do: () => this.choice(0.5) },
null: { weight: 1, do: () => null },
array: {
weight: 1,
do: () => {
const numItems = this.nextInt(4)
const result = []
for (let i = 0; i < numItems; i++) {
result.push(this.nextJsonValue())
}
return result
},
},
object: {
weight: 1,
do: () => {
const numItems = this.nextInt(4)
const result = {} as any
for (let i = 0; i < numItems; i++) {
result[this.nextPropertyName()] = this.nextJsonValue()
}
return result
},
},
})
}
nextTestType(depth: number): TestType {
if (depth >= 3) {
return this.selectOne(Object.values(builtinTypes))
}
return this.executeOne<TestType>({
primitive: () => this.selectOne(Object.values(builtinTypes)),
array: () => generateArrayType(this, depth),
object: () => generateObjectType(this, {}, depth),
union: () => generateUnionType(this, depth),
dict: () => generateDictType(this, depth),
model: () => {
const objType = generateObjectType(this, {}, depth)
const name = this.nextPropertyName()
return {
...objType,
validator: T.model(name, objType.validator),
}
},
})
}
}
type TestType = {
validator: T.Validator<any>
generateValid: (source: RandomSource) => any
generateInvalid: (source: RandomSource) => any
}
const builtinTypes = {
string: {
validator: T.string,
generateValid: (source) => source.selectOne(['a', 'b', 'c', 'd']),
generateInvalid: (source) => source.selectOne([5, /regexp/, {}]),
},
number: {
validator: T.number,
generateValid: (source) => source.nextInt(5),
generateInvalid: (source) => source.selectOne(['a', /num/]),
},
integer: {
validator: T.integer,
generateValid: (source) => source.nextInt(5),
generateInvalid: (source) => source.selectOne([0.2, '3', 5n, /int/]),
},
json: {
validator: T.jsonValue,
generateValid: (source) => source.nextJsonValue(),
generateInvalid: (source) => source.selectOne([/regexp/, 343n, { key: /regexp/ }]),
},
} as const satisfies Record<string, TestType>
function generateObjectType(
source: RandomSource,
injectProperties: Record<string, TestType>,
depth: number
): TestType {
const numProperties = source.nextIntInRange(1, 5)
const propertyTypes: Record<string, TestType> = {
...injectProperties,
}
const optionalTypes = new Set<string>()
const nullableTypes = new Set<string>()
for (let i = 0; i < numProperties; i++) {
const type = source.nextTestType(depth + 1)
const name = source.nextPropertyName()
if (source.choice(0.2)) {
optionalTypes.add(name)
}
if (source.choice(0.2)) {
nullableTypes.add(name)
}
let validator = type.validator
if (nullableTypes.has(name)) {
validator = validator.nullable()
}
if (optionalTypes.has(name)) {
validator = validator.optional()
}
propertyTypes[name] = { ...type, validator }
}
const generateValid = (source: RandomSource) => {
const result = {} as any
for (const [name, type] of Object.entries(propertyTypes)) {
if (optionalTypes.has(name) && source.choice(0.2)) {
continue
} else if (nullableTypes.has(name) && source.choice(0.2)) {
result[name] = null
continue
}
result[name] = type.generateValid(source)
}
return result
}
return {
validator: T.object(mapObjectMapValues(propertyTypes, (_, { validator }) => validator)),
generateValid,
generateInvalid: (source) => {
return source.executeOne<any>({
otherType: () =>
source.executeOne<any>({
string: () => source.selectOne(['a', 'b', 'c', 'd']),
number: () => source.nextInt(5),
array: () => [source.nextId(), source.nextFloat()],
bool: () => true,
}),
missingProperty: () => {
const val = generateValid(source)
const keyToDelete = source.selectOne(
Object.keys(val).filter((key) => !optionalTypes.has(key))
)
if (!keyToDelete) {
// no non-optional properties, do a invalid property test instead
val[keyToDelete] =
propertyTypes[source.selectOne(Object.keys(propertyTypes))].generateInvalid(source)
return val
}
delete val[keyToDelete]
return val
},
extraProperty: () => {
const val = generateValid(source)
val[source.nextPropertyName() + '_'] = source.nextJsonValue()
return val
},
invalidProperty: () => {
const val = generateValid(source)
const keyToChange = source.selectOne(Object.keys(propertyTypes))
val[keyToChange] = propertyTypes[keyToChange].generateInvalid(source)
return val
},
})
},
}
}
function generateDictType(source: RandomSource, depth: number): TestType {
const keyType = builtinTypes.string
const keySet = ['a', 'b', 'c', 'd', 'e', 'f'] as const
const valueType = source.nextTestType(depth + 1)
const validator = T.dict(keyType.validator, valueType.validator)
const generateValid = (source: RandomSource) => {
const result = {} as any
const numItems = source.nextInt(4)
for (let i = 0; i < numItems; i++) {
result[source.selectOne(keySet)] = valueType.generateValid(source)
}
return result
}
return {
validator,
generateValid,
generateInvalid: (source) => {
const result = generateValid(source)
const key = source.selectOne(Object.keys(result)) ?? source.nextPropertyName()
result[key] = valueType.generateInvalid(source)
return result
},
}
}
function createLiteralType(value: string): TestType {
return {
validator: T.literal(value),
generateValid: () => value,
generateInvalid: (source) => source.selectOne(['_invalid_' + value, 2324, {}]),
}
}
function generateUnionType(source: RandomSource, depth: number): TestType {
const key = source.selectOne(['type', 'name', 'kind'])
const numMembers = source.nextIntInRange(1, 4)
const members: TestType[] = []
const unionMap: Record<string, Validator<any>> = {}
for (let i = 0; i < numMembers; i++) {
const id = source.nextId()
const keyType = createLiteralType(id)
const type = generateObjectType(source, { [key]: keyType }, depth + 1)
members.push(type)
unionMap[id] = type.validator
}
const validator = T.union(key, unionMap)
return {
validator,
generateValid: (source) => {
const member = source.selectOne(members)
return member.generateValid(source)
},
generateInvalid(source) {
return source.executeOne<any>({
otherType: () => source.selectOne(['_invalid_', 2324, {}]),
badMember: {
weight: 4,
do() {
const member = source.selectOne(members)
return member.generateInvalid(source)
},
},
})
},
}
}
function generateArrayType(source: RandomSource, depth: number): TestType {
const valueType = source.nextTestType(depth + 1)
const validator = T.arrayOf(valueType.validator)
const generateValid = (source: RandomSource) => {
const result = [] as any[]
const numItems = source.nextInt(4)
for (let i = 0; i < numItems; i++) {
result.push(valueType.generateValid(source))
}
return result
}
return {
validator,
generateValid,
generateInvalid: (source) => {
return source.executeOne<any>({
otherType: () =>
source.executeOne<any>({
string: () => source.nextId(),
number: () => source.nextInt(100),
object: () => ({ key: source.nextId() }),
}),
invalidItem: () => {
const val = generateValid(source)
if (val.length === 0) {
return [valueType.generateInvalid(source)]
}
const indexToChange = source.nextInt(val.length)
val[indexToChange] = valueType.generateInvalid(source)
return val
},
})
},
}
}
function runTest(seed: number) {
test(`fuzz test with seed ${seed}`, () => {
const source = new RandomSource(seed)
const type = source.nextTestType(0)
const oldValid = type.generateValid(source)
const newValid = source.choice(0.5) ? type.generateValid(source) : oldValid
const didChange = !isEqual(oldValid, newValid)
const invalid = type.generateInvalid(source)
expect(type.validator.validate(oldValid)).toBe(oldValid)
expect(type.validator.validate(newValid)).toBe(newValid)
expect(() => {
type.validator.validate(invalid)
}).toThrow()
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).not.toThrow()
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, invalid)).toThrow()
if (didChange) {
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(newValid)
} else {
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(oldValid)
}
})
}
const NUM_TESTS = 1000
const source = new RandomSource(Math.random())
// 54480484
const onlySeed: null | number = null
if (onlySeed) {
runTest(onlySeed)
} else {
for (let i = 0; i < NUM_TESTS; i++) {
const seed = source.nextInt(100000000)
runTest(seed)
}
}

View file

@ -59,7 +59,7 @@ describe('validations', () => {
x: 132,
y: NaN,
})
).toThrowErrorMatchingInlineSnapshot(`"At shape().y: Expected a number, got NaN"`)
).toThrowErrorMatchingInlineSnapshot(`"At shape.y: Expected a number, got NaN"`)
expect(() =>
T.model(
@ -70,7 +70,7 @@ describe('validations', () => {
})
).validate({ id: 'abc13', color: 'rubbish' })
).toThrowErrorMatchingInlineSnapshot(
`"At shape().color: Expected "red" or "green" or "blue", got rubbish"`
`"At shape.color: Expected "red" or "green" or "blue", got rubbish"`
)
})

View file

@ -7559,6 +7559,7 @@ __metadata:
dependencies:
"@tldraw/utils": "workspace:*"
lazyrepo: "npm:0.0.0-alpha.27"
lodash.isequal: "npm:^4.5.0"
languageName: unknown
linkType: soft