chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
ignorePatterns: ['**/tests__'],
});
@@ -0,0 +1,63 @@
{
"name": "@flowgram.ai/json-schema",
"version": "0.1.8",
"description": "json schema type manager",
"keywords": [
"flow",
"variable",
"scope",
"engine"
],
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "exit 0",
"test:cov": "exit 0",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/variable-core": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"inversify": "^6.0.1",
"lodash-es": "^4.17.21",
"reflect-metadata": "~0.2.2"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.0.0",
"tsup": "^8.0.1",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
import { Emitter } from '@flowgram.ai/utils';
import { BaseTypeRegistry, TypeRegistryCreator } from './types';
@injectable()
export abstract class BaseTypeManager<
Schema,
Registry extends BaseTypeRegistry,
Manager extends BaseTypeManager<Schema, Registry, Manager>
> {
protected typeRegistryMap: Map<string, Registry> = new Map();
protected onTypeRegistryChangeEmitter = new Emitter<Registry[]>();
public onTypeRegistryChange = this.onTypeRegistryChangeEmitter.event;
/**
* 获取 typeSchema 对应的 type
* 不能直接访问 type 的原因的是
* Schema 中 type 可能为空,需要获取 $ref 中的 type
*/
protected abstract getTypeNameFromSchema(typeSchema: Schema): string;
getTypeByName(typeName: string): Registry | undefined {
return this.typeRegistryMap.get(typeName);
}
getTypeBySchema(type: Schema): Registry | undefined {
const key = this.getTypeNameFromSchema(type);
return this.typeRegistryMap.get(key);
}
getDefaultTypeRegistry(): Registry | undefined {
return this.typeRegistryMap.get('default');
}
/**
* 注册 TypeRegistry
*/
register(
originRegistry: Partial<Registry> | TypeRegistryCreator<Schema, Registry, Manager>
): void {
const registry =
typeof originRegistry === 'function'
? originRegistry({ typeManager: this as unknown as Manager })
: originRegistry;
const type = registry.type;
if (!type) {
return;
}
let originTypeRegistry = this.getTypeByName(type);
let extendTypeRegistry = registry.extend
? this.getTypeByName(registry.extend)
: this.getDefaultTypeRegistry();
if (originTypeRegistry) {
// 如果是重复注册的类型,进行 merge
this.typeRegistryMap.set(type, {
...extendTypeRegistry,
...originTypeRegistry,
...registry,
});
} else {
this.typeRegistryMap.set(type, {
...extendTypeRegistry,
...registry,
} as Registry);
}
}
triggerChanges() {
this.onTypeRegistryChangeEmitter.fire(Array.from(this.typeRegistryMap.values()));
}
/**
* 获取全量的 TypeRegistries
*/
public getAllTypeRegistries(): Registry[] {
return Array.from(this.typeRegistryMap.values());
}
unregister(type: string): void {
this.typeRegistryMap.delete(type);
}
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { BaseTypeManager } from './base-type-manager';
export { BaseTypeRegistry, TypeRegistryCreator } from './types';
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type BaseTypeManager } from './base-type-manager';
/**
* Base information for TypeRegistry
*/
export interface BaseTypeRegistry {
/**
* type reference
*/
type: string;
/**
* The inherited type. If there is an inheritance, the definition of this type will use the inherited type definition,
* and the new definition will override the inherited definition.
*/
extend?: string;
}
/**
* TypeRegistryCreator
*/
export type TypeRegistryCreator<
Schema,
Registry extends BaseTypeRegistry,
Manager extends BaseTypeManager<Schema, Registry, Manager>
> = (ctx: { typeManager: Manager }) => Partial<Registry>;
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ContainerModule } from 'inversify';
import { JsonSchemaTypeManager } from './json-schema';
import { BaseTypeManager } from './base';
export const TypeManager = Symbol('TypeManager');
export const jsonSchemaContainerModule = new ContainerModule((bind) => {
bind(BaseTypeManager).to(JsonSchemaTypeManager).inSingletonScope();
});
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { createContext, useContext, useMemo } from 'react';
import { usePlaygroundContainer } from '@flowgram.ai/core';
import {
IJsonSchema,
jsonSchemaTypeManager,
JsonSchemaTypeManager,
JsonSchemaTypeRegistry,
} from './json-schema';
import { BaseTypeManager, TypeRegistryCreator } from './base';
// use global default
const TypePresetContext = createContext<JsonSchemaTypeManager | null>(null);
export const useTypeManager = () => {
const typeManagerFromContext = useContext(TypePresetContext);
const container = usePlaygroundContainer();
if (typeManagerFromContext) {
return typeManagerFromContext;
}
if (container?.isBound?.(BaseTypeManager)) {
return container.get(BaseTypeManager);
}
// Global Singleton
return jsonSchemaTypeManager;
};
export const TypePresetProvider = <
Registry extends JsonSchemaTypeRegistry = JsonSchemaTypeRegistry
>({
children,
types,
}: React.PropsWithChildren<{
types: (
| Partial<Registry>
| TypeRegistryCreator<IJsonSchema, Registry, JsonSchemaTypeManager<IJsonSchema, Registry>>
)[];
}>) => {
const schemaManager = useMemo(() => {
const typeManager = new JsonSchemaTypeManager<IJsonSchema, Registry>();
types.forEach((_type) => typeManager.register(_type));
return typeManager;
}, [...types]);
return (
<TypePresetContext.Provider value={schemaManager as unknown as JsonSchemaTypeManager}>
{children}
</TypePresetContext.Provider>
);
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './base';
export * from './json-schema';
export { useTypeManager, TypePresetProvider } from './context';
export { jsonSchemaContainerModule } from './container-module';
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { JsonSchemaTypeManager } from './json-schema-type-manager';
export {
JsonSchemaBasicType,
IJsonSchema,
IBasicJsonSchema,
JsonSchemaTypeRegistry,
JsonSchemaTypeRegistryCreator,
} from './types';
export { JsonSchemaUtils } from './utils';
export { JsonSchemaTypeManager } from './json-schema-type-manager';
// Global JsonSchemaTypeManager
export const jsonSchemaTypeManager = new JsonSchemaTypeManager();
@@ -0,0 +1,151 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { injectable } from 'inversify';
import { IJsonSchema, JsonSchemaTypeRegistry, JsonSchemaTypeRegistryCreator } from './types';
import { defaultTypeDefinitionRegistry } from './type-definition/default';
import { dateTimeRegistryCreator } from './type-definition/date-time';
import { BaseTypeManager } from '../base';
import {
arrayRegistryCreator,
booleanRegistryCreator,
integerRegistryCreator,
mapRegistryCreator,
numberRegistryCreator,
objectRegistryCreator,
stringRegistryCreator,
unknownRegistryCreator,
} from './type-definition';
@injectable()
export class JsonSchemaTypeManager<
Schema extends Partial<IJsonSchema> = IJsonSchema,
Registry extends JsonSchemaTypeRegistry<Schema> = JsonSchemaTypeRegistry<Schema>
> extends BaseTypeManager<Schema, Registry, JsonSchemaTypeManager<Schema, Registry>> {
/**
* get type name
* @param typeSchema
* @returns
*/
protected getTypeNameFromSchema(typeSchema: Schema): string {
if (!typeSchema) {
return 'unknown';
}
if (typeSchema.enum) {
return 'enum';
}
if (typeSchema.format && typeSchema.type === 'string') {
return typeSchema.format;
}
return typeSchema.type || typeSchema.$ref || 'unknown';
}
constructor() {
super();
const registries = [
defaultTypeDefinitionRegistry,
stringRegistryCreator,
integerRegistryCreator,
numberRegistryCreator,
booleanRegistryCreator,
objectRegistryCreator,
arrayRegistryCreator,
unknownRegistryCreator,
mapRegistryCreator,
dateTimeRegistryCreator,
];
registries.forEach((registry) => {
this.register(
registry as unknown as JsonSchemaTypeRegistryCreator<
Schema,
Registry,
JsonSchemaTypeManager<Schema, Registry>
>
);
});
}
/**
* Get TypeRegistries based on the current parentType
*/
public getTypeRegistriesWithParentType = (parentType = ''): Registry[] =>
this.getAllTypeRegistries()
.filter((v) => v.type !== 'default')
.filter((v) => !v.parentType || v.parentType.includes(parentType));
/**
* Get the deepest child field of a field
* Array<Array<String>> -> String
*/
getTypeSchemaDeepChildField = (type: Schema) => {
let registry = this.getTypeBySchema(type);
let childType = type;
while (registry?.getItemType && registry.getItemType(childType)) {
childType = registry.getItemType(childType)!;
registry = this.getTypeBySchema(childType);
}
return childType;
};
/**
* Get the plain text display string of the type schema, for example:
* Array<Array<String>>, Map<String, Number>
*/
public getComplexText = (type: Schema): string => {
const registry = this.getTypeBySchema(type);
if (registry?.customComplexText) {
return registry.customComplexText(type);
}
if (registry?.container && type.items) {
return `${registry.label}<${this.getComplexText(type.items as Schema)}>`;
} else if (registry?.container && type.additionalProperties) {
return `${registry.label}<String, ${this.getComplexText(
type.additionalProperties as Schema
)}>`;
} else {
return registry?.label || type.type || 'unknown';
}
};
public getDisplayIcon = (type: Schema) => {
const registry = this.getTypeBySchema(type);
return registry?.getDisplayIcon?.(type) || registry?.icon || <></>;
};
public getTypeSchemaProperties = (type: Schema) => {
const registry = this.getTypeBySchema(type);
return registry?.getTypeSchemaProperties(type);
};
public getPropertiesParent = (type: Schema) => {
const registry = this.getTypeBySchema(type);
return registry?.getPropertiesParent(type);
};
public getJsonPaths = (type: Schema) => {
const registry = this.getTypeBySchema(type);
return registry?.getJsonPaths(type);
};
public canAddField = (type: Schema) => {
const registry = this.getTypeBySchema(type);
return registry?.canAddField(type);
};
public getDefaultValue = (type: Schema) => {
const registry = this.getTypeBySchema(type);
return registry?.getDefaultValue();
};
}
@@ -0,0 +1,107 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
export const arrayObjectIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1.58105H3.6139V2.87326H1.36702V13.1264H3.6139V14.4186H0V1.58105ZM3.41656 13.3264V13.3266H1.17155V13.3264H3.41656ZM0.197344 14.2186H0.199219V1.78125H3.41656V1.78105H0.197344V14.2186ZM12.3861 1.58105H16V14.4186H12.3861V13.1264H14.633V2.87326H12.3861V1.58105ZM12.5834 2.67326V1.78105H15.8027V1.78125H12.5853V2.67326H12.5834ZM12.5853 13.3266V14.2186H12.5834V13.3264H14.8303V2.67345H14.8322V13.3266H12.5853ZM3.82031 5.9091C3.82031 5.18535 4.40703 4.59863 5.13078 4.59863C5.85453 4.59863 6.44124 5.18535 6.44124 5.9091C6.44124 6.56485 5.9596 7.1081 5.33078 7.2044V8.70018H5.32877C5.32982 8.75093 5.33078 8.80912 5.33078 8.87034V9.72111C5.33078 10.0195 5.57268 10.2614 5.87109 10.2614H6.24124C6.55613 10.2614 6.8114 10.5167 6.8114 10.8316C6.8114 11.1465 6.55613 11.4017 6.24124 11.4017H5.87109C4.94291 11.4017 4.19047 10.6493 4.19047 9.72111V6.82186C3.96158 6.58607 3.82031 6.26397 3.82031 5.9091ZM7.33679 5.9091C7.33679 5.59421 7.59205 5.33894 7.90694 5.33894H11.6085C11.9234 5.33894 12.1786 5.59421 12.1786 5.9091C12.1786 6.22399 11.9234 6.47925 11.6085 6.47925H7.90694C7.59205 6.47925 7.33679 6.22399 7.33679 5.9091ZM7.33679 9.86846C7.33679 9.55357 7.59205 9.2983 7.90694 9.2983H11.6085C11.9234 9.2983 12.1786 9.55357 12.1786 9.86846C12.1786 10.1833 11.9234 10.4386 11.6085 10.4386H7.90694C7.59205 10.4386 7.33679 10.1833 7.33679 9.86846Z"
fill="currentColor"
/>
</svg>
);
export const arrayStringIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1.58105H3.6139V2.87326H1.36702V13.1264H3.6139V14.4186H0V1.58105ZM3.41656 13.3264V13.3266H1.17155V13.3264H3.41656ZM0.197344 14.2186H0.199219V1.78125H3.41656V1.78105H0.197344V14.2186ZM12.3861 1.58105H16V14.4186H12.3861V13.1264H14.633V2.87326H12.3861V1.58105ZM12.5834 2.67326V1.78105H15.8027V1.78125H12.5853V2.67326H12.5834ZM12.5853 13.3266V14.2186H12.5834V13.3264H14.8303V2.67345H14.8322V13.3266H12.5853ZM5.23701 4.07158C5.50364 3.3161 6.56205 3.28894 6.86709 4.02974L10 11.6383C10.1329 11.9609 9.979 12.3302 9.65631 12.4631C9.33363 12.596 8.96434 12.4421 8.83147 12.1194L7.8021 9.61951H4.61903L3.7474 12.0891C3.63126 12.4182 3.27034 12.5908 2.94127 12.4747C2.6122 12.3585 2.43958 11.9976 2.55573 11.6685L5.23701 4.07158ZM6.08814 5.45704L5.06505 8.35579H7.28174L6.08814 5.45704ZM8.81938 6.07534C8.81938 5.75166 9.08177 5.48926 9.40545 5.48926H12.8941C13.2178 5.48926 13.4802 5.75166 13.4802 6.07534C13.4802 6.39902 13.2178 6.66142 12.8941 6.66142H9.40545C9.08177 6.66142 8.81938 6.39902 8.81938 6.07534ZM10.2668 9.69181C10.2668 9.36812 10.5292 9.10573 10.8529 9.10573H12.8941C13.2178 9.10573 13.4802 9.36812 13.4802 9.69181C13.4802 10.0155 13.2178 10.2779 12.8941 10.2779H10.8529C10.5292 10.2779 10.2668 10.0155 10.2668 9.69181Z"
fill="currentColor"
/>
</svg>
);
export const arrayIntegerIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1.58105H3.6139V2.87326H1.36702V13.1264H3.6139V14.4186H0V1.58105ZM3.41656 13.3264V13.3266H1.17155V13.3264H3.41656ZM0.197344 14.2186H0.199219V1.78125H3.41656V1.78105H0.197344V14.2186ZM12.3861 1.58105H16V14.4186H12.3861V13.1264H14.633V2.87326H12.3861V1.58105ZM12.5834 2.67326V1.78105H15.8027V1.78125H12.5853V2.67326H12.5834ZM12.5853 13.3266V14.2186H12.5834V13.3264H14.8303V2.67345H14.8322V13.3266H12.5853ZM10.3614 5.22374C10.7161 4.90585 11.1581 4.75 11.6762 4.75C12.2173 4.75 12.6723 4.91467 13.0281 5.25207L13.0291 5.253C13.3852 5.59688 13.561 6.03946 13.561 6.56767C13.561 6.89 13.4945 7.17448 13.3539 7.41445C13.2572 7.57972 13.1279 7.71948 12.9685 7.83428C13.1575 7.95643 13.3099 8.11182 13.4225 8.30109C13.5793 8.5644 13.6531 8.88311 13.6531 9.24936C13.6531 9.83787 13.4612 10.3151 13.0656 10.6612C12.6982 10.9795 12.2305 11.1341 11.6762 11.1341C11.1356 11.1341 10.6805 10.9925 10.324 10.6977C9.92124 10.3691 9.71723 9.90026 9.69942 9.31256L9.69473 9.15802H10.846L10.8539 9.2997C10.8689 9.5698 10.9591 9.75553 11.1096 9.87941L11.1106 9.88027C11.2519 9.99882 11.4365 10.0631 11.6762 10.0631C11.9765 10.0631 12.1743 9.98692 12.2984 9.86071C12.4229 9.73404 12.4984 9.53136 12.4984 9.22422C12.4984 8.92116 12.4215 8.72127 12.2939 8.59581C12.1658 8.46989 11.961 8.39373 11.6511 8.39373H11.3586V7.34788H11.6511C11.9297 7.34788 12.111 7.27834 12.2238 7.16555C12.3366 7.05276 12.4062 6.87138 12.4062 6.59281C12.4062 6.30696 12.3378 6.12041 12.2277 6.00501C12.1188 5.89092 11.9446 5.82098 11.6762 5.82098C11.4248 5.82098 11.2539 5.88537 11.1407 5.99325C11.0268 6.10185 10.9497 6.27522 10.9291 6.5375L10.9183 6.67577H9.76788L9.77492 6.51904C9.79886 5.98644 9.99237 5.54989 10.3614 5.22374ZM5.91032 5.26037C6.26612 4.92297 6.72112 4.7583 7.26219 4.7583C7.80751 4.7583 8.26297 4.91938 8.61401 5.25194L8.61501 5.25289C8.96719 5.59272 9.13852 6.04185 9.13852 6.58435C9.13852 6.84997 9.08709 7.09565 8.9817 7.31883L8.98114 7.31999C8.89563 7.49712 8.74775 7.71415 8.54418 7.96862L8.54322 7.96981L6.87446 10.0127H9.13852V11.0753H5.36909V10.1089L7.69946 7.27679C7.89456 7.04062 7.98374 6.80773 7.98374 6.57597C7.98374 6.29602 7.91626 6.11385 7.8078 6.00122C7.70036 5.88964 7.52811 5.8209 7.26219 5.8209C7.04017 5.8209 6.87439 5.88173 6.75075 5.99193C6.61227 6.11766 6.53226 6.30918 6.53226 6.59273V6.74273H5.37747V6.59273C5.37747 6.05443 5.55248 5.60586 5.90934 5.2613L5.91032 5.26037ZM3.50907 4.80865H4.56964V11.0754H3.41486V6.2201L2.25 7.24249V5.89561L3.50907 4.80865Z"
fill="currentColor"
/>
</svg>
);
export const arrayNumberIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6139 1.58154H0V14.4191H3.6139V13.1269H1.36702V2.87375H3.6139V1.58154ZM3.41656 13.3271V13.3269H1.17155V13.3271H3.41656ZM0.199219 14.2191H0.197344V1.78154H3.41656V1.78174H0.199219V14.2191ZM16 1.58154H12.3861V2.87375H14.633V13.1269H12.3861V14.4191H16V1.58154ZM12.5834 1.78154V2.67375H12.5853V1.78174H15.8027V1.78154H12.5834ZM12.5853 14.2191V13.3271H14.8322V2.67394H14.8303V13.3269H12.5834V14.2191H12.5853ZM6.86771 4.5C5.87019 4.5 5.00104 5.30767 5.00104 6.31667V9.63333C5.00104 10.6423 5.87019 11.45 6.86771 11.45C7.86523 11.45 8.73438 10.6423 8.73438 9.63333V6.31667C8.73438 5.30767 7.86523 4.5 6.86771 4.5ZM11.1177 4.5C10.1202 4.5 9.25104 5.30767 9.25104 6.31667V9.63333C9.25104 10.6423 10.1202 11.45 11.1177 11.45C12.1152 11.45 12.9844 10.6423 12.9844 9.63333V6.31667C12.9844 5.30767 12.1152 4.5 11.1177 4.5ZM6.13438 6.31667C6.13438 5.9503 6.47884 5.63333 6.86771 5.63333C7.25657 5.63333 7.60104 5.9503 7.60104 6.31667V9.63333C7.60104 9.9997 7.25657 10.3167 6.86771 10.3167C6.47884 10.3167 6.13438 9.9997 6.13438 9.63333V6.31667ZM10.3844 6.31667C10.3844 5.9503 10.7288 5.63333 11.1177 5.63333C11.5066 5.63333 11.851 5.9503 11.851 6.31667V9.63333C11.851 9.9997 11.5066 10.3167 11.1177 10.3167C10.7288 10.3167 10.3844 9.9997 10.3844 9.63333V6.31667ZM3.75938 9.85C3.33135 9.85 2.98438 10.197 2.98438 10.625C2.98438 11.053 3.33135 11.4 3.75938 11.4C4.1874 11.4 4.53438 11.053 4.53438 10.625C4.53438 10.197 4.1874 9.85 3.75938 9.85Z"
fill="currentColor"
/>
</svg>
);
export const arrayBooleanIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 1.58105H3.6139V2.87326H1.36702V13.1264H3.6139V14.4186H0V1.58105ZM3.41656 13.3264V13.3266H1.17155V13.3264H3.41656ZM0.197344 14.2186H0.199219V1.78125H3.41656V1.78105H0.197344V14.2186ZM12.3861 1.58105H16V14.4186H12.3861V13.1264H14.633V2.87326H12.3861V1.58105ZM12.5834 2.67326V1.78105H15.8027V1.78125H12.5853V2.67326H12.5834ZM12.5853 13.3266V14.2186H12.5834V13.3264H14.8303V2.67345H14.8322V13.3266H12.5853ZM2.75 7.99993C2.75 6.14518 4.25358 4.6416 6.10833 4.6416H9.775C11.6298 4.6416 13.1333 6.14518 13.1333 7.99993C13.1333 9.85469 11.6298 11.3583 9.775 11.3583H6.10833C4.25358 11.3583 2.75 9.85469 2.75 7.99993ZM6.10833 5.85827C4.92552 5.85827 3.96667 6.81713 3.96667 7.99993C3.96667 9.18274 4.92552 10.1416 6.10833 10.1416H9.775C10.9578 10.1416 11.9167 9.18274 11.9167 7.99993C11.9167 6.81713 10.9578 5.85827 9.775 5.85827H6.10833ZM8.25 7.99993C8.25 7.1577 8.93277 6.47493 9.775 6.47493C10.6172 6.47493 11.3 7.1577 11.3 7.99993C11.3 8.84217 10.6172 9.52493 9.775 9.52493C8.93277 9.52493 8.25 8.84217 8.25 7.99993ZM9.775 7.6916C9.60471 7.6916 9.46667 7.82965 9.46667 7.99993C9.46667 8.17022 9.60471 8.30827 9.775 8.30827C9.94529 8.30827 10.0833 8.17022 10.0833 7.99993C10.0833 7.82965 9.94529 7.6916 9.775 7.6916Z"
fill="currentColor"
/>
</svg>
);
export const arrayDateTimeIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6139 1.58105H0V14.4186H3.6139V13.1264H1.36702V2.87326H3.6139V1.58105ZM3.41656 13.3266V13.3264H1.16987V13.3266H3.41656ZM0.197537 14.2186H0.197344V1.78105H3.41656V1.78125H0.197537V14.2186ZM16 1.58105H12.3861V2.87326H14.633V13.1264H12.3861V14.4186H16V1.58105ZM12.5834 1.78105V2.67326H12.5836V1.78125H15.8027V1.78105H12.5834ZM12.5836 14.2186V13.3266H14.8305V2.67345H14.8303V13.3264H12.5834V14.2186H12.5836Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.40039 4.00008C4.40039 3.63189 4.69887 3.33341 5.06706 3.33341H5.33372V3.06675C5.33372 2.7722 5.5725 2.53341 5.86706 2.53341C6.16161 2.53341 6.40039 2.7722 6.40039 3.06675V3.33341H9.60039V3.06675C9.60039 2.7722 9.83917 2.53341 10.1337 2.53341C10.4283 2.53341 10.6671 2.7722 10.6671 3.06675V3.33341H10.9337C11.3019 3.33341 11.6004 3.63189 11.6004 4.00008V6.20897C11.2633 6.00831 10.9047 5.84009 10.5292 5.70896V4.66675H5.47159V10.5144H5.92177C6.05468 10.8997 6.23605 11.2622 6.45957 11.5957H5.06706C4.69887 11.5957 4.40039 11.2972 4.40039 10.929V4.00008ZM6.13372 6.13341C6.13372 5.83886 6.3725 5.60008 6.66706 5.60008H7.33372C7.62828 5.60008 7.86706 5.83886 7.86706 6.13341C7.86706 6.42797 7.62828 6.66675 7.33372 6.66675H6.66706C6.3725 6.66675 6.13372 6.42797 6.13372 6.13341Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.60039 6.66675C8.00437 6.66675 6.71016 7.96095 6.71016 9.55697C6.71016 11.153 8.00437 12.4472 9.60039 12.4472C11.1964 12.4472 12.4906 11.153 12.4906 9.55697C12.4906 7.96095 11.1964 6.66675 9.60039 6.66675ZM9.06706 8.40008C9.06706 8.10553 9.30584 7.86675 9.60039 7.86675C9.89494 7.86675 10.1337 8.10553 10.1337 8.40008V9.3362L10.7955 9.99795C11.0037 10.2062 11.0037 10.5438 10.7955 10.7521C10.5872 10.9604 10.2495 10.9604 10.0413 10.7521L9.2233 9.93412C9.12327 9.8341 9.06706 9.69844 9.06706 9.55697V8.40008Z"
fill="currentColor"
/>
</svg>
);
export const arrayMapIcon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6139 1.58105H0V14.4186H3.6139V13.1264H1.36702V2.87326H3.6139V1.58105ZM3.41656 13.3266V13.3264H1.16987V13.3266H3.41656ZM0.197537 14.2186H0.197344V1.78105H3.41656V1.78125H0.197537V14.2186ZM16 1.58105H12.3861V2.87326H14.633V13.1264H12.3861V14.4186H16V1.58105ZM12.5834 1.78105V2.67326H12.5836V1.78125H15.8027V1.78105H12.5834ZM12.5836 14.2186V13.3266H14.8305V2.67345H14.8303V13.3264H12.5834V14.2186H12.5836Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.02962 3.6001H10.7696C11.0983 3.6001 11.4119 3.73574 11.6424 3.97635C11.8719 4.21592 11.9994 4.53988 11.9994 4.87541V10.0898C12.0056 10.2146 11.9675 10.3376 11.8918 10.4372L11.8662 10.4709L11.8287 10.4905C11.6508 10.5836 11.5323 10.6684 11.4581 10.7562C11.3893 10.8377 11.3538 10.9274 11.3538 11.0488C11.3538 11.2093 11.3914 11.285 11.4495 11.3472C11.5199 11.4227 11.6265 11.4867 11.8093 11.5964C11.8188 11.6022 11.8286 11.608 11.8385 11.614L11.8704 11.6331L11.8927 11.6629C11.9509 11.7407 11.9869 11.8329 11.997 11.9295C12.007 12.0258 11.9909 12.123 11.9503 12.2109C11.9101 12.2996 11.8454 12.375 11.7639 12.4282C11.682 12.4817 11.5865 12.5106 11.4887 12.5114L11.487 12.5114H5.02962C4.70105 12.5114 4.38727 12.3758 4.1573 12.1349C3.92697 11.8925 3.79871 11.5702 3.79981 11.2358V4.87541C3.79981 4.54075 3.92717 4.21692 4.15725 3.97736C4.26942 3.85898 4.40438 3.76453 4.55403 3.69969C4.7039 3.63476 4.86629 3.60087 5.02962 3.6001ZM5.03192 4.65263C5.00441 4.65342 4.97731 4.65952 4.95211 4.6706C4.92641 4.6819 4.90318 4.69816 4.88376 4.71845C4.86435 4.73874 4.84912 4.76266 4.83897 4.78884C4.82881 4.81502 4.82391 4.84295 4.82456 4.87103L4.82467 4.87541L4.82461 9.64304C4.94924 9.6064 5.07861 9.5878 5.2092 9.58824L10.9746 9.58755V4.87065C10.9752 4.8426 10.9703 4.81472 10.9602 4.78858C10.95 4.76244 10.9348 4.73857 10.9153 4.71832C10.8959 4.69807 10.8727 4.68184 10.847 4.67056C10.8219 4.65951 10.7948 4.65342 10.7673 4.65263H10.7696V4.46303L10.7657 4.65259L10.7673 4.65263H5.03192ZM5.2088 10.6401C5.00466 10.6401 4.82461 10.8155 4.82461 11.0498C4.82461 11.2833 5.00452 11.4589 5.2088 11.4589H10.4698C10.4687 11.456 10.4676 11.4531 10.4665 11.4502C10.4145 11.3112 10.4004 11.1647 10.4 11.0496C10.4 10.9312 10.4101 10.7838 10.4699 10.6401L5.2088 10.6401Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.71924 4.53617C5.71924 4.25475 5.94738 4.02661 6.2288 4.02661C6.51022 4.02661 6.73835 4.25475 6.73835 4.53617V9.65545C6.73835 9.93688 6.51022 10.165 6.2288 10.165C5.94737 10.165 5.71924 9.93688 5.71924 9.65545V4.53617Z"
fill="currentColor"
/>
</svg>
);
@@ -0,0 +1,124 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
export const arrayRegistryCreator: JsonSchemaTypeRegistryCreator = ({ typeManager }) => {
const icon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.23759 1.00342H2.00391V14.997H5.23759V13.6251H3.35127V2.37534H5.23759V1.00342Z"
fill="currentColor"
/>
<path
d="M10.7624 1.00342H13.9961V14.997H10.7624V13.6251H12.6487V2.37534H10.7624V1.00342Z"
fill="currentColor"
/>
</svg>
);
const getDisplayIcon = (type: IJsonSchema): JSX.Element => {
const item = type.items;
const config = item ? typeManager.getTypeBySchema(item) : undefined;
if (config?.type === 'array' && item) {
return getDisplayIcon(item);
}
return config?.arrayIcon || icon;
};
return {
type: 'array',
label: 'Array',
icon,
container: true,
getJsonPaths: (type: IJsonSchema) => {
const itemDefinition = type.items && typeManager.getTypeBySchema(type.items);
const childrenPath = itemDefinition?.getJsonPaths
? itemDefinition.getJsonPaths(type.items!)
: [];
return ['items', ...childrenPath];
},
getDefaultValue: () => [],
getSupportedItemTypes: (): Array<{ type: string; disabled?: string }> =>
typeManager.getTypeRegistriesWithParentType('array'),
getTypeSchemaProperties: (type: IJsonSchema): Record<string, IJsonSchema> => {
const itemDefinition = type.items && typeManager.getTypeBySchema(type.items);
return (itemDefinition && itemDefinition.getTypeSchemaProperties?.(type.items!)) || {};
},
canAddField: (type: IJsonSchema) => {
if (!type.items) {
return false;
}
const childConfig = typeManager.getTypeBySchema(type.items);
return childConfig?.canAddField?.(type.items) || false;
},
getItemType: (type) => type.items,
getStringValueByTypeSchema: (type: IJsonSchema): string => {
if (!type.items) {
return type.type || '';
}
const childConfig = typeManager.getTypeBySchema(type.items);
return [type.type, childConfig?.getStringValueByTypeSchema?.(type.items)].join('-');
},
getTypeSchemaByStringValue: (optionValue: string): IJsonSchema => {
if (!optionValue) {
return { type: 'array' };
}
const [root, ...rest] = optionValue.split('-');
const rootType = typeManager.getTypeByName(root);
if (!rootType) {
return { type: 'array' };
}
let itemType;
if (rootType.getTypeSchemaByStringValue) {
itemType = rootType.getTypeSchemaByStringValue(rest.join('-'))!;
} else {
itemType = rootType?.getDefaultSchema();
}
return {
type: 'array',
items: itemType,
};
},
getDefaultSchema: (): IJsonSchema => ({
type: 'array',
items: { type: 'string' },
}),
getPropertiesParent: (type: IJsonSchema) => {
const itemDef = type.items && typeManager.getTypeBySchema(type.items);
return itemDef && itemDef.getPropertiesParent
? itemDef.getPropertiesParent(type.items!)
: type;
},
getDisplayIcon,
};
};
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { JsonSchemaTypeRegistryCreator } from '../types';
import { arrayBooleanIcon } from './array-icons';
export const booleanRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'boolean',
label: 'Boolean',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.668 4.66683H5.33463C3.49369 4.66683 2.0013 6.15921 2.0013 8.00016C2.0013 9.84111 3.49369 11.3335 5.33463 11.3335H10.668C12.5089 11.3335 14.0013 9.84111 14.0013 8.00016C14.0013 6.15921 12.5089 4.66683 10.668 4.66683ZM5.33463 3.3335C2.75731 3.3335 0.667969 5.42283 0.667969 8.00016C0.667969 10.5775 2.75731 12.6668 5.33463 12.6668H10.668C13.2453 12.6668 15.3346 10.5775 15.3346 8.00016C15.3346 5.42283 13.2453 3.3335 10.668 3.3335H5.33463Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.66797 8.00016C8.66797 6.89559 9.5634 6.00016 10.668 6.00016C11.7725 6.00016 12.668 6.89559 12.668 8.00016C12.668 9.10473 11.7725 10.0002 10.668 10.0002C9.5634 10.0002 8.66797 9.10473 8.66797 8.00016ZM10.668 7.3335C10.2998 7.3335 10.0013 7.63197 10.0013 8.00016C10.0013 8.36835 10.2998 8.66683 10.668 8.66683C11.0362 8.66683 11.3346 8.36835 11.3346 8.00016C11.3346 7.63197 11.0362 7.3335 10.668 7.3335Z"
fill="currentColor"
/>
</svg>
),
arrayIcon: arrayBooleanIcon,
getDefaultSchema: () => ({ type: 'boolean' }),
getValueText: (value?: unknown) => (value === undefined ? '' : value ? 'True' : 'False'),
getDefaultValue: () => false,
});
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { JsonSchemaTypeRegistryCreator } from '../types';
import { arrayDateTimeIcon } from './array-icons';
export const dateTimeRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'date-time',
label: 'DateTime',
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
focusable="false"
aria-hidden="true"
>
<path
d="M2 5v14a3 3 0 0 0 3 3h7.1a7.02 7.02 0 0 1-1.43-2H6a2 2 0 0 1-2-2V8a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2.67c.75.36 1.43.85 2 1.43V5a3 3 0 0 0-3-3H5a3 3 0 0 0-3 3Z"
fill="currentColor"
></path>
<path d="M16 10h1c-.54 0-1.06.06-1.57.18A1 1 0 0 1 16 10Z" fill="currentColor"></path>
<path
d="M13.5 10.94a1 1 0 0 0-1-.94h-1a1 1 0 0 0-1 1v1a1 1 0 0 0 .77.97 7.03 7.03 0 0 1 2.23-2.03Z"
fill="currentColor"
></path>
<path
d="M7 10a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1a1 1 0 0 0-1-1H7Z"
fill="currentColor"
></path>
<path
d="M6 16a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-1Z"
fill="currentColor"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22 17a5 5 0 1 1-10 0 5 5 0 0 1 10 0Zm-4-2a1 1 0 1 0-2 0v2c0 .27.1.52.3.7l1.5 1.5a1 1 0 0 0 1.4-1.4L18 16.58V15Z"
fill="currentColor"
></path>
</svg>
),
arrayIcon: arrayDateTimeIcon,
// TODO date-time compat format
// https://json-schema.org/understanding-json-schema/reference/type#built-in-formats
getDefaultSchema: () => ({
type: 'date-time',
}),
getValueText: (value?: unknown) => (value ? `${value}` : ''),
getDefaultValue: () => '',
});
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
export const defaultTypeDefinitionRegistry: JsonSchemaTypeRegistryCreator = ({ typeManager }) => ({
type: 'default',
label: '',
icon: <></>,
container: false,
getJsonPaths: () => [],
getDisplayIcon: (schema): JSX.Element => typeManager.getTypeBySchema(schema)?.icon || <></>,
getPropertiesParent: () => undefined,
canAddField: () => false,
getDefaultValue: () => undefined,
getValueText: () => '',
getStringValueByTypeSchema: (type: IJsonSchema): string | undefined => type.type,
getTypeSchemaProperties: () => undefined,
getDisplayLabel: (schema) => (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{typeManager.getTypeBySchema(schema)?.label}
<span
style={{
fontSize: 12,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{typeManager.getComplexText(schema)}
</span>
</div>
),
getDisplayText: (type: IJsonSchema) => typeManager.getComplexText(type),
});
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { stringRegistryCreator } from './string';
export { objectRegistryCreator } from './object';
export { numberRegistryCreator } from './number';
export { booleanRegistryCreator } from './boolean';
export { arrayRegistryCreator } from './array';
export { integerRegistryCreator } from './integer';
export { unknownRegistryCreator } from './unknown';
export { mapRegistryCreator } from './map';
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
import { arrayIntegerIcon } from './array-icons';
export const integerRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'integer',
label: 'Integer',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15.132 11.4601C15.644 11.0121 15.9 10.3921 15.9 9.60007C15.9 8.60807 15.5 7.93607 14.7 7.58407C15.412 7.23207 15.768 6.62407 15.768 5.76007C15.768 5.05607 15.536 4.48007 15.072 4.03207C14.608 3.59207 14.012 3.37207 13.284 3.37207C12.588 3.37207 12.008 3.58007 11.544 3.99607C11.064 4.42007 10.808 4.98807 10.776 5.70007H12C12.064 4.88407 12.492 4.47607 13.284 4.47607C14.124 4.47607 14.544 4.91607 14.544 5.79607C14.544 6.66007 14.112 7.09207 13.248 7.09207H13.044V8.16007H13.248C14.2 8.16007 14.676 8.62807 14.676 9.56407C14.676 10.5081 14.212 10.9801 13.284 10.9801C12.9 10.9801 12.584 10.8761 12.336 10.6681C12.064 10.4441 11.916 10.1161 11.892 9.68407H10.668C10.692 10.4761 10.964 11.0841 11.484 11.5081C11.948 11.8921 12.548 12.0841 13.284 12.0841C14.036 12.0841 14.652 11.8761 15.132 11.4601Z"
fill="currentColor"
/>
<path
d="M4.46875 12.0003V10.9083L7.75675 6.91228C8.06075 6.54428 8.21275 6.16428 8.21275 5.77228C8.21275 4.90828 7.79675 4.47628 6.96475 4.47628C6.60475 4.47628 6.31275 4.57628 6.08875 4.77628C5.83275 5.00828 5.70475 5.34828 5.70475 5.79628H4.48075C4.48075 5.07628 4.71275 4.49228 5.17675 4.04428C5.64075 3.60428 6.23675 3.38428 6.96475 3.38428C7.70075 3.38428 8.29675 3.60028 8.75275 4.03228C9.20875 4.47228 9.43675 5.05628 9.43675 5.78428C9.43675 6.13628 9.36875 6.45628 9.23275 6.74428C9.12075 6.97628 8.92075 7.27228 8.63275 7.63228L5.95675 10.9083H9.43675V12.0003H4.46875Z"
fill="currentColor"
/>
<path
d="M1.668 12.0001V4.78805L0 6.25205V4.89605L1.668 3.45605H2.892V12.0001H1.668Z"
fill="currentColor"
/>
</svg>
),
arrayIcon: arrayIntegerIcon,
getDefaultSchema: (): IJsonSchema => ({ type: 'integer' }),
getValueText: (value: unknown) => (value !== undefined ? `${value}` : ''),
getDefaultValue: () => 0,
});
@@ -0,0 +1,130 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
import { arrayMapIcon } from './array-icons';
export const mapRegistryCreator: JsonSchemaTypeRegistryCreator = ({ typeManager }) => {
const icon = (
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M877.860571 938.642286h-645.851428c-27.574857 0-54.052571-11.337143-73.508572-31.744a110.957714 110.957714 0 0 1-30.500571-76.8V193.828571c0-28.745143 10.971429-56.32 30.500571-76.726857a101.888 101.888 0 0 1 73.508572-31.817143h574.171428c27.501714 0 53.979429 11.337143 73.508572 31.744 19.529143 20.333714 30.500571 48.054857 30.500571 76.8v522.020572a34.157714 34.157714 0 0 1-6.948571 22.820571c-37.156571 19.382857-57.636571 39.350857-57.636572 72.630857 0 39.716571 19.894857 50.029714 57.636572 72.777143a34.816 34.816 0 0 1-8.045714 49.298286 32.256 32.256 0 0 1-17.334858 5.193143z m-32.256-254.537143V193.828571a40.228571 40.228571 0 0 0-39.497142-41.179428H232.009143a40.301714 40.301714 0 0 0-39.497143 41.252571V699.245714c17.773714-9.874286 37.449143-14.994286 57.417143-14.921143h595.675428v-0.073142z m-595.675428 187.245714h566.198857c-22.893714-11.190857-27.940571-39.497143-28.013714-59.977143 0-20.260571 3.218286-43.885714 28.013714-59.904h-566.125714c-31.670857 0-57.417143 26.843429-57.417143 59.977143 0 33.060571 25.746286 59.904 57.344 59.904z"
fill="currentColor"
></path>
<path
d="M320 128m32.036571 0l-0.073142 0q32.036571 0 32.036571 32.036571l0 511.926858q0 32.036571-32.036571 32.036571l0.073142 0q-32.036571 0-32.036571-32.036571l0-511.926858q0-32.036571 32.036571-32.036571Z"
fill="currentColor"
></path>
</svg>
);
return {
type: 'map',
label: 'Map',
icon,
arrayIcon: arrayMapIcon,
container: true,
getJsonPaths: (type: IJsonSchema) => {
const itemDefinition =
type.additionalProperties && typeManager.getTypeBySchema(type.additionalProperties);
const childrenPath = itemDefinition?.getJsonPaths
? itemDefinition.getJsonPaths(type.additionalProperties!)
: [];
return ['additionalProperties', ...childrenPath];
},
getDefaultValue: () => [],
getSupportedItemTypes: (): Array<{ type: string; disabled?: string }> =>
typeManager.getTypeRegistriesWithParentType('map'),
getTypeSchemaProperties: (type: IJsonSchema): Record<string, IJsonSchema> => {
const itemDefinition =
type.additionalProperties && typeManager.getTypeBySchema(type.additionalProperties);
return (
(itemDefinition && itemDefinition.getTypeSchemaProperties?.(type.additionalProperties!)) ||
{}
);
},
canAddField: (type: IJsonSchema) => {
if (!type.additionalProperties) {
return false;
}
const childConfig = typeManager.getTypeBySchema(type.additionalProperties);
return childConfig?.canAddField?.(type.additionalProperties) || false;
},
getItemType: (type) => type.additionalProperties,
getStringValueByTypeSchema: (type: IJsonSchema): string => {
if (!type.additionalProperties) {
return type.type || '';
}
const childConfig = typeManager.getTypeBySchema(type.additionalProperties);
return [type.type, childConfig?.getStringValueByTypeSchema?.(type.additionalProperties)].join(
'-'
);
},
getTypeSchemaByStringValue: (optionValue: string): IJsonSchema => {
if (!optionValue) {
return { type: 'map' };
}
const [root, ...rest] = optionValue.split('-');
const rooType = typeManager.getTypeByName(root);
if (!rooType) {
return { type: 'map' };
}
let itemType;
if (rooType.getTypeSchemaByStringValue) {
itemType = rooType.getTypeSchemaByStringValue(rest.join('-'))!;
} else {
itemType = rooType?.getDefaultSchema();
}
return {
type: 'map',
additionalProperties: itemType,
};
},
getDefaultSchema: (): IJsonSchema => ({
type: 'map',
additionalProperties: { type: 'string' },
}),
getPropertiesParent: (type: IJsonSchema) => {
const itemDef =
type.additionalProperties && typeManager.getTypeBySchema(type.additionalProperties);
return itemDef && itemDef.getPropertiesParent
? itemDef.getPropertiesParent(type.additionalProperties!)
: type;
},
};
};
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
import { arrayNumberIcon } from './array-icons';
export const numberRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'number',
label: 'Number',
extend: 'integer',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.44151 5.3068C3.44151 3.83404 4.71542 2.64014 6.18818 2.64014C7.66094 2.64014 8.93484 3.83404 8.93484 5.3068V10.6135C8.93484 12.0862 7.66094 13.2801 6.18818 13.2801C4.71542 13.2801 3.44151 12.0862 3.44151 10.6135V5.3068ZM7.60151 5.3068C7.60151 4.57042 6.92456 3.97347 6.18818 3.97347C5.4518 3.97347 4.77484 4.57042 4.77484 5.3068V10.6135C4.77484 11.3498 5.4518 11.9468 6.18818 11.9468C6.92456 11.9468 7.60151 11.3498 7.60151 10.6135V5.3068Z"
fill="currentColor"
/>
<path
d="M12.9882 2.64014C11.5154 2.64014 10.2415 3.83404 10.2415 5.3068V10.6135C10.2415 12.0862 11.5154 13.2801 12.9882 13.2801C14.4609 13.2801 15.7348 12.0862 15.7348 10.6135V5.3068C15.7348 3.83404 14.4609 2.64014 12.9882 2.64014ZM14.4015 10.6135C14.4015 11.3498 13.7246 11.9468 12.9882 11.9468C12.2518 11.9468 11.5748 11.3498 11.5748 10.6135V5.3068C11.5748 4.57042 12.2518 3.97347 12.9882 3.97347C13.7246 3.97347 14.4015 4.57042 14.4015 5.3068V10.6135Z"
fill="currentColor"
/>
<path
d="M1.21484 13.2001C1.76713 13.2001 2.21484 12.7524 2.21484 12.2001C2.21484 11.6479 1.76713 11.2001 1.21484 11.2001C0.662559 11.2001 0.214844 11.6479 0.214844 12.2001C0.214844 12.7524 0.662559 13.2001 1.21484 13.2001Z"
fill="currentColor"
/>
</svg>
),
arrayIcon: arrayNumberIcon,
getValueText: (value?: unknown) => (value ? `${value}` : ''),
getDefaultSchema: (): IJsonSchema => ({ type: 'number' }),
});
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
import { arrayObjectIcon } from './array-icons';
export const objectRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'object',
label: 'Object',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.33893 1.5835C5.66613 1.5835 5.93137 1.88142 5.93137 2.20862C5.93137 2.53582 5.66613 2.76838 5.33893 2.76838H4.9099C4.34717 2.76838 4.08062 3.07557 4.08062 3.71921V6.58633C4.08062 7.30996 3.80723 7.84734 3.26798 8.19105C3.11426 8.28902 3.10884 8.55273 3.26068 8.65359C3.80476 9.01503 4.08062 9.53994 4.08062 10.2434V13.1251C4.08062 13.7395 4.34717 14.0613 4.9099 14.0613H5.33893C5.66613 14.0613 5.93137 14.3435 5.93137 14.6707C5.93137 14.9979 5.66613 15.2462 5.33893 15.2462H4.64335C3.99177 15.2462 3.48828 15.0268 3.13287 14.6172C2.80708 14.2369 2.64419 13.7103 2.64419 13.0666V10.3165C2.64419 9.8923 2.55534 9.58511 2.37764 9.39494C2.26816 9.27135 1.80618 9.17938 1.38154 9.11602C1.02726 9.06315 0.759057 8.76744 0.765747 8.4093C0.772379 8.0543 1.03439 7.7566 1.38545 7.70346C1.80778 7.63952 2.26906 7.54968 2.37764 7.43477C2.55534 7.22997 2.64419 6.92278 2.64419 6.51319V3.77772C2.64419 3.11945 2.80708 2.59284 3.13287 2.21251C3.48828 1.78829 3.99177 1.5835 4.64335 1.5835H5.33893Z"
fill="currentColor"
/>
<path
d="M10.962 15.2463C10.6348 15.2463 10.3696 14.9483 10.3696 14.6211C10.3696 14.2939 10.6348 14.0614 10.962 14.0614H11.391C11.9538 14.0614 12.2203 13.7542 12.2203 13.1105V10.2434C12.2203 9.51979 12.4937 8.98241 13.033 8.6387C13.1867 8.54073 13.1921 8.27703 13.0403 8.17616C12.4962 7.81472 12.2203 7.28982 12.2203 6.58638V3.70463C12.2203 3.09024 11.9538 2.76842 11.391 2.76842L10.962 2.76842C10.6348 2.76842 10.3696 2.48627 10.3696 2.15907C10.3696 1.83188 10.6348 1.58354 10.962 1.58354L11.6576 1.58354C12.3092 1.58354 12.8127 1.80296 13.1681 2.21255C13.4939 2.59289 13.6568 3.1195 13.6568 3.76314V6.51324C13.6568 6.93745 13.7456 7.24464 13.9233 7.43481C14.03 7.5553 14.4328 7.64858 14.8186 7.71393C15.1718 7.77376 15.4401 8.06977 15.4334 8.42791C15.4268 8.78291 15.1646 9.08018 14.814 9.13633C14.4306 9.19774 14.0291 9.28303 13.9233 9.39499C13.7456 9.59978 13.6568 9.90697 13.6568 10.3166V13.052C13.6568 13.7103 13.4939 14.2369 13.1681 14.6172C12.8127 15.0415 12.3092 15.2463 11.6576 15.2463H10.962Z"
fill="currentColor"
/>
</svg>
),
arrayIcon: arrayObjectIcon,
getTypeSchemaProperties: (type: IJsonSchema): Record<string, IJsonSchema> =>
type.properties || {},
getPropertiesParent: (type) => type,
canAddField: () => true,
getDefaultSchema: (): IJsonSchema => ({
type: 'object',
required: [],
properties: {},
}),
getIJsonSchemaPropertiesParent: (type: IJsonSchema) => type,
getValueText: () => '',
getJsonPaths: () => ['properties'],
getDefaultValue: () => ({}),
});
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { JsonSchemaTypeRegistryCreator } from '../types';
import { arrayStringIcon } from './array-icons';
export const stringRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'string',
label: 'String',
icon: (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.3342 3.33321C8.96601 3.33321 8.66753 3.63169 8.66753 3.99988C8.66753 4.36807 8.96601 4.66655 9.3342 4.66655H14.6675C15.0357 4.66655 15.3342 4.36807 15.3342 3.99988C15.3342 3.63169 15.0357 3.33321 14.6675 3.33321H9.3342Z"
fill="currentColor"
/>
<path
d="M10.0009 7.99988C10.0009 7.63169 10.2993 7.33321 10.6675 7.33321H14.6675C15.0357 7.33321 15.3342 7.63169 15.3342 7.99988C15.3342 8.36807 15.0357 8.66655 14.6675 8.66655H10.6675C10.2993 8.66655 10.0009 8.36807 10.0009 7.99988Z"
fill="currentColor"
/>
<path
d="M12.0009 11.3332C11.6327 11.3332 11.3342 11.6317 11.3342 11.9999C11.3342 12.3681 11.6327 12.6665 12.0009 12.6665H14.6675C15.0357 12.6665 15.3342 12.3681 15.3342 11.9999C15.3342 11.6317 15.0357 11.3332 14.6675 11.3332H12.0009Z"
fill="currentColor"
/>
<path
d="M9.86659 14.1482L8.23444 10.1844H3.18136C3.13868 10.1844 3.09685 10.1808 3.05616 10.1738L1.66589 14.1129C1.53049 14.4965 1.10971 14.6978 0.726058 14.5624C0.342408 14.427 0.141166 14.0062 0.276572 13.6225L4.37566 2.00848C4.71323 1.05202 6.05321 1.01763 6.4394 1.95552L11.2289 13.5872C11.3838 13.9634 11.2044 14.394 10.8282 14.5489C10.452 14.7038 10.0215 14.5244 9.86659 14.1482ZM5.44412 3.40791L3.57241 8.71109H7.62778L5.44412 3.40791Z"
fill="currentColor"
/>
</svg>
),
arrayIcon: arrayStringIcon,
getDefaultSchema: () => ({
type: 'string',
}),
getValueText: (value?: unknown) => (value ? `${value}` : ''),
getDefaultValue: () => '',
});
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { IJsonSchema, JsonSchemaTypeRegistryCreator } from '../types';
export const unknownRegistryCreator: JsonSchemaTypeRegistryCreator = () => ({
type: 'unknown',
label: 'Unknown',
parentType: [],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
focusable="false"
aria-hidden="true"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.22183 4.22182C6.21136 2.23232 8.96273 1 12 1C15.0373 1 17.7886 2.23231 19.7782 4.22182L19.0711 4.92893L19.7782 4.22183C21.7677 6.21136 23 8.96273 23 12C23 15.0373 21.7677 17.7886 19.7782 19.7782C17.7886 21.7677 15.0373 23 12 23C8.96273 23 6.21136 21.7677 4.22183 19.7782L4.92893 19.0711L4.22182 19.7782C2.23231 17.7886 1 15.0373 1 12C1 8.96273 2.23232 6.21136 4.22182 4.22183L4.22183 4.22182ZM12 3C9.51447 3 7.26584 4.00626 5.63603 5.63604C4.00625 7.26585 3 9.51447 3 12C3 14.4855 4.00627 16.7342 5.63604 18.3639C7.26584 19.9937 9.51447 21 12 21C14.4855 21 16.7342 19.9937 18.3639 18.3639C19.9937 16.7342 21 14.4855 21 12C21 9.51447 19.9937 7.26584 18.3639 5.63604C16.7342 4.00627 14.4855 3 12 3Z"
fill="currentColor"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 9.31245C8 7.10331 9.79086 5.31245 12 5.31245C14.2091 5.31245 16 7.10331 16 9.31245C16 11.1763 14.7252 12.7424 13 13.1864V14.3124C13 14.8647 12.5523 15.3124 12 15.3124C11.4477 15.3124 11 14.8647 11 14.3124V12.3124C11 11.7602 11.4477 11.3124 12 11.3124C13.1046 11.3124 14 10.417 14 9.31245C14 8.20788 13.1046 7.31245 12 7.31245C10.8954 7.31245 10 8.20788 10 9.31245C10 9.86473 9.55228 10.3124 9 10.3124C8.44772 10.3124 8 9.86473 8 9.31245Z"
fill="currentColor"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 18.8125C12.6904 18.8125 13.25 18.2528 13.25 17.5625C13.25 16.8721 12.6904 16.3125 12 16.3125C11.3097 16.3125 10.75 16.8721 10.75 17.5625C10.75 18.2528 11.3097 18.8125 12 18.8125Z"
fill="currentColor"
></path>
</svg>
),
getDefaultSchema: (): IJsonSchema => ({ type: 'unknown' } as unknown as IJsonSchema),
getDefaultValue: () => undefined,
});
@@ -0,0 +1,173 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { BaseTypeRegistry, TypeRegistryCreator } from '../base';
import { type JsonSchemaTypeManager } from './json-schema-type-manager';
export type JsonSchemaBasicType =
| 'boolean'
| 'string'
| 'integer'
| 'number'
| 'object'
| 'array'
| 'map';
export interface IJsonSchema<T = string> {
type?: T;
/**
* The format of string
* https://json-schema.org/understanding-json-schema/reference/type#format
*/
format?: string;
default?: any;
title?: string;
description?: string;
enum?: (string | number)[];
properties?: Record<string, IJsonSchema<T>>;
additionalProperties?: IJsonSchema<T>;
items?: IJsonSchema<T>;
required?: string[];
$ref?: string;
extra?: {
index?: number;
// Used in BaseType.isEqualWithJSONSchema, the type comparison will be weak
weak?: boolean;
// Set the render component
formComponent?: string;
[key: string]: any;
};
}
export type IBasicJsonSchema = IJsonSchema<JsonSchemaBasicType>;
/**
* TypeRegistry based on IJsonSchema
*/
export interface JsonSchemaTypeRegistry<Schema extends Partial<IJsonSchema> = IJsonSchema>
extends BaseTypeRegistry {
/**
* The icon of this type
*/
icon: React.JSX.Element;
/**
* The icon displayed when this type is used as an array item
*/
arrayIcon?: React.JSX.Element;
/**
* The display text of this type, not including the icon
*/
label: string;
/**
* Whether it is a container type
*/
container: boolean;
/**
* Supported parent types. Some types can only appear as subtypes in type selection, but not as basic types.
*/
parentType?: string[];
/*
* Get supported sub-types
*/
getSupportedItemTypes?: (ctx: {
level: number;
parentTypes?: string[];
}) => Array<{ type: string; disabled?: string }>;
/**
* Get the display label
*/
getDisplayLabel: (typeSchema: Schema) => React.JSX.Element;
/**
* Get the display text
*/
getDisplayText: (typeSchema: Schema) => string | undefined;
/**
* Get the sub-type
*/
getItemType?: (typeSchema: Schema) => Schema | undefined;
/**
* Generate default Schema
*/
getDefaultSchema: () => Schema;
/**
* onInit initialization logic, which is called at the appropriate time externally to register data into the type system
*/
onInit?: () => void;
/**
* Whether to allow adding fields, such as object
*/
canAddField: (typeSchema: Schema) => boolean;
/**
* Get the string value, for example
* { type: "array", items: { type: "string" } }
* The value is "array-string"
*
* The use case is that in some UI components, a string value needs to be generated
*/
getStringValueByTypeSchema?: (optionValue: Schema) => string | undefined;
/**
* Restore the string value to typeSchema
* "array-string" is restored to
* { type: "array", items: { type: "string" } }
*/
getTypeSchemaByStringValue?: (type: string) => Schema;
/**
* Get the display icon, and the composite icon of the array is also processed
*/
getDisplayIcon: (typeSchema: Schema) => JSX.Element;
/**
* Get sub-properties
*/
getTypeSchemaProperties: (typeSchema: Schema) => Record<string, Schema> | undefined;
/**
* Get the default value
*/
getDefaultValue: () => unknown;
/**
* Get the display text based on the value
*/
getValueText: (value?: unknown) => string;
/**
* Get the json path of a certain type in the flow schema
* For example
* { type: "object", properties: { name: { type: "string" } } }
* -> ['properties', 'name']
* { type: "array", items: { type: "string" } }
* ->['items']
*/
getJsonPaths: (typeSchema: Schema) => string[];
/**
* Get the parent node of the sub-field
* object is itself
* array<object> is items
* map<object> is additionalProperties
*/
getPropertiesParent: (typeSchema: Schema) => Schema | undefined;
/**
* The complexText of a custom type
* For example, Array<string>, can be modified
*/
customComplexText?: (typeSchema: Schema) => string;
}
export type JsonSchemaTypeRegistryCreator<
Schema extends Partial<IJsonSchema> = IJsonSchema,
Registry extends JsonSchemaTypeRegistry<Schema> = JsonSchemaTypeRegistry<Schema>,
Manager extends JsonSchemaTypeManager<Schema, Registry> = JsonSchemaTypeManager<Schema, Registry>
> = TypeRegistryCreator<Schema, Registry, Manager>;
@@ -0,0 +1,212 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { get } from 'lodash-es';
import {
ASTFactory,
ASTKind,
ASTMatch,
ASTNode,
ASTNodeJSON,
BaseType,
} from '@flowgram.ai/variable-core';
import { IJsonSchema } from './types';
export namespace JsonSchemaUtils {
/**
* Converts a JSON schema to an Abstract Syntax Tree (AST) representation.
* This function recursively processes the JSON schema and creates corresponding AST nodes.
*
* For more information on JSON Schema, refer to the official documentation:
* https://json-schema.org/
*
* @param jsonSchema - The JSON schema to convert.
* @returns An AST node representing the JSON schema, or undefined if the schema type is not recognized.
*/
export function schemaToAST(jsonSchema: IJsonSchema): ASTNodeJSON | undefined {
const { type, extra, required } = jsonSchema || {};
const { weak = false } = extra || {};
if (!type) {
return undefined;
}
switch (type) {
case 'object':
if (weak) {
return { kind: ASTKind.Object, weak: true };
}
return ASTFactory.createObject({
properties: Object.entries(jsonSchema.properties || {})
/**
* Sorts the properties of a JSON schema based on the 'extra.index' field.
* If the 'extra.index' field is not present, the property will be treated as having an index of 0.
*/
.sort((a, b) => (get(a?.[1], 'extra.index') || 0) - (get(b?.[1], 'extra.index') || 0))
.map(([key, _property]) => ({
key,
type: schemaToAST(_property),
meta: {
title: _property.title,
description: _property.description,
required: !!required?.includes(key),
default: _property.default,
},
})),
});
case 'array':
if (weak) {
return { kind: ASTKind.Array, weak: true };
}
return ASTFactory.createArray({
items: schemaToAST(jsonSchema.items!),
});
case 'map':
if (weak) {
return { kind: ASTKind.Map, weak: true };
}
return ASTFactory.createMap({
valueType: schemaToAST(jsonSchema.additionalProperties!),
});
case 'string':
return ASTFactory.createString({ format: jsonSchema.format });
case 'number':
return ASTFactory.createNumber();
case 'boolean':
return ASTFactory.createBoolean();
case 'integer':
return ASTFactory.createInteger();
default:
// If the type is not recognized, return CustomType
return ASTFactory.createCustomType({ typeName: type });
}
}
/**
* Convert AST To JSON Schema
* @param typeAST
* @returns
*/
export function astToSchema(
typeAST?: ASTNode,
options?: {
drilldown?: boolean;
drilldownObject?: boolean;
drilldownMap?: boolean;
drilldownArray?: boolean;
}
): IJsonSchema | undefined {
const {
drilldown = true,
drilldownMap = drilldown,
drilldownObject = drilldown,
drilldownArray = drilldown,
} = options || {};
if (!typeAST) {
return undefined;
}
if (ASTMatch.isString(typeAST)) {
return {
type: 'string',
format: typeAST.format,
};
}
if (ASTMatch.isBoolean(typeAST)) {
return {
type: 'boolean',
};
}
if (ASTMatch.isNumber(typeAST)) {
return {
type: 'number',
};
}
if (ASTMatch.isInteger(typeAST)) {
return {
type: 'integer',
};
}
if (ASTMatch.isObject(typeAST)) {
return {
type: 'object',
required: typeAST.properties
.filter((property) => property.meta?.required)
.map((property) => property.key),
properties: drilldownObject
? Object.fromEntries(
typeAST.properties.map((property) => {
const schema = astToSchema(property.type);
if (property.meta?.title && schema) {
schema.title = property.meta.title;
}
if (property.meta?.description && schema) {
schema.description = property.meta.description;
}
if (property.meta?.default && schema) {
schema.default = property.meta.default;
}
return [property.key, schema!];
})
)
: {},
};
}
if (ASTMatch.isArray(typeAST)) {
return {
type: 'array',
items: drilldownArray ? astToSchema(typeAST.items) : undefined,
};
}
if (ASTMatch.isMap(typeAST)) {
return {
type: 'map',
items: drilldownMap ? astToSchema(typeAST.valueType) : undefined,
};
}
if (ASTMatch.isCustomType(typeAST)) {
return {
type: typeAST.typeName,
};
}
console.warn('JsonSchemaUtils.astToSchema: AST must extends BaseType', typeAST);
return undefined;
}
/**
* Check if the AST type is match the JSON Schema
* @param typeAST
* @param schema
* @returns
*/
export function isASTMatchSchema(
typeAST: BaseType,
schema: IJsonSchema | IJsonSchema[]
): boolean {
if (Array.isArray(schema)) {
return typeAST.isTypeEqual(
ASTFactory.createUnion({
types: schema.map((_schema) => schemaToAST(_schema)!).filter(Boolean),
})
);
}
return typeAST.isTypeEqual(schemaToAST(schema));
}
}
@@ -0,0 +1,3 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const path = require('path');
import { defineConfig } from 'vitest/config';
export default defineConfig({
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
globals: true,
mockReset: false,
environment: 'jsdom',
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
exclude: [
'**/__mocks__/**',
'**/node_modules/**',
'**/dist/**',
'**/lib/**', // lib 编译结果忽略掉
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
],
},
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import 'reflect-metadata';
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Container, ContainerModule, interfaces } from 'inversify';
import { ScopeChain, VariableContainerModule } from '../src';
import { MockScopeChain } from './mock-chain';
import { createPlaygroundContainer } from '@flowgram.ai/core';
export function getContainer(customModule?: interfaces.ContainerModuleCallBack): Container {
const container = createPlaygroundContainer() as Container;
container.load(VariableContainerModule);
container.bind(ScopeChain).to(MockScopeChain).inSingletonScope();
if(customModule) {
container.load(new ContainerModule(customModule))
}
return container;
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ScopeChain, Scope } from '../src';
/**
* 规则:
* - Global 覆盖所有 Scope,所有 Scope 依赖 Global
* - 存在环路:cycle1 -> cycle2 -> cycle3 -> cycle1
*/
export class MockScopeChain extends ScopeChain {
getDeps(scope: Scope): Scope[] {
const res: Scope[] = [];
if (scope.id === 'global') {
return [];
}
const global = this.variableEngine.getScopeById('global');
if (global) {
res.push(global);
}
// 模拟循环依赖场景
if (String(scope.id).startsWith('cycle')) {
return this.variableEngine
.getAllScopes()
.filter((_scope) => String(_scope.id).startsWith('cycle') && _scope.id !== scope.id);
}
return res;
}
getCovers(scope: Scope): Scope[] {
if (scope.id === 'global') {
return this.variableEngine.getAllScopes().filter(_scope => _scope.id !== 'global');
}
// 模拟循环依赖场景
if (String(scope.id).startsWith('cycle')) {
return this.variableEngine
.getAllScopes()
.filter((_scope) => String(_scope.id).startsWith('cycle') && _scope.id !== scope.id);
}
return [];
}
sortAll(): Scope[] {
return this.variableEngine.getAllScopes();
}
}
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ObjectJSON, VariableDeclarationListJSON } from '../src';
export const simpleVariableList = {
kind: ASTKind.VariableDeclarationList,
declarations: [
{
type: ASTKind.String,
key: 'string',
},
{
type: ASTKind.Boolean,
key: 'boolean',
},
{
// VariableDeclarationList 的 declarations 中可以不用声明 Kind
// kind: ASTKind.VariableDeclaration,
type: ASTKind.Number,
key: 'number',
},
{
kind: ASTKind.VariableDeclaration,
type: ASTKind.Integer,
key: 'integer',
},
{
kind: ASTKind.VariableDeclaration,
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.String,
// Object 的 properties 中可以不用声明 Kind
kind: ASTKind.Property,
},
{
key: 'key2',
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.Number,
},
],
} as ObjectJSON,
},
{
key: 'key3',
type: {
kind: ASTKind.Array,
itemType: ASTKind.String,
},
},
{
key: 'key4',
type: {
kind: ASTKind.Array,
items: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.Boolean,
},
],
} as ObjectJSON,
},
},
],
} as ObjectJSON,
key: 'object',
},
{
kind: ASTKind.VariableDeclaration,
type: { kind: ASTKind.Map, valueType: ASTKind.Number },
key: 'map',
},
],
} as VariableDeclarationListJSON;
@@ -0,0 +1,97 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test Key Path Expression V2 > init const test_key_path = source.a 1`] = `
{
"initializer": {
"keyPath": [
"source",
"a",
],
"kind": "KeyPathExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "String",
},
}
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 1`] = `
[
1,
{
"kind": "Boolean",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 2`] = `
[
2,
{
"kind": "Number",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 3`] = `
[
3,
undefined,
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 4`] = `
[
4,
{
"kind": "Number",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 5`] = `
[
5,
undefined,
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 6`] = `
[
6,
{
"kind": "Number",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 7`] = `
{
"initializer": {
"enumerateFor": {
"keyPath": [
"source",
"b",
],
"kind": "KeyPathExpression",
},
"kind": "EnumerateExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "Number",
},
}
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 8`] = `
[
7,
undefined,
]
`;
@@ -0,0 +1,170 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test Basic Variable Declaration > test globalVariableTable variables 1`] = `
[
"string",
"boolean",
"number",
"integer",
"object",
"map",
]
`;
exports[`test Basic Variable Declaration > test remove variable, update variable, add variable by from a new json 1`] = `
[
"object",
"new_integer",
]
`;
exports[`test Basic Variable Declaration > test remove variable, update variable, add variable by from a new json 2`] = `[]`;
exports[`test Basic Variable Declaration > test simple variable declarations 1`] = `
{
"declarations": [
{
"key": "string",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "String",
},
},
{
"key": "boolean",
"kind": "VariableDeclaration",
"order": 1,
"type": {
"kind": "Boolean",
},
},
{
"key": "number",
"kind": "VariableDeclaration",
"order": 2,
"type": {
"kind": "Number",
},
},
{
"key": "integer",
"kind": "VariableDeclaration",
"order": 3,
"type": {
"kind": "Integer",
},
},
{
"key": "object",
"kind": "VariableDeclaration",
"order": 4,
"type": {
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "String",
},
},
{
"key": "key2",
"kind": "Property",
"type": {
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "Number",
},
},
],
},
},
{
"key": "key3",
"kind": "Property",
"type": {
"kind": "Array",
},
},
{
"key": "key4",
"kind": "Property",
"type": {
"items": {
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "Boolean",
},
},
],
},
"kind": "Array",
},
},
],
},
},
{
"key": "map",
"kind": "VariableDeclaration",
"order": 5,
"type": {
"keyType": {
"kind": "String",
},
"kind": "Map",
"valueType": {
"kind": "Number",
},
},
},
],
"kind": "VariableDeclarationList",
}
`;
exports[`test Basic Variable Declaration > variable declaration subscribe 1`] = `
{
"kind": "Number",
}
`;
exports[`test Basic Variable Declaration > variable declaration subscribe 2`] = `
{
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "String",
},
},
],
}
`;
exports[`test Basic Variable Declaration > variable declaration subscribe 3`] = `
{
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "Number",
},
},
],
}
`;
@@ -0,0 +1,97 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test Variable With Initializer > init const test_key_path = source.a 1`] = `
{
"initializer": {
"keyPath": [
"source",
"a",
],
"kind": "KeyPathExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "String",
},
}
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 1`] = `
[
1,
{
"kind": "Boolean",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 2`] = `
[
2,
{
"kind": "Number",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 3`] = `
[
3,
undefined,
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 4`] = `
[
4,
{
"kind": "Number",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 5`] = `
[
5,
undefined,
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 6`] = `
[
6,
{
"kind": "Number",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 7`] = `
{
"initializer": {
"enumerateFor": {
"keyPath": [
"source",
"b",
],
"kind": "KeyPathExpression",
},
"kind": "EnumerateExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "Number",
},
}
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 8`] = `
[
7,
undefined,
]
`;
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { VariableEngine } from '../../src/variable-engine';
import { ASTNode, postConstructAST } from '../../src/ast';
import { getContainer } from '../../__mocks__/container';
describe('test ast decorators', () => {
const container = getContainer();
const variableEngine: VariableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
test('@postConstructAST()', () => {
let postConstructCalled = false;
class PostConstructTest extends ASTNode {
static kind = 'PostConstructTest';
testFlag = false;
@postConstructAST()
init() {
postConstructCalled = true;
this.testFlag = true;
}
fromJSON(json: any): void {
// do nothing
}
toJSON() {
return {
testFlag: this.testFlag,
};
}
}
variableEngine.astRegisters.registerAST(PostConstructTest);
const ast: PostConstructTest = testScope.ast.set('test', {
kind: 'PostConstructTest',
});
const ast2: PostConstructTest = testScope.ast.set('test', {
kind: 'PostConstructTest',
});
expect(postConstructCalled).toBeTruthy();
expect(ast.testFlag).toBeTruthy();
expect(ast2.testFlag).toBeTruthy();
});
test('@postConstructAST() Annotate Only One time', () => {
expect(() => {
class PostConstructTest2 extends ASTNode {
static kind = 'PostConstructTest2';
testFlag1 = false;
testFlag2 = false;
@postConstructAST()
init() {
this.testFlag1 = true;
}
@postConstructAST()
init2() {
this.testFlag2 = true;
}
fromJSON(json: any): void {
// do nothing
}
toJSON() {
return {
testFlag1: this.testFlag1,
testFlag2: this.testFlag2,
};
}
}
variableEngine.astRegisters.registerAST(PostConstructTest2);
}).toThrowError();
});
});
@@ -0,0 +1,338 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import { getParentFields } from '../../src/ast/utils/variable-field';
import { ASTKind, VariableEngine, VariableDeclaration, ASTFactory } from '../../src';
import { getContainer } from '../../__mocks__/container';
const {
createVariableDeclaration,
createObject,
createProperty,
createString,
createNumber,
createKeyPathExpression,
createArray,
createEnumerateExpression,
} = ASTFactory;
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试通过表达式生成的变量
*/
describe('test Key Path Expression V2', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalScope = variableEngine.createScope('global');
const testScope = variableEngine.createScope('test');
const globalTestVariable = globalScope.ast.set<VariableDeclaration>(
'test',
createVariableDeclaration({
key: 'source',
type: createObject({
properties: [
createProperty({
key: 'a',
type: createString(),
}),
createProperty({
key: 'b',
type: createNumber(),
}),
],
}),
})
);
test('init const test_key_path = source.a', () => {
const variableByKeyPath = testScope.ast.set<VariableDeclaration>(
'variableByKeyPath',
createVariableDeclaration({
key: 'test_key_path',
initializer: createKeyPathExpression({
keyPath: ['source', 'a'],
}),
})
)!;
expect(variableByKeyPath.initializer?.kind).toEqual(ASTKind.KeyPathExpression);
expect(variableByKeyPath.initializer?.refs.map((_ref) => _ref?.key)).toEqual(['a']);
// variableByKeyPath 的类型重新建立了实例,其父节点为当前节点
expect(getParentFields(variableByKeyPath.type).map((_field) => _field?.key)).toEqual([
'test_key_path',
]);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.String });
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
});
test('subscribe variable changes by expression', () => {
// const variableByKeyPath = source.a;
const variableByKeyPath = testScope.output.getVariableByKey('test_key_path')!;
let typeChangeTimes = 0;
variableByKeyPath.onTypeChange((type) => {
typeChangeTimes++;
expect([typeChangeTimes, type?.toJSON()]).toMatchSnapshot();
});
// source.a 发生变化时,则响应更新变量
// source.a -> boolean
globalTestVariable.getByKeyPath(['a'])?.updateType(ASTKind.Boolean);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Boolean });
expect(typeChangeTimes).toBe(1);
// 改成引用 source.b,响应更新变量
// const variableByKeyPath = source.b;
variableByKeyPath.updateInitializer({
kind: ASTKind.KeyPathExpression,
keyPath: ['source', 'b'],
});
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(2);
// source.b 被删除,变量类型变为空
globalTestVariable.type.fromJSON({
properties: [
{
key: 'a',
type: ASTKind.String,
},
],
});
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(3);
// source.b 加回来,变量类型变回来且触发表达式更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createNumber() }),
],
})
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(4);
// 改成 EnumerateExpression
variableByKeyPath.updateInitializer(
createEnumerateExpression({
enumerateFor: createKeyPathExpression({
keyPath: ['source', 'b'],
}),
})
);
expect(variableByKeyPath.type).toBeUndefined();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
variableByKeyPath.initializer?.refreshRefs();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
expect(typeChangeTimes).toBe(5);
// 数组下钻,类型也会更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({
key: 'b',
type: createArray({
items: createNumber(),
}),
}),
],
})
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(6);
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
// 原作用域删除,显示为空类型
globalScope.dispose();
expect(variableByKeyPath.scope.depScopes.map((_scope) => _scope.id)).toEqual([]);
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(7);
});
const cycle1 = variableEngine.createScope('cycle1');
const cycle2 = variableEngine.createScope('cycle2');
const cycle3 = variableEngine.createScope('cycle3');
test('cycle scope with normal refs', () => {
const cycle1Var = cycle1.ast.set<VariableDeclaration>(
'var',
createVariableDeclaration({
key: 'cycle1_var',
type: createObject({
properties: [
createProperty({
key: 'a',
type: createArray({
items: createString(),
}),
}),
],
}),
})
);
const cycle2Var = cycle2.ast.set<VariableDeclaration>(
'var',
createVariableDeclaration({
key: 'cycle2_var',
initializer: createEnumerateExpression({
enumerateFor: createKeyPathExpression({
keyPath: ['cycle1_var', 'a'],
}),
}),
})
);
const cycle3Var = cycle3.ast.set<VariableDeclaration>(
'var',
createVariableDeclaration({
key: 'cycle3_var',
initializer: createKeyPathExpression({
keyPath: ['cycle2_var'],
}),
})
);
expect(cycle1Var.type.toJSON()).toEqual({
kind: ASTKind.Object,
properties: [
{
kind: ASTKind.Property,
key: 'a',
type: {
kind: ASTKind.Array,
items: { kind: ASTKind.String },
},
},
],
});
expect(cycle2Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
expect(cycle3Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
});
test('cycle scope with child ref', () => {
const cycle1Var = cycle1.ast.get('var')! as VariableDeclaration;
const cycle2Var = cycle2.ast.get('var')! as VariableDeclaration;
const cycle3Var = cycle3.ast.get('var')! as VariableDeclaration;
cycle1Var.fromJSON({
type: createObject({
properties: [
createProperty({
key: 'a',
type: createArray({
items: createString(),
}),
}),
createProperty({
key: 'b',
initializer: createKeyPathExpression({
keyPath: ['cycle3_var'],
}),
}),
],
}),
});
expect(cycle1Var.type.toJSON()).toEqual({
kind: ASTKind.Object,
properties: [
{
kind: ASTKind.Property,
key: 'a',
type: {
kind: ASTKind.Array,
items: { kind: ASTKind.String },
},
},
{
kind: ASTKind.Property,
key: 'b',
initializer: {
kind: ASTKind.KeyPathExpression,
keyPath: ['cycle3_var'],
},
type: {
kind: ASTKind.String,
},
},
],
});
expect(cycle2Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
expect(cycle3Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
});
test('cycle scope with cycle ref', () => {
const cycle1Var = cycle1.ast.get('var')! as VariableDeclaration;
const cycle2Var = cycle2.ast.get('var')! as VariableDeclaration;
const cycle3Var = cycle3.ast.get('var')! as VariableDeclaration;
cycle1Var.fromJSON({
type: createObject({
properties: [
createProperty({
key: 'a',
initializer: createKeyPathExpression({
keyPath: ['cycle3_var'],
}),
}),
createProperty({
key: 'b',
initializer: createKeyPathExpression({
keyPath: ['cycle3_var'],
}),
}),
],
}),
});
expect(cycle1Var.type.toJSON()).toEqual({
kind: ASTKind.Object,
properties: [
{
kind: ASTKind.Property,
key: 'a',
initializer: {
kind: ASTKind.KeyPathExpression,
keyPath: ['cycle3_var'],
},
// 发生循环引用时,type 清空
},
{
kind: ASTKind.Property,
key: 'b',
initializer: {
kind: ASTKind.KeyPathExpression,
keyPath: ['cycle3_var'],
},
// 发生循环引用时,type 清空
},
],
});
expect(cycle2Var.type?.toJSON()).toBeUndefined();
expect(cycle3Var.type?.toJSON()).toBeUndefined();
});
});
@@ -0,0 +1,208 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import {
ASTKind,
ASTMatch,
ObjectType,
NumberType,
VariableEngine,
VariableDeclaration,
} from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试基本的变量声明场景
*/
describe('test Basic Variable Declaration', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalVariableTable = variableEngine.globalVariableTable;
const testScope = variableEngine.createScope('test');
test('test simple variable declarations', () => {
const simpleCase = testScope.ast.set('simple case', simpleVariableList);
expect(simpleCase?.toJSON()).toMatchSnapshot();
});
test('test globalVariableTable variables', () => {
expect(globalVariableTable.variables.map((_v) => _v.key)).toMatchSnapshot();
});
test('test get variable by key path', () => {
const undefinedCases = [
['object', 'key2', 'unExisted'],
['unExisted'],
['string', 'drilldownString'],
['object', 'key1', 'drilldownString'],
['object', 'key4', 'unExistedKeyInArray', 'key1'],
];
const availableCases: [string[], string][] = [
[['object', 'key2', 'key1'], ASTKind.Number],
[['object', 'key4', '0', 'key1'], ASTKind.Boolean],
];
availableCases.forEach(([_case, _resType]) => {
expect(globalVariableTable.getByKeyPath(_case)?.type.kind).toEqual(_resType);
});
undefinedCases.forEach((_case) => {
expect(globalVariableTable.getByKeyPath(_case)).toBeUndefined();
});
});
test('test remove variable, update variable, add variable by from a new json', () => {
const previousVariable1 = globalVariableTable.getByKeyPath(['object', 'key2', 'key1'])!;
const previousVariable2 = globalVariableTable.getByKeyPath(['integer'])!;
const previousVariable3 = globalVariableTable.getByKeyPath(['object', 'key4'])!;
testScope.ast.set('simple case', {
kind: ASTKind.VariableDeclarationList,
declarations: [
{
kind: ASTKind.VariableDeclaration,
type: ASTKind.Integer,
key: 'new_integer',
},
{
kind: ASTKind.VariableDeclaration,
key: 'object',
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key2',
type: {
kind: ASTKind.Object,
},
},
{
key: 'key4',
type: ASTKind.String,
},
{
key: 'key5',
type: ASTKind.Array,
},
],
},
},
],
});
expect(previousVariable1.disposed).toBe(true);
expect(previousVariable2.disposed).toBe(true);
expect(previousVariable3.disposed).toBe(false);
expect(previousVariable3.version).toBe(1); // 更新次数为一次
expect(testScope.ast.version).toBe(2); // 调用了两次 fromJSON,因此更新了两次
expect(globalVariableTable.variables.map((_v) => _v.key)).toMatchSnapshot();
expect(globalVariableTable.version).toBe(2 + 1); // 调用了两次 fromJSON + Object 变量的下钻发生变化,因此 version 是 2 + 1
expect(testScope.available.variables.map((_v) => _v.key)).toMatchSnapshot();
});
test('remove variables', () => {
let isOutputChanged = false;
let isAnyVariableChanged = false;
testScope.output.onDataChange(() => {
isOutputChanged = true;
});
testScope.output.onAnyVariableChange(() => {
isAnyVariableChanged = true;
});
// 删除所有变量
testScope.ast.set('simple case', {
kind: ASTKind.VariableDeclarationList,
declarations: [],
});
expect(isOutputChanged).toBeTruthy();
expect(isAnyVariableChanged).toBeFalsy();
});
test('variable declaration subscribe', () => {
const declaration: VariableDeclaration = testScope.ast.set('subscribeTest', {
kind: ASTKind.VariableDeclaration,
type: ASTKind.String,
ui: { label: 'test Label' },
})!;
let declarationChangeTimes = 0;
let typeChangeTimes = 0;
let outputChangeTimes = 0;
let anyVariableChangeTimes = 0;
testScope.output.onDataChange(() => {
outputChangeTimes++;
});
testScope.output.onAnyVariableChange(() => {
anyVariableChangeTimes++;
});
declaration.subscribe(() => {
declarationChangeTimes++;
});
declaration.onTypeChange((type) => {
expect(type?.toJSON()).toMatchSnapshot();
typeChangeTimes++;
});
declaration.fromJSON({
type: ASTKind.String,
meta: { label: 'test New Label' },
});
expect(declarationChangeTimes).toBe(1);
expect(anyVariableChangeTimes).toBe(1);
expect(typeChangeTimes).toBe(0);
expect(outputChangeTimes).toBe(0);
expect(declaration.meta.label).toEqual('test New Label');
declaration.fromJSON({
type: ASTKind.Number,
meta: { label: 'test Label' },
});
expect(ASTMatch.is(declaration.type, NumberType)).toBeTruthy();
expect(declarationChangeTimes).toBe(2);
expect(anyVariableChangeTimes).toBe(2);
expect(typeChangeTimes).toBe(1);
expect(outputChangeTimes).toBe(0);
expect(declaration.meta.label).toEqual('test Label');
declaration.fromJSON({
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.String,
},
],
},
meta: { label: 'test Label' },
});
expect(ASTMatch.is(declaration.type, ObjectType)).toBeTruthy();
expect(declarationChangeTimes).toBe(3);
expect(anyVariableChangeTimes).toBe(3);
expect(typeChangeTimes).toBe(2);
expect(outputChangeTimes).toBe(0);
// 变量下钻字段变换类型,变量也会更新
const key1Variable = (declaration.type as ObjectType).getByKeyPath(['key1']);
key1Variable?.updateType(ASTKind.Number);
expect(declarationChangeTimes).toBe(4);
expect(anyVariableChangeTimes).toBe(4);
expect(typeChangeTimes).toBe(3);
expect(outputChangeTimes).toBe(0);
});
});
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import {
ASTMatch,
ObjectType,
NumberType,
VariableEngine,
StringType,
VariableDeclarationList,
BooleanType,
IntegerType,
MapType,
ArrayType,
} from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试基本的变量声明场景
*/
describe('test Basic Variable Declaration', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
test('test simple variable match', () => {
const simpleCase = testScope.ast.set('simple case', simpleVariableList);
if (!ASTMatch.isVariableDeclarationList(simpleCase)) {
throw new Error('simpleCase is not a VariableDeclarationList');
}
expect(ASTMatch.isVariableDeclarationList(simpleCase)).toBeTruthy();
expect(ASTMatch.is(simpleCase, VariableDeclarationList)).toBeTruthy();
const stringDeclaration = simpleCase.declarations[0];
const booleanDeclaration = simpleCase.declarations[1];
const numberDeclaration = simpleCase.declarations[2];
const integerDeclaration = simpleCase.declarations[3];
const objectDeclaration = simpleCase.declarations[4];
const mapDeclaration = simpleCase.declarations[5];
const arrayProperty = testScope.output.globalVariableTable.getByKeyPath(['object', 'key4']);
expect(ASTMatch.isString(stringDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(stringDeclaration.type, StringType)).toBeTruthy();
expect(ASTMatch.isBoolean(booleanDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(booleanDeclaration.type, BooleanType)).toBeTruthy();
expect(ASTMatch.isNumber(numberDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(numberDeclaration.type, NumberType)).toBeTruthy();
expect(ASTMatch.isInteger(integerDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(integerDeclaration.type, IntegerType)).toBeTruthy();
expect(ASTMatch.isObject(objectDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(objectDeclaration.type, ObjectType)).toBeTruthy();
expect(ASTMatch.isMap(mapDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(mapDeclaration.type, MapType)).toBeTruthy();
if (!ASTMatch.isProperty(arrayProperty)) {
throw new Error('arrayProperty is not a Property');
}
expect(ASTMatch.isArray(arrayProperty.type)).toBeTruthy();
expect(ASTMatch.is(arrayProperty.type, ArrayType)).toBeTruthy();
});
});
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { VariableEngine } from '../../src/variable-engine';
import { ASTFactory } from '../../src/ast';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
describe('Test Variable ThrowError', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
testScope.ast.set('simple case', simpleVariableList);
test('throw error when call getByKeyPath in base type', () => {
const stringVariable = testScope.output.getVariableByKey('string');
expect(() => stringVariable?.type.getByKeyPath(['throw'])).toThrowError();
});
test('not throw error when variable is disposed for twice', () => {
const stringVariable = testScope.output.getVariableByKey('string');
expect(stringVariable?.disposed).toBeFalsy();
// 删除所有变量
testScope.ast.set(
'simple case',
ASTFactory.createVariableDeclarationList({ declarations: [] }),
);
expect(stringVariable?.disposed).toBeTruthy();
stringVariable?.dispose();
expect(stringVariable?.disposed).toBeTruthy();
});
test('not throw error when scope is disposed and fire its events', () => {
const toDisposeScope = variableEngine.createScope('toDisposeTest');
let eventCalledTimes = 0;
toDisposeScope.event.on('test', () => eventCalledTimes++);
toDisposeScope.refreshDeps();
toDisposeScope.event.dispatch({ type: 'test' });
expect(toDisposeScope.disposed).toBeFalsy();
expect(eventCalledTimes).toBe(1);
toDisposeScope.dispose();
expect(toDisposeScope.disposed).toBeTruthy();
toDisposeScope.event.dispatch({ type: 'test' });
toDisposeScope.refreshDeps();
expect(eventCalledTimes).toBe(1);
});
test('not throw error when ast is disposed and fire its events', () => {
const toDisposeAST = testScope.ast.set(
'dispose case',
ASTFactory.createVariableDeclaration({
key: 'toDispose',
type: ASTFactory.createString(),
}),
);
let changeTimes = 0;
toDisposeAST.subscribe(() => changeTimes++);
toDisposeAST.fromJSON({
key: 'toDispose',
type: ASTFactory.createNumber(),
});
expect(changeTimes).toBe(1);
expect(toDisposeAST.disposed).toBeFalsy();
testScope.ast.remove('dispose case');
expect(toDisposeAST.disposed).toBeTruthy();
toDisposeAST.fireChange();
expect(changeTimes).toBe(1);
});
});
@@ -0,0 +1,195 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { VariableEngine } from '../../src/variable-engine';
import { ASTFactory, ASTKind, CustomType, VariableDeclaration } from '../../src/ast';
import { getContainer } from '../../__mocks__/container';
const {
createObject,
createNumber,
createInteger,
createBoolean,
createString,
createArray,
createCustomType,
createMap,
createProperty,
createUnion,
create,
} = ASTFactory;
describe('Test Variable Type Equal', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
const testObject1 = createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createNumber() }),
createProperty({ key: 'c', type: createBoolean() }),
createProperty({ key: 'd', type: createObject({}) }),
createProperty({ key: 'e', type: createArray({}) }),
createProperty({ key: 'f', type: createMap({}) }),
createProperty({ key: 'g', type: createCustomType({ typeName: 'Custom' }) }),
],
});
const testObject2 = createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createInteger() }),
createProperty({ key: 'c', type: createBoolean() }),
],
});
const testObject3 = createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: testObject2 }),
createProperty({
key: 'c',
type: createArray({
items: testObject2,
}),
}),
createProperty({
key: 'd',
type: createArray({ items: createString() }),
}),
createProperty({
key: 'e',
type: createMap({
valueType: createNumber(),
}),
}),
createProperty({
key: 'f',
type: createMap({
valueType: createString(),
}),
}),
],
});
const variable1: VariableDeclaration = testScope.ast.set('variable1', {
kind: ASTKind.VariableDeclaration,
key: 'variable1',
type: testObject1,
});
const variable2: VariableDeclaration = testScope.ast.set('variable2', {
kind: ASTKind.VariableDeclaration,
key: 'variable2',
type: testObject2,
});
const variable3: VariableDeclaration = testScope.ast.set('variable3', {
kind: ASTKind.VariableDeclaration,
key: 'variable3',
type: testObject3,
});
test('Test Simple Equal', () => {
expect(variable1.type.isTypeEqual(testObject1)).toBeTruthy();
expect(variable2.type.isTypeEqual(testObject2)).toBeTruthy();
expect(variable3.type.isTypeEqual(testObject3)).toBeTruthy();
expect(variable1.type.isTypeEqual(testObject2)).toBeFalsy();
expect(variable2.type.isTypeEqual(testObject3)).toBeFalsy();
});
test('Test Union Type Equal', () => {
expect(
variable1.type.isTypeEqual(
createUnion({
types: [testObject1, testObject2, testObject3],
})
)
).toBeTruthy();
expect(
variable2.type.isTypeEqual(
createUnion({
types: [testObject1, testObject2, testObject3],
})
)
).toBeTruthy();
expect(
variable3.type.isTypeEqual(
createUnion({
types: [testObject1, testObject2, testObject3],
})
)
).toBeTruthy();
expect(variable1.type.isTypeEqual({ kind: ASTKind.Union })).toBeFalsy();
});
test('Test Union Type Equal In Child Properties', () => {
const UnionBasicTypes = createUnion({
types: [ASTKind.String, ASTKind.Number, ASTKind.Boolean],
});
expect(
variable2.type.isTypeEqual({
kind: ASTKind.Object,
properties: [
{
key: 'a',
type: UnionBasicTypes,
},
{
key: 'b',
type: UnionBasicTypes,
},
{
key: 'c',
type: UnionBasicTypes,
},
],
})
);
});
test('Test Weak Compare', () => {
expect(variable1.type.isTypeEqual({ kind: ASTKind.Object, weak: true })).toBeTruthy();
expect(variable2.type.isTypeEqual({ kind: ASTKind.Object, weak: true })).toBeTruthy();
expect(variable3.type.isTypeEqual({ kind: ASTKind.Object, weak: true })).toBeTruthy();
expect(
variable3.getByKeyPath(['c'])?.type.isTypeEqual({ kind: ASTKind.Array, weak: true })
).toBeTruthy();
expect(
variable3.getByKeyPath(['d'])?.type.isTypeEqual({ kind: ASTKind.Array, weak: true })
).toBeTruthy();
expect(
variable3.getByKeyPath(['e'])?.type.isTypeEqual({ kind: ASTKind.Map, weak: true })
).toBeTruthy();
expect(
variable3.getByKeyPath(['f'])?.type.isTypeEqual({ kind: ASTKind.Map, weak: true })
).toBeTruthy();
});
test('CustomType Equal', () => {
const customType1 = createCustomType({ typeName: 'Custom' });
const customType2 = createCustomType({ typeName: 'Custom2' });
const customType3 = create(CustomType, { typeName: 'Custom' });
const unionCustomTypes = createUnion({
types: [customType1, customType2],
});
const customTypeProperty = variable1.getByKeyPath(['g'])?.type;
expect(customTypeProperty?.isTypeEqual(customType1)).toBeTruthy();
expect(customTypeProperty?.isTypeEqual(customType2)).toBeFalsy();
expect(customTypeProperty?.isTypeEqual(customType3)).toBeTruthy();
expect(customTypeProperty?.isTypeEqual(unionCustomTypes)).toBeTruthy();
});
});
@@ -0,0 +1,168 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import { ASTKind, VariableEngine, VariableDeclaration, ASTFactory } from '../../src';
import { getContainer } from '../../__mocks__/container';
const {
createVariableDeclaration,
createObject,
createProperty,
createString,
createNumber,
createKeyPathExpression,
createArray,
createEnumerateExpression,
} = ASTFactory;
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试通过表达式生成的变量
*/
describe('test Variable With Initializer', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalScope = variableEngine.createScope('global');
const testScope = variableEngine.createScope('test');
const globalTestVariable = globalScope.ast.set<VariableDeclaration>(
'test',
createVariableDeclaration({
key: 'source',
type: createObject({
properties: [
createProperty({
key: 'a',
type: createString(),
}),
createProperty({
key: 'b',
type: createNumber(),
}),
],
}),
}),
);
test('test depScopes', () => {
expect(testScope.depScopes.map(_scope => _scope.id)).toEqual(['global']);
});
test('init const test_key_path = source.a', () => {
const variableByKeyPath = testScope.ast.set<VariableDeclaration>(
'variableByKeyPath',
createVariableDeclaration({
key: 'test_key_path',
initializer: createKeyPathExpression({
keyPath: ['source', 'a'],
}),
}),
)!;
expect(variableByKeyPath.initializer?.kind).toEqual(ASTKind.KeyPathExpression);
expect(variableByKeyPath.initializer?.refs.map(_ref => _ref?.key)).toEqual(['a']);
expect(variableByKeyPath.initializer?.parentFields.map(_field => _field?.key)).toEqual([
'test_key_path',
]);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.String });
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
});
test('subscribe variable changes by expression', () => {
// const variableByKeyPath = source.a;
const variableByKeyPath = testScope.output.getVariableByKey('test_key_path')!;
let typeChangeTimes = 0;
variableByKeyPath.onTypeChange(type => {
typeChangeTimes++;
expect([typeChangeTimes, type?.toJSON()]).toMatchSnapshot();
});
// source.a 发生变化时,则响应更新变量
// source.a -> boolean
globalTestVariable.getByKeyPath(['a'])?.updateType(ASTKind.Boolean);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Boolean });
expect(typeChangeTimes).toBe(1);
// 改成引用 source.b,响应更新变量
// const variableByKeyPath = source.b;
variableByKeyPath.updateInitializer({
kind: ASTKind.KeyPathExpression,
keyPath: ['source', 'b'],
});
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(2);
// source.b 被删除,变量类型变为空
globalTestVariable.type.fromJSON({
properties: [
{
key: 'a',
type: ASTKind.String,
},
],
});
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(3);
// source.b 加回来,变量类型变回来且触发表达式更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createNumber() }),
],
}),
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(4);
// 改成 EnumerateExpression
variableByKeyPath.updateInitializer(
createEnumerateExpression({
enumerateFor: createKeyPathExpression({
keyPath: ['source', 'b'],
}),
}),
);
expect(variableByKeyPath.type).toBeUndefined();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
variableByKeyPath.initializer?.refreshRefs();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
expect(typeChangeTimes).toBe(5);
// 数组下钻,类型也会更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({
key: 'b',
type: createArray({
items: createNumber(),
}),
}),
],
}),
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(6);
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
// 原作用域删除,显示为空类型
globalScope.dispose();
expect(variableByKeyPath.scope.depScopes.map(_scope => _scope.id)).toEqual([]);
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(7);
});
});
@@ -0,0 +1,179 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { isEqual } from 'lodash-es';
import { Container, injectable } from 'inversify';
import { Emitter } from '@flowgram.ai/utils';
import { CreateASTParams } from '../../src/ast/types';
import {
ASTFactory,
BaseExpression,
BaseType,
VariableDeclaration,
VariableEngine,
injectToAST,
} from '../../src';
import { getContainer } from '../../__mocks__/container';
interface PyExpressionJSON {
content: string;
uri: string;
}
const delay = (timeout: number) => new Promise((resolve) => setTimeout(resolve, timeout));
describe('Case Run Down: Python Expression In Blockwise', () => {
@injectable()
class PythonService {
// 模拟 Blockwise 表达式后端存储推导的信息
uriToType: Record<string, any> = {
'blockwise://expression/a': { kind: 'String' },
'blockwise://expression/b': { kind: 'Number' },
};
// 模拟 Blockwise 单表达式推导函数
async infer(json: PyExpressionJSON) {
return Promise.resolve(this.uriToType[json.uri]);
}
// 模拟表达式后端重新推导表达式
batchInferEmitter = new Emitter<void>();
onBatchInfer = this.batchInferEmitter.event;
}
class PythonExpression extends BaseExpression<PyExpressionJSON> {
@injectToAST(PythonService) declare service: PythonService;
static kind: string = 'BlockwisePythonExpression';
_uri?: string;
_content?: string;
// Blockwise 通过 schema 变更时输出的方式来进行类型推导
getRefFields() {
return [];
}
returnType: BaseType<any> | undefined;
_prevType: any;
// Blockwise 中 表达式通过 uri 来进行索引定位
fromJSON(json: PyExpressionJSON): void {
if (json.uri !== this._uri || json.content !== this._content) {
this.service.infer(json).then((res) => {
this._prevType = res;
this.updateChildNodeByKey('returnType', res);
});
this._uri = json.uri;
this._content = json.content;
}
}
toJSON(): PyExpressionJSON {
return {
content: this._content!,
uri: this._uri!,
};
}
constructor(params: CreateASTParams) {
super(params);
this.toDispose.push(
// 监听后端触发批量校验
this.service.onBatchInfer(() => {
const nextType = this.service.uriToType[this._uri!];
if (!isEqual(nextType, this._prevType)) {
this.updateChildNodeByKey('returnType', nextType);
this._prevType = nextType;
}
})
);
}
}
const createBlockwisePythonExpression = (json: PyExpressionJSON) => ({
kind: 'BlockwisePythonExpression',
...json,
});
const container: Container = getContainer((bind) => {
bind(PythonService).toSelf().inSingletonScope();
});
const variableEngine: VariableEngine = container.get(VariableEngine);
const pythonService: PythonService = container.get(PythonService);
variableEngine.astRegisters.registerAST(PythonExpression, () => ({
pythonService,
}));
const testScope = variableEngine.createScope('test');
test('1. Infer When Expression Changed', async () => {
const variable1: VariableDeclaration = testScope.ast.set(
'variable1',
ASTFactory.createVariableDeclaration({
key: 'variable1',
initializer: createBlockwisePythonExpression({
uri: 'blockwise://expression/a',
content: 'a + b',
}),
})
);
await delay(0);
expect(variable1.type?.kind).toEqual('String');
expect(variable1.version).toEqual(1);
// 更新表达式
pythonService.uriToType['blockwise://expression/a'] = { kind: 'Number' };
variable1.fromJSON({
initializer: createBlockwisePythonExpression({
uri: 'blockwise://expression/a',
content: 'a + b + c',
}),
});
await delay(0);
expect(variable1.type?.kind).toEqual('Number');
expect(variable1.version).toEqual(2);
});
test('2. Infer When Global Update Triggered', async () => {
const variable2: VariableDeclaration = testScope.ast.set(
'variable2',
ASTFactory.createVariableDeclaration({
key: 'variable2',
initializer: createBlockwisePythonExpression({
uri: 'blockwise://expression/b',
content: 'a + b',
}),
})
);
await delay(0);
expect(variable2.type?.kind).toEqual('Number');
expect(variable2.version).toEqual(1);
// 1. 表达式类型没有变化
pythonService.uriToType['blockwise://expression/b'] = { kind: 'Number' };
pythonService.batchInferEmitter.fire();
await delay(0);
expect(variable2.type?.kind).toEqual('Number');
expect(variable2.version).toEqual(1);
// 2. 表达式类型发生了变化
pythonService.uriToType['blockwise://expression/b'] = { kind: 'Boolean' };
pythonService.batchInferEmitter.fire();
await delay(0);
expect(variable2.type?.kind).toEqual('Boolean');
expect(variable2.version).toEqual(2);
});
});
@@ -0,0 +1,146 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, beforeEach, expect } from 'vitest';
import { cloneDeep } from 'lodash-es';
import { ASTNode, ASTNodeFlags, VariableEngine, VariableFieldKeyRenameService } from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
function getFieldKeys(node: ASTNode): string[] {
let _curr = node?.parent;
const parentKeys: string[] = [];
while (_curr) {
if (_curr.flags & ASTNodeFlags.VariableField) {
parentKeys.unshift(_curr.key);
}
_curr = _curr.parent;
}
return [...parentKeys, node.key];
}
/**
* 测试变量 Key Rename 的场景
*/
describe('test Listen Variable Key Rename', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const renameService = container.get(VariableFieldKeyRenameService);
const testScope = variableEngine.createScope('test');
let renameInfo: { before: string[]; after: string[] } | null = null;
let disposeList: string[][] = [];
renameService.onRename((_rename) => {
renameInfo = {
before: getFieldKeys(_rename.before),
after: getFieldKeys(_rename.after),
};
});
renameService.onDisposeInList((_field) => {
disposeList.push(getFieldKeys(_field));
});
beforeEach(() => {
testScope.ast.set('test', simpleVariableList);
renameInfo = null;
disposeList = [];
});
const produceNextJSON = (producer: (json: any) => void) => {
const next = cloneDeep(simpleVariableList);
producer(next);
return next;
};
test.each([
// 更改一个字段
[
produceNextJSON((json) => {
json.declarations[0].key = 'string1111';
}),
['string'],
['string1111'],
[],
],
// 更改一个下钻字段
[
produceNextJSON((json) => {
json.declarations[4].type.properties[0].key = 'changedKey';
}),
['object', 'key1'],
['object', 'changedKey'],
[],
],
// 更改字段和他的类型
[
produceNextJSON((json) => {
json.declarations[0].key = 'string1111';
json.declarations[0].type = 'Number';
}),
null,
null,
[['string']],
],
// 更改多个下钻字段
[
produceNextJSON((json) => {
json.declarations[4].type.properties[0].key = 'changedKey';
json.declarations[4].type.properties[1].key = 'changedKey222';
}),
null,
null,
[
['object', 'key1'],
['object', 'key2'],
],
],
// 更改多个字段
[
produceNextJSON((json) => {
json.declarations[0].key = 'string1111';
json.declarations[1].key = 'boolean1111';
}),
null,
null,
[['string'], ['boolean']],
],
// 删除变量
[
produceNextJSON((json) => {
json.declarations = json.declarations.slice(1);
}),
null,
null,
[['string']],
],
// 添加变量
[
produceNextJSON((json) => {
json.declarations.push({ kind: 'String', key: 'newKey' });
}),
null,
null,
[],
],
])('test variable change', (_json, _before, _after, _disposeList) => {
testScope.ast.set('test', _json);
expect(renameInfo?.before || null).toEqual(_before);
expect(renameInfo?.after || null).toEqual(_after);
expect(disposeList).toEqual(_disposeList);
});
});
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import { VariableEngine } from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试基本的变量声明场景
*/
describe('test Variable Engine', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalVariableTable = variableEngine.globalVariableTable;
const testScope = variableEngine.createScope('test');
const simpleCase = testScope.ast.set('simple case', simpleVariableList);
test('test variable Engine Dispose', () => {
// unbind All will trigger @preDestroy
container.unbindAll();
expect(variableEngine.chain.disposed).toBeTruthy();
expect(testScope.disposed).toBeTruthy();
expect(simpleCase.disposed).toBeTruthy();
expect(globalVariableTable.variableKeys.length).toBe(0);
});
});
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
ignorePatterns: ['**/tests__'],
});
@@ -0,0 +1,65 @@
{
"name": "@flowgram.ai/variable-core",
"version": "0.1.8",
"description": "variable engine based on scope",
"keywords": [
"flow",
"variable",
"scope",
"engine"
],
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"fast-equals": "^2.0.0",
"inversify": "^6.0.1",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
"reflect-metadata": "~0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.0.0",
"tsup": "^8.0.1",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,374 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
BehaviorSubject,
animationFrameScheduler,
debounceTime,
distinctUntilChanged,
map,
skip,
tap,
} from 'rxjs';
import { nanoid } from 'nanoid';
import { isNil, omitBy } from 'lodash-es';
import { shallowEqual } from 'fast-equals';
import { Disposable, DisposableCollection } from '@flowgram.ai/utils';
import { subsToDisposable } from '../utils/toDisposable';
import { updateChildNodeHelper } from './utils/helpers';
import { type Scope } from '../scope';
import {
type ASTNodeJSON,
type ObserverOrNext,
type ASTKindType,
type CreateASTParams,
type Identifier,
SubscribeConfig,
GlobalEventActionType,
DisposeASTAction,
UpdateASTAction,
} from './types';
import { ASTNodeFlags } from './flags';
export interface ASTNodeRegistry<JSON extends ASTNodeJSON = any> {
kind: string;
new (params: CreateASTParams, injectOpts: any): ASTNode<JSON>;
}
/**
* An `ASTNode` represents a fundamental unit of variable information within the system's Abstract Syntax Tree.
* It can model various constructs, for example:
* - **Declarations**: `const a = 1`
* - **Expressions**: `a.b.c`
* - **Types**: `number`, `string`, `boolean`
*
* Here is some characteristic of ASTNode:
* - **Tree-like Structure**: ASTNodes can be nested to form a tree, representing complex variable structures.
* - **Extendable**: New features can be added by extending the base ASTNode class.
* - **Reactive**: Changes in an ASTNode's value trigger events, enabling reactive programming patterns.
* - **Serializable**: ASTNodes can be converted to and from a JSON format (ASTNodeJSON) for storage or transmission.
*/
export abstract class ASTNode<JSON extends ASTNodeJSON = any> implements Disposable {
/**
* @deprecated
* Get the injected options for the ASTNode.
*
* Please use `@injectToAst(XXXService) declare xxxService: XXXService` to achieve external dependency injection.
*/
public readonly opts?: any;
/**
* The unique identifier of the ASTNode, which is **immutable**.
* - Immutable: Once assigned, the key cannot be changed.
* - Automatically generated if not specified, and cannot be changed as well.
* - If a new key needs to be generated, the current ASTNode should be destroyed and a new ASTNode should be generated.
*/
public readonly key: Identifier;
/**
* The kind of the ASTNode.
*/
static readonly kind: ASTKindType;
/**
* Node flags, used to record some flag information.
*/
public readonly flags: number = ASTNodeFlags.None;
/**
* The scope in which the ASTNode is located.
*/
public readonly scope: Scope;
/**
* The parent ASTNode.
*/
public readonly parent: ASTNode | undefined;
/**
* The version number of the ASTNode, which increments by 1 each time `fireChange` is called.
*/
protected _version: number = 0;
/**
* Update lock.
* - When set to `true`, `fireChange` will not trigger any events.
* - This is useful when multiple updates are needed, and you want to avoid multiple triggers.
*/
public changeLocked = false;
/**
* Parameters related to batch updates.
*/
private _batch: {
batching: boolean;
hasChangesInBatch: boolean;
} = {
batching: false,
hasChangesInBatch: false,
};
/**
* AST node change Observable events, implemented based on RxJS.
* - Emits the current ASTNode value upon subscription.
* - Emits a new value whenever `fireChange` is called.
*/
public readonly value$: BehaviorSubject<ASTNode> = new BehaviorSubject<ASTNode>(this as ASTNode);
/**
* Child ASTNodes.
*/
protected _children = new Set<ASTNode>();
/**
* List of disposal handlers for the ASTNode.
*/
public readonly toDispose: DisposableCollection = new DisposableCollection(
Disposable.create(() => {
// When a child element is deleted, the parent element triggers an update.
this.parent?.fireChange();
this.children.forEach((child) => child.dispose());
})
);
/**
* Callback triggered upon disposal.
*/
onDispose = this.toDispose.onDispose;
/**
* Constructor.
* @param createParams Necessary parameters for creating an ASTNode.
* @param injectOptions Dependency injection for various modules.
*/
constructor({ key, parent, scope }: CreateASTParams, opts?: any) {
this.scope = scope;
this.parent = parent;
this.opts = opts;
// Initialize the key value. If a key is passed in, use it; otherwise, generate a random one using nanoid.
this.key = key || nanoid();
// All `fireChange` calls within the subsequent `fromJSON` will be merged into one.
this.fromJSON = this.withBatchUpdate(this.fromJSON.bind(this));
// Add the kind field to the JSON output.
const rawToJSON = this.toJSON?.bind(this);
this.toJSON = () =>
omitBy(
{
// always include kind
kind: this.kind,
...(rawToJSON?.() || {}),
},
// remove undefined fields
isNil
) as JSON;
}
/**
* The type of the ASTNode.
*/
get kind(): string {
if (!(this.constructor as any).kind) {
throw new Error(`ASTNode Registry need a kind: ${this.constructor.name}`);
}
return (this.constructor as any).kind;
}
/**
* Parses AST JSON data.
* @param json AST JSON data.
*/
abstract fromJSON(json: JSON): void;
/**
* Gets all child ASTNodes of the current ASTNode.
*/
get children(): ASTNode[] {
return Array.from(this._children);
}
/**
* Serializes the current ASTNode to ASTNodeJSON.
* @returns
*/
abstract toJSON(): JSON;
/**
* Creates a child ASTNode.
* @param json The AST JSON of the child ASTNode.
* @returns
*/
protected createChildNode<ChildNode extends ASTNode = ASTNode>(json: ASTNodeJSON): ChildNode {
const astRegisters = this.scope.variableEngine.astRegisters;
const child = astRegisters.createAST(json, {
parent: this,
scope: this.scope,
}) as ChildNode;
// Add to the _children set.
this._children.add(child);
child.toDispose.push(
Disposable.create(() => {
this._children.delete(child);
})
);
return child;
}
/**
* Updates a child ASTNode, quickly implementing the consumption logic for child ASTNode updates.
* @param keyInThis The specified key on the current object.
*/
protected updateChildNodeByKey(keyInThis: keyof this, nextJSON?: ASTNodeJSON) {
this.withBatchUpdate(updateChildNodeHelper).call(this, {
getChildNode: () => this[keyInThis] as ASTNode,
updateChildNode: (_node) => ((this as any)[keyInThis] = _node),
removeChildNode: () => ((this as any)[keyInThis] = undefined),
nextJSON,
});
}
/**
* Batch updates the ASTNode, merging all `fireChange` calls within the batch function into one.
* @param updater The batch function.
* @returns
*/
protected withBatchUpdate<ParamTypes extends any[], ReturnType>(
updater: (...args: ParamTypes) => ReturnType
) {
return (...args: ParamTypes) => {
// Nested batchUpdate can only take effect once.
if (this._batch.batching) {
return updater.call(this, ...args);
}
this._batch.hasChangesInBatch = false;
this._batch.batching = true;
const res = updater.call(this, ...args);
this._batch.batching = false;
if (this._batch.hasChangesInBatch) {
this.fireChange();
}
this._batch.hasChangesInBatch = false;
return res;
};
}
/**
* Triggers an update for the current node.
*/
fireChange(): void {
if (this.changeLocked || this.disposed) {
return;
}
if (this._batch.batching) {
this._batch.hasChangesInBatch = true;
return;
}
this._version++;
this.value$.next(this);
this.dispatchGlobalEvent<UpdateASTAction>({ type: 'UpdateAST' });
this.parent?.fireChange();
}
/**
* The version value of the ASTNode.
* - You can used to check whether ASTNode are updated.
*/
get version(): number {
return this._version;
}
/**
* The unique hash value of the ASTNode.
* - It will update when the ASTNode is updated.
* - You can used to check two ASTNode are equal.
*/
get hash(): string {
return `${this._version}${this.kind}${this.key}`;
}
/**
* Listens for changes to the ASTNode.
* @param observer The listener callback.
* @param selector Listens for specified data.
* @returns
*/
subscribe<Data = this>(
observer: ObserverOrNext<Data>,
{ selector, debounceAnimation, triggerOnInit }: SubscribeConfig<this, Data> = {}
): Disposable {
return subsToDisposable(
this.value$
.pipe(
map(() => (selector ? selector(this) : (this as any))),
distinctUntilChanged(
(a, b) => shallowEqual(a, b),
(value) => {
if (value instanceof ASTNode) {
// If the value is an ASTNode, compare its hash.
return value.hash;
}
return value;
}
),
// By default, skip the first trigger of BehaviorSubject.
triggerOnInit ? tap(() => null) : skip(1),
// All updates within each animationFrame are merged into one.
debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null)
)
.subscribe(observer)
);
}
/**
* Dispatches a global event for the current ASTNode.
* @param event The global event.
*/
dispatchGlobalEvent<ActionType extends GlobalEventActionType = GlobalEventActionType>(
event: Omit<ActionType, 'ast'>
) {
this.scope.event.dispatch({
...event,
ast: this,
});
}
/**
* Disposes the ASTNode.
*/
dispose(): void {
// Prevent multiple disposals.
if (this.toDispose.disposed) {
return;
}
this.toDispose.dispose();
this.dispatchGlobalEvent<DisposeASTAction>({ type: 'DisposeAST' });
// When the complete event is emitted, ensure that the current ASTNode is in a disposed state.
this.value$.complete();
this.value$.unsubscribe();
}
get disposed(): boolean {
return this.toDispose.disposed;
}
/**
* Extended information of the ASTNode.
*/
[key: string]: unknown;
}
@@ -0,0 +1,129 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { omit } from 'lodash-es';
import { injectable } from 'inversify';
import { POST_CONSTRUCT_AST_SYMBOL } from './utils/inversify';
import { ASTKindType, ASTNodeJSON, CreateASTParams, NewASTAction } from './types';
import { ArrayType } from './type/array';
import {
BooleanType,
CustomType,
IntegerType,
MapType,
NumberType,
ObjectType,
StringType,
} from './type';
import { EnumerateExpression, KeyPathExpression, WrapArrayExpression } from './expression';
import { Property, VariableDeclaration, VariableDeclarationList } from './declaration';
import { DataNode, MapNode } from './common';
import { ASTNode, ASTNodeRegistry } from './ast-node';
type DataInjector = () => Record<string, any>;
/**
* Register the AST node to the engine.
*/
@injectable()
export class ASTRegisters {
/**
* @deprecated Please use `@injectToAst(XXXService) declare xxxService: XXXService` to achieve external dependency injection.
*/
protected injectors: Map<ASTKindType, DataInjector> = new Map();
protected astMap: Map<ASTKindType, ASTNodeRegistry> = new Map();
/**
* Core AST node registration.
*/
constructor() {
this.registerAST(StringType);
this.registerAST(NumberType);
this.registerAST(BooleanType);
this.registerAST(IntegerType);
this.registerAST(ObjectType);
this.registerAST(ArrayType);
this.registerAST(MapType);
this.registerAST(CustomType);
this.registerAST(Property);
this.registerAST(VariableDeclaration);
this.registerAST(VariableDeclarationList);
this.registerAST(KeyPathExpression);
this.registerAST(EnumerateExpression);
this.registerAST(WrapArrayExpression);
this.registerAST(MapNode);
this.registerAST(DataNode);
}
/**
* Creates an AST node.
* @param param Creation parameters.
* @returns
*/
createAST<ReturnNode extends ASTNode = ASTNode>(
json: ASTNodeJSON,
{ parent, scope }: CreateASTParams
): ReturnNode {
const Registry = this.astMap.get(json.kind!);
if (!Registry) {
throw Error(`ASTKind: ${String(json.kind)} can not find its ASTNode Registry`);
}
const injector = this.injectors.get(json.kind!);
const node = new Registry(
{
key: json.key,
scope,
parent,
},
injector?.() || {}
) as ReturnNode;
// Do not trigger fireChange during initial creation.
node.changeLocked = true;
node.fromJSON(omit(json, ['key', 'kind']));
node.changeLocked = false;
node.dispatchGlobalEvent<NewASTAction>({ type: 'NewAST' });
if (Reflect.hasMetadata(POST_CONSTRUCT_AST_SYMBOL, node)) {
const postConstructKey = Reflect.getMetadata(POST_CONSTRUCT_AST_SYMBOL, node);
(node[postConstructKey] as () => void)?.();
}
return node;
}
/**
* Gets the node Registry by AST node type.
* @param kind
* @returns
*/
getASTRegistryByKind(kind: ASTKindType) {
return this.astMap.get(kind);
}
/**
* Registers an AST node.
* @param ASTNode
*/
registerAST(
ASTNode: ASTNodeRegistry,
/**
* @deprecated Please use `@injectToAst(XXXService) declare xxxService: XXXService` to achieve external dependency injection.
*/
injector?: DataInjector
) {
this.astMap.set(ASTNode.kind, ASTNode);
if (injector) {
this.injectors.set(ASTNode.kind, injector);
}
}
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { shallowEqual } from 'fast-equals';
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
/**
* Represents a general data node with no child nodes.
*/
export class DataNode<Data = any> extends ASTNode {
static kind: string = ASTKind.DataNode;
protected _data: Data;
/**
* The data of the node.
*/
get data(): Data {
return this._data;
}
/**
* Deserializes the `DataNodeJSON` to the `DataNode`.
* @param json The `DataNodeJSON` to deserialize.
*/
fromJSON(json: Data): void {
const { kind, ...restData } = json as ASTNodeJSON;
if (!shallowEqual(restData, this._data)) {
this._data = restData as unknown as Data;
this.fireChange();
}
}
/**
* Serialize the `DataNode` to `DataNodeJSON`.
* @returns The JSON representation of `DataNode`.
*/
toJSON() {
return {
kind: ASTKind.DataNode,
...this._data,
};
}
/**
* Partially update the data of the node.
* @param nextData The data to be updated.
*/
partialUpdate(nextData: Data) {
if (!shallowEqual(nextData, this._data)) {
this._data = {
...this._data,
...nextData,
};
this.fireChange();
}
}
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { DataNode } from './data-node';
export { ListNode, type ListNodeJSON } from './list-node';
export { MapNode, type MapNodeJSON } from './map-node';
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
/**
* ASTNodeJSON representation of `ListNode`
*/
export interface ListNodeJSON {
/**
* The list of nodes.
*/
list: ASTNodeJSON[];
}
/**
* Represents a list of nodes.
*/
export class ListNode extends ASTNode<ListNodeJSON> {
static kind: string = ASTKind.ListNode;
protected _list: ASTNode[];
/**
* The list of nodes.
*/
get list(): ASTNode[] {
return this._list;
}
/**
* Deserializes the `ListNodeJSON` to the `ListNode`.
* @param json The `ListNodeJSON` to deserialize.
*/
fromJSON({ list }: ListNodeJSON): void {
// Children that exceed the length need to be destroyed.
this._list.slice(list.length).forEach((_item) => {
_item.dispose();
this.fireChange();
});
// Processing of remaining children.
this._list = list.map((_item, idx) => {
const prevItem = this._list[idx];
if (prevItem.kind !== _item.kind) {
prevItem.dispose();
this.fireChange();
return this.createChildNode(_item);
}
prevItem.fromJSON(_item);
return prevItem;
});
}
/**
* Serialize the `ListNode` to `ListNodeJSON`.
* @returns The JSON representation of `ListNode`.
*/
toJSON() {
return {
kind: ASTKind.ListNode,
list: this._list.map((item) => item.toJSON()),
};
}
}
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { updateChildNodeHelper } from '../utils/helpers';
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
/**
* ASTNodeJSON representation of `MapNode`
*/
export interface MapNodeJSON {
/**
* The map of nodes.
*/
map: [string, ASTNodeJSON][];
}
/**
* Represents a map of nodes.
*/
export class MapNode extends ASTNode<MapNodeJSON> {
static kind: string = ASTKind.MapNode;
protected map: Map<string, ASTNode> = new Map<string, ASTNode>();
/**
* Deserializes the `MapNodeJSON` to the `MapNode`.
* @param json The `MapNodeJSON` to deserialize.
*/
fromJSON({ map }: MapNodeJSON): void {
const removedKeys = new Set(this.map.keys());
for (const [key, item] of map || []) {
removedKeys.delete(key);
this.set(key, item);
}
for (const removeKey of Array.from(removedKeys)) {
this.remove(removeKey);
}
}
/**
* Serialize the `MapNode` to `MapNodeJSON`.
* @returns The JSON representation of `MapNode`.
*/
toJSON() {
return {
kind: ASTKind.MapNode,
map: Array.from(this.map.entries()),
};
}
/**
* Set a node in the map.
* @param key The key of the node.
* @param nextJSON The JSON representation of the node.
* @returns The node instance.
*/
set<Node extends ASTNode = ASTNode>(key: string, nextJSON: ASTNodeJSON): Node {
return this.withBatchUpdate(updateChildNodeHelper).call(this, {
getChildNode: () => this.get(key),
removeChildNode: () => this.map.delete(key),
updateChildNode: (nextNode) => this.map.set(key, nextNode),
nextJSON,
}) as Node;
}
/**
* Remove a node from the map.
* @param key The key of the node.
*/
remove(key: string) {
this.get(key)?.dispose();
this.map.delete(key);
this.fireChange();
}
/**
* Get a node from the map.
* @param key The key of the node.
* @returns The node instance if found, otherwise `undefined`.
*/
get<Node extends ASTNode = ASTNode>(key: string): Node | undefined {
return this.map.get(key) as Node | undefined;
}
}
@@ -0,0 +1,184 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { shallowEqual } from 'fast-equals';
import { getParentFields } from '../utils/variable-field';
import { ASTNodeJSON, ASTNodeJSONOrKind, Identifier } from '../types';
import { type BaseType } from '../type';
import { ASTNodeFlags } from '../flags';
import { type BaseExpression } from '../expression';
import { ASTNode } from '../ast-node';
/**
* ASTNodeJSON representation of `BaseVariableField`
*/
export interface BaseVariableFieldJSON<VariableMeta = any> extends ASTNodeJSON {
/**
* key of the variable field
* - For `VariableDeclaration`, the key should be global unique.
* - For `Property`, the key is the property name.
*/
key: Identifier;
/**
* type of the variable field, similar to js code:
* `const v: string`
*/
type?: ASTNodeJSONOrKind;
/**
* initializer of the variable field, similar to js code:
* `const v = 'hello'`
*
* with initializer, the type of field will be inferred from the initializer.
*/
initializer?: ASTNodeJSON;
/**
* meta data of the variable field, you cans store information like `title`, `icon`, etc.
*/
meta?: VariableMeta;
}
/**
* Variable Field abstract class, which is the base class for `VariableDeclaration` and `Property`
*
* - `VariableDeclaration` is used to declare a variable in a block scope.
* - `Property` is used to declare a property in an object.
*/
export abstract class BaseVariableField<VariableMeta = any> extends ASTNode<
BaseVariableFieldJSON<VariableMeta>
> {
public flags: ASTNodeFlags = ASTNodeFlags.VariableField;
protected _type?: BaseType;
protected _meta: VariableMeta = {} as any;
protected _initializer?: BaseExpression;
/**
* Parent variable fields, sorted from closest to farthest
*/
get parentFields(): BaseVariableField[] {
return getParentFields(this);
}
/**
* KeyPath of the variable field, sorted from farthest to closest
*/
get keyPath(): string[] {
return [...this.parentFields.reverse().map((_field) => _field.key), this.key];
}
/**
* Metadata of the variable field, you cans store information like `title`, `icon`, etc.
*/
get meta(): VariableMeta {
return this._meta;
}
/**
* Type of the variable field, similar to js code:
* `const v: string`
*/
get type(): BaseType {
return (this._initializer?.returnType || this._type)!;
}
/**
* Initializer of the variable field, similar to js code:
* `const v = 'hello'`
*
* with initializer, the type of field will be inferred from the initializer.
*/
get initializer(): BaseExpression | undefined {
return this._initializer;
}
/**
* The global unique hash of the field, and will be changed when the field is updated.
*/
get hash(): string {
return `[${this._version}]${this.keyPath.join('.')}`;
}
/**
* Deserialize the `BaseVariableFieldJSON` to the `BaseVariableField`.
* @param json ASTJSON representation of `BaseVariableField`
*/
fromJSON({ type, initializer, meta }: Omit<BaseVariableFieldJSON<VariableMeta>, 'key'>): void {
// 类型变化
this.updateType(type);
// 表达式更新
this.updateInitializer(initializer);
// Extra 更新
this.updateMeta(meta!);
}
/**
* Update the type of the variable field
* @param type type ASTJSON representation of Type
*/
updateType(type: BaseVariableFieldJSON['type']) {
const nextTypeJson = typeof type === 'string' ? { kind: type } : type;
this.updateChildNodeByKey('_type', nextTypeJson);
}
/**
* Update the initializer of the variable field
* @param nextInitializer initializer ASTJSON representation of Expression
*/
updateInitializer(nextInitializer?: BaseVariableFieldJSON['initializer']) {
this.updateChildNodeByKey('_initializer', nextInitializer);
}
/**
* Update the meta data of the variable field
* @param nextMeta meta data of the variable field
*/
updateMeta(nextMeta: VariableMeta) {
if (!shallowEqual(nextMeta, this._meta)) {
this._meta = nextMeta;
this.fireChange();
}
}
/**
* Get the variable field by keyPath, similar to js code:
* `v.a.b`
* @param keyPath
* @returns
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
if (this.type?.flags & ASTNodeFlags.DrilldownType) {
return this.type.getByKeyPath(keyPath) as BaseVariableField | undefined;
}
return undefined;
}
/**
* Subscribe to type change of the variable field
* @param observer
* @returns
*/
onTypeChange(observer: (type: ASTNode | undefined) => void) {
return this.subscribe(observer, { selector: (curr) => curr.type });
}
/**
* Serialize the variable field to JSON
* @returns ASTNodeJSON representation of `BaseVariableField`
*/
toJSON(): BaseVariableFieldJSON<VariableMeta> {
return {
key: this.key,
type: this.type?.toJSON(),
initializer: this.initializer?.toJSON(),
meta: this._meta,
};
}
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { VariableDeclaration, type VariableDeclarationJSON } from './variable-declaration';
export {
VariableDeclarationList,
type VariableDeclarationListJSON,
type VariableDeclarationListChangeAction,
} from './variable-declaration-list';
export { type PropertyJSON, Property } from './property';
export { BaseVariableField } from './base-variable-field';
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { BaseVariableField, BaseVariableFieldJSON } from './base-variable-field';
/**
* ASTNodeJSON representation of the `Property`.
*/
export type PropertyJSON<VariableMeta = any> = BaseVariableFieldJSON<VariableMeta>;
/**
* `Property` is a variable field that represents a property of a `ObjectType`.
*/
export class Property<VariableMeta = any> extends BaseVariableField<VariableMeta> {
static kind: string = ASTKind.Property;
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { GlobalEventActionType } from '../types';
import { ASTNode } from '../ast-node';
import { type VariableDeclarationJSON, VariableDeclaration } from './variable-declaration';
export interface VariableDeclarationListJSON<VariableMeta = any> {
/**
* `declarations` must be of type `VariableDeclaration`, so the business can omit the `kind` field.
*/
declarations?: VariableDeclarationJSON<VariableMeta>[];
/**
* The starting order number for variables.
*/
startOrder?: number;
}
export type VariableDeclarationListChangeAction = GlobalEventActionType<
'VariableListChange',
{
prev: VariableDeclaration[];
next: VariableDeclaration[];
},
VariableDeclarationList
>;
export class VariableDeclarationList extends ASTNode<VariableDeclarationListJSON> {
static kind: string = ASTKind.VariableDeclarationList;
/**
* Map of variable declarations, keyed by variable name.
*/
declarationTable: Map<string, VariableDeclaration> = new Map();
/**
* Variable declarations, sorted by `order`.
*/
declarations: VariableDeclaration[];
/**
* Deserialize the `VariableDeclarationListJSON` to the `VariableDeclarationList`.
* - VariableDeclarationListChangeAction will be dispatched after deserialization.
*
* @param declarations Variable declarations.
* @param startOrder The starting order number for variables. Default is 0.
*/
fromJSON({ declarations, startOrder }: VariableDeclarationListJSON): void {
const removedKeys = new Set(this.declarationTable.keys());
const prev = [...(this.declarations || [])];
// Iterate over the new properties.
this.declarations = (declarations || []).map(
(declaration: VariableDeclarationJSON, idx: number) => {
const order = (startOrder || 0) + idx;
// If the key is not set, reuse the previous key.
const declarationKey = declaration.key || this.declarations?.[idx]?.key;
const existDeclaration = this.declarationTable.get(declarationKey);
if (declarationKey) {
removedKeys.delete(declarationKey);
}
if (existDeclaration) {
existDeclaration.fromJSON({ order, ...declaration });
return existDeclaration;
} else {
const newDeclaration = this.createChildNode({
order,
...declaration,
kind: ASTKind.VariableDeclaration,
}) as VariableDeclaration;
this.fireChange();
this.declarationTable.set(newDeclaration.key, newDeclaration);
return newDeclaration;
}
}
);
// Delete variables that no longer exist.
removedKeys.forEach((key) => {
const declaration = this.declarationTable.get(key);
declaration?.dispose();
this.declarationTable.delete(key);
});
this.dispatchGlobalEvent<VariableDeclarationListChangeAction>({
type: 'VariableListChange',
payload: {
prev,
next: [...this.declarations],
},
});
}
/**
* Serialize the `VariableDeclarationList` to the `VariableDeclarationListJSON`.
* @returns ASTJSON representation of `VariableDeclarationList`
*/
toJSON() {
return {
kind: ASTKind.VariableDeclarationList,
declarations: this.declarations.map((_declaration) => _declaration.toJSON()),
};
}
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, GlobalEventActionType, type CreateASTParams } from '../types';
import { BaseVariableField, BaseVariableFieldJSON } from './base-variable-field';
/**
* ASTNodeJSON representation of the `VariableDeclaration`.
*/
export type VariableDeclarationJSON<VariableMeta = any> = BaseVariableFieldJSON<VariableMeta> & {
/**
* Variable sorting order, which is used to sort variables in `scope.outputs.variables`
*/
order?: number;
};
/**
* Action type for re-sorting variable declarations.
*/
export type ReSortVariableDeclarationsAction = GlobalEventActionType<'ReSortVariableDeclarations'>;
/**
* `VariableDeclaration` is a variable field that represents a variable declaration.
*/
export class VariableDeclaration<VariableMeta = any> extends BaseVariableField<VariableMeta> {
static kind: string = ASTKind.VariableDeclaration;
protected _order: number = 0;
/**
* Variable sorting order, which is used to sort variables in `scope.outputs.variables`
*/
get order(): number {
return this._order;
}
constructor(params: CreateASTParams) {
super(params);
}
/**
* Deserialize the `VariableDeclarationJSON` to the `VariableDeclaration`.
*/
fromJSON({ order, ...rest }: Omit<VariableDeclarationJSON<VariableMeta>, 'key'>): void {
// Update order.
this.updateOrder(order);
// Update other information.
super.fromJSON(rest as BaseVariableFieldJSON<VariableMeta>);
}
/**
* Update the sorting order of the variable declaration.
* @param order Variable sorting order. Default is 0.
*/
updateOrder(order: number = 0): void {
if (order !== this._order) {
this._order = order;
this.dispatchGlobalEvent<ReSortVariableDeclarationsAction>({
type: 'ReSortVariableDeclarations',
});
this.fireChange();
}
}
/**
* Serialize the `VariableDeclaration` to `VariableDeclarationJSON`.
* @returns The JSON representation of `VariableDeclaration`.
*/
toJSON(): VariableDeclarationJSON<VariableMeta> {
return {
...super.toJSON(),
order: this.order,
};
}
}
@@ -0,0 +1,117 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
type Observable,
distinctUntilChanged,
map,
switchMap,
combineLatest,
of,
Subject,
share,
} from 'rxjs';
import { shallowEqual } from 'fast-equals';
import { getParentFields } from '../utils/variable-field';
import { ASTNodeJSON, type CreateASTParams } from '../types';
import { type BaseType } from '../type';
import { ASTNodeFlags } from '../flags';
import { type BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
import { subsToDisposable } from '../../utils/toDisposable';
import { IVariableTable } from '../../scope/types';
type ExpressionRefs = (BaseVariableField | undefined)[];
/**
* Base class for all expressions.
*
* All other expressions should extend this class.
*/
export abstract class BaseExpression<JSON extends ASTNodeJSON = any> extends ASTNode<JSON> {
public flags: ASTNodeFlags = ASTNodeFlags.Expression;
/**
* Get the global variable table, which is used to access referenced variables.
*/
get globalVariableTable(): IVariableTable {
return this.scope.variableEngine.globalVariableTable;
}
/**
* Parent variable fields, sorted from closest to farthest.
*/
get parentFields(): BaseVariableField[] {
return getParentFields(this);
}
/**
* Get the variable fields referenced by the expression.
*
* This method should be implemented by subclasses.
* @returns An array of referenced variable fields.
*/
abstract getRefFields(): ExpressionRefs;
/**
* The return type of the expression.
*/
abstract returnType: BaseType | undefined;
/**
* The variable fields referenced by the expression.
*/
protected _refs: ExpressionRefs = [];
/**
* The variable fields referenced by the expression.
*/
get refs(): ExpressionRefs {
return this._refs;
}
protected refreshRefs$: Subject<void> = new Subject();
/**
* Refresh the variable references.
*/
refreshRefs() {
this.refreshRefs$.next();
}
/**
* An observable that emits the referenced variable fields when they change.
*/
refs$: Observable<ExpressionRefs> = this.refreshRefs$.pipe(
map(() => this.getRefFields()),
distinctUntilChanged<ExpressionRefs>(shallowEqual),
switchMap((refs) =>
!refs?.length
? of([])
: combineLatest(
refs.map((ref) =>
ref
? (ref.value$ as unknown as Observable<BaseVariableField | undefined>)
: of(undefined)
)
)
),
share()
);
constructor(params: CreateASTParams, opts?: any) {
super(params, opts);
this.toDispose.push(
subsToDisposable(
this.refs$.subscribe((_refs: ExpressionRefs) => {
this._refs = _refs;
this.fireChange();
})
)
);
}
}
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ASTNodeJSON } from '../types';
import { ArrayType } from '../type/array';
import { BaseType } from '../type';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `EnumerateExpression`
*/
export interface EnumerateExpressionJSON {
/**
* The expression to be enumerated.
*/
enumerateFor: ASTNodeJSON;
}
/**
* Represents an enumeration expression, which iterates over a list and returns the type of the enumerated variable.
*/
export class EnumerateExpression extends BaseExpression<EnumerateExpressionJSON> {
static kind: string = ASTKind.EnumerateExpression;
protected _enumerateFor: BaseExpression | undefined;
/**
* The expression to be enumerated.
*/
get enumerateFor() {
return this._enumerateFor;
}
/**
* The return type of the expression.
*/
get returnType(): BaseType | undefined {
// The return value of the enumerated expression.
const childReturnType = this.enumerateFor?.returnType;
if (childReturnType?.kind === ASTKind.Array) {
// Get the item type of the array.
return (childReturnType as ArrayType).items;
}
return undefined;
}
/**
* Get the variable fields referenced by the expression.
* @returns An empty array, as this expression does not reference any variables.
*/
getRefFields(): [] {
return [];
}
/**
* Deserializes the `EnumerateExpressionJSON` to the `EnumerateExpression`.
* @param json The `EnumerateExpressionJSON` to deserialize.
*/
fromJSON({ enumerateFor: expression }: EnumerateExpressionJSON): void {
this.updateChildNodeByKey('_enumerateFor', expression);
}
/**
* Serialize the `EnumerateExpression` to `EnumerateExpressionJSON`.
* @returns The JSON representation of `EnumerateExpression`.
*/
toJSON() {
return {
kind: ASTKind.EnumerateExpression,
enumerateFor: this.enumerateFor?.toJSON(),
};
}
}
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { BaseExpression } from './base-expression';
export { EnumerateExpression, type EnumerateExpressionJSON } from './enumerate-expression';
export { KeyPathExpression, type KeyPathExpressionJSON } from './keypath-expression';
export { LegacyKeyPathExpression } from './legacy-keypath-expression';
export { WrapArrayExpression, type WrapArrayExpressionJSON } from './wrap-array-expression';
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { distinctUntilChanged } from 'rxjs';
import { shallowEqual } from 'fast-equals';
import { checkRefCycle } from '../utils/expression';
import { ASTNodeJSON, ASTKind, CreateASTParams } from '../types';
import { BaseType } from '../type';
import { type BaseVariableField } from '../declaration';
import { subsToDisposable } from '../../utils/toDisposable';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `KeyPathExpression`
*/
export interface KeyPathExpressionJSON {
/**
* The key path of the variable.
*/
keyPath: string[];
}
/**
* Represents a key path expression, which is used to reference a variable by its key path.
*
* This is the V2 of `KeyPathExpression`, with the following improvements:
* - `returnType` is copied to a new instance to avoid reference issues.
* - Circular reference detection is introduced.
*/
export class KeyPathExpression<
CustomPathJSON extends ASTNodeJSON = KeyPathExpressionJSON
> extends BaseExpression<CustomPathJSON> {
static kind: string = ASTKind.KeyPathExpression;
protected _keyPath: string[] = [];
protected _rawPathJson: CustomPathJSON;
/**
* The key path of the variable.
*/
get keyPath(): string[] {
return this._keyPath;
}
/**
* Get the variable fields referenced by the expression.
* @returns An array of referenced variable fields.
*/
getRefFields(): BaseVariableField[] {
const ref = this.scope.available.getByKeyPath(this._keyPath);
// When refreshing references, check for circular references. If a circular reference exists, do not reference the variable.
if (checkRefCycle(this, [ref])) {
// Prompt that a circular reference exists.
console.warn(
'[CustomKeyPathExpression] checkRefCycle: Reference Cycle Existed',
this.parentFields.map((_field) => _field.key).reverse()
);
return [];
}
return ref ? [ref] : [];
}
/**
* The return type of the expression.
*
* A new `returnType` node is generated directly, instead of reusing the existing one, to ensure that different key paths do not point to the same field.
*/
_returnType: BaseType;
/**
* The return type of the expression.
*/
get returnType() {
return this._returnType;
}
/**
* Parse the business-defined path expression into a key path.
*
* Businesses can quickly customize their own path expressions by modifying this method.
* @param json The path expression defined by the business.
* @returns The key path.
*/
protected parseToKeyPath(json: CustomPathJSON): string[] {
// The default JSON is in KeyPathExpressionJSON format.
return (json as unknown as KeyPathExpressionJSON).keyPath;
}
/**
* Deserializes the `KeyPathExpressionJSON` to the `KeyPathExpression`.
* @param json The `KeyPathExpressionJSON` to deserialize.
*/
fromJSON(json: CustomPathJSON): void {
const keyPath = this.parseToKeyPath(json);
if (!shallowEqual(keyPath, this._keyPath)) {
this._keyPath = keyPath;
this._rawPathJson = json;
// After the keyPath is updated, the referenced variables need to be refreshed.
this.refreshRefs();
}
}
/**
* Get the return type JSON by reference.
* @param _ref The referenced variable field.
* @returns The JSON representation of the return type.
*/
getReturnTypeJSONByRef(_ref: BaseVariableField | undefined): ASTNodeJSON | undefined {
return _ref?.type?.toJSON();
}
constructor(params: CreateASTParams, opts: any) {
super(params, opts);
this.toDispose.pushAll([
// Can be used when the variable list changes (when there are additions or deletions).
this.scope.available.onVariableListChange(() => {
this.refreshRefs();
}),
// When the referable variable pointed to by this._keyPath changes, refresh the reference data.
this.scope.available.onAnyVariableChange((_v) => {
if (_v.key === this._keyPath[0]) {
this.refreshRefs();
}
}),
subsToDisposable(
this.refs$
.pipe(
distinctUntilChanged(
(prev, next) => prev === next,
(_refs) => _refs?.[0]?.type?.hash
)
)
.subscribe((_type) => {
const [ref] = this._refs;
this.updateChildNodeByKey('_returnType', this.getReturnTypeJSONByRef(ref));
})
),
]);
}
/**
* Serialize the `KeyPathExpression` to `KeyPathExpressionJSON`.
* @returns The JSON representation of `KeyPathExpression`.
*/
toJSON() {
return this._rawPathJson;
}
}
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { shallowEqual } from 'fast-equals';
import { ASTNodeJSON, ASTKind, CreateASTParams } from '../types';
import { BaseType } from '../type';
import { ASTNodeFlags } from '../flags';
import { type BaseVariableField } from '../declaration';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `KeyPathExpression`
*/
export interface KeyPathExpressionJSON {
/**
* The key path of the variable.
*/
keyPath: string[];
}
/**
* @deprecated Use `KeyPathExpression` instead.
* Represents a key path expression, which is used to reference a variable by its key path.
*/
export class LegacyKeyPathExpression<
CustomPathJSON extends ASTNodeJSON = KeyPathExpressionJSON
> extends BaseExpression<CustomPathJSON> {
static kind: string = ASTKind.KeyPathExpression;
protected _keyPath: string[] = [];
protected _rawPathJson: CustomPathJSON;
/**
* The key path of the variable.
*/
get keyPath(): string[] {
return this._keyPath;
}
/**
* Get the variable fields referenced by the expression.
* @returns An array of referenced variable fields.
*/
getRefFields(): BaseVariableField[] {
const ref = this.scope.available.getByKeyPath(this._keyPath);
return ref ? [ref] : [];
}
/**
* The return type of the expression.
*/
get returnType(): BaseType | undefined {
const [refNode] = this._refs || [];
// Get the type of the referenced variable.
if (refNode && refNode.flags & ASTNodeFlags.VariableField) {
return refNode.type;
}
return;
}
/**
* Parse the business-defined path expression into a key path.
*
* Businesses can quickly customize their own path expressions by modifying this method.
* @param json The path expression defined by the business.
* @returns The key path.
*/
protected parseToKeyPath(json: CustomPathJSON): string[] {
// The default JSON is in KeyPathExpressionJSON format.
return (json as unknown as KeyPathExpressionJSON).keyPath;
}
/**
* Deserializes the `KeyPathExpressionJSON` to the `KeyPathExpression`.
* @param json The `KeyPathExpressionJSON` to deserialize.
*/
fromJSON(json: CustomPathJSON): void {
const keyPath = this.parseToKeyPath(json);
if (!shallowEqual(keyPath, this._keyPath)) {
this._keyPath = keyPath;
this._rawPathJson = json;
// After the keyPath is updated, the referenced variables need to be refreshed.
this.refreshRefs();
}
}
constructor(params: CreateASTParams, opts: any) {
super(params, opts);
this.toDispose.pushAll([
// Can be used when the variable list changes (when there are additions or deletions).
this.scope.available.onVariableListChange(() => {
this.refreshRefs();
}),
// When the referable variable pointed to by this._keyPath changes, refresh the reference data.
this.scope.available.onAnyVariableChange((_v) => {
if (_v.key === this._keyPath[0]) {
this.refreshRefs();
}
}),
]);
}
/**
* Serialize the `KeyPathExpression` to `KeyPathExpressionJSON`.
* @returns The JSON representation of `KeyPathExpression`.
*/
toJSON() {
return this._rawPathJson;
}
}
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { postConstructAST } from '../utils/inversify';
import { ASTKind, ASTNodeJSON } from '../types';
import { BaseType } from '../type';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `WrapArrayExpression`
*/
export interface WrapArrayExpressionJSON {
/**
* The expression to be wrapped.
*/
wrapFor: ASTNodeJSON;
}
/**
* Represents a wrap expression, which wraps an expression with an array.
*/
export class WrapArrayExpression extends BaseExpression<WrapArrayExpressionJSON> {
static kind: string = ASTKind.WrapArrayExpression;
protected _wrapFor: BaseExpression | undefined;
protected _returnType: BaseType | undefined;
/**
* The expression to be wrapped.
*/
get wrapFor() {
return this._wrapFor;
}
/**
* The return type of the expression.
*/
get returnType(): BaseType | undefined {
return this._returnType;
}
/**
* Refresh the return type of the expression.
*/
refreshReturnType() {
// The return value of the wrapped expression.
const childReturnTypeJSON = this.wrapFor?.returnType?.toJSON();
this.updateChildNodeByKey('_returnType', {
kind: ASTKind.Array,
items: childReturnTypeJSON,
});
}
/**
* Get the variable fields referenced by the expression.
* @returns An empty array, as this expression does not reference any variables.
*/
getRefFields(): [] {
return [];
}
/**
* Deserializes the `WrapArrayExpressionJSON` to the `WrapArrayExpression`.
* @param json The `WrapArrayExpressionJSON` to deserialize.
*/
fromJSON({ wrapFor: expression }: WrapArrayExpressionJSON): void {
this.updateChildNodeByKey('_wrapFor', expression);
}
/**
* Serialize the `WrapArrayExpression` to `WrapArrayExpressionJSON`.
* @returns The JSON representation of `WrapArrayExpression`.
*/
toJSON() {
return {
kind: ASTKind.WrapArrayExpression,
wrapFor: this.wrapFor?.toJSON(),
};
}
@postConstructAST()
protected init() {
this.refreshReturnType = this.refreshReturnType.bind(this);
this.toDispose.push(
this.subscribe(this.refreshReturnType, {
selector: (curr) => curr.wrapFor?.returnType,
triggerOnInit: true,
})
);
}
}
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ASTNodeJSON } from './types';
import { StringJSON } from './type/string';
import { MapJSON } from './type/map';
import { ArrayJSON } from './type/array';
import { CustomTypeJSON, ObjectJSON, UnionJSON } from './type';
import {
EnumerateExpressionJSON,
KeyPathExpressionJSON,
WrapArrayExpressionJSON,
} from './expression';
import { PropertyJSON, VariableDeclarationJSON, VariableDeclarationListJSON } from './declaration';
import { ASTNode } from './ast-node';
/**
* Variable-core ASTNode factories.
*/
export namespace ASTFactory {
/**
* Type-related factories.
*/
/**
* Creates a `String` type node.
*/
export const createString = (json?: StringJSON) => ({
kind: ASTKind.String,
...(json || {}),
});
/**
* Creates a `Number` type node.
*/
export const createNumber = () => ({ kind: ASTKind.Number });
/**
* Creates a `Boolean` type node.
*/
export const createBoolean = () => ({ kind: ASTKind.Boolean });
/**
* Creates an `Integer` type node.
*/
export const createInteger = () => ({ kind: ASTKind.Integer });
/**
* Creates an `Object` type node.
*/
export const createObject = (json: ObjectJSON) => ({
kind: ASTKind.Object,
...json,
});
/**
* Creates an `Array` type node.
*/
export const createArray = (json: ArrayJSON) => ({
kind: ASTKind.Array,
...json,
});
/**
* Creates a `Map` type node.
*/
export const createMap = (json: MapJSON) => ({
kind: ASTKind.Map,
...json,
});
/**
* Creates a `Union` type node.
*/
export const createUnion = (json: UnionJSON) => ({
kind: ASTKind.Union,
...json,
});
/**
* Creates a `CustomType` node.
*/
export const createCustomType = (json: CustomTypeJSON) => ({
kind: ASTKind.CustomType,
...json,
});
/**
* Declaration-related factories.
*/
/**
* Creates a `VariableDeclaration` node.
*/
export const createVariableDeclaration = <VariableMeta = any>(
json: VariableDeclarationJSON<VariableMeta>
) => ({
kind: ASTKind.VariableDeclaration,
...json,
});
/**
* Creates a `Property` node.
*/
export const createProperty = <VariableMeta = any>(json: PropertyJSON<VariableMeta>) => ({
kind: ASTKind.Property,
...json,
});
/**
* Creates a `VariableDeclarationList` node.
*/
export const createVariableDeclarationList = (json: VariableDeclarationListJSON) => ({
kind: ASTKind.VariableDeclarationList,
...json,
});
/**
* Expression-related factories.
*/
/**
* Creates an `EnumerateExpression` node.
*/
export const createEnumerateExpression = (json: EnumerateExpressionJSON) => ({
kind: ASTKind.EnumerateExpression,
...json,
});
/**
* Creates a `KeyPathExpression` node.
*/
export const createKeyPathExpression = (json: KeyPathExpressionJSON) => ({
kind: ASTKind.KeyPathExpression,
...json,
});
/**
* Creates a `WrapArrayExpression` node.
*/
export const createWrapArrayExpression = (json: WrapArrayExpressionJSON) => ({
kind: ASTKind.WrapArrayExpression,
...json,
});
/**
* Create by AST Class.
*/
/**
* Creates Type-Safe ASTNodeJSON object based on the provided AST class.
*
* @param targetType Target ASTNode class.
* @param json The JSON data for the node.
* @returns The ASTNode JSON object.
*/
export const create = <JSON extends ASTNodeJSON>(
targetType: { kind: string; new (...args: any[]): ASTNode<JSON> },
json: JSON
) => ({ kind: targetType.kind, ...json });
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* ASTNode flags. Stored in the `flags` property of the `ASTNode`.
*/
export enum ASTNodeFlags {
/**
* None.
*/
None = 0,
/**
* Variable Field.
*/
VariableField = 1 << 0,
/**
* Expression.
*/
Expression = 1 << 2,
/**
* # Variable Type Flags
*/
/**
* Basic type.
*/
BasicType = 1 << 3,
/**
* Drillable variable type.
*/
DrilldownType = 1 << 4,
/**
* Enumerable variable type.
*/
EnumerateType = 1 << 5,
/**
* Composite type, currently not in use.
*/
UnionType = 1 << 6,
/**
* Variable type.
*/
VariableType = BasicType | DrilldownType | EnumerateType | UnionType,
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export {
type ASTNodeJSON,
ASTKind,
type GetKindJSON,
type GetKindJSONOrKind,
type CreateASTParams,
type GlobalEventActionType,
} from './types';
export { ASTRegisters } from './ast-registers';
export { ASTNode, type ASTNodeRegistry } from './ast-node';
export { ASTNodeFlags } from './flags';
export * from './common';
export * from './declaration';
export * from './type';
export * from './expression';
export { ASTFactory } from './factory';
export { ASTMatch } from './match';
export { injectToAST, postConstructAST } from './utils/inversify';
export { isMatchAST } from './utils/helpers';
@@ -0,0 +1,146 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from './types';
import {
type StringType,
type NumberType,
type BooleanType,
type IntegerType,
type ObjectType,
type ArrayType,
type MapType,
type CustomType,
} from './type';
import { ASTNodeFlags } from './flags';
import {
type WrapArrayExpression,
type EnumerateExpression,
type KeyPathExpression,
} from './expression';
import {
type BaseVariableField,
type Property,
type VariableDeclaration,
type VariableDeclarationList,
} from './declaration';
import { type ASTNode } from './ast-node';
/**
* Variable-core ASTNode matchers.
*
* - Typescript code inside if statement will be type guarded.
*/
export namespace ASTMatch {
/**
* # Type-related matchers.
*/
/**
* Check if the node is a `StringType`.
*/
export const isString = (node?: ASTNode): node is StringType => node?.kind === ASTKind.String;
/**
* Check if the node is a `NumberType`.
*/
export const isNumber = (node?: ASTNode): node is NumberType => node?.kind === ASTKind.Number;
/**
* Check if the node is a `BooleanType`.
*/
export const isBoolean = (node?: ASTNode): node is BooleanType => node?.kind === ASTKind.Boolean;
/**
* Check if the node is a `IntegerType`.
*/
export const isInteger = (node?: ASTNode): node is IntegerType => node?.kind === ASTKind.Integer;
/**
* Check if the node is a `ObjectType`.
*/
export const isObject = (node?: ASTNode): node is ObjectType => node?.kind === ASTKind.Object;
/**
* Check if the node is a `ArrayType`.
*/
export const isArray = (node?: ASTNode): node is ArrayType => node?.kind === ASTKind.Array;
/**
* Check if the node is a `MapType`.
*/
export const isMap = (node?: ASTNode): node is MapType => node?.kind === ASTKind.Map;
/**
* Check if the node is a `CustomType`.
*/
export const isCustomType = (node?: ASTNode): node is CustomType =>
node?.kind === ASTKind.CustomType;
/**
* # Declaration-related matchers.
*/
/**
* Check if the node is a `VariableDeclaration`.
*/
export const isVariableDeclaration = <VariableMeta = any>(
node?: ASTNode
): node is VariableDeclaration<VariableMeta> => node?.kind === ASTKind.VariableDeclaration;
/**
* Check if the node is a `Property`.
*/
export const isProperty = <VariableMeta = any>(node?: ASTNode): node is Property<VariableMeta> =>
node?.kind === ASTKind.Property;
/**
* Check if the node is a `BaseVariableField`.
*/
export const isBaseVariableField = (node?: ASTNode): node is BaseVariableField =>
!!(node?.flags || 0 & ASTNodeFlags.VariableField);
/**
* Check if the node is a `VariableDeclarationList`.
*/
export const isVariableDeclarationList = (node?: ASTNode): node is VariableDeclarationList =>
node?.kind === ASTKind.VariableDeclarationList;
/**
* # Expression-related matchers.
*/
/**
* Check if the node is a `EnumerateExpression`.
*/
export const isEnumerateExpression = (node?: ASTNode): node is EnumerateExpression =>
node?.kind === ASTKind.EnumerateExpression;
/**
* Check if the node is a `WrapArrayExpression`.
*/
export const isWrapArrayExpression = (node?: ASTNode): node is WrapArrayExpression =>
node?.kind === ASTKind.WrapArrayExpression;
/**
* Check if the node is a `KeyPathExpression`.
*/
export const isKeyPathExpression = (node?: ASTNode): node is KeyPathExpression =>
node?.kind === ASTKind.KeyPathExpression;
/**
* Check ASTNode Match by ASTClass
*
* @param node ASTNode to be checked.
* @param targetType Target ASTNode class.
* @returns Whether the node is of the target type.
*/
export function is<TargetASTNode extends ASTNode>(
node?: ASTNode,
targetType?: { kind: string; new (...args: any[]): TargetASTNode }
): node is TargetASTNode {
return node?.kind === targetType?.kind;
}
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { type BaseVariableField } from '../declaration';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `ArrayType`
*/
export interface ArrayJSON {
/**
* The type of the items in the array.
*/
items?: ASTNodeJSONOrKind;
}
/**
* Represents an array type.
*/
export class ArrayType extends BaseType<ArrayJSON> {
public flags: ASTNodeFlags = ASTNodeFlags.DrilldownType | ASTNodeFlags.EnumerateType;
static kind: string = ASTKind.Array;
/**
* The type of the items in the array.
*/
items: BaseType;
/**
* Deserializes the `ArrayJSON` to the `ArrayType`.
* @param json The `ArrayJSON` to deserialize.
*/
fromJSON({ items }: ArrayJSON): void {
this.updateChildNodeByKey('items', parseTypeJsonOrKind(items));
}
/**
* Whether the items type can be drilled down.
*/
get canDrilldownItems(): boolean {
return !!(this.items?.flags & ASTNodeFlags.DrilldownType);
}
/**
* Get a variable field by key path.
* @param keyPath The key path to search for.
* @returns The variable field if found, otherwise `undefined`.
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
const [curr, ...rest] = keyPath || [];
if (curr === '0' && this.canDrilldownItems) {
// The first item of the array.
return this.items.getByKeyPath(rest);
}
return undefined;
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
const isSuperEqual = super.isTypeEqual(targetTypeJSONOrKind);
if (targetTypeJSON?.weak || targetTypeJSON?.kind === ASTKind.Union) {
return isSuperEqual;
}
return (
targetTypeJSON &&
isSuperEqual &&
// Weak comparison, only need to compare the Kind.
(targetTypeJSON?.weak || this.customStrongEqual(targetTypeJSON))
);
}
/**
* Array strong comparison.
* @param targetTypeJSON The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
protected customStrongEqual(targetTypeJSON: ASTNodeJSON): boolean {
if (!this.items) {
return !(targetTypeJSON as ArrayJSON)?.items;
}
return this.items?.isTypeEqual((targetTypeJSON as ArrayJSON).items);
}
/**
* Serialize the `ArrayType` to `ArrayJSON`
* @returns The JSON representation of `ArrayType`.
*/
toJSON() {
return {
kind: ASTKind.Array,
items: this.items?.toJSON(),
};
}
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
import { UnionJSON } from './union';
/**
* Base class for all types.
*
* All other types should extend this class.
*/
export abstract class BaseType<JSON extends ASTNodeJSON = any> extends ASTNode<JSON> {
public flags: number = ASTNodeFlags.BasicType;
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
// If it is a Union type, it is sufficient for one of the subtypes to be equal.
if (targetTypeJSON?.kind === ASTKind.Union) {
return ((targetTypeJSON as UnionJSON)?.types || [])?.some((_subType) =>
this.isTypeEqual(_subType)
);
}
return this.kind === targetTypeJSON?.kind;
}
/**
* Get a variable field by key path.
*
* This method should be implemented by drillable types.
* @param keyPath The key path to search for.
* @returns The variable field if found, otherwise `undefined`.
*/
getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
throw new Error(`Get By Key Path is not implemented for Type: ${this.kind}`);
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { BaseType } from './base-type';
/**
* Represents a boolean type.
*/
export class BooleanType extends BaseType {
static kind: string = ASTKind.Boolean;
/**
* Deserializes the `BooleanJSON` to the `BooleanType`.
* @param json The `BooleanJSON` to deserialize.
*/
fromJSON(): void {
// noop
}
toJSON() {
return {};
}
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSONOrKind } from '../types';
import { type UnionJSON } from './union';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `CustomType`
*/
export interface CustomTypeJSON {
/**
* The name of the custom type.
*/
typeName: string;
}
/**
* Represents a custom type.
*/
export class CustomType extends BaseType<CustomTypeJSON> {
static kind: string = ASTKind.CustomType;
protected _typeName: string;
/**
* The name of the custom type.
*/
get typeName(): string {
return this._typeName;
}
/**
* Deserializes the `CustomTypeJSON` to the `CustomType`.
* @param json The `CustomTypeJSON` to deserialize.
*/
fromJSON(json: CustomTypeJSON): void {
if (this._typeName !== json.typeName) {
this._typeName = json.typeName;
this.fireChange();
}
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
// If it is a Union type, it is sufficient for one of the subtypes to be equal.
if (targetTypeJSON?.kind === ASTKind.Union) {
return ((targetTypeJSON as UnionJSON)?.types || [])?.some((_subType) =>
this.isTypeEqual(_subType)
);
}
return targetTypeJSON?.kind === this.kind && targetTypeJSON?.typeName === this.typeName;
}
toJSON() {
return {
typeName: this.typeName,
};
}
}
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { StringType } from './string';
export { IntegerType } from './integer';
export { BooleanType } from './boolean';
export { NumberType } from './number';
export { ArrayType } from './array';
export { MapType } from './map';
export {
type ObjectJSON as ObjectJSON,
ObjectType,
type ObjectPropertiesChangeAction,
} from './object';
export { BaseType } from './base-type';
export { type UnionJSON } from './union';
export { CustomType, type CustomTypeJSON } from './custom-type';
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type';
/**
* Represents an integer type.
*/
export class IntegerType extends BaseType {
public flags: ASTNodeFlags = ASTNodeFlags.BasicType;
static kind: string = ASTKind.Integer;
/**
* Deserializes the `IntegerJSON` to the `IntegerType`.
* @param json The `IntegerJSON` to deserialize.
*/
fromJSON(): void {
// noop
}
toJSON() {
return {};
}
}
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `MapType`
*/
export interface MapJSON {
/**
* The type of the keys in the map.
*/
keyType?: ASTNodeJSONOrKind;
/**
* The type of the values in the map.
*/
valueType?: ASTNodeJSONOrKind;
}
/**
* Represents a map type.
*/
export class MapType extends BaseType<MapJSON> {
static kind: string = ASTKind.Map;
/**
* The type of the keys in the map.
*/
keyType: BaseType;
/**
* The type of the values in the map.
*/
valueType: BaseType;
/**
* Deserializes the `MapJSON` to the `MapType`.
* @param json The `MapJSON` to deserialize.
*/
fromJSON({ keyType = ASTKind.String, valueType }: MapJSON): void {
// Key defaults to String.
this.updateChildNodeByKey('keyType', parseTypeJsonOrKind(keyType));
this.updateChildNodeByKey('valueType', parseTypeJsonOrKind(valueType));
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
const isSuperEqual = super.isTypeEqual(targetTypeJSONOrKind);
if (targetTypeJSON?.weak || targetTypeJSON?.kind === ASTKind.Union) {
return isSuperEqual;
}
return (
targetTypeJSON &&
isSuperEqual &&
// Weak comparison, only need to compare the Kind.
(targetTypeJSON?.weak || this.customStrongEqual(targetTypeJSON))
);
}
/**
* Map strong comparison.
* @param targetTypeJSON The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
protected customStrongEqual(targetTypeJSON: ASTNodeJSON): boolean {
const { keyType = ASTKind.String, valueType } = targetTypeJSON as MapJSON;
const isValueTypeEqual =
(!valueType && !this.valueType) || this.valueType?.isTypeEqual(valueType);
return isValueTypeEqual && this.keyType?.isTypeEqual(keyType);
}
/**
* Serialize the node to a JSON object.
* @returns The JSON representation of the node.
*/
toJSON() {
return {
kind: ASTKind.Map,
keyType: this.keyType?.toJSON(),
valueType: this.valueType?.toJSON(),
};
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { BaseType } from './base-type';
/**
* Represents a number type.
*/
export class NumberType extends BaseType {
static kind: string = ASTKind.Number;
/**
* Deserializes the `NumberJSON` to the `NumberType`.
* @param json The `NumberJSON` to deserialize.
*/
fromJSON(): void {
// noop
}
toJSON() {
return {};
}
}
@@ -0,0 +1,185 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { xor } from 'lodash-es';
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTNodeJSON, ASTKind, ASTNodeJSONOrKind, type GlobalEventActionType } from '../types';
import { ASTNodeFlags } from '../flags';
import { Property, type PropertyJSON } from '../declaration/property';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `ObjectType`
*/
export interface ObjectJSON<VariableMeta = any> {
/**
* The properties of the object.
*
* The `properties` of an Object must be of type `Property`, so the business can omit the `kind` field.
*/
properties?: PropertyJSON<VariableMeta>[];
}
/**
* Action type for object properties change.
*/
export type ObjectPropertiesChangeAction = GlobalEventActionType<
'ObjectPropertiesChange',
{
prev: Property[];
next: Property[];
},
ObjectType
>;
/**
* Represents an object type.
*/
export class ObjectType extends BaseType<ObjectJSON> {
public flags: ASTNodeFlags = ASTNodeFlags.DrilldownType;
static kind: string = ASTKind.Object;
/**
* A map of property keys to `Property` instances.
*/
propertyTable: Map<string, Property> = new Map();
/**
* An array of `Property` instances.
*/
properties: Property[];
/**
* Deserializes the `ObjectJSON` to the `ObjectType`.
* @param json The `ObjectJSON` to deserialize.
*/
fromJSON({ properties }: ObjectJSON): void {
const removedKeys = new Set(this.propertyTable.keys());
const prev = [...(this.properties || [])];
// Iterate over the new properties.
this.properties = (properties || []).map((property: PropertyJSON) => {
const existProperty = this.propertyTable.get(property.key);
removedKeys.delete(property.key);
if (existProperty) {
existProperty.fromJSON(property as PropertyJSON);
return existProperty;
} else {
const newProperty = this.createChildNode({
...property,
kind: ASTKind.Property,
}) as Property;
this.fireChange();
this.propertyTable.set(property.key, newProperty);
// TODO: When a child node is actively destroyed, delete the information in the table.
return newProperty;
}
});
// Delete properties that no longer exist.
removedKeys.forEach((key) => {
const property = this.propertyTable.get(key);
property?.dispose();
this.propertyTable.delete(key);
this.fireChange();
});
this.dispatchGlobalEvent<ObjectPropertiesChangeAction>({
type: 'ObjectPropertiesChange',
payload: {
prev,
next: [...this.properties],
},
});
}
/**
* Serialize the `ObjectType` to `ObjectJSON`.
* @returns The JSON representation of `ObjectType`.
*/
toJSON() {
return {
properties: this.properties.map((_property) => _property.toJSON()),
};
}
/**
* Get a variable field by key path.
* @param keyPath The key path to search for.
* @returns The variable field if found, otherwise `undefined`.
*/
getByKeyPath(keyPath: string[]): Property | undefined {
const [curr, ...restKeyPath] = keyPath;
const property = this.propertyTable.get(curr);
// Found the end of the path.
if (!restKeyPath.length) {
return property;
}
// Otherwise, continue searching downwards.
if (property?.type && property?.type?.flags & ASTNodeFlags.DrilldownType) {
return property.type.getByKeyPath(restKeyPath) as Property | undefined;
}
return undefined;
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
const isSuperEqual = super.isTypeEqual(targetTypeJSONOrKind);
if (targetTypeJSON?.weak || targetTypeJSON?.kind === ASTKind.Union) {
return isSuperEqual;
}
return (
targetTypeJSON &&
isSuperEqual &&
// Weak comparison, only need to compare the Kind.
(targetTypeJSON?.weak || this.customStrongEqual(targetTypeJSON))
);
}
/**
* Object type strong comparison.
* @param targetTypeJSON The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
protected customStrongEqual(targetTypeJSON: ASTNodeJSON): boolean {
const targetProperties = (targetTypeJSON as ObjectJSON).properties || [];
const sourcePropertyKeys = Array.from(this.propertyTable.keys());
const targetPropertyKeys = targetProperties.map((_target) => _target.key);
const isKeyStrongEqual = !xor(sourcePropertyKeys, targetPropertyKeys).length;
return (
isKeyStrongEqual &&
targetProperties.every((targetProperty) => {
const sourceProperty = this.propertyTable.get(targetProperty.key);
return (
sourceProperty &&
sourceProperty.key === targetProperty.key &&
sourceProperty.type?.isTypeEqual(targetProperty?.type)
);
})
);
}
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of the `StringType`.
*/
export interface StringJSON {
/**
* see https://json-schema.org/understanding-json-schema/reference/type#format
*/
format?: string;
}
export class StringType extends BaseType<StringJSON> {
public flags: ASTNodeFlags = ASTNodeFlags.BasicType;
static kind: string = ASTKind.String;
protected _format?: string;
/**
* see https://json-schema.org/understanding-json-schema/reference/string#format
*/
get format() {
return this._format;
}
/**
* Deserialize the `StringJSON` to the `StringType`.
*
* @param json StringJSON representation of the `StringType`.
*/
fromJSON(json?: StringJSON): void {
if (json?.format !== this._format) {
this._format = json?.format;
this.fireChange();
}
}
/**
* Serialize the `StringType` to `StringJSON`.
* @returns The JSON representation of `StringType`.
*/
toJSON() {
return {
format: this._format,
};
}
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTNodeJSONOrKind } from '../types';
/**
* ASTNodeJSON representation of `UnionType`, which union multiple `BaseType`.
*/
export interface UnionJSON {
types?: ASTNodeJSONOrKind[];
}
@@ -0,0 +1,188 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type Observer } from 'rxjs';
import { type Scope } from '../scope';
import { type ASTNode } from './ast-node';
export type ASTKindType = string;
export type Identifier = string;
/**
* ASTNodeJSON is the JSON representation of an ASTNode.
*/
export interface ASTNodeJSON {
/**
* Kind is the type of the AST node.
*/
kind?: ASTKindType;
/**
* Key is the unique identifier of the node.
* If not provided, the node will generate a default key value.
*/
key?: Identifier;
[key: string]: any;
}
/**
* Core AST node types.
*/
export enum ASTKind {
/**
* # Type-related.
* - A set of type AST nodes based on JSON types is implemented internally by default.
*/
/**
* String type.
*/
String = 'String',
/**
* Number type.
*/
Number = 'Number',
/**
* Integer type.
*/
Integer = 'Integer',
/**
* Boolean type.
*/
Boolean = 'Boolean',
/**
* Object type.
*/
Object = 'Object',
/**
* Array type.
*/
Array = 'Array',
/**
* Map type.
*/
Map = 'Map',
/**
* Union type.
* Commonly used for type checking, generally not exposed to the business.
*/
Union = 'Union',
/**
* Any type.
* Commonly used for business logic.
*/
Any = 'Any',
/**
* Custom type.
* For business-defined types.
*/
CustomType = 'CustomType',
/**
* # Declaration-related.
*/
/**
* Field definition for Object drill-down.
*/
Property = 'Property',
/**
* Variable declaration.
*/
VariableDeclaration = 'VariableDeclaration',
/**
* Variable declaration list.
*/
VariableDeclarationList = 'VariableDeclarationList',
/**
* # Expression-related.
*/
/**
* Access fields on variables through the path system.
*/
KeyPathExpression = 'KeyPathExpression',
/**
* Iterate over specified data.
*/
EnumerateExpression = 'EnumerateExpression',
/**
* Wrap with Array Type.
*/
WrapArrayExpression = 'WrapArrayExpression',
/**
* # General-purpose AST nodes.
*/
/**
* General-purpose List<ASTNode> storage node.
*/
ListNode = 'ListNode',
/**
* General-purpose data storage node.
*/
DataNode = 'DataNode',
/**
* General-purpose Map<string, ASTNode> storage node.
*/
MapNode = 'MapNode',
}
export interface CreateASTParams {
scope: Scope;
key?: Identifier;
parent?: ASTNode;
}
export type ASTNodeJSONOrKind = string | ASTNodeJSON;
export type ObserverOrNext<T> = Partial<Observer<T>> | ((value: T) => void);
export interface SubscribeConfig<This, Data> {
// Merge all changes within one animationFrame into a single one.
debounceAnimation?: boolean;
// Respond with a value by default upon subscription.
triggerOnInit?: boolean;
selector?: (curr: This) => Data;
}
/**
* TypeUtils to get the JSON representation of an AST node with a specific kind.
*/
export type GetKindJSON<KindType extends string, JSON extends ASTNodeJSON> = {
kind: KindType;
key?: Identifier;
} & JSON;
/**
* TypeUtils to get the JSON representation of an AST node with a specific kind or just the kind string.
*/
export type GetKindJSONOrKind<KindType extends string, JSON extends ASTNodeJSON> =
| ({
kind: KindType;
key?: Identifier;
} & JSON)
| KindType;
/**
* Global event action type.
* - Global event might be dispatched from `ASTNode` or `Scope`.
*/
export interface GlobalEventActionType<
Type = string,
Payload = any,
AST extends ASTNode = ASTNode
> {
type: Type;
payload?: Payload;
ast?: AST;
}
export type NewASTAction = GlobalEventActionType<'NewAST'>;
export type UpdateASTAction = GlobalEventActionType<'UpdateAST'>;
export type DisposeASTAction = GlobalEventActionType<'DisposeAST'>;
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { intersection } from 'lodash-es';
import { ASTNodeFlags } from '../flags';
import { type BaseExpression } from '../expression';
import { type BaseVariableField } from '../declaration';
import { type ASTNode } from '../ast-node';
import { getParentFields } from './variable-field';
import { getAllChildren } from './helpers';
/**
* Get all variables referenced by child ASTs.
* @param ast The ASTNode to traverse.
* @returns All variables referenced by child ASTs.
*/
export function getAllRefs(ast: ASTNode): BaseVariableField[] {
return getAllChildren(ast)
.filter((_child) => _child.flags & ASTNodeFlags.Expression)
.map((_child) => (_child as BaseExpression).refs)
.flat()
.filter(Boolean) as BaseVariableField[];
}
/**
* Checks for circular references.
* @param curr The current expression.
* @param refNode The referenced variable node.
* @returns Whether a circular reference exists.
*/
export function checkRefCycle(
curr: BaseExpression,
refNodes: (BaseVariableField | undefined)[]
): boolean {
// If there are no circular references in the scope, then it is impossible to have a circular reference.
if (
intersection(curr.scope.coverScopes, refNodes.map((_ref) => _ref?.scope).filter(Boolean))
.length === 0
) {
return false;
}
// BFS traversal.
const visited = new Set<BaseVariableField>();
const queue = [...refNodes];
while (queue.length) {
const currNode = queue.shift()!;
visited.add(currNode);
for (const ref of getAllRefs(currNode).filter((_ref) => !visited.has(_ref))) {
queue.push(ref);
}
}
// If the referenced variables include the parent variable of the expression, then there is a circular reference.
return intersection(Array.from(visited), getParentFields(curr)).length > 0;
}
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTMatch } from '../match';
import { ASTNode } from '../ast-node';
export function updateChildNodeHelper(
this: ASTNode,
{
getChildNode,
updateChildNode,
removeChildNode,
nextJSON,
}: {
getChildNode: () => ASTNode | undefined;
updateChildNode: (nextNode: ASTNode) => void;
removeChildNode: () => void;
nextJSON?: ASTNodeJSON;
}
): ASTNode | undefined {
const currNode: ASTNode | undefined = getChildNode();
const isNewKind = currNode?.kind !== nextJSON?.kind;
// If `nextJSON` does not pass a key value, the key value remains unchanged by default.
const isNewKey = nextJSON?.key && nextJSON?.key !== currNode?.key;
if (isNewKind || isNewKey) {
// The previous node needs to be destroyed.
if (currNode) {
currNode.dispose();
removeChildNode();
}
if (nextJSON) {
const newNode = this.createChildNode(nextJSON);
updateChildNode(newNode);
this.fireChange();
return newNode;
} else {
// Also trigger an update when deleting a child node directly.
this.fireChange();
}
} else if (nextJSON) {
currNode?.fromJSON(nextJSON);
}
return currNode;
}
export function parseTypeJsonOrKind(typeJSONOrKind?: ASTNodeJSONOrKind): ASTNodeJSON | undefined {
return typeof typeJSONOrKind === 'string' ? { kind: typeJSONOrKind } : typeJSONOrKind;
}
// Get all children.
export function getAllChildren(ast: ASTNode): ASTNode[] {
return [...ast.children, ...ast.children.map((_child) => getAllChildren(_child)).flat()];
}
/**
* isMatchAST is same as ASTMatch.is
* @param node
* @param targetType
* @returns
*/
export function isMatchAST<TargetASTNode extends ASTNode>(
node?: ASTNode,
targetType?: { kind: string; new (...args: any[]): TargetASTNode }
): node is TargetASTNode {
return ASTMatch.is(node, targetType);
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { interfaces } from 'inversify';
import { type ASTNode } from '../ast-node';
export const injectToAST = (serviceIdentifier: interfaces.ServiceIdentifier) =>
function (target: any, propertyKey: string) {
if (!serviceIdentifier) {
throw new Error(
`ServiceIdentifier ${serviceIdentifier} in @lazyInject is Empty, it might be caused by file circular dependency, please check it.`
);
}
const descriptor = {
get() {
const container = (this as ASTNode).scope.variableEngine.container;
return container.get(serviceIdentifier);
},
set() {},
configurable: true,
enumerable: true,
} as any;
// Object.defineProperty(target, propertyKey, descriptor);
return descriptor;
};
export const POST_CONSTRUCT_AST_SYMBOL = Symbol('post_construct_ast');
export const postConstructAST = () => (target: any, propertyKey: string) => {
// Only run once.
if (!Reflect.hasMetadata(POST_CONSTRUCT_AST_SYMBOL, target)) {
Reflect.defineMetadata(POST_CONSTRUCT_AST_SYMBOL, propertyKey, target);
} else {
throw Error('Duplication Post Construct AST');
}
};
@@ -0,0 +1,5 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTNodeFlags } from '../flags';
import { BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
/**
* Parent variable fields, sorted from nearest to farthest.
*/
export function getParentFields(ast: ASTNode): BaseVariableField[] {
let curr = ast.parent;
const res: BaseVariableField[] = [];
while (curr) {
if (curr.flags & ASTNodeFlags.VariableField) {
res.push(curr as BaseVariableField);
}
curr = curr.parent;
}
return res;
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { VariableContainerModule } from './variable-container-module';
export { VariableEngine } from './variable-engine';
export { VariableEngineProvider } from './providers';
export * from './react';
export * from './scope';
export * from './ast';
export * from './services';
export { type Observer } from 'rxjs';
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { interfaces } from 'inversify';
import { type VariableEngine } from './variable-engine';
/**
* A provider for dynamically obtaining the `VariableEngine` instance.
* This is used to prevent circular dependencies when injecting `VariableEngine`.
*/
export const VariableEngineProvider = Symbol('DynamicVariableEngine');
export type VariableEngineProvider = () => VariableEngine;
/**
* A provider for obtaining the Inversify container instance.
* This allows other parts of the application, like AST nodes, to access the container for dependency injection.
*/
export const ContainerProvider = Symbol('ContainerProvider');
export type ContainerProvider = () => interfaces.Container;
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable react/prop-types */
import React, { createContext, useContext } from 'react';
import { Scope } from '../scope';
interface ScopeContextProps {
scope: Scope;
}
const ScopeContext = createContext<ScopeContextProps>(null!);
/**
* ScopeProvider provides the scope to its children via context.
*/
export const ScopeProvider = (
props: React.PropsWithChildren<{
/**
* scope used in the context
*/
scope?: Scope;
/**
* @deprecated use scope prop instead, this is kept for backward compatibility
*/
value?: ScopeContextProps;
}>
) => {
const { scope, value, children } = props;
const scopeToUse = scope || value?.scope;
if (!scopeToUse) {
throw new Error('[ScopeProvider] scope is required');
}
return <ScopeContext.Provider value={{ scope: scopeToUse }}>{children}</ScopeContext.Provider>;
};
/**
* useCurrentScope returns the scope provided by ScopeProvider.
* @returns
*/
export const useCurrentScope = <Strict extends boolean = false>(params?: {
/**
* whether to throw error when no scope in ScopeProvider is found
*/
strict: Strict;
}): Strict extends true ? Scope : Scope | undefined => {
const { strict = false } = params || {};
const context = useContext(ScopeContext);
if (!context) {
if (strict) {
throw new Error('useCurrentScope must be used within a <ScopeProvider scope={scope}>');
}
console.warn('useCurrentScope should be used within a <ScopeProvider scope={scope}>');
}
return context?.scope;
};
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh, useService } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { VariableEngine } from '../../variable-engine';
import { VariableDeclaration } from '../../ast';
/**
* Get available variable list in the current scope.
*
* - If no scope, return global variable list.
* - The hook is reactive to variable list or any variables change.
*/
export function useAvailableVariables(): VariableDeclaration[] {
const scope = useCurrentScope();
const variableEngine: VariableEngine = useService(VariableEngine);
const refresh = useRefresh();
useEffect(() => {
// 没有作用域时,监听全局变量表
if (!scope) {
const disposable = variableEngine.globalVariableTable.onListOrAnyVarChange(() => {
refresh();
});
return () => disposable.dispose();
}
const disposable = scope.available.onDataChange(() => {
refresh();
});
return () => disposable.dispose();
}, []);
// 没有作用域时,使用全局变量表
return scope ? scope.available.variables : variableEngine.globalVariableTable.variables;
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { VariableDeclaration } from '../../ast';
/**
* Get output variable list in the current scope.
*
* - The hook is reactive to variable list or any variables change.
*/
export function useOutputVariables(): VariableDeclaration[] {
const scope = useCurrentScope();
const refresh = useRefresh();
useEffect(() => {
if (!scope) {
throw new Error(
'[useOutputVariables]: No scope found, useOutputVariables must be used in <ScopeProvider>'
);
}
const disposable = scope.output.onListOrAnyVarChange(() => {
refresh();
});
return () => disposable.dispose();
}, []);
return scope?.output.variables || [];
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { ScopeAvailableData } from '../../scope/datas';
/**
* Get the available variables in the current scope.
* 获取作用域的可访问变量
*
* @returns the available variables in the current scope
*/
export function useScopeAvailable(params?: { autoRefresh?: boolean }): ScopeAvailableData {
const { autoRefresh = true } = params || {};
const scope = useCurrentScope({ strict: true });
const refresh = useRefresh();
useEffect(() => {
if (!autoRefresh) {
return () => null;
}
const disposable = scope.available.onListOrAnyVarChange(() => {
refresh();
});
return () => disposable.dispose();
}, [autoRefresh]);
return scope.available;
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { ScopeProvider, useCurrentScope } from './context';
export { useScopeAvailable } from './hooks/use-scope-available';
export { useAvailableVariables } from './hooks/use-available-variables';
export { useOutputVariables } from './hooks/use-output-variables';
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { ScopeOutputData } from './scope-output-data';
export { ScopeAvailableData } from './scope-available-data';
export { ScopeEventData } from './scope-event-data';
@@ -0,0 +1,234 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
Observable,
Subject,
animationFrameScheduler,
debounceTime,
distinctUntilChanged,
map,
merge,
share,
skip,
startWith,
switchMap,
tap,
} from 'rxjs';
import { flatten } from 'lodash-es';
import { shallowEqual } from 'fast-equals';
import { Disposable } from '@flowgram.ai/utils';
import { Emitter } from '@flowgram.ai/utils';
import { IVariableTable } from '../types';
import { type Scope } from '../scope';
import { subsToDisposable } from '../../utils/toDisposable';
import { createMemo } from '../../utils/memo';
import { SubscribeConfig } from '../../ast/types';
import { ASTNode, BaseVariableField, VariableDeclaration } from '../../ast';
/**
* Manages the available variables within a scope.
*/
export class ScopeAvailableData {
protected memo = createMemo();
/**
* The global variable table from the variable engine.
*/
get globalVariableTable(): IVariableTable {
return this.scope.variableEngine.globalVariableTable;
}
protected _version: number = 0;
protected refresh$: Subject<void> = new Subject();
protected _variables: VariableDeclaration[] = [];
/**
* The current version of the available data, which increments on each change.
*/
get version() {
return this._version;
}
protected bumpVersion() {
this._version = this._version + 1;
if (this._version === Number.MAX_SAFE_INTEGER) {
this._version = 0;
}
}
/**
* Refreshes the list of available variables.
* This should be called when the dependencies of the scope change.
*/
refresh(): void {
// Do not trigger refresh for a disposed scope.
if (this.scope.disposed) {
return;
}
this.refresh$.next();
}
/**
* An observable that emits when the list of available variables changes.
*/
protected variables$: Observable<VariableDeclaration[]> = this.refresh$.pipe(
// Map to the flattened list of variables from all dependency scopes.
map(() => flatten(this.depScopes.map((scope) => scope.output.variables || []))),
// Use shallow equality to check if the variable list has changed.
distinctUntilChanged<VariableDeclaration[]>(shallowEqual),
share()
);
/**
* An observable that emits when any variable in the available list changes its value.
*/
protected anyVariableChange$: Observable<VariableDeclaration> = this.variables$.pipe(
switchMap((_variables) =>
merge(
..._variables.map((_v) =>
_v.value$.pipe<any>(
// Skip the initial value of the BehaviorSubject.
skip(1)
)
)
)
),
share()
);
/**
* Subscribes to changes in any variable's value in the available list.
* @param observer A function to be called with the changed variable.
* @returns A disposable to unsubscribe from the changes.
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void) {
return subsToDisposable(this.anyVariableChange$.subscribe(observer));
}
/**
* Subscribes to changes in the list of available variables.
* @param observer A function to be called with the new list of variables.
* @returns A disposable to unsubscribe from the changes.
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void) {
return subsToDisposable(this.variables$.subscribe(observer));
}
/**
* @deprecated
*/
protected onDataChangeEmitter = new Emitter<VariableDeclaration[]>();
protected onListOrAnyVarChangeEmitter = new Emitter<VariableDeclaration[]>();
/**
* @deprecated use available.onListOrAnyVarChange instead
*/
public onDataChange = this.onDataChangeEmitter.event;
/**
* An event that fires when the variable list changes or any variable's value is updated.
*/
public onListOrAnyVarChange = this.onListOrAnyVarChangeEmitter.event;
constructor(public readonly scope: Scope) {
this.scope.toDispose.pushAll([
this.onVariableListChange((_variables) => {
this._variables = _variables;
this.memo.clear();
this.onDataChangeEmitter.fire(this._variables);
this.bumpVersion();
this.onListOrAnyVarChangeEmitter.fire(this._variables);
}),
this.onAnyVariableChange(() => {
this.onDataChangeEmitter.fire(this._variables);
this.bumpVersion();
this.onListOrAnyVarChangeEmitter.fire(this._variables);
}),
Disposable.create(() => {
this.refresh$.complete();
this.refresh$.unsubscribe();
}),
]);
}
/**
* Gets the list of available variables.
*/
get variables(): VariableDeclaration[] {
return this._variables;
}
/**
* Gets the keys of the available variables.
*/
get variableKeys(): string[] {
return this.memo('availableKeys', () => this._variables.map((_v) => _v.key));
}
/**
* Gets the dependency scopes.
*/
get depScopes(): Scope[] {
return this.scope.depScopes;
}
/**
* Retrieves a variable field by its key path from the available variables.
* @param keyPath The key path to the variable field.
* @returns The found `BaseVariableField` or `undefined`.
*/
getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
// Check if the variable is accessible in the current scope.
if (!this.variableKeys.includes(keyPath[0])) {
return;
}
return this.globalVariableTable.getByKeyPath(keyPath);
}
/**
* Tracks changes to a variable field by its key path.
* This includes changes to its type, value, or any nested properties.
* @param keyPath The key path to the variable field to track.
* @param cb The callback to execute when the variable changes.
* @param opts Configuration options for the subscription.
* @returns A disposable to unsubscribe from the tracking.
*/
trackByKeyPath<Data = BaseVariableField | undefined>(
keyPath: string[] = [],
cb: (variable?: Data) => void,
opts?: SubscribeConfig<BaseVariableField | undefined, Data>
): Disposable {
const { triggerOnInit = true, debounceAnimation, selector } = opts || {};
return subsToDisposable(
merge(this.anyVariableChange$, this.variables$)
.pipe(
triggerOnInit ? startWith() : tap(() => null),
map(() => {
const v = this.getByKeyPath(keyPath);
return selector ? selector(v) : (v as any);
}),
distinctUntilChanged(
(a, b) => shallowEqual(a, b),
(value) => {
if (value instanceof ASTNode) {
// If the value is an ASTNode, compare its hash for changes.
return value.hash;
}
return value;
}
),
// Debounce updates to a single emission per animation frame.
debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null)
)
.subscribe(cb)
);
}
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Subject, filter } from 'rxjs';
import { Disposable } from '@flowgram.ai/utils';
import { type Scope } from '../scope';
import { subsToDisposable } from '../../utils/toDisposable';
import { type GlobalEventActionType } from '../../ast';
type Observer<ActionType extends GlobalEventActionType = GlobalEventActionType> = (
action: ActionType
) => void;
/**
* Manages global events within a scope.
*/
export class ScopeEventData {
event$: Subject<GlobalEventActionType> = new Subject<GlobalEventActionType>();
/**
* Dispatches a global event.
* @param action The event action to dispatch.
*/
dispatch<ActionType extends GlobalEventActionType = GlobalEventActionType>(action: ActionType) {
if (this.scope.disposed) {
return;
}
this.event$.next(action);
}
/**
* Subscribes to all global events.
* @param observer The observer function to call with the event action.
* @returns A disposable to unsubscribe from the events.
*/
subscribe<ActionType extends GlobalEventActionType = GlobalEventActionType>(
observer: Observer<ActionType>
): Disposable {
return subsToDisposable(this.event$.subscribe(observer as Observer));
}
/**
* Subscribes to a specific type of global event.
* @param type The type of the event to subscribe to.
* @param observer The observer function to call with the event action.
* @returns A disposable to unsubscribe from the event.
*/
on<ActionType extends GlobalEventActionType = GlobalEventActionType>(
type: ActionType['type'],
observer: Observer<ActionType>
): Disposable {
return subsToDisposable(
this.event$.pipe(filter((_action) => _action.type === type)).subscribe(observer as Observer)
);
}
constructor(public readonly scope: Scope) {
scope.toDispose.pushAll([
this.subscribe((_action) => {
scope.variableEngine.fireGlobalEvent(_action);
}),
]);
}
}
@@ -0,0 +1,151 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { VariableTable } from '../variable-table';
import { IVariableTable } from '../types';
import { type Scope } from '../scope';
import { type VariableEngine } from '../../variable-engine';
import { createMemo } from '../../utils/memo';
import { NewASTAction } from '../../ast/types';
import { DisposeASTAction } from '../../ast/types';
import { ReSortVariableDeclarationsAction } from '../../ast/declaration/variable-declaration';
import { ASTKind, type VariableDeclaration } from '../../ast';
/**
* Manages the output variables of a scope.
*/
export class ScopeOutputData {
protected variableTable: IVariableTable;
protected memo = createMemo();
/**
* The variable engine instance.
*/
get variableEngine(): VariableEngine {
return this.scope.variableEngine;
}
/**
* The global variable table from the variable engine.
*/
get globalVariableTable(): IVariableTable {
return this.scope.variableEngine.globalVariableTable;
}
/**
* The current version of the output data, which increments on each change.
*/
get version() {
return this.variableTable.version;
}
/**
* @deprecated use onListOrAnyVarChange instead
*/
get onDataChange() {
return this.variableTable.onDataChange.bind(this.variableTable);
}
/**
* An event that fires when the list of output variables changes.
*/
get onVariableListChange() {
return this.variableTable.onVariableListChange.bind(this.variableTable);
}
/**
* An event that fires when any output variable's value changes.
*/
get onAnyVariableChange() {
return this.variableTable.onAnyVariableChange.bind(this.variableTable);
}
/**
* An event that fires when the output variable list changes or any variable's value is updated.
*/
get onListOrAnyVarChange() {
return this.variableTable.onListOrAnyVarChange.bind(this.variableTable);
}
protected _hasChanges = false;
constructor(public readonly scope: Scope) {
// Setup scope variable table based on globalVariableTable
this.variableTable = new VariableTable(scope.variableEngine.globalVariableTable);
this.scope.toDispose.pushAll([
// When the root AST node is updated, check if there are any changes.
this.scope.ast.subscribe(() => {
if (this._hasChanges) {
this.memo.clear();
this.notifyCoversChange();
this.variableTable.fireChange();
this._hasChanges = false;
}
}),
this.scope.event.on<DisposeASTAction>('DisposeAST', (_action) => {
if (_action.ast?.kind === ASTKind.VariableDeclaration) {
this.removeVariableFromTable(_action.ast.key);
}
}),
this.scope.event.on<NewASTAction>('NewAST', (_action) => {
if (_action.ast?.kind === ASTKind.VariableDeclaration) {
this.addVariableToTable(_action.ast as VariableDeclaration);
}
}),
this.scope.event.on<ReSortVariableDeclarationsAction>('ReSortVariableDeclarations', () => {
this._hasChanges = true;
}),
this.variableTable,
]);
}
/**
* The output variable declarations of the scope, sorted by order.
*/
get variables(): VariableDeclaration[] {
return this.memo('variables', () =>
this.variableTable.variables.sort((a, b) => a.order - b.order)
);
}
/**
* The keys of the output variables.
*/
get variableKeys(): string[] {
return this.memo('variableKeys', () => this.variableTable.variableKeys);
}
protected addVariableToTable(variable: VariableDeclaration) {
if (variable.scope !== this.scope) {
throw Error('VariableDeclaration must be a ast node in scope');
}
(this.variableTable as VariableTable).addVariableToTable(variable);
this._hasChanges = true;
}
protected removeVariableFromTable(key: string) {
(this.variableTable as VariableTable).removeVariableFromTable(key);
this._hasChanges = true;
}
/**
* Retrieves a variable declaration by its key.
* @param key The key of the variable.
* @returns The `VariableDeclaration` or `undefined` if not found.
*/
getVariableByKey(key: string) {
return this.variableTable.getVariableByKey(key);
}
/**
* Notifies the covering scopes that the available variables have changed.
*/
notifyCoversChange(): void {
this.scope.coverScopes.forEach((scope) => scope.available.refresh());
}
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { ScopeChain } from './scope-chain';
export { Scope } from './scope';
export { ScopeOutputData } from './datas';
export { type IVariableTable } from './types';
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { inject, injectable } from 'inversify';
import { DisposableCollection, type Event } from '@flowgram.ai/utils';
import { VariableEngineProvider } from '../providers';
import { type Scope } from './scope';
/**
* Manages the dependency relationships between scopes.
* This is an abstract class, and specific implementations determine how the scope order is managed.
*/
@injectable()
export abstract class ScopeChain {
readonly toDispose: DisposableCollection = new DisposableCollection();
@inject(VariableEngineProvider) variableEngineProvider: VariableEngineProvider;
get variableEngine() {
return this.variableEngineProvider();
}
constructor() {}
/**
* Refreshes the dependency and coverage relationships for all scopes.
*/
refreshAllChange(): void {
this.variableEngine.getAllScopes().forEach((_scope) => {
_scope.refreshCovers();
_scope.refreshDeps();
});
}
/**
* Gets the dependency scopes for a given scope.
* @param scope The scope to get dependencies for.
* @returns An array of dependency scopes.
*/
abstract getDeps(scope: Scope): Scope[];
/**
* Gets the covering scopes for a given scope.
* @param scope The scope to get covers for.
* @returns An array of covering scopes.
*/
abstract getCovers(scope: Scope): Scope[];
/**
* Sorts all scopes based on their dependency relationships.
* @returns A sorted array of all scopes.
*/
abstract sortAll(): Scope[];
dispose(): void {
this.toDispose.dispose();
}
get disposed(): boolean {
return this.toDispose.disposed;
}
get onDispose(): Event<void> {
return this.toDispose.onDispose;
}
}
@@ -0,0 +1,200 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { DisposableCollection } from '@flowgram.ai/utils';
import { type VariableEngine } from '../variable-engine';
import { createMemo } from '../utils/memo';
import { ASTKind, type ASTNode, type ASTNodeJSON, MapNode } from '../ast';
import { ScopeAvailableData, ScopeEventData, ScopeOutputData } from './datas';
/**
* Interface for the Scope constructor.
*/
export interface IScopeConstructor {
new (options: {
id: string | symbol;
variableEngine: VariableEngine;
meta?: Record<string, any>;
}): Scope;
}
/**
* Represents a variable scope, which manages its own set of variables and their lifecycle.
* - `scope.output` represents the variables declared within this scope.
* - `scope.available` represents all variables accessible from this scope, including those from parent scopes.
*/
export class Scope<ScopeMeta extends Record<string, any> = Record<string, any>> {
/**
* A unique identifier for the scope.
*/
readonly id: string | symbol;
/**
* The variable engine instance this scope belongs to.
*/
readonly variableEngine: VariableEngine;
/**
* Metadata associated with the scope, which can be extended by higher-level business logic.
*/
readonly meta: ScopeMeta;
/**
* The root AST node for this scope, which is a MapNode.
* It stores various data related to the scope, such as `outputs`.
*/
readonly ast: MapNode;
/**
* Manages the available variables for this scope.
*/
readonly available: ScopeAvailableData;
/**
* Manages the output variables for this scope.
*/
readonly output: ScopeOutputData;
/**
* Manages event dispatching and handling for this scope.
*/
readonly event: ScopeEventData;
/**
* A memoization utility for caching computed values.
*/
protected memo = createMemo();
public toDispose: DisposableCollection = new DisposableCollection();
constructor(options: { id: string | symbol; variableEngine: VariableEngine; meta?: ScopeMeta }) {
this.id = options.id;
this.meta = options.meta || ({} as any);
this.variableEngine = options.variableEngine;
this.event = new ScopeEventData(this);
this.ast = this.variableEngine.astRegisters.createAST(
{
kind: ASTKind.MapNode,
key: String(this.id),
},
{
scope: this,
}
) as MapNode;
this.output = new ScopeOutputData(this);
this.available = new ScopeAvailableData(this);
}
/**
* Refreshes the covering scopes.
*/
refreshCovers(): void {
this.memo.clear('covers');
}
/**
* Refreshes the dependency scopes and the available variables.
*/
refreshDeps(): void {
this.memo.clear('deps');
this.available.refresh();
}
/**
* Gets the scopes that this scope depends on.
*/
get depScopes(): Scope[] {
return this.memo('deps', () =>
this.variableEngine.chain
.getDeps(this)
.filter((_scope) => Boolean(_scope) && !_scope?.disposed)
);
}
/**
* Gets the scopes that are covered by this scope.
*/
get coverScopes(): Scope[] {
return this.memo('covers', () =>
this.variableEngine.chain
.getCovers(this)
.filter((_scope) => Boolean(_scope) && !_scope?.disposed)
);
}
/**
* Disposes of the scope and its resources.
* This will also trigger updates in dependent and covering scopes.
*/
dispose(): void {
this.ast.dispose();
this.toDispose.dispose();
// When a scope is disposed, update its dependent and covering scopes.
this.coverScopes.forEach((_scope) => _scope.refreshDeps());
this.depScopes.forEach((_scope) => _scope.refreshCovers());
}
onDispose = this.toDispose.onDispose;
get disposed(): boolean {
return this.toDispose.disposed;
}
/**
* Sets a variable in the scope with the default key 'outputs'.
*
* @param json The JSON representation of the AST node to set.
* @returns The created or updated AST node.
*/
public setVar<Node extends ASTNode = ASTNode>(json: ASTNodeJSON): Node;
/**
* Sets a variable in the scope with a specified key.
*
* @param key The key of the variable to set.
* @param json The JSON representation of the AST node to set.
* @returns The created or updated AST node.
*/
public setVar<Node extends ASTNode = ASTNode>(key: string, json: ASTNodeJSON): Node;
public setVar<Node extends ASTNode = ASTNode>(
arg1: string | ASTNodeJSON,
arg2?: ASTNodeJSON
): Node {
if (typeof arg1 === 'string' && arg2 !== undefined) {
return this.ast.set(arg1, arg2);
}
if (typeof arg1 === 'object' && arg2 === undefined) {
return this.ast.set('outputs', arg1);
}
throw new Error('Invalid arguments');
}
/**
* Retrieves a variable from the scope by its key.
*
* @param key The key of the variable to retrieve. Defaults to 'outputs'.
* @returns The AST node for the variable, or `undefined` if not found.
*/
public getVar<Node extends ASTNode = ASTNode>(key: string = 'outputs') {
return this.ast.get<Node>(key);
}
/**
* Clears a variable from the scope by its key.
*
* @param key The key of the variable to clear. Defaults to 'outputs'.
*/
public clearVar(key: string = 'outputs') {
return this.ast.remove(key);
}
}
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Event, Disposable } from '@flowgram.ai/utils';
import { BaseVariableField, VariableDeclaration } from '../ast';
import { type Scope } from './scope';
/**
* Parameters for getting all scopes.
*/
export interface GetAllScopeParams {
/**
* Whether to sort the scopes.
*/
sort?: boolean;
}
/**
* Action type for scope changes.
*/
export interface ScopeChangeAction {
type: 'add' | 'delete' | 'update' | 'available';
scope: Scope;
}
/**
* Interface for a variable table.
*/
export interface IVariableTable extends Disposable {
/**
* The parent variable table.
*/
parentTable?: IVariableTable;
/**
* @deprecated Use `onVariableListChange` or `onAnyVariableChange` instead.
*/
onDataChange: Event<void>;
/**
* The current version of the variable table.
*/
version: number;
/**
* The list of variables in the table.
*/
variables: VariableDeclaration[];
/**
* The keys of the variables in the table.
*/
variableKeys: string[];
/**
* Fires a change event.
*/
fireChange(): void;
/**
* Gets a variable or property by its key path.
* @param keyPath The key path to the variable or property.
* @returns The found `BaseVariableField` or `undefined`.
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined;
/**
* Gets a variable by its key.
* @param key The key of the variable.
* @returns The found `VariableDeclaration` or `undefined`.
*/
getVariableByKey(key: string): VariableDeclaration | undefined;
/**
* Disposes the variable table.
*/
dispose(): void;
/**
* Subscribes to changes in the variable list.
* @param observer The observer function.
* @returns A disposable to unsubscribe.
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void): Disposable;
/**
* Subscribes to changes in any variable's value.
* @param observer The observer function.
* @returns A disposable to unsubscribe.
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void): Disposable;
/**
* Subscribes to both variable list changes and any variable's value changes.
* @param observer The observer function.
* @returns A disposable to unsubscribe.
*/
onListOrAnyVarChange(observer: () => void): Disposable;
}
@@ -0,0 +1,203 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Observable, Subject, merge, share, skip, switchMap } from 'rxjs';
import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
import { subsToDisposable } from '../utils/toDisposable';
import { BaseVariableField } from '../ast/declaration/base-variable-field';
import { VariableDeclaration } from '../ast';
import { IVariableTable } from './types';
/**
* A class that stores and manages variables in a table-like structure.
* It provides methods for adding, removing, and retrieving variables, as well as
* observables for listening to changes in the variable list and individual variables.
*/
export class VariableTable implements IVariableTable {
protected table: Map<string, VariableDeclaration> = new Map();
toDispose = new DisposableCollection();
/**
* @deprecated
*/
protected onDataChangeEmitter = new Emitter<void>();
protected variables$: Subject<VariableDeclaration[]> = new Subject<VariableDeclaration[]>();
/**
* An observable that listens for value changes on any variable within the table.
*/
protected anyVariableChange$: Observable<VariableDeclaration> = this.variables$.pipe(
switchMap((_variables) =>
merge(
..._variables.map((_v) =>
_v.value$.pipe<any>(
// Skip the initial value of the BehaviorSubject
skip(1)
)
)
)
),
share()
);
/**
* Subscribes to updates on any variable in the list.
* @param observer A function to be called when any variable's value changes.
* @returns A disposable object to unsubscribe from the updates.
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void) {
return subsToDisposable(this.anyVariableChange$.subscribe(observer));
}
/**
* Subscribes to changes in the variable list (additions or removals).
* @param observer A function to be called when the list of variables changes.
* @returns A disposable object to unsubscribe from the updates.
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void) {
return subsToDisposable(this.variables$.subscribe(observer));
}
/**
* Subscribes to both variable list changes and updates to any variable in the list.
* @param observer A function to be called when either the list or a variable in it changes.
* @returns A disposable collection to unsubscribe from both events.
*/
onListOrAnyVarChange(observer: () => void) {
const disposables = new DisposableCollection();
disposables.pushAll([this.onVariableListChange(observer), this.onAnyVariableChange(observer)]);
return disposables;
}
/**
* @deprecated Use onListOrAnyVarChange instead.
*/
public onDataChange = this.onDataChangeEmitter.event;
protected _version: number = 0;
/**
* Fires change events to notify listeners that the data has been updated.
*/
fireChange() {
this.bumpVersion();
this.onDataChangeEmitter.fire();
this.variables$.next(this.variables);
this.parentTable?.fireChange();
}
/**
* The current version of the variable table, incremented on each change.
*/
get version(): number {
return this._version;
}
/**
* Increments the version number, resetting to 0 if it reaches MAX_SAFE_INTEGER.
*/
protected bumpVersion() {
this._version = this._version + 1;
if (this._version === Number.MAX_SAFE_INTEGER) {
this._version = 0;
}
}
constructor(
/**
* An optional parent table. If provided, this table will contain all variables
* from the current table.
*/
public parentTable?: IVariableTable
) {
this.toDispose.pushAll([
this.onDataChangeEmitter,
// Activate the share() operator
this.onAnyVariableChange(() => {
this.bumpVersion();
}),
]);
}
/**
* An array of all variables in the table.
*/
get variables(): VariableDeclaration[] {
return Array.from(this.table.values());
}
/**
* An array of all variable keys in the table.
*/
get variableKeys(): string[] {
return Array.from(this.table.keys());
}
/**
* Retrieves a variable or a nested property field by its key path.
* @param keyPath An array of keys representing the path to the desired field.
* @returns The found variable or property field, or undefined if not found.
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
const [variableKey, ...propertyKeys] = keyPath || [];
if (!variableKey) {
return;
}
const variable = this.getVariableByKey(variableKey);
return propertyKeys.length ? variable?.getByKeyPath(propertyKeys) : variable;
}
/**
* Retrieves a variable by its key.
* @param key The key of the variable to retrieve.
* @returns The variable declaration if found, otherwise undefined.
*/
getVariableByKey(key: string) {
return this.table.get(key);
}
/**
* Adds a variable to the table.
* If a parent table exists, the variable is also added to the parent.
* @param variable The variable declaration to add.
*/
addVariableToTable(variable: VariableDeclaration) {
this.table.set(variable.key, variable);
if (this.parentTable) {
(this.parentTable as VariableTable).addVariableToTable(variable);
}
}
/**
* Removes a variable from the table.
* If a parent table exists, the variable is also removed from the parent.
* @param key The key of the variable to remove.
*/
removeVariableFromTable(key: string) {
this.table.delete(key);
if (this.parentTable) {
(this.parentTable as VariableTable).removeVariableFromTable(key);
}
}
/**
* Disposes of all resources used by the variable table.
*/
dispose(): void {
this.variableKeys.forEach((_key) =>
(this.parentTable as VariableTable)?.removeVariableFromTable(_key)
);
this.parentTable?.fireChange();
this.variables$.complete();
this.variables$.unsubscribe();
this.toDispose.dispose();
}
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { VariableFieldKeyRenameService } from './variable-field-key-rename-service';

Some files were not shown because too many files have changed in this diff Show More