From 4a2040f92ce6a13a03977195ebf8985dcc19b5d7 Mon Sep 17 00:00:00 2001 From: David Sheldrick Date: Tue, 20 Feb 2024 12:35:25 +0000 Subject: [PATCH] Faster validations + record reference stability at the same time (#2848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/dotcom-asset-upload/package.json | 2 +- .../context-toolbar/ContextToolbar.tsx | 2 +- .../examples/custom-styles/FilterStyleUi.tsx | 7 +- package.json | 2 +- packages/editor/api-report.md | 13 +- packages/editor/api/api.json | 57 ++- packages/editor/src/lib/editor/Editor.ts | 9 +- .../editor/src/lib/utils/SharedStylesMap.ts | 2 +- packages/store/api-report.md | 11 +- packages/store/api/api.json | 30 +- packages/store/src/lib/RecordType.ts | 9 +- packages/store/src/lib/Store.ts | 16 +- packages/store/src/lib/StoreSchema.ts | 2 +- packages/tldraw/api-report.md | 8 +- packages/tldraw/api/api.json | 75 +-- .../StylePanel/DefaultStylePanelContent.tsx | 6 +- .../StylePanel/DoubleDropdownPicker.tsx | 9 +- .../components/StylePanel/DropdownPicker.tsx | 7 +- .../primitives/TldrawUiButtonPicker.tsx | 9 +- .../src/lib/ui/hooks/useRevelantStyles.ts | 3 +- packages/tldraw/src/lib/utils/tldr/file.ts | 2 +- packages/tlschema/api-report.md | 55 ++- packages/tlschema/api/api.json | 204 +++++++- packages/tlschema/src/assets/TLBaseAsset.ts | 16 +- packages/tlschema/src/createTLSchema.ts | 9 +- packages/tlschema/src/index.ts | 2 +- packages/tlschema/src/shapes/TLBaseShape.ts | 4 +- packages/tlschema/src/styles/StyleProp.ts | 13 + packages/validate/api-report.md | 19 +- packages/validate/api/api.json | 340 ++++++++++++- packages/validate/package.json | 3 +- packages/validate/src/lib/validation.ts | 449 ++++++++++++++---- .../validate/src/test/validation.fuzz.test.ts | 379 +++++++++++++++ packages/validate/src/test/validation.test.ts | 4 +- yarn.lock | 1 + 35 files changed, 1486 insertions(+), 293 deletions(-) create mode 100644 packages/validate/src/test/validation.fuzz.test.ts 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