diff --git a/apps/dotcom-asset-upload/package.json b/apps/dotcom-asset-upload/package.json index f02ef3c45..1e66c17b3 100644 --- a/apps/dotcom-asset-upload/package.json +++ b/apps/dotcom-asset-upload/package.json @@ -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" }, diff --git a/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx b/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx index 2b0380dbc..4790e884d 100644 --- a/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx +++ b/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx @@ -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! diff --git a/apps/examples/src/examples/custom-styles/FilterStyleUi.tsx b/apps/examples/src/examples/custom-styles/FilterStyleUi.tsx index 907fde08b..ca75cf3e8 100644 --- a/apps/examples/src/examples/custom-styles/FilterStyleUi.tsx +++ b/apps/examples/src/examples/custom-styles/FilterStyleUi.tsx @@ -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)) }) }} > diff --git a/package.json b/package.json index 0c5337bad..0bc6f1f07 100644 --- a/package.json +++ b/package.json @@ -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'" }, diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 807b9a68f..79e28713a 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -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 { setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this; setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this; setStyleForNextShapes(style: StyleProp, value: T, historyOptions?: TLCommandHistoryOptions): this; - setStyleForSelectedShapes(style: StyleProp, value: T, historyOptions?: TLCommandHistoryOptions): this; + setStyleForSelectedShapes>(style: S, value: StylePropValue, historyOptions?: TLCommandHistoryOptions): this; shapeUtils: { readonly [K in string]?: ShapeUtil; }; @@ -884,7 +885,7 @@ export class Editor extends EventEmitter { stretchShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this; // (undocumented) styleProps: { - [key: string]: Map, string>; + [key: string]: Map, string>; }; readonly textMeasure: TextManager; toggleLock(shapes: TLShape[] | TLShapeId[]): this; @@ -1462,10 +1463,10 @@ export { react } // @public export class ReadonlySharedStyleMap { // (undocumented) - [Symbol.iterator](): IterableIterator<[StyleProp, SharedStyle]>; + [Symbol.iterator](): IterableIterator<[StyleProp, SharedStyle]>; constructor(entries?: Iterable<[StyleProp, SharedStyle]>); // (undocumented) - entries(): IterableIterator<[StyleProp, SharedStyle]>; + entries(): IterableIterator<[StyleProp, SharedStyle]>; // (undocumented) equals(other: ReadonlySharedStyleMap): boolean; // (undocumented) @@ -1473,9 +1474,9 @@ export class ReadonlySharedStyleMap { // (undocumented) getAsKnownValue(prop: StyleProp): T | undefined; // (undocumented) - keys(): IterableIterator>; + keys(): IterableIterator>; // @internal (undocumented) - protected map: Map, SharedStyle>; + protected map: Map, SharedStyle>; // (undocumented) get size(): number; // (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 18f510b1e..3802abd40 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -17658,7 +17658,7 @@ "excerptTokens": [ { "kind": "Content", - "text": "setStyleForSelectedShapes(style: " + "text": "setStyleForSelectedShapes" + "text": "" + }, + { + "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": "" }, { "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": ", string>;\n }" + "text": ", string>;\n }" }, { "kind": "Content", @@ -28523,7 +28536,7 @@ }, { "kind": "Content", - "text": ", " + "text": ", " }, { "kind": "Reference", @@ -28632,7 +28645,7 @@ }, { "kind": "Content", - "text": ", " + "text": ", " }, { "kind": "Reference", @@ -28872,7 +28885,7 @@ }, { "kind": "Content", - "text": ">" + "text": ">" }, { "kind": "Content", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 9698f1047..ab4499975 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -5,6 +5,7 @@ import { InstancePageStateRecordType, PageRecordType, StyleProp, + StylePropValue, TLArrowShape, TLAsset, TLAssetId, @@ -737,7 +738,7 @@ export class Editor extends EventEmitter { */ shapeUtils: { readonly [K in string]?: ShapeUtil } - styleProps: { [key: string]: Map, string> } + styleProps: { [key: string]: Map, string> } /** * Get a shape util from a shape itself. @@ -7385,9 +7386,9 @@ export class Editor extends EventEmitter { * * @public */ - setStyleForSelectedShapes( - style: StyleProp, - value: T, + setStyleForSelectedShapes>( + style: S, + value: StylePropValue, historyOptions?: TLCommandHistoryOptions ): this { const selectedShapes = this.getSelectedShapes() diff --git a/packages/editor/src/lib/utils/SharedStylesMap.ts b/packages/editor/src/lib/utils/SharedStylesMap.ts index faec03cdf..d726b8a08 100644 --- a/packages/editor/src/lib/utils/SharedStylesMap.ts +++ b/packages/editor/src/lib/utils/SharedStylesMap.ts @@ -35,7 +35,7 @@ function sharedStyleEquals(a: SharedStyle, b: SharedStyle | undefined) */ export class ReadonlySharedStyleMap { /** @internal */ - protected map: Map, SharedStyle> + protected map: Map, SharedStyle> constructor(entries?: Iterable<[StyleProp, SharedStyle]>) { this.map = new Map(entries) diff --git a/packages/store/api-report.md b/packages/store/api-report.md index ad8d23601..0e4dffd1d 100644 --- a/packages/store/api-report.md +++ b/packages/store/api-report.md @@ -163,9 +163,7 @@ export class RecordType Exclude, RequiredProperties>; readonly migrations: Migrations; - readonly validator?: { - validate: (r: unknown) => R; - } | StoreValidator; + readonly validator?: StoreValidator; readonly scope?: RecordScope; }); clone(record: R): R; @@ -183,11 +181,9 @@ export class RecordType R; - } | StoreValidator; + readonly validator: StoreValidator; withDefaultProperties, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType>; } @@ -344,6 +340,7 @@ export type StoreSnapshot = { // @public (undocumented) export type StoreValidator = { validate: (record: unknown) => R; + validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R; }; // @public (undocumented) diff --git a/packages/store/api/api.json b/packages/store/api/api.json index 9b1afaf38..ba02d30e0 100644 --- a/packages/store/api/api.json +++ b/packages/store/api/api.json @@ -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", diff --git a/packages/store/src/lib/RecordType.ts b/packages/store/src/lib/RecordType.ts index df791be91..00d8443e1 100644 --- a/packages/store/src/lib/RecordType.ts +++ b/packages/store/src/lib/RecordType.ts @@ -29,7 +29,7 @@ export class RecordType< > { readonly createDefaultProperties: () => Exclude, RequiredProperties> readonly migrations: Migrations - readonly validator: StoreValidator | { validate: (r: unknown) => R } + readonly validator: StoreValidator readonly scope: RecordScope @@ -44,7 +44,7 @@ export class RecordType< config: { readonly createDefaultProperties: () => Exclude, RequiredProperties> readonly migrations: Migrations - readonly validator?: StoreValidator | { validate: (r: unknown) => R } + readonly validator?: StoreValidator 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) } } diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index 803fe132d..b665b6751 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -84,6 +84,7 @@ export type StoreSnapshot = { /** @public */ export type StoreValidator = { validate: (record: unknown) => R + validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R } /** @public */ @@ -389,24 +390,19 @@ export class Store { 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) diff --git a/packages/store/src/lib/StoreSchema.ts b/packages/store/src/lib/StoreSchema.ts index 60151f800..4c2067230 100644 --- a/packages/store/src/lib/StoreSchema.ts +++ b/packages/store/src/lib/StoreSchema.ts @@ -85,7 +85,7 @@ export class StoreSchema { 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({ diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index e1caffc92..2ba88eab6 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -195,9 +195,9 @@ export class ArrowShapeUtil extends ShapeUtil { 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 { isPrecise: boolean; }>; point: ObjectValidator< { - type: "point"; x: number; y: number; + type: "point"; }>; }, never>; bend: Validator; @@ -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<((props: TLUiButtonPickerProps) => 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[]): null | ReadonlySharedStyleMap; // @public (undocumented) export function useTldrawUiComponents(): Partial<{ diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 9245c5bea..bd9837f29 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -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": "<((props: " - }, - { - "kind": "Reference", - "text": "TLUiButtonPickerProps", - "canonicalReference": "@tldraw/tldraw!TLUiButtonPickerProps:interface" - }, - { - "kind": "Content", - "text": ") => 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": "[]" }, { "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 } diff --git a/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx b/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx index 12c985c6c..0629afd9d 100644 --- a/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx +++ b/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx @@ -12,6 +12,8 @@ import { LineShapeSplineStyle, ReadonlySharedStyleMap, StyleProp, + TLArrowShapeArrowheadStyle, + TLDefaultVerticalAlignStyle, minBy, useEditor, useValue, @@ -199,7 +201,7 @@ function TextStylePickerSet({ styles }: { styles: ReadonlySharedStyleMap }) { ) : ( - type="icon" id="geo-vertical-alignment" uiType="verticalAlign" @@ -270,7 +272,7 @@ function ArrowheadStylePickerSet({ styles }: { styles: ReadonlySharedStyleMap }) } return ( - label={'style-panel.arrowheads'} uiTypeA="arrowheadStart" styleA={ArrowShapeArrowheadStartStyle} diff --git a/packages/tldraw/src/lib/ui/components/StylePanel/DoubleDropdownPicker.tsx b/packages/tldraw/src/lib/ui/components/StylePanel/DoubleDropdownPicker.tsx index 4979d8fab..ba5e7b194 100644 --- a/packages/tldraw/src/lib/ui/components/StylePanel/DoubleDropdownPicker.tsx +++ b/packages/tldraw/src/lib/ui/components/StylePanel/DoubleDropdownPicker.tsx @@ -27,7 +27,7 @@ interface DoubleDropdownPickerProps { onValueChange: (style: StyleProp, value: T, squashing: boolean) => void } -export const DoubleDropdownPicker = React.memo(function DoubleDropdownPicker({ +function _DoubleDropdownPicker({ label, uiTypeA, uiTypeB, @@ -137,4 +137,9 @@ export const DoubleDropdownPicker = React.memo(function DoubleDropdownPicker ) -}) +} + +// need to memo like this to get generics +export const DoubleDropdownPicker = React.memo( + _DoubleDropdownPicker +) as typeof _DoubleDropdownPicker diff --git a/packages/tldraw/src/lib/ui/components/StylePanel/DropdownPicker.tsx b/packages/tldraw/src/lib/ui/components/StylePanel/DropdownPicker.tsx index 0137cf668..0a47af8ad 100644 --- a/packages/tldraw/src/lib/ui/components/StylePanel/DropdownPicker.tsx +++ b/packages/tldraw/src/lib/ui/components/StylePanel/DropdownPicker.tsx @@ -25,7 +25,7 @@ interface DropdownPickerProps { onValueChange: (style: StyleProp, value: T, squashing: boolean) => void } -export const DropdownPicker = React.memo(function DropdownPicker({ +function _DropdownPicker({ id, label, uiType, @@ -76,4 +76,7 @@ export const DropdownPicker = React.memo(function DropdownPicker ) -}) +} + +// need to export like this to get generics +export const DropdownPicker = React.memo(_DropdownPicker) as typeof _DropdownPicker diff --git a/packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx b/packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx index 34d7bd141..100dfc205 100644 --- a/packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx +++ b/packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx @@ -25,10 +25,7 @@ export interface TLUiButtonPickerProps { onValueChange: (style: StyleProp, value: T, squashing: boolean) => void } -/** @public */ -export const TldrawUiButtonPicker = memo(function TldrawUiButtonPicker( - props: TLUiButtonPickerProps -) { +function _TldrawUiButtonPicker(props: TLUiButtonPickerProps) { const { uiType, items, @@ -125,4 +122,6 @@ export const TldrawUiButtonPicker = memo(function TldrawUiButtonPicker ) -}) +} +/** @public */ +export const TldrawUiButtonPicker = memo(_TldrawUiButtonPicker) as typeof _TldrawUiButtonPicker diff --git a/packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts b/packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts index cac7a0211..e376a5282 100644 --- a/packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts +++ b/packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts @@ -5,11 +5,12 @@ import { DefaultSizeStyle, ReadonlySharedStyleMap, SharedStyleMap, + StyleProp, useEditor, useValue, } from '@tldraw/editor' -const selectToolStyles = Object.freeze([ +const selectToolStyles: readonly StyleProp[] = Object.freeze([ DefaultColorStyle, DefaultDashStyle, DefaultFillStyle, diff --git a/packages/tldraw/src/lib/utils/tldr/file.ts b/packages/tldraw/src/lib/utils/tldr/file.ts index 9414860a0..5632c7379 100644 --- a/packages/tldraw/src/lib/utils/tldr/file.ts +++ b/packages/tldraw/src/lib/utils/tldr/file.ts @@ -56,7 +56,7 @@ const tldrawFileValidator: T.Validator = T.object({ }), records: T.arrayOf( T.object({ - id: T.string as T.Validator>, + id: T.string as any as T.Validator>, typeName: T.string, }).allowUnknownProperties() ), diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 459662af1..70334d14a 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -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; text: T.Validator; @@ -116,13 +116,19 @@ export const CameraRecordType: RecordType; export const canvasUiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">; // @public -export function createAssetValidator(type: Type, props: T.Validator): T.ObjectValidator<{ - id: TLAssetId; - typeName: 'asset'; - type: Type; - props: Props; - meta: JsonObject; -}>; +export function createAssetValidator(type: Type, props: T.Validator): 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; }, meta?: { [K in keyof Meta]: T.Validatable; -}): T.ObjectValidator>; +}): 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[P]; } & { [P_1 in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: TLBaseShape[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; isClosed: T.Validator; isPen: T.Validator; @@ -515,7 +521,7 @@ export const highlightShapeProps: { segments: T.ArrayOfValidator<{ type: "free" | "straight"; points: VecModel[]; - }>; + } & {}>; isComplete: T.Validator; isPen: T.Validator; }; @@ -533,10 +539,10 @@ export const imageShapeProps: { playing: T.Validator; url: T.Validator; assetId: T.Validator; - 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 any; - }>; - meta?: Record any; - }>; + props?: Record; + meta?: Record; }; // @internal (undocumented) @@ -755,8 +757,13 @@ export class StyleProp implements T.Validatable { readonly type: T.Validatable; // (undocumented) validate(value: unknown): Type; + // (undocumented) + validateUsingKnownGoodVersion(prevValue: Type, newValue: unknown): Type; } +// @public (undocumented) +export type StylePropValue> = T extends StyleProp ? U : never; + // @internal (undocumented) export const textShapeMigrations: Migrations; diff --git a/packages/tlschema/api/api.json b/packages/tlschema/api/api.json index 49718853b..2f54eb9e4 100644 --- a/packages/tlschema/api/api.json +++ b/packages/tlschema/api/api.json @@ -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": ">" + "text": "[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": "[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": " any;\n }>;\n meta?: " + "text": ";\n meta?: " }, { "kind": "Reference", @@ -3070,7 +3106,16 @@ }, { "kind": "Content", - "text": " any;\n }>;\n}" + "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" + }, + { + "kind": "Content", + "text": "> = " + }, + { + "kind": "Content", + "text": "T extends " + }, + { + "kind": "Reference", + "text": "StyleProp", + "canonicalReference": "@tldraw/tlschema!StyleProp:class" + }, + { + "kind": "Content", + "text": " ? 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", diff --git a/packages/tlschema/src/assets/TLBaseAsset.ts b/packages/tlschema/src/assets/TLBaseAsset.ts index 9360b7ce3..c55856b36 100644 --- a/packages/tlschema/src/assets/TLBaseAsset.ts +++ b/packages/tlschema/src/assets/TLBaseAsset.ts @@ -27,14 +27,14 @@ export const assetIdValidator = idValidator('asset') export function createAssetValidator( type: Type, props: T.Validator -): 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), diff --git a/packages/tlschema/src/createTLSchema.ts b/packages/tlschema/src/createTLSchema.ts index 7152ec706..252faed7d 100644 --- a/packages/tlschema/src/createTLSchema.ts +++ b/packages/tlschema/src/createTLSchema.ts @@ -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 any }> - meta?: Record any }> + props?: Record + meta?: Record } /** @public */ diff --git a/packages/tlschema/src/index.ts b/packages/tlschema/src/index.ts index 9aef3104e..f2cbccb12 100644 --- a/packages/tlschema/src/index.ts +++ b/packages/tlschema/src/index.ts @@ -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, diff --git a/packages/tlschema/src/shapes/TLBaseShape.ts b/packages/tlschema/src/shapes/TLBaseShape.ts index 7853a54c1..59db20ba6 100644 --- a/packages/tlschema/src/shapes/TLBaseShape.ts +++ b/packages/tlschema/src/shapes/TLBaseShape.ts @@ -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), - meta: meta ? T.object(meta) : (T.jsonValue as T.ObjectValidator), + props: props ? T.object(props) : (T.jsonValue as any), + meta: meta ? T.object(meta) : (T.jsonValue as any), }) } diff --git a/packages/tlschema/src/styles/StyleProp.ts b/packages/tlschema/src/styles/StyleProp.ts index f0b908555..8ca0bf7fa 100644 --- a/packages/tlschema/src/styles/StyleProp.ts +++ b/packages/tlschema/src/styles/StyleProp.ts @@ -90,6 +90,14 @@ export class StyleProp implements T.Validatable { 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 extends StyleProp { super(id, defaultValue, T.literalEnum(...values)) } } + +/** + * @public + */ +export type StylePropValue> = T extends StyleProp ? U : never diff --git a/packages/validate/api-report.md b/packages/validate/api-report.md index b44556ed9..885de46eb 100644 --- a/packages/validate/api-report.md +++ b/packages/validate/api-report.md @@ -86,7 +86,11 @@ const number: Validator; // @public function object(config: { readonly [K in keyof Shape]: Validatable; -}): ObjectValidator; +}): ObjectValidator<{ + [P in ExtractRequiredKeys]: Shape[P]; +} & { + [P in ExtractOptionalKeys]?: Shape[P]; +}>; // @public (undocumented) export class ObjectValidator extends Validator { @@ -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 ? T : never; +type TypeOf> = V extends Validatable ? T : never; // @public function union>(key: Key, config: Config): UnionValidator; @@ -187,6 +192,7 @@ const unknownObject: Validator>; // @public (undocumented) type Validatable = { 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 implements Validatable { - constructor(validationFn: ValidatorFn); + constructor(validationFn: ValidatorFn, validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn); check(name: string, checkFn: (value: T) => void): Validator; // (undocumented) check(checkFn: (value: T) => void): Validator; @@ -212,12 +218,19 @@ export class Validator implements Validatable { refine(otherValidationFn: (value: T) => U): Validator; validate(value: unknown): T; // (undocumented) + validateUsingKnownGoodVersion(knownGoodValue: T, newValue: unknown): T; + // (undocumented) + readonly validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn; + // (undocumented) readonly validationFn: ValidatorFn; } // @public (undocumented) type ValidatorFn = (value: unknown) => T; +// @public (undocumented) +type ValidatorUsingKnownGoodVersionFn = (knownGoodValue: In, value: unknown) => Out; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/validate/api/api.json b/packages/validate/api/api.json index 1920a5626..607d8d512 100644 --- a/packages/validate/api/api.json +++ b/packages/validate/api/api.json @@ -2142,7 +2142,25 @@ }, { "kind": "Content", - "text": "" + "text": "<{\n [P in " + }, + { + "kind": "Reference", + "text": "ExtractRequiredKeys", + "canonicalReference": "@tldraw/validate!~ExtractRequiredKeys:type" + }, + { + "kind": "Content", + "text": "]: Shape[P];\n} & {\n [P in " + }, + { + "kind": "Reference", + "text": "ExtractOptionalKeys", + "canonicalReference": "@tldraw/validate!~ExtractOptionalKeys:type" + }, + { + "kind": "Content", + "text": "]?: 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": "" + "text": "" }, { "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": "" }, + { + "kind": "Content", + "text": ", validateUsingKnownGoodVersionFn?: " + }, + { + "kind": "Content", + "text": "undefined | " + }, + { + "kind": "Reference", + "text": "ValidatorUsingKnownGoodVersionFn", + "canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type" + }, + { + "kind": "Content", + "text": "" + }, { "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": "" + }, + { + "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 = " + }, + { + "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": "" }, + { + "kind": "Content", + "text": ", validateUsingKnownGoodVersionFn?: " + }, + { + "kind": "Content", + "text": "undefined | " + }, + { + "kind": "Reference", + "text": "ValidatorUsingKnownGoodVersionFn", + "canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type" + }, + { + "kind": "Content", + "text": "" + }, { "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": "" + }, + { + "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", diff --git a/packages/validate/package.json b/packages/validate/package.json index 4e08d6732..d34857668 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -53,6 +53,7 @@ } }, "devDependencies": { - "lazyrepo": "0.0.0-alpha.27" + "lazyrepo": "0.0.0-alpha.27", + "lodash.isequal": "^4.5.0" } } diff --git a/packages/validate/src/lib/validation.ts b/packages/validate/src/lib/validation.ts index 0a34cb08f..e16e52805 100644 --- a/packages/validate/src/lib/validation.ts +++ b/packages/validate/src/lib/validation.ts @@ -9,9 +9,26 @@ import { /** @public */ export type ValidatorFn = (value: unknown) => T +/** @public */ +export type ValidatorUsingKnownGoodVersionFn = ( + knownGoodValue: In, + value: unknown +) => Out /** @public */ -export type Validatable = { validate: (value: unknown) => T } +export type Validatable = { + 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): string | null { if (!path.length) { @@ -92,11 +109,14 @@ function typeToString(value: unknown): string { } /** @public */ -export type TypeOf> = V extends Validatable ? T : never +export type TypeOf> = V extends Validatable ? T : never /** @public */ export class Validator implements Validatable { - constructor(readonly validationFn: ValidatorFn) {} + constructor( + readonly validationFn: ValidatorFn, + readonly validateUsingKnownGoodVersionFn?: ValidatorUsingKnownGoodVersionFn + ) {} /** * Asserts that the passed value is of the correct type and returns it. The returned value is @@ -110,6 +130,18 @@ export class Validator implements Validatable { 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 implements Validatable { * if the value can't be converted to the new type, or return the new type otherwise. */ refine(otherValidationFn: (value: T) => U): Validator { - 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 implements Validatable { /** @public */ export class ArrayOfValidator extends Validator { constructor(readonly itemValidator: Validatable) { - 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 extends Validator { }, 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).validate(getOwnProperty(object, key)) - }) - } + for (const [key, validator] of Object.entries(config)) { + prefixError(key, () => { + ;(validator as Validator).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).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 extends Validator { extend>(extension: { readonly [K in keyof Extension]: Validatable }): ObjectValidator { - 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 | 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( @@ -314,20 +460,65 @@ export class DictValidator extends Validator, public readonly valueValidator: Validatable ) { - 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 - }) + return object as Record + }, + (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) : knownGoodValue + } + ) } } @@ -477,6 +668,14 @@ export const unknownObject = new Validator>((value) => { return value as Record }) +type ExtractRequiredKeys = { + [K in keyof T]: undefined extends T[K] ? never : K +}[keyof T] + +type ExtractOptionalKeys = { + [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>((value) => { */ export function object(config: { readonly [K in keyof Shape]: Validatable -}): ObjectValidator { - return new ObjectValidator(config) +}): ObjectValidator< + { [P in ExtractRequiredKeys]: Shape[P] } & { [P in ExtractOptionalKeys]?: Shape[P] } +> { + return new ObjectValidator(config) as any +} + +function isPlainObject(value: unknown): value is Record { + 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((value): JsonValue => { - if (isValidJson(value)) { - return value as JsonValue - } +export const jsonValue: Validator = new Validator( + (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( name: string, validator: Validatable ): Validator { - 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(values: ReadonlySet): Validator { /** @public */ export function optional(validator: Validatable): Validator { - 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(validator: Validatable): Validator { - 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 */ diff --git a/packages/validate/src/test/validation.fuzz.test.ts b/packages/validate/src/test/validation.fuzz.test.ts new file mode 100644 index 000000000..0f12eec9b --- /dev/null +++ b/packages/validate/src/test/validation.fuzz.test.ts @@ -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(arr: readonly T[]): T { + return arr[this.nextInt(arr.length)] + } + + choice(probability: number): boolean { + return this.nextFloat() < probability + } + + executeOne( + _choices: Record 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({ + 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({ + 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 + 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 + +function generateObjectType( + source: RandomSource, + injectProperties: Record, + depth: number +): TestType { + const numProperties = source.nextIntInRange(1, 5) + const propertyTypes: Record = { + ...injectProperties, + } + const optionalTypes = new Set() + const nullableTypes = new Set() + 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({ + otherType: () => + source.executeOne({ + 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> = {} + 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({ + 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({ + otherType: () => + source.executeOne({ + 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) + } +} diff --git a/packages/validate/src/test/validation.test.ts b/packages/validate/src/test/validation.test.ts index b8b363876..5f2576e59 100644 --- a/packages/validate/src/test/validation.test.ts +++ b/packages/validate/src/test/validation.test.ts @@ -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"` ) }) diff --git a/yarn.lock b/yarn.lock index 1717c326a..5272ee1b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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