@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.
Files changed (40) hide show
  1. package/README.md +13 -7
  2. package/package.json +9 -3
  3. package/src/cli/development-logs.js +5 -2
  4. package/src/cli/index.js +11 -23
  5. package/src/cli/local-commands.js +1 -0
  6. package/src/cli/project-init.js +2 -1
  7. package/src/forms/array-contract.d.ts +32 -0
  8. package/src/forms/array-contract.js +138 -0
  9. package/src/forms/factory.js +9 -4
  10. package/src/forms/field-array.d.ts +16 -0
  11. package/src/forms/field-array.js +52 -0
  12. package/src/forms/index.d.ts +5 -1
  13. package/src/forms/schema.d.ts +30 -11
  14. package/src/forms/schema.js +29 -6
  15. package/src/forms/state.d.ts +6 -4
  16. package/src/forms/state.js +14 -24
  17. package/src/forms/validation.d.ts +3 -3
  18. package/src/forms/validation.js +24 -26
  19. package/src/server/index.d.ts +40 -5
  20. package/src/server/index.js +21 -0
  21. package/src/server/steps/index.d.ts +71 -0
  22. package/src/server/steps/index.js +15 -0
  23. package/src/server/triggers/index.d.ts +68 -0
  24. package/src/server/triggers/index.js +27 -0
  25. package/src/server/workflow-blocks/index.d.ts +72 -0
  26. package/src/server/workflow-blocks/index.js +32 -0
  27. package/src/toolchain/allowed-packages.json +7 -0
  28. package/src/toolchain/dependency-policy.js +175 -0
  29. package/src/toolchain/index.js +0 -88
  30. package/src/toolchain/real-build.js +59 -17
  31. package/src/toolchain/source-discovery.js +76 -65
  32. package/src/toolchain/workflow-blocks/discovery.js +133 -0
  33. package/src/toolchain/workflow-steps/manifest.js +99 -0
  34. package/src/toolchain/workflow-triggers/manifest.js +47 -0
  35. package/src/ui/components.generated.js +3 -2
  36. package/src/ui/factory.js +1 -1
  37. package/src/ui/index.d.ts +5 -5
  38. package/src/ui/index.js +1 -1
  39. package/src/ui/validation.js +3 -3
  40. package/src/cli/validate-bundle.js +0 -36
@@ -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.keys(schema.fields).length > 30 || !['onBlur', 'onChange', 'onSubmit'].includes(validationMode)
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 (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name) || ['constructor', 'prototype'].includes(name)) fail();
72
+ if (!validFieldKey(name)) fail();
66
73
  const descriptor = descriptors.get(input);
67
- const field = structuredClone(descriptor?.field ?? input);
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
- return { schema: wire, ...(configured ? { validation } : {}), ...(remote ? {
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
  }
@@ -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: Readonly<Partial<Record<Names<S, T>, string>>> } : {})
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: Readonly<Partial<Record<Names<S, T>, boolean>>> } : {});
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;
@@ -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 > 30 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))) fail();
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) assertValue(snapshot.values[name], fields[name]);
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
- for (const value of Object.values(snapshot.errors)) if (typeof value !== 'string' || !value || value.length > 4096) fail();
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
- if (Object.values(snapshot.validatingFields).some(value => typeof value !== 'boolean')) fail();
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, string | boolean | number | import("./record-options.js").RecordRef | null>; fields: string[]; includeForm: boolean;
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'>, fields: Record<string, unknown>): ValidationResponse;
9
+ export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'> & { values?: Record<string, unknown> }, fields: Record<string, unknown>): ValidationResponse;
@@ -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 > 30 || new Set(value.fields).size !== value.fields.length
14
- || value.fields.some(name => typeof name !== 'string' || !Object.hasOwn(fields, 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
- only(value.fieldErrors, Object.keys(fields));
22
- if (Object.values(value.fieldErrors).some(error => !validMessage(error)) || value.formError !== undefined && !validMessage(value.formError)) fail();
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
- const value = request.values[name];
35
- if (field.type === 'boolean') { if (typeof value !== 'boolean') fail(); }
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[name];
54
- if (!fields[name].required && (value === '' || value === null)) continue;
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
- // 字段 custom 错误优先于跨字段错误。
72
- const response = { formId, runId: request.runId, fieldErrors: { ...result?.fieldErrors, ...fieldErrors }, ...(result?.formError === undefined ? {} : { formError: result.formError }) };
73
- if (result?.fieldErrors !== undefined) {
74
- only(result.fieldErrors, Object.keys(fields));
75
- if (Object.values(result.fieldErrors).some(error => !validMessage(error))) fail();
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); }),
@@ -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
- /** 当前服务端执行可使用的 Workspace 外部系统连接。 */
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
 
@@ -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
+ }