@zhin.js/schema 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,21 +124,51 @@ const formatter = Schema.resolve('number')
124
124
 
125
125
  ## 与 Zhin.js 集成
126
126
 
127
- 在插件中使用 `defineSchema` 定义配置(自动注册到 Web 控制台表单渲染):
127
+ Plugin Runtime 可以直接使用同一份声明推导类型、生成标准 JSON Schema、校验配置、填充默认值并向 Console 提供表单元信息:
128
128
 
129
129
  ```typescript
130
- import { usePlugin, Schema } from 'zhin.js'
130
+ import { definePlugin, Schema } from 'zhin.js'
131
131
 
132
- const { defineSchema } = usePlugin()
132
+ const configSchema = Schema.object({
133
+ interval: Schema.number().min(1).default(300).description('轮询间隔,秒').effect('reload'),
134
+ enabled: Schema.boolean().default(true),
135
+ apiKey: Schema.string().required().secret().component('password'),
136
+ label: Schema.string(),
137
+ })
133
138
 
134
- const getConfig = defineSchema(Schema.object({
135
- port: Schema.number().default(8080).description('服务端口'),
136
- enabled: Schema.boolean().default(true).description('是否启用'),
137
- }))
139
+ type Config = Schema.Output<typeof configSchema>
140
+ // interval、enabled、apiKey 为必有字段;label 为 string | undefined。
141
+
142
+ export default definePlugin({
143
+ name: 'subscription',
144
+ config: configSchema,
145
+ setup({ config }) {
146
+ const current = config.get() // 自动推导 Config,不再手写泛型
147
+ console.log(current.interval, current.enabled)
148
+ },
149
+ })
150
+ ```
151
+
152
+ 应用配置的密钥仍使用环境引用:
138
153
 
139
- const config = getConfig()
154
+ ```yaml
155
+ plugins:
156
+ subscription:
157
+ apiKey: ${SUBSCRIPTION_API_KEY}
140
158
  ```
141
159
 
160
+ `secret()` 声明仅用于表单和数据处理提示,不储存密钥、也不负责读取环境变量;它会导出标准 `writeOnly: true` 与 `x-zhin-config.secret` 元信息,并拒绝密钥默认值。环境展开和配置校验由现有 Runtime 完成。`.effect('reload')` 表明插件更新后重载;系统配置的进程重启由 Root Host 决定。
161
+
162
+ `schema.toJsonSchema()` 返回标准 JSON Schema 2020-12;默认值、范围、枚举、描述与 `x-zhin-config` 表单提示均从同一声明生成。Runtime 的现有校验器负责校验与默认值,不增加第二套配置引擎。对象默认不接受未知属性;字典使用 `Schema.dict()`。配置只能使用 JSON 值;业务 `date/regexp` 无法导出为配置契约。
163
+
164
+ 这份内联声明适用于 trusted 插件;isolated 插件保持根目录 `schema.json`,Host 不执行其入口。单个插件不能同时声明 `config` 与 `schema.json`,避免两个来源漂移。`schema.json` 仍支持只有 JSON 配置契约的插件。
165
+
166
+ 插件配置根必须是对象;没有自有字段时可省略 `properties`,`{}` 是合法的空配置声明。声明字段使用 `properties`,不能只依赖根级组合或引用来提供字段。Console 支持基础嵌套对象、列表、字典及字段元信息;`union`、`intersect`、`tuple` 可以导出标准校验契约,但组合类型和 `$ref` 的完整表单编辑仍需独立 Console 前端协同验收,当前 Console 对不支持的形态明确报错。
167
+
168
+ `union` 的默认值应声明在联合字段自身(例如 `Schema.union([...]).default(...)`);联合分支内部的默认值无法由 Runtime 确定应用到哪一分支,因此导出时明确拒绝。直接作为联合选项的 `Schema.const(...)` 可以使用,但分支对象内的 `Schema.const(...)` 也携带默认值,同样受此限制。
169
+
170
+ 现有业务输入解析和 `schema.toJSON()` / `Schema.fromJSON()` 保持各自的内部序列化格式;使用标准配置契约请调用 `.toJsonSchema()`。
171
+
142
172
  ## 工具函数
