@77-zhike/app-sdk 0.2.0-d7b1.7 → 0.2.0-d7b1.9
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 +13 -7
- package/package.json +9 -3
- package/src/cli/development-logs.js +5 -2
- package/src/cli/index.js +11 -23
- package/src/cli/local-commands.js +1 -0
- package/src/cli/project-init.js +2 -1
- package/src/forms/array-contract.d.ts +32 -0
- package/src/forms/array-contract.js +138 -0
- package/src/forms/factory.js +9 -4
- package/src/forms/field-array.d.ts +16 -0
- package/src/forms/field-array.js +52 -0
- package/src/forms/index.d.ts +5 -1
- package/src/forms/schema.d.ts +30 -11
- package/src/forms/schema.js +29 -6
- package/src/forms/state.d.ts +6 -4
- package/src/forms/state.js +14 -24
- package/src/forms/validation.d.ts +3 -3
- package/src/forms/validation.js +24 -26
- package/src/server/index.d.ts +40 -5
- package/src/server/index.js +21 -0
- package/src/server/steps/index.d.ts +71 -0
- package/src/server/steps/index.js +15 -0
- package/src/server/triggers/index.d.ts +68 -0
- package/src/server/triggers/index.js +27 -0
- package/src/server/workflow-blocks/index.d.ts +72 -0
- package/src/server/workflow-blocks/index.js +32 -0
- package/src/toolchain/allowed-packages.json +7 -0
- package/src/toolchain/dependency-policy.js +175 -0
- package/src/toolchain/index.js +0 -88
- package/src/toolchain/real-build.js +59 -17
- package/src/toolchain/source-discovery.js +76 -65
- package/src/toolchain/workflow-blocks/discovery.js +133 -0
- package/src/toolchain/workflow-steps/manifest.js +99 -0
- package/src/toolchain/workflow-triggers/manifest.js +47 -0
- package/src/ui/components.generated.js +3 -2
- package/src/ui/factory.js +1 -1
- package/src/ui/index.d.ts +5 -5
- package/src/ui/index.js +1 -1
- package/src/ui/validation.js +3 -3
- package/src/cli/validate-bundle.js +0 -36
package/src/forms/schema.js
CHANGED
|
@@ -3,6 +3,8 @@ import { recordObjectsForField } from './record-options.js';
|
|
|
3
3
|
import { freezeFormState } from './state.js';
|
|
4
4
|
import { createValidationCallback } from './validation.js';
|
|
5
5
|
|
|
6
|
+
import { validFieldKey, assertArrayCapacity } from './array-contract.js';
|
|
7
|
+
|
|
6
8
|
const descriptors = new WeakMap();
|
|
7
9
|
const fail = () => { throw new Error('runtime_form_schema_invalid'); };
|
|
8
10
|
const message = value => {
|
|
@@ -18,7 +20,7 @@ function builder(field, validators = []) {
|
|
|
18
20
|
if (!options || Object.keys(options).some(key => key !== 'message')) fail();
|
|
19
21
|
messages[key] = message(options.message);
|
|
20
22
|
} else delete messages[key];
|
|
21
|
-
return next({ [field.type === 'string' ? `${key}Length` : key]: value, messages });
|
|
23
|
+
return next({ [field.type === 'array' ? `${key}Items` : field.type === 'string' ? `${key}Length` : key]: value, messages });
|
|
22
24
|
};
|
|
23
25
|
const methods = {
|
|
24
26
|
label(value) { return next({ label: value }); },
|
|
@@ -29,10 +31,11 @@ function builder(field, validators = []) {
|
|
|
29
31
|
return next({}, [...validators, validator]);
|
|
30
32
|
},
|
|
31
33
|
};
|
|
32
|
-
if (['string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
|
|
34
|
+
if (['array', 'string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
|
|
33
35
|
methods.min = (value, options) => rule('min', value, options);
|
|
34
36
|
methods.max = (value, options) => rule('max', value, options);
|
|
35
37
|
}
|
|
38
|
+
if (field.type === 'array') { delete methods.required; delete methods.optional; }
|
|
36
39
|
if (field.type === 'string') for (const format of ['email', 'url']) methods[format] = options => {
|
|
37
40
|
const messages = { ...field.messages };
|
|
38
41
|
if (options === undefined) delete messages.format;
|
|
@@ -51,6 +54,10 @@ export const Forms = Object.freeze({
|
|
|
51
54
|
},
|
|
52
55
|
...Object.fromEntries(['string', 'number', 'boolean', 'plainDate', 'dateTime'].map(type => [type, () => builder({ type })])),
|
|
53
56
|
enum: options => builder({ type: 'enum', options: Array.isArray(options) ? options.map(option => Object.freeze({ ...option })) : options }),
|
|
57
|
+
array(fields) {
|
|
58
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || !Object.keys(fields).length || Object.keys(fields).length > 12 || Object.values(fields).some(field => !descriptors.has(field) || field.type === 'array')) fail();
|
|
59
|
+
return builder({ type: 'array', minItems: 0, maxItems: 50, item: { type: 'object', fields: { ...fields } } });
|
|
60
|
+
},
|
|
54
61
|
record: objectSlug => builder({ type: 'record', objectSlug }),
|
|
55
62
|
});
|
|
56
63
|
|
|
@@ -58,13 +65,26 @@ export const Forms = Object.freeze({
|
|
|
58
65
|
export function compileForm(options, formId, bindCallback = callback => callback) {
|
|
59
66
|
const { schema, validate, validationMode = 'onBlur' } = options;
|
|
60
67
|
if (!schema || !schema.fields || typeof schema.fields !== 'object' || Array.isArray(schema.fields) || Object.keys(schema).some(key => key !== 'fields')
|
|
61
|
-
|| Object.
|
|
68
|
+
|| Object.values(schema.fields).filter(field => field.type !== 'array').length > 30 || Object.values(schema.fields).filter(field => field.type === 'array').length > 3 || !['onBlur', 'onChange', 'onSubmit'].includes(validationMode)
|
|
62
69
|
|| (validate !== undefined && typeof validate !== 'function')) fail();
|
|
63
70
|
const fields = {}, validators = {};
|
|
64
71
|
for (const [name, input] of Object.entries(schema.fields)) {
|
|
65
|
-
if (
|
|
72
|
+
if (!validFieldKey(name)) fail();
|
|
66
73
|
const descriptor = descriptors.get(input);
|
|
67
|
-
const
|
|
74
|
+
const source = descriptor?.field ?? input;
|
|
75
|
+
if (source?.type === 'array') {
|
|
76
|
+
if (Object.keys(source).some(key => !['type', 'label', 'minItems', 'maxItems', 'item', 'messages'].includes(key)) || typeof source.label !== 'string' || !source.label.trim() || source.label.length > 200
|
|
77
|
+
|| !Number.isSafeInteger(source.minItems) || source.minItems < 0 || !Number.isSafeInteger(source.maxItems) || source.maxItems < 1 || source.maxItems > 50 || source.minItems > source.maxItems
|
|
78
|
+
|| source.item?.type !== 'object' || Object.keys(source.item).some(key => !['type', 'fields'].includes(key)) || !source.item.fields || Object.values(source.item.fields).some(field => field.type === 'array')
|
|
79
|
+
|| !Object.keys(source.item.fields).length || Object.keys(source.item.fields).length > 12) fail();
|
|
80
|
+
const child = compileForm({ schema: { fields: source.item.fields } }, formId, bindCallback);
|
|
81
|
+
fields[name] = { ...source, item: { type: 'object', fields: child.schema.fields } };
|
|
82
|
+
if (source.messages) { if (Object.keys(source.messages).some(key => !['min', 'max'].includes(key))) fail(); Object.values(source.messages).forEach(message); }
|
|
83
|
+
for (const [key, checks] of Object.entries(child.validators)) validators[`${name}[*].${key}`] = checks;
|
|
84
|
+
if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const field = structuredClone(source);
|
|
68
88
|
if (!field || !['string', 'boolean', 'enum', 'number', 'plainDate', 'dateTime', 'record'].includes(field.type)
|
|
69
89
|
|| typeof field.label !== 'string' || descriptor && !field.label.trim() || field.label.length > 200) fail();
|
|
70
90
|
const allowed = ['type', 'label', 'required', 'messages', ...(field.type === 'string' ? ['minLength', 'maxLength', 'format'] : field.type === 'enum' ? ['options'] : field.type === 'record' ? ['objectSlug', 'objects'] : ['number', 'plainDate', 'dateTime'].includes(field.type) ? ['min', 'max'] : [])];
|
|
@@ -99,7 +119,10 @@ export function compileForm(options, formId, bindCallback = callback => callback
|
|
|
99
119
|
const validation = { fields: Object.keys(validators), form: Boolean(validate), mode: validationMode };
|
|
100
120
|
const remote = Boolean(validation.fields.length || validate);
|
|
101
121
|
const configured = remote || options.validationMode !== undefined || Object.values(schema.fields).some(field => descriptors.has(field));
|
|
102
|
-
|
|
122
|
+
if (options.initialValues !== undefined) assertArrayCapacity(fields, options.initialValues);
|
|
123
|
+
const result = { schema: wire, ...(configured ? { validation } : {}), ...(remote ? {
|
|
103
124
|
onValidate: createValidationCallback(formId, fields, validators, validate ? bindCallback(validate) : undefined),
|
|
104
125
|
} : {}) };
|
|
126
|
+
Object.defineProperty(result, 'validators', { value: validators });
|
|
127
|
+
return result;
|
|
105
128
|
}
|
package/src/forms/state.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { FormSchema, FormValues } from './schema.js';
|
|
2
|
+
import type { FormSchema, FormValues, FormFieldErrors, FormArrayErrors } from './schema.js';
|
|
3
3
|
export type FormStateSelection<S extends FormSchema> = {
|
|
4
4
|
values?: true | readonly (keyof S['fields'] & string)[];
|
|
5
|
+
arrayErrors?: true | readonly (keyof FormArrayErrors<S> & string)[];
|
|
5
6
|
errors?: true | readonly (keyof S['fields'] & string)[];
|
|
6
7
|
submitting?: boolean;
|
|
7
8
|
validating?: boolean;
|
|
@@ -12,17 +13,18 @@ type Names<S extends FormSchema, T> = T extends true ? keyof FormValues<S> : T e
|
|
|
12
13
|
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
13
14
|
export type SelectedFormState<S extends FormSchema, Selection extends FormStateSelection<S>> =
|
|
14
15
|
(Selection extends { values: infer T } ? { readonly values: ReadonlyValue<Pick<FormValues<S>, Names<S, T>>> } : {})
|
|
15
|
-
& (Selection extends { errors: infer T } ? { readonly errors:
|
|
16
|
+
& (Selection extends { errors: infer T } ? { readonly errors: ReadonlyValue<Pick<FormFieldErrors<S>, Names<S, T>>> } : {})
|
|
17
|
+
& (Selection extends { arrayErrors: infer T } ? { readonly arrayErrors: Readonly<Pick<FormArrayErrors<S>, Extract<Names<S, T>, keyof FormArrayErrors<S>>>> } : {})
|
|
16
18
|
& (Selection extends { submitting: true } ? { readonly submitting: boolean } : {})
|
|
17
19
|
& (Selection extends { validating: true } ? { readonly validating: boolean } : {})
|
|
18
20
|
& (Selection extends { formError: true } ? { readonly formError: string | null } : {})
|
|
19
|
-
& (Selection extends { validatingFields: infer T } ? { readonly validatingFields:
|
|
21
|
+
& (Selection extends { validatingFields: infer T } ? { readonly validatingFields: ReadonlyValue<Pick<FormFieldErrors<S, boolean>, Names<S, T>>> } : {});
|
|
20
22
|
export type WithStateComponent<S extends FormSchema> = <const Selection extends FormStateSelection<S>>(props: {
|
|
21
23
|
selection: Selection;
|
|
22
24
|
children: (state: SelectedFormState<S, Selection>) => ReactNode;
|
|
23
25
|
}) => ReactNode;
|
|
24
26
|
|
|
25
|
-
export type StateSelection = { values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
|
|
27
|
+
export type StateSelection = { arrayErrors?: true | readonly string[]; values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
|
|
26
28
|
export function selectedNames(selection: true | readonly string[] | undefined, fields: Record<string, unknown>): string[];
|
|
27
29
|
export function assertFormStateSelection(selection: unknown, fields: Record<string, unknown>): StateSelection;
|
|
28
30
|
export function assertFormStateSnapshot(snapshot: unknown, formId: string, fields: Record<string, unknown>, selection: StateSelection): unknown;
|
package/src/forms/state.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { assertFieldValue, assertErrorTree, assertArrayErrors, normalizeFormValues, formBytes, ARRAY_LIMITS } from './array-contract.js';
|
|
1
2
|
import { assertJsonValue } from '../ui/validation.js';
|
|
2
|
-
import { isDateTime, isPlainDate } from './values.js';
|
|
3
|
-
import { assertRecordRef, recordObjectsForField } from './record-options.js';
|
|
4
3
|
const fail = () => { throw new Error('runtime_form_state_invalid'); };
|
|
5
4
|
const only = (value, keys) => {
|
|
6
5
|
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
@@ -9,47 +8,40 @@ export function selectedNames(selection, fields) {
|
|
|
9
8
|
return selection === true ? Object.keys(fields) : Array.isArray(selection) ? selection : [];
|
|
10
9
|
}
|
|
11
10
|
export function assertFormStateSelection(selection, fields) {
|
|
12
|
-
only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'formError']);
|
|
13
|
-
for (const kind of ['values', 'errors', 'validatingFields']) {
|
|
11
|
+
only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'arrayErrors', 'formError']);
|
|
12
|
+
for (const kind of ['values', 'errors', 'validatingFields', 'arrayErrors']) {
|
|
14
13
|
const names = selection[kind];
|
|
15
14
|
if (names === undefined || names === true) continue;
|
|
16
|
-
if (!Array.isArray(names) || names.length >
|
|
15
|
+
if (!Array.isArray(names) || names.length > 33 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))) fail();
|
|
16
|
+
if (kind === 'arrayErrors' && names.some(name => fields[name].type !== 'array')) fail();
|
|
17
17
|
}
|
|
18
18
|
for (const key of ['submitting', 'validating', 'formError']) if (selection[key] !== undefined && typeof selection[key] !== 'boolean') fail();
|
|
19
19
|
return selection;
|
|
20
20
|
}
|
|
21
|
-
function assertValue(value, field) {
|
|
22
|
-
switch (field.type) {
|
|
23
|
-
case 'boolean': if (typeof value !== 'boolean') fail(); break;
|
|
24
|
-
case 'number': if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) fail(); break;
|
|
25
|
-
case 'plainDate': if (value !== null && !isPlainDate(value)) fail(); break;
|
|
26
|
-
case 'dateTime': if (value !== null && !isDateTime(value)) fail(); break;
|
|
27
|
-
case 'record': if (value !== null) assertRecordRef(value, recordObjectsForField(field)); break;
|
|
28
|
-
default: if (typeof value !== 'string' || value.length > 10000) fail();
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
21
|
export function assertFormStateSnapshot(snapshot, formId, fields, selection) {
|
|
32
|
-
assertJsonValue(snapshot);
|
|
22
|
+
assertJsonValue(snapshot, Object.values(fields).some(field => field.type === 'array') ? { maxBytes: ARRAY_LIMITS.messageBytes, maxEntries: 4096 } : undefined);
|
|
33
23
|
assertFormStateSelection(selection, fields);
|
|
34
|
-
const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
|
|
24
|
+
const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.arrayErrors !== undefined ? ['arrayErrors'] : []), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
|
|
35
25
|
only(snapshot, keys);
|
|
36
26
|
if (Object.keys(snapshot).length !== keys.length || snapshot.formId !== formId || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1) fail();
|
|
37
27
|
if (selection.values !== undefined) {
|
|
38
28
|
const names = selectedNames(selection.values, fields);
|
|
39
29
|
only(snapshot.values, names);
|
|
40
30
|
if (Object.keys(snapshot.values).length !== names.length) fail();
|
|
41
|
-
for (const name of names)
|
|
31
|
+
for (const name of names) assertFieldValue(snapshot.values[name], fields[name]);
|
|
42
32
|
}
|
|
43
33
|
if (selection.errors !== undefined) {
|
|
44
34
|
only(snapshot.errors, selectedNames(selection.errors, fields));
|
|
45
|
-
|
|
35
|
+
assertErrorTree(snapshot.errors, fields, snapshot.values);
|
|
46
36
|
}
|
|
47
37
|
for (const key of ['submitting', 'validating']) if (selection[key] && typeof snapshot[key] !== 'boolean') fail();
|
|
48
38
|
if (selection.formError && snapshot.formError !== null && (typeof snapshot.formError !== 'string' || !snapshot.formError || snapshot.formError.length > 4096)) fail();
|
|
49
39
|
if (selection.validatingFields !== undefined) {
|
|
50
40
|
only(snapshot.validatingFields, selectedNames(selection.validatingFields, fields));
|
|
51
|
-
|
|
41
|
+
assertErrorTree(snapshot.validatingFields, fields, snapshot.values, true);
|
|
52
42
|
}
|
|
43
|
+
if (selection.arrayErrors !== undefined) { only(snapshot.arrayErrors, selectedNames(selection.arrayErrors, fields)); assertArrayErrors(snapshot.arrayErrors, fields); }
|
|
44
|
+
if (Object.values(fields).some(field => field.type === 'array') && formBytes(snapshot) > ARRAY_LIMITS.messageBytes) fail();
|
|
53
45
|
return snapshot;
|
|
54
46
|
}
|
|
55
47
|
export function freezeFormState(value) {
|
|
@@ -58,11 +50,9 @@ export function freezeFormState(value) {
|
|
|
58
50
|
}
|
|
59
51
|
export function initialFormState(formId, fields, initialValues, selection) {
|
|
60
52
|
const snapshot = { formId, revision: 1 };
|
|
61
|
-
if (selection.values !== undefined) snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name =>
|
|
62
|
-
const value = initialValues[name];
|
|
63
|
-
return [name, fields[name].type === 'dateTime' && value ? new Date(value).toISOString() : value && typeof value === 'object' ? { ...value } : value];
|
|
64
|
-
}));
|
|
53
|
+
if (selection.values !== undefined) { const values = normalizeFormValues(fields, initialValues); snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name => [name, values[name]])); }
|
|
65
54
|
if (selection.errors !== undefined) snapshot.errors = {};
|
|
55
|
+
if (selection.arrayErrors !== undefined) snapshot.arrayErrors = {};
|
|
66
56
|
for (const key of ['submitting', 'validating']) if (selection[key]) snapshot[key] = false;
|
|
67
57
|
if (selection.formError) snapshot.formError = null;
|
|
68
58
|
if (selection.validatingFields !== undefined) snapshot.validatingFields = {};
|
|
@@ -2,8 +2,8 @@ export const VALIDATION_TIMEOUT_MS: 10000;
|
|
|
2
2
|
export interface ValidationMetadata { fields: string[]; form: boolean; mode: 'onBlur' | 'onChange' | 'onSubmit'; }
|
|
3
3
|
export interface ValidationRequest {
|
|
4
4
|
formId: string; runId: number; reason: 'blur' | 'change' | 'submit';
|
|
5
|
-
values: Record<string,
|
|
5
|
+
values: Record<string, unknown>; fields: string[]; includeForm: boolean;
|
|
6
6
|
}
|
|
7
|
-
export interface ValidationResponse { formId: string; runId: number; fieldErrors: Record<string, string>; formError?: string; }
|
|
7
|
+
export interface ValidationResponse { formId: string; runId: number; fieldErrors: Record<string, string | (Record<string, string> | null)[]>; arrayErrors?: Record<string, string>; formError?: string; }
|
|
8
8
|
export function assertValidationMetadata(value: unknown, fields: Record<string, unknown>): ValidationMetadata;
|
|
9
|
-
export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'
|
|
9
|
+
export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'> & { values?: Record<string, unknown> }, fields: Record<string, unknown>): ValidationResponse;
|
package/src/forms/validation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFieldValue, assertArrayCapacity, assertErrorTree, assertArrayErrors, fieldAtPath, valueAtPath, rulePath, flattenErrors, nestErrors, formBytes, ARRAY_LIMITS } from './array-contract.js';
|
|
1
2
|
import { freezeFormState } from './state.js';
|
|
2
3
|
import { isDateTime, isPlainDate } from './values.js';
|
|
3
4
|
import { assertRecordRef, recordObjectsForField } from './record-options.js';
|
|
@@ -10,16 +11,18 @@ const only = (value, keys) => {
|
|
|
10
11
|
const validMessage = value => typeof value === 'string' && value.trim() && value.length <= 300;
|
|
11
12
|
export function assertValidationMetadata(value, fields) {
|
|
12
13
|
only(value, ['fields', 'form', 'mode']);
|
|
13
|
-
if (!Array.isArray(value.fields) || value.fields.length >
|
|
14
|
-
|| value.fields.some(name => typeof name
|
|
14
|
+
if (!Array.isArray(value.fields) || value.fields.length > 69 || new Set(value.fields).size !== value.fields.length
|
|
15
|
+
|| value.fields.some(name => { try { fieldAtPath(fields, typeof name === 'string' ? name.replace('[*]', '[0]') : name); return false; } catch { return true; } })
|
|
15
16
|
|| typeof value.form !== 'boolean' || !['onBlur', 'onChange', 'onSubmit'].includes(value.mode)) fail();
|
|
16
17
|
return value;
|
|
17
18
|
}
|
|
18
19
|
export function assertValidationResponse(value, request, fields) {
|
|
19
|
-
only(value, ['formId', 'runId', 'fieldErrors', 'formError']);
|
|
20
|
+
only(value, ['formId', 'runId', 'fieldErrors', 'arrayErrors', 'formError']);
|
|
20
21
|
if (value.formId !== request.formId || value.runId !== request.runId) fail();
|
|
21
|
-
|
|
22
|
-
if (
|
|
22
|
+
assertErrorTree(value.fieldErrors, fields, request.values, false, 300);
|
|
23
|
+
if (value.arrayErrors !== undefined) assertArrayErrors(value.arrayErrors, fields, 300);
|
|
24
|
+
if (value.formError !== undefined && !validMessage(value.formError)) fail();
|
|
25
|
+
if (Object.values(fields).some(field => field.type === 'array') && formBytes({ ...value, values: request.values }) > ARRAY_LIMITS.messageBytes - 32768) fail();
|
|
23
26
|
return value;
|
|
24
27
|
}
|
|
25
28
|
function assertRequest(request, formId, fields, validators, validate) {
|
|
@@ -27,17 +30,12 @@ function assertRequest(request, formId, fields, validators, validate) {
|
|
|
27
30
|
if (request.formId !== formId || !Number.isSafeInteger(request.runId) || request.runId < 1
|
|
28
31
|
|| !['blur', 'change', 'submit'].includes(request.reason) || typeof request.includeForm !== 'boolean'
|
|
29
32
|
|| request.includeForm && !validate || !Array.isArray(request.fields) || new Set(request.fields).size !== request.fields.length
|
|
30
|
-
|| request.fields.some(name => !Object.hasOwn(validators, name))) fail();
|
|
33
|
+
|| request.fields.length > 303 || request.fields.some(name => !Object.hasOwn(validators, rulePath(name)))) fail();
|
|
31
34
|
only(request.values, Object.keys(fields));
|
|
32
35
|
if (Object.keys(request.values).length !== Object.keys(fields).length) fail();
|
|
33
|
-
for (const [name, field] of Object.entries(fields))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else if (field.type === 'record') { if (value !== null) assertRecordRef(value, recordObjectsForField(field)); }
|
|
37
|
-
else if (field.type === 'number') { if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) fail(); }
|
|
38
|
-
else if (['plainDate', 'dateTime'].includes(field.type)) { if (value !== null && !(field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value))) fail(); }
|
|
39
|
-
else if (typeof value !== 'string' || value.length > 10000) fail();
|
|
40
|
-
}
|
|
36
|
+
for (const [name, field] of Object.entries(fields)) assertFieldValue(request.values[name], field);
|
|
37
|
+
assertArrayCapacity(fields, request.values);
|
|
38
|
+
for (const path of request.fields) if (valueAtPath(request.values, path) === undefined) fail();
|
|
41
39
|
}
|
|
42
40
|
export function createValidationCallback(formId, fields, validators, validate) {
|
|
43
41
|
let busy = false;
|
|
@@ -47,16 +45,16 @@ export function createValidationCallback(formId, fields, validators, validate) {
|
|
|
47
45
|
busy = true;
|
|
48
46
|
const request = freezeFormState(structuredClone(input));
|
|
49
47
|
let active = true, timer, position = 0;
|
|
50
|
-
const fieldErrors = {};
|
|
48
|
+
const fieldErrors = {}, arrayErrors = {};
|
|
51
49
|
const runField = async () => {
|
|
52
50
|
while (active && position < request.fields.length) {
|
|
53
|
-
const name = request.fields[position++], value = request.values
|
|
54
|
-
if (!
|
|
55
|
-
for (const validator of validators[name]) {
|
|
51
|
+
const name = request.fields[position++], value = valueAtPath(request.values, name), field = fieldAtPath(fields, name);
|
|
52
|
+
if (!field.required && (value === '' || value === null)) continue;
|
|
53
|
+
for (const validator of validators[rulePath(name)]) {
|
|
56
54
|
if (!active) return;
|
|
57
55
|
const error = await validator(value);
|
|
58
56
|
if (!active) return;
|
|
59
|
-
if (error !== undefined) { if (!validMessage(error)) fail(); fieldErrors[name] = error; break; }
|
|
57
|
+
if (error !== undefined) { if (!validMessage(error)) fail(); (field.type === 'array' ? arrayErrors : fieldErrors)[name] = error; break; }
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
60
|
};
|
|
@@ -67,13 +65,13 @@ export function createValidationCallback(formId, fields, validators, validate) {
|
|
|
67
65
|
if (!active) return;
|
|
68
66
|
const result = request.includeForm ? await validate(request.values) : undefined;
|
|
69
67
|
if (!active) return;
|
|
70
|
-
if (result !== undefined) { only(result, ['fieldErrors', 'formError']); }
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
if (result !== undefined) { only(result, ['fieldErrors', 'arrayErrors', 'formError']); }
|
|
69
|
+
if (result?.fieldErrors !== undefined) assertErrorTree(result.fieldErrors, fields, request.values, false, 300);
|
|
70
|
+
if (result?.arrayErrors !== undefined) assertArrayErrors(result.arrayErrors, fields, 300);
|
|
71
|
+
// 每个叶子合并,不能用整行覆盖同一行其他字段的跨字段错误。
|
|
72
|
+
const arrays = { ...result?.arrayErrors, ...arrayErrors };
|
|
73
|
+
const response = { formId, runId: request.runId, fieldErrors: nestErrors({ ...flattenErrors(result?.fieldErrors), ...fieldErrors }),
|
|
74
|
+
...(Object.keys(arrays).length ? { arrayErrors: arrays } : {}), ...(result?.formError === undefined ? {} : { formError: result.formError }) };
|
|
77
75
|
return assertValidationResponse(response, request, fields);
|
|
78
76
|
})(),
|
|
79
77
|
new Promise((_, reject) => { timer = setTimeout(() => { active = false; reject(new Error('runtime_form_validation_timeout')); }, VALIDATION_TIMEOUT_MS); }),
|
package/src/server/index.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { JsonValue } from "../index.js";
|
|
2
|
+
export { defineWorkflowStep, defineWorkflowStepExecute } from "./steps/index.js";
|
|
3
|
+
export type { WorkflowObjectSchema, WorkflowScalarSchema, WorkflowStepDefinition, WorkflowStepOutcome, WorkflowStepResult, WorkflowSchemaValue } from "./steps/index.js";
|
|
4
|
+
export { defineWorkflowTrigger, defineWorkflowTriggerActivate, defineWorkflowTriggerOnEvent, defineWorkflowTriggerDeactivate } from "./triggers/index.js";
|
|
5
|
+
export type { WorkflowTriggerDefinition, WorkflowTriggerOutcome, WorkflowTriggerEventResult } from "./triggers/index.js";
|
|
6
|
+
export {
|
|
7
|
+
defineWorkflowBlock,
|
|
8
|
+
defineWorkflowBlockExecute,
|
|
9
|
+
defineWorkflowBlockActivate,
|
|
10
|
+
defineWorkflowBlockTrigger,
|
|
11
|
+
defineWorkflowBlockDeactivate,
|
|
12
|
+
} from "./workflow-blocks/index.js";
|
|
13
|
+
export type {
|
|
14
|
+
WorkflowBlockDefinition,
|
|
15
|
+
WorkflowStepBlockDefinition,
|
|
16
|
+
WorkflowTriggerBlockDefinition,
|
|
17
|
+
} from "./workflow-blocks/index.js";
|
|
2
18
|
|
|
3
19
|
/** 当前执行的 App API Token;由可信 Runtime Module 在服务端模块求值前注入。 */
|
|
4
20
|
export const ZHIKE_API_TOKEN: string;
|
|
@@ -206,18 +222,37 @@ export interface WorkspaceWebhookBody {
|
|
|
206
222
|
events: WorkspaceWebhookEvent[];
|
|
207
223
|
}
|
|
208
224
|
|
|
209
|
-
|
|
210
|
-
export interface WorkspaceConnection {
|
|
225
|
+
interface WorkspaceConnectionBase {
|
|
211
226
|
/** 当前 Workspace Connection 的稳定 ID。 */
|
|
212
227
|
id: string;
|
|
213
228
|
/** 当前 Connect/Reconnect generation 的非敏感稳定表示。 */
|
|
214
229
|
generation: string;
|
|
215
|
-
/**
|
|
216
|
-
type: "secret" | "oauth2";
|
|
217
|
-
/** Secret 或当前 OAuth access token,仅存在于本次服务端执行内存。 */
|
|
230
|
+
/** Secret 或当前 OAuth/OIDC access token,仅存在于本次服务端执行内存。 */
|
|
218
231
|
value: string;
|
|
219
232
|
}
|
|
220
233
|
|
|
234
|
+
/** 当前服务端执行可使用的 Workspace 外部系统连接。 */
|
|
235
|
+
export type WorkspaceConnection = WorkspaceConnectionBase & (
|
|
236
|
+
| {
|
|
237
|
+
type: "secret";
|
|
238
|
+
protocol: "secret";
|
|
239
|
+
}
|
|
240
|
+
| {
|
|
241
|
+
type: "oauth2";
|
|
242
|
+
protocol: "oauth2";
|
|
243
|
+
/** OAuth 2.0 access token;等同 value。 */
|
|
244
|
+
accessToken: string;
|
|
245
|
+
}
|
|
246
|
+
| {
|
|
247
|
+
type: "oauth2";
|
|
248
|
+
protocol: "oidc";
|
|
249
|
+
/** OIDC access token;等同 value。 */
|
|
250
|
+
accessToken: string;
|
|
251
|
+
/** OIDC ID token;仅存在于当前服务端执行内存。 */
|
|
252
|
+
idToken: string;
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
221
256
|
/** 同步读取当前执行的连接快照;未连接或暂不可用时抛出安全错误,不在 getter 内刷新。 */
|
|
222
257
|
export function getWorkspaceConnection(): Readonly<WorkspaceConnection>;
|
|
223
258
|
|
package/src/server/index.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
const SERVER_FUNCTION_MARKER = Symbol.for("zhike.server-function.v1");
|
|
2
|
+
export { defineWorkflowStep, defineWorkflowStepExecute } from "./steps/index.js";
|
|
3
|
+
export { defineWorkflowTrigger, defineWorkflowTriggerActivate, defineWorkflowTriggerOnEvent, defineWorkflowTriggerDeactivate } from "./triggers/index.js";
|
|
4
|
+
export {
|
|
5
|
+
defineWorkflowBlock,
|
|
6
|
+
defineWorkflowBlockExecute,
|
|
7
|
+
defineWorkflowBlockActivate,
|
|
8
|
+
defineWorkflowBlockTrigger,
|
|
9
|
+
defineWorkflowBlockDeactivate,
|
|
10
|
+
} from "./workflow-blocks/index.js";
|
|
2
11
|
const LIFECYCLE_EVENT_MARKER = Symbol.for("zhike.lifecycle-event.v1");
|
|
3
12
|
const WEBHOOK_HANDLER_MARKER = Symbol.for("zhike.webhook-handler.v1");
|
|
4
13
|
const WORKSPACE_CONNECTION_EVENTS = new Set([
|
|
@@ -145,11 +154,23 @@ export function getWorkspaceConnection() {
|
|
|
145
154
|
error.code = code;
|
|
146
155
|
throw error;
|
|
147
156
|
}
|
|
157
|
+
if (connection.protocol === "oidc" && (typeof connection.idToken !== "string" || !connection.idToken)) {
|
|
158
|
+
const error = new Error("connection_credential_unavailable");
|
|
159
|
+
error.code = "connection_credential_unavailable";
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const protocol = connection.type === "secret"
|
|
163
|
+
? "secret"
|
|
164
|
+
: (connection.protocol === "oidc" && typeof connection.idToken === "string" && connection.idToken
|
|
165
|
+
? "oidc" : "oauth2");
|
|
148
166
|
return Object.freeze({
|
|
149
167
|
id: connection.id,
|
|
150
168
|
generation: connection.generation,
|
|
151
169
|
type: connection.type,
|
|
170
|
+
protocol,
|
|
152
171
|
value: connection.value,
|
|
172
|
+
...(connection.type === "oauth2" ? { accessToken: connection.value } : {}),
|
|
173
|
+
...(protocol === "oidc" ? { idToken: connection.idToken } : {}),
|
|
153
174
|
});
|
|
154
175
|
}
|
|
155
176
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type WorkflowScalarSchema =
|
|
2
|
+
| { readonly type: "string"; readonly minLength?: number; readonly enum?: readonly string[]; readonly title?: string; readonly description?: string; readonly examples?: readonly string[] }
|
|
3
|
+
| { readonly type: "number"; readonly title?: string; readonly description?: string; readonly examples?: readonly number[] }
|
|
4
|
+
| { readonly type: "boolean"; readonly title?: string; readonly description?: string; readonly examples?: readonly boolean[] };
|
|
5
|
+
|
|
6
|
+
export interface WorkflowObjectSchema {
|
|
7
|
+
readonly type: "object";
|
|
8
|
+
readonly properties: Readonly<Record<string, WorkflowScalarSchema>>;
|
|
9
|
+
readonly required?: readonly string[];
|
|
10
|
+
readonly additionalProperties: false;
|
|
11
|
+
readonly title?: string;
|
|
12
|
+
readonly description?: string;
|
|
13
|
+
readonly examples?: readonly (string | number | boolean)[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WorkflowStepOutcome {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly dataSchema: WorkflowObjectSchema | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkflowStepDefinition<
|
|
23
|
+
Config extends WorkflowObjectSchema = WorkflowObjectSchema,
|
|
24
|
+
Outcomes extends readonly WorkflowStepOutcome[] = readonly WorkflowStepOutcome[],
|
|
25
|
+
> {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly title: string;
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly connection: "workspace" | "none";
|
|
30
|
+
readonly configSchema: Config;
|
|
31
|
+
readonly outcomes: Outcomes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ScalarValue<S> = S extends { readonly enum: readonly (infer E)[] } ? E
|
|
35
|
+
: S extends { readonly type: "string" } ? string
|
|
36
|
+
: S extends { readonly type: "number" } ? number
|
|
37
|
+
: S extends { readonly type: "boolean" } ? boolean
|
|
38
|
+
: never;
|
|
39
|
+
|
|
40
|
+
type RequiredNames<S extends WorkflowObjectSchema> = S["required"] extends readonly string[]
|
|
41
|
+
? S["required"][number] : never;
|
|
42
|
+
|
|
43
|
+
export type WorkflowSchemaValue<S extends WorkflowObjectSchema> = {
|
|
44
|
+
[K in keyof S["properties"] as K extends RequiredNames<S> ? K : never]-?: ScalarValue<S["properties"][K]>;
|
|
45
|
+
} & {
|
|
46
|
+
[K in keyof S["properties"] as K extends RequiredNames<S> ? never : K]?: ScalarValue<S["properties"][K]>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type OutcomeResult<O extends WorkflowStepOutcome> = O extends WorkflowStepOutcome
|
|
50
|
+
? O["dataSchema"] extends WorkflowObjectSchema
|
|
51
|
+
? { readonly outcome: O["id"]; readonly data: WorkflowSchemaValue<O["dataSchema"]> }
|
|
52
|
+
: { readonly outcome: O["id"]; readonly data: null }
|
|
53
|
+
: never;
|
|
54
|
+
|
|
55
|
+
export type WorkflowStepResult<Outcomes extends readonly WorkflowStepOutcome[]> =
|
|
56
|
+
OutcomeResult<Outcomes[number]>;
|
|
57
|
+
|
|
58
|
+
export function defineWorkflowStep<
|
|
59
|
+
const Config extends WorkflowObjectSchema,
|
|
60
|
+
const Outcomes extends readonly [WorkflowStepOutcome, ...WorkflowStepOutcome[]],
|
|
61
|
+
>(definition: WorkflowStepDefinition<Config, Outcomes>): WorkflowStepDefinition<Config, Outcomes>;
|
|
62
|
+
|
|
63
|
+
export function defineWorkflowStepExecute<
|
|
64
|
+
Config extends WorkflowObjectSchema,
|
|
65
|
+
Outcomes extends readonly WorkflowStepOutcome[],
|
|
66
|
+
>(
|
|
67
|
+
step: WorkflowStepDefinition<Config, Outcomes>,
|
|
68
|
+
execute: (params: Readonly<{ config: WorkflowSchemaValue<Config> }>) =>
|
|
69
|
+
WorkflowStepResult<Outcomes> | Promise<WorkflowStepResult<Outcomes>>,
|
|
70
|
+
): (params: Readonly<{ config: WorkflowSchemaValue<Config> }>) =>
|
|
71
|
+
WorkflowStepResult<Outcomes> | Promise<WorkflowStepResult<Outcomes>>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const STEP_MARKER = Symbol.for("zhike.workflow-step.v1");
|
|
2
|
+
|
|
3
|
+
export function defineWorkflowStep(definition) {
|
|
4
|
+
if (!definition || typeof definition !== "object") {
|
|
5
|
+
throw new TypeError("workflow_step_definition_invalid");
|
|
6
|
+
}
|
|
7
|
+
return Object.freeze({ ...definition, [STEP_MARKER]: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function defineWorkflowStepExecute(step, execute) {
|
|
11
|
+
if (!step?.[STEP_MARKER] || typeof execute !== "function") {
|
|
12
|
+
throw new TypeError("workflow_step_execute_invalid");
|
|
13
|
+
}
|
|
14
|
+
return execute;
|
|
15
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { WorkflowObjectSchema, WorkflowSchemaValue } from "../steps/index.js";
|
|
2
|
+
|
|
3
|
+
export interface WorkflowTriggerOutcome {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly dataSchema: WorkflowObjectSchema;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WorkflowTriggerDefinition<
|
|
10
|
+
Config extends WorkflowObjectSchema = WorkflowObjectSchema,
|
|
11
|
+
Outcomes extends readonly WorkflowTriggerOutcome[] = readonly WorkflowTriggerOutcome[],
|
|
12
|
+
> {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
readonly connection: "workspace" | "none";
|
|
17
|
+
readonly requestHeaders?: readonly string[];
|
|
18
|
+
readonly configSchema: Config;
|
|
19
|
+
readonly outcomes: Outcomes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type OutcomeResult<O extends WorkflowTriggerOutcome> = O extends WorkflowTriggerOutcome
|
|
23
|
+
? {
|
|
24
|
+
readonly type: "outcome";
|
|
25
|
+
readonly id: O["id"];
|
|
26
|
+
readonly eventKey: string;
|
|
27
|
+
readonly data: WorkflowSchemaValue<O["dataSchema"]>;
|
|
28
|
+
}
|
|
29
|
+
: never;
|
|
30
|
+
|
|
31
|
+
export type WorkflowTriggerEventResult<Outcomes extends readonly WorkflowTriggerOutcome[]> =
|
|
32
|
+
| { readonly type: "no-op" }
|
|
33
|
+
| { readonly type: "error"; readonly code: "invalid_signature"; readonly retryable: false }
|
|
34
|
+
| OutcomeResult<Outcomes[number]>;
|
|
35
|
+
|
|
36
|
+
export function defineWorkflowTrigger<
|
|
37
|
+
const Config extends WorkflowObjectSchema,
|
|
38
|
+
const Outcomes extends readonly [WorkflowTriggerOutcome, ...WorkflowTriggerOutcome[]],
|
|
39
|
+
>(definition: WorkflowTriggerDefinition<Config, Outcomes>): WorkflowTriggerDefinition<Config, Outcomes>;
|
|
40
|
+
|
|
41
|
+
export function defineWorkflowTriggerActivate<Config extends WorkflowObjectSchema>(
|
|
42
|
+
trigger: WorkflowTriggerDefinition<Config>,
|
|
43
|
+
handler: (params: Readonly<{
|
|
44
|
+
config: WorkflowSchemaValue<Config>;
|
|
45
|
+
activationId: string;
|
|
46
|
+
triggerCallbackUrl: string;
|
|
47
|
+
}>) => Promise<Readonly<{ subscriptionRef: string | null }>>,
|
|
48
|
+
): typeof handler;
|
|
49
|
+
|
|
50
|
+
export function defineWorkflowTriggerOnEvent<
|
|
51
|
+
Config extends WorkflowObjectSchema,
|
|
52
|
+
Outcomes extends readonly WorkflowTriggerOutcome[],
|
|
53
|
+
>(
|
|
54
|
+
trigger: WorkflowTriggerDefinition<Config, Outcomes>,
|
|
55
|
+
handler: (params: Readonly<{
|
|
56
|
+
config: WorkflowSchemaValue<Config>;
|
|
57
|
+
body: string;
|
|
58
|
+
headers: Readonly<Record<string, string>>;
|
|
59
|
+
}>) => Promise<WorkflowTriggerEventResult<Outcomes>>,
|
|
60
|
+
): typeof handler;
|
|
61
|
+
|
|
62
|
+
export function defineWorkflowTriggerDeactivate(
|
|
63
|
+
trigger: WorkflowTriggerDefinition,
|
|
64
|
+
handler: (params: Readonly<{
|
|
65
|
+
activationId: string;
|
|
66
|
+
subscriptionRef: string | null;
|
|
67
|
+
}>) => Promise<void>,
|
|
68
|
+
): typeof handler;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const TRIGGER_MARKER = Symbol.for("zhike.workflow-trigger.v1");
|
|
2
|
+
|
|
3
|
+
export function defineWorkflowTrigger(definition) {
|
|
4
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
|
|
5
|
+
throw new TypeError("workflow_trigger_definition_invalid");
|
|
6
|
+
}
|
|
7
|
+
return Object.freeze({ ...definition, [TRIGGER_MARKER]: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function bindTriggerHandler(trigger, handler, kind) {
|
|
11
|
+
if (!trigger?.[TRIGGER_MARKER] || typeof handler !== "function") {
|
|
12
|
+
throw new TypeError(`workflow_trigger_${kind}_invalid`);
|
|
13
|
+
}
|
|
14
|
+
return handler;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defineWorkflowTriggerActivate(trigger, handler) {
|
|
18
|
+
return bindTriggerHandler(trigger, handler, "activate");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defineWorkflowTriggerOnEvent(trigger, handler) {
|
|
22
|
+
return bindTriggerHandler(trigger, handler, "on_event");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defineWorkflowTriggerDeactivate(trigger, handler) {
|
|
26
|
+
return bindTriggerHandler(trigger, handler, "deactivate");
|
|
27
|
+
}
|