143
173
 
144
174
  - `isEmpty(value)` - 检查值是否为空(`undefined`、`null`、空字符串、空数组、空对象)
@@ -181,7 +211,10 @@ const config = getConfig()
181
211
  | `.max(value)` | 设置最大值 |
182
212
  | `.step(value)` | 设置步长 |
183
213
  | `.component(name)` | 指定渲染组件 |
184
- | `.toJSON()` | 序列化为 JSON |
214
+ | `.toJSON()` | 业务 Schema 的内部序列化 |
215
+ | `.toJsonSchema()` | 导出标准配置 JSON Schema |
216
+ | `.secret()` | 密钥表单元信息,配置继续使用环境引用 |
217
+ | `.effect('reload')` | 配置生效方式提示 |
185
218
 
186
219
  ## License
187
220
 
package/lib/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './schema.js';
2
2
  export * from './validate.js';
3
3
  export * from './utils.js';
4
+ export * from './json-schema.js';
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './schema.js';
2
2
  export * from './validate.js';
3
3
  export * from './utils.js';
4
+ export * from './json-schema.js';
@@ -0,0 +1,12 @@
1
+ import type { Schema } from './schema.js';
2
+ /** Form annotations on the same JSON Schema used for configuration validation. */
3
+ export interface ConfigFieldMetadata {
4
+ readonly secret?: boolean;
5
+ readonly hidden?: boolean;
6
+ readonly component?: string;
7
+ readonly options?: readonly Schema.Option[];
8
+ readonly effect?: 'reload';
9
+ }
10
+ export type ConfigJsonSchema = Readonly<Record<string, unknown>>;
11
+ /** Export serializable configuration declarations; non-JSON business types are explicit errors. */
12
+ export declare function toJsonSchema(schema: Schema): ConfigJsonSchema;
@@ -0,0 +1,183 @@
1
+ /** Export serializable configuration declarations; non-JSON business types are explicit errors. */
2
+ export function toJsonSchema(schema) {
3
+ const { meta, options } = schema;
4
+ const result = {};
5
+ switch (meta.type) {
6
+ case 'string':
7
+ case 'number':
8
+ case 'boolean':
9
+ result.type = meta.type;
10
+ break;
11
+ case 'object': {
12
+ result.type = 'object';
13
+ result.additionalProperties = false;
14
+ const shape = options.object ?? {};
15
+ result.properties = Object.fromEntries(Object.entries(shape).map(([key, field]) => [key, toJsonSchema(field)]));
16
+ const required = Object.entries(shape).filter(([, field]) => field.meta.required).map(([key]) => key);
17
+ if (required.length)
18
+ result.required = required;
19
+ break;
20
+ }
21
+ case 'dict':
22
+ result.type = 'object';
23
+ result.additionalProperties = toJsonSchema(options.inner);
24
+ break;
25
+ case 'list':
26
+ result.type = 'array';
27
+ result.items = toJsonSchema(options.inner);
28
+ break;
29
+ case 'tuple':
30
+ result.type = 'array';
31
+ result.prefixItems = (options.list ?? []).map(toJsonSchema);
32
+ result.items = false;
33
+ result.minItems = result.maxItems = options.list?.length ?? 0;
34
+ break;
35
+ case 'union':
36
+ for (const branch of options.list ?? [])
37
+ assertUnionBranchDefaults(branch, true);
38
+ result.anyOf = (options.list ?? []).map(toJsonSchema);
39
+ break;
40
+ case 'intersect': {
41
+ const branches = (options.list ?? []).map(toJsonSchema);
42
+ if (branches.every(branch => branch.type === 'object')) {
43
+ result.type = 'object';
44
+ // Close the combined shape, rather than making each object reject
45
+ // properties contributed by the other intersection branches.
46
+ result.unevaluatedProperties = false;
47
+ result.allOf = branches.map(branch => {
48
+ // The outer intersection owns closure, including nested intersections.
49
+ const { additionalProperties, unevaluatedProperties, ...rest } = branch;
50
+ const openBranch = { ...rest };
51
+ if (additionalProperties !== false && additionalProperties !== undefined) {
52
+ openBranch.additionalProperties = additionalProperties;
53
+ }
54
+ if (unevaluatedProperties !== false && unevaluatedProperties !== undefined) {
55
+ openBranch.unevaluatedProperties = unevaluatedProperties;
56
+ }
57
+ return openBranch;
58
+ });
59
+ }
60
+ else
61
+ result.allOf = branches;
62
+ break;
63
+ }
64
+ case 'const':
65
+ result.const = meta.default;
66
+ break;
67
+ case 'any': break;
68
+ default: throw new TypeError(`Schema type ${meta.type} cannot describe JSON configuration`);
69
+ }
70
+ if (meta.description !== undefined)
71
+ result.description = meta.description;
72
+ if (meta.default !== undefined) {
73
+ assertJsonValue(meta.default);
74
+ assertPublicDefault(schema, meta.default);
75
+ result.default = structuredClone(meta.default);
76
+ }
77
+ const minKey = meta.type === 'number' ? 'minimum' : meta.type === 'string' ? 'minLength' : meta.type === 'list' ? 'minItems' : undefined;
78
+ const maxKey = meta.type === 'number' ? 'maximum' : meta.type === 'string' ? 'maxLength' : meta.type === 'list' ? 'maxItems' : undefined;
79
+ if (meta.min !== undefined && minKey)
80
+ result[minKey] = meta.min;
81
+ if (meta.max !== undefined && maxKey)
82
+ result[maxKey] = meta.max;
83
+ if (meta.step !== undefined && meta.type === 'number') {
84
+ if (!Number.isFinite(meta.step) || meta.step <= 0)
85
+ throw new TypeError('Configuration step must be a positive finite number');
86
+ result.multipleOf = meta.step;
87
+ }
88
+ if (meta.options?.length) {
89
+ for (const option of meta.options)
90
+ assertJsonValue(option.value);
91
+ result.enum = structuredClone(meta.options.map(option => option.value));
92
+ }
93
+ if (meta.secret)
94
+ result.writeOnly = true;
95
+ const annotations = {
96
+ ...(meta.secret ? { secret: true } : {}),
97
+ ...(meta.hidden ? { hidden: true } : {}),
98
+ ...(meta.component !== undefined ? { component: meta.component } : {}),
99
+ ...(meta.options !== undefined ? { options: structuredClone(meta.options) } : {}),
100
+ ...(meta.effect !== undefined ? { effect: meta.effect } : {}),
101
+ };
102
+ if (Object.keys(annotations).length)
103
+ result['x-zhin-config'] = annotations;
104
+ return result;
105
+ }
106
+ /** Ajv cannot apply defaults inside anyOf alternatives without choosing a branch. */
107
+ function assertUnionBranchDefaults(schema, branchRoot = false) {
108
+ // A literal alternative carries its value as an intrinsic const annotation;
109
+ // it never fills a nested property and must remain usable in literal unions.
110
+ if (schema.meta.default !== undefined && !(branchRoot && schema.meta.type === 'const')) {
111
+ throw new TypeError('Defaults inside union alternatives cannot be applied by Runtime; place the default on the union field');
112
+ }
113
+ for (const field of Object.values(schema.options.object ?? {}))
114
+ assertUnionBranchDefaults(field);
115
+ if (schema.options.inner)
116
+ assertUnionBranchDefaults(schema.options.inner);
117
+ for (const item of schema.options.list ?? [])
118
+ assertUnionBranchDefaults(item, schema.meta.type === 'union');
119
+ }
120
+ /** Defaults on an enclosing object/list must not smuggle a secret into forms. */
121
+ function assertPublicDefault(schema, value) {
122
+ if (value === undefined)
123
+ return;
124
+ if (schema.meta.secret) {
125
+ throw new TypeError('Secret configuration fields cannot contain default values; reference an environment variable');
126
+ }
127
+ switch (schema.meta.type) {
128
+ case 'object':
129
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
130
+ for (const [key, field] of Object.entries(schema.options.object ?? {})) {
131
+ assertPublicDefault(field, value[key]);
132
+ }
133
+ }
134
+ break;
135
+ case 'dict':
136
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
137
+ for (const item of Object.values(value))
138
+ assertPublicDefault(schema.options.inner, item);
139
+ }
140
+ break;
141
+ case 'list':
142
+ case 'tuple':
143
+ if (Array.isArray(value))
144
+ value.forEach((item, index) => {
145
+ const inner = schema.meta.type === 'list' ? schema.options.inner : schema.options.list?.[index];
146
+ if (inner)
147
+ assertPublicDefault(inner, item);
148
+ });
149
+ break;
150
+ case 'union':
151
+ // Validate-only matching is deliberately broader than JSON constraints:
152
+ // inspect every structurally matching branch, including overlaps, so a
153
+ // secret branch can never be bypassed by choosing a public alternative.
154
+ for (const branch of schema.options.list ?? []) {
155
+ if (branch.safeParse(value).success)
156
+ assertPublicDefault(branch, value);
157
+ }
158
+ break;
159
+ case 'intersect':
160
+ for (const branch of schema.options.list ?? [])
161
+ assertPublicDefault(branch, value);
162
+ break;
163
+ }
164
+ }
165
+ function assertJsonValue(value, parents = new Set()) {
166
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
167
+ return;
168
+ if (typeof value === 'number' && Number.isFinite(value))
169
+ return;
170
+ if (typeof value === 'object') {
171
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
172
+ throw new TypeError('Configuration defaults must contain only JSON values');
173
+ }
174
+ if (parents.has(value))
175
+ throw new TypeError('Configuration defaults cannot be cyclic');
176
+ parents.add(value);
177
+ for (const item of Object.values(value))
178
+ assertJsonValue(item, parents);
179
+ parents.delete(value);
180
+ return;
181
+ }
182
+ throw new TypeError('Configuration defaults must contain only JSON values');
183
+ }
package/lib/schema.d.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { safeParseSchema, type SchemaSafeParseResult } from './validate.js';
2
+ import { type ConfigFieldMetadata } from './json-schema.js';
2
3
  export { SchemaValidationError, type SchemaSafeParseResult, type SchemaValidationIssue } from './validate.js';
3
4
  export { safeParseSchema };
5
+ declare const configPresent: unique symbol;
6
+ declare const inputRequired: unique symbol;
4
7
  export declare class Schema<S = any, T = S> {
5
8
  meta: Schema.Meta<S, T>;
6
9
  options: Schema.Options;
10
+ /** Type-only output witness used by Plugin configuration inference. */
11
+ readonly output?: T;
7
12
  [Symbol.toStringTag]: string;
8
13
  constructor(meta: Schema.Meta<S, T>, options?: Schema.Options);
9
14
  static fromJSON<S, T>(json: Schema.JSON<S, T>): Schema<S, T>;
@@ -13,13 +18,19 @@ export declare class Schema<S = any, T = S> {
13
18
  meta: boolean;
14
19
  };
15
20
  /** 设置是否必填 */
16
- required(): this;
21
+ required(): this & Schema.Present & Schema.Required;
17
22
  /** 是否隐藏 */
18
23
  hidden(): this;
19
24
  /** 设置描述 */
20
25
  description(description: string): this;
21
26
  /** 设置默认值 */
22
- default(defaultValue: T): this;
27
+ default(defaultValue: T): this & Schema.Present;
28
+ /** Secret values remain environment references in application configuration. */
29
+ secret(): this;
30
+ /** Declare the effect shown in configuration forms. */
31
+ effect(effect: ConfigFieldMetadata['effect']): this;
32
+ /** Export the standard JSON Schema consumed by Runtime and Console. */
33
+ toJsonSchema(): Readonly<Record<string, unknown>>;
23
34
  /** 设置选项列表 */
24
35
  option(list: (T | Schema.Option<T>)[]): this;
25
36
  /** 设置是否允许多选 */
@@ -43,18 +54,18 @@ export declare class Schema<S = any, T = S> {
43
54
  /** 声明一个日期类型 */
44
55
  static date(key?: string): Schema<number | Date, Date>;
45
56
  /** 声明一个字典类型 */
46
- static dict<X extends Schema>(input: X, key?: string): Schema<Record<string, Schema.Types<X>>, Record<string, Schema.Types<X>>>;
47
- static object<X extends Record<string, Schema<any, any>>>(input: X, key?: string): Schema<Schema.RecordTypes<X>, Schema.RecordTypes<X>>;
57
+ static dict<X extends Schema>(input: X, key?: string): Schema<Record<string, Schema.Input<X>>, Record<string, Schema.Output<X>>>;
58
+ static object<X extends Record<string, Schema<any, any>>>(input: X, key?: string): Schema<Schema.InputRecord<X>, Schema.RecordTypes<X>>;
48
59
  /** 声明一个列表类型 */
49
- static list<X extends Schema>(inner: X, key?: string): Schema<Schema.Types<X>[], Schema.Types<X>[]>;
60
+ static list<X extends Schema>(inner: X, key?: string): Schema<Schema.Input<X>[], Schema.Output<X>[]>;
50
61
  /** 声明一个元组类型 */
51
- static tuple<X extends readonly any[]>(list: X, key?: string): Schema<Schema.Tuple<X>, Schema.Tuple<X>>;
62
+ static tuple<const X extends readonly any[]>(list: X, key?: string): Schema<Schema.InputTuple<X>, Schema.Tuple<X>>;
52
63
  /** 声明一个联合类型 */
53
- static union<X extends readonly Schema[]>(list: X, key?: string): Schema<Schema.Union<X>>;
64
+ static union<const X extends readonly Schema[]>(list: X, key?: string): Schema<Schema.InputUnion<X>, Schema.Union<X>>;
54
65
  /** 声明一个交叉类型 */
55
- static intersect<X extends readonly any[]>(list: X, key?: string): Schema<Schema.Intersect<X>, Schema.Intersect<X>>;
66
+ static intersect<const X extends readonly any[]>(list: X, key?: string): Schema<Schema.InputIntersect<X>, Schema.Intersect<X>>;
56
67
  /** 声明一个常量 */
57
- static const<X extends string | number | boolean>(value: X, key?: string): Schema<X, X>;
68
+ static const<X extends string | number | boolean>(value: X, key?: string): Schema<X> & Schema.Present;
58
69
  /** 声明任意类型 */
59
70
  static any(key?: any): Schema<any, any>;
60
71
  /**
@@ -63,7 +74,7 @@ export declare class Schema<S = any, T = S> {
63
74
  */
64
75
  static discriminatedUnion<D extends string, V extends Record<string, Record<string, Schema<any, any>>>>(discriminator: D, variants: V, key?: string): Schema<any>;
65
76
  /** validate-only:不填默认值、不 throw */
66
- safeParse(value?: unknown): SchemaSafeParseResult<T>;
77
+ safeParse(value?: unknown): SchemaSafeParseResult<S | undefined>;
67
78
  static resolve<T extends string>(type: T): Schema.Formatter;
68
79
  static extend<T extends string>(type: T, formatter: Schema.Formatter): void;
69
80
  }
@@ -71,6 +82,22 @@ export interface Schema<S = any> {
71
82
  (value?: S, key?: string): S;
72
83
  }
73
84
  export declare namespace Schema {
85
+ interface Present {
86
+ readonly [configPresent]: true;
87
+ }
88
+ interface Required {
89
+ readonly [inputRequired]: true;
90
+ }
91
+ type Input<T> = T extends Schema<infer I, any> ? I : never;
92
+ type Output<T> = T extends Schema<any, infer O> ? O : never;
93
+ type InputRecord<X extends Record<string, Schema>> = {
94
+ [K in keyof X as X[K] extends Required ? K : never]-?: Input<X[K]>;
95
+ } & {
96
+ [K in keyof X as X[K] extends Required ? never : K]?: Input<X[K]>;
97
+ };
98
+ type InputUnion<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? Input<L> | InputUnion<R> : never;
99
+ type InputIntersect<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? Input<L> & InputIntersect<R> : unknown;
100
+ type InputTuple<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [Input<L>, ...InputTuple<R>] : [];
74
101
  const formatters: Map<string, Formatter>;
75
102
  type Formatter<S = any, T = S> = (this: Schema, key: string, value: S) => T;
76
103
  type JSON<S = any, T = S> = Meta<S, T> & {
@@ -91,15 +118,19 @@ export declare namespace Schema {
91
118
  min?: number;
92
119
  max?: number;
93
120
  step?: number;
121
+ secret?: boolean;
122
+ effect?: ConfigFieldMetadata['effect'];
94
123
  }
95
124
  interface Options {
96
125
  object?: Record<string, Schema>;
97
126
  inner?: Schema;
98
127
  list?: readonly Schema[];
99
128
  }
100
- type Types<T> = T extends Schema<infer S> ? S : never;
129
+ type Types<T> = Output<T>;
101
130
  type RecordTypes<T> = T extends Record<string, Schema> ? {
102
- [K in keyof T]?: Types<T[K]>;
131
+ [K in keyof T as T[K] extends Present ? K : never]-?: Output<T[K]>;
132
+ } & {
133
+ [K in keyof T as T[K] extends Present ? never : K]?: Output<T[K]>;
103
134
  } : unknown;
104
135
  type Union<T extends readonly any[]> = T extends readonly [infer L, ...infer R] ? Types<L> | Union<R> : never;
105
136
  type Intersect<T extends readonly any[]> = T extends readonly [infer L, ...infer R] ? Types<L> & Intersect<R> : unknown;
package/lib/schema.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isEmpty } from './utils.js';
2
2
  import { safeParseSchema } from './validate.js';
3
+ import { toJsonSchema } from './json-schema.js';
3
4
  export { SchemaValidationError } from './validate.js';
4
5
  export { safeParseSchema };
5
6
  export class Schema {
@@ -79,6 +80,24 @@ export class Schema {
79
80
  this.meta.default = defaultValue;
80
81
  return this;
81
82
  }
83
+ /** Secret values remain environment references in application configuration. */
84
+ secret() {
85
+ if (this.meta.type !== 'string')
86
+ throw new TypeError('secret only supports string fields');
87
+ this.meta.secret = true;
88
+ return this;
89
+ }
90
+ /** Declare the effect shown in configuration forms. */
91
+ effect(effect) {
92
+ if (effect !== 'reload')
93
+ throw new TypeError('Plugin configuration fields support only reload');
94
+ this.meta.effect = effect;
95
+ return this;
96
+ }
97
+ /** Export the standard JSON Schema consumed by Runtime and Console. */
98
+ toJsonSchema() {
99
+ return toJsonSchema(this);
100
+ }
82
101
  /** 设置选项列表 */
83
102
  option(list) {
84
103
  this.meta.options = Schema.formatOptionList(list);
@@ -255,7 +274,7 @@ export class Schema {
255
274
  Schema.checkDefault = checkDefault;
256
275
  function formatOptionList(list) {
257
276
  return list.map(item => {
258
- if (typeof item === 'string') {
277
+ if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
259
278
  return {
260
279
  label: `${item}`,
261
280
  value: item,
package/lib/validate.d.ts CHANGED
@@ -15,4 +15,4 @@ export type SchemaSafeParseResult<T> = {
15
15
  success: false;
16
16
  error: SchemaValidationError;
17
17
  };
18
- export declare function safeParseSchema<T>(schema: Schema<T>, value: unknown): SchemaSafeParseResult<T>;
18
+ export declare function safeParseSchema<S, T>(schema: Schema<S, T>, value: unknown): SchemaSafeParseResult<S | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/schema",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Configuration validation and schema system for Zhin.js",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",