openxiangda-devkit-core 2.0.0-alpha.52 → 2.0.0-alpha.56

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 (46) hide show
  1. package/dist/application-services.d.ts +0 -5
  2. package/dist/application-services.d.ts.map +1 -1
  3. package/dist/application-services.js +0 -5
  4. package/dist/application-services.js.map +1 -1
  5. package/dist/compiler/ai-catalog.d.ts.map +1 -1
  6. package/dist/compiler/ai-catalog.js +76 -20
  7. package/dist/compiler/ai-catalog.js.map +1 -1
  8. package/dist/compiler/bundle.d.ts.map +1 -1
  9. package/dist/compiler/bundle.js +259 -60
  10. package/dist/compiler/bundle.js.map +1 -1
  11. package/dist/compiler/config.d.ts +32 -5
  12. package/dist/compiler/config.d.ts.map +1 -1
  13. package/dist/compiler/config.js +555 -46
  14. package/dist/compiler/config.js.map +1 -1
  15. package/dist/compiler/field-codec.d.ts +7 -0
  16. package/dist/compiler/field-codec.d.ts.map +1 -0
  17. package/dist/compiler/field-codec.js +124 -0
  18. package/dist/compiler/field-codec.js.map +1 -0
  19. package/dist/compiler/field-physical-plan.d.ts +9 -0
  20. package/dist/compiler/field-physical-plan.d.ts.map +1 -0
  21. package/dist/compiler/field-physical-plan.js +59 -0
  22. package/dist/compiler/field-physical-plan.js.map +1 -0
  23. package/dist/compiler/field-policy-path.d.ts +10 -0
  24. package/dist/compiler/field-policy-path.d.ts.map +1 -0
  25. package/dist/compiler/field-policy-path.js +264 -0
  26. package/dist/compiler/field-policy-path.js.map +1 -0
  27. package/dist/compiler/field-query-plan.d.ts +13 -0
  28. package/dist/compiler/field-query-plan.d.ts.map +1 -0
  29. package/dist/compiler/field-query-plan.js +122 -0
  30. package/dist/compiler/field-query-plan.js.map +1 -0
  31. package/dist/compiler/field-surface.d.ts +5 -5
  32. package/dist/compiler/field-surface.d.ts.map +1 -1
  33. package/dist/compiler/field-surface.js +41 -24
  34. package/dist/compiler/field-surface.js.map +1 -1
  35. package/dist/compiler/package-compiler.d.ts.map +1 -1
  36. package/dist/compiler/package-compiler.js +4 -1
  37. package/dist/compiler/package-compiler.js.map +1 -1
  38. package/dist/control-plane-client.d.ts +8 -30
  39. package/dist/control-plane-client.d.ts.map +1 -1
  40. package/dist/control-plane-client.js +6 -11
  41. package/dist/control-plane-client.js.map +1 -1
  42. package/dist/testing.d.ts +39 -0
  43. package/dist/testing.d.ts.map +1 -1
  44. package/dist/testing.js +169 -0
  45. package/dist/testing.js.map +1 -1
  46. package/package.json +3 -2
@@ -1,9 +1,22 @@
1
- import { SCHEMA_VERSIONS, DATA_FIELD_TYPES, nativePlatformCapabilityCatalog, validateDataResource, } from 'openxiangda-contracts';
1
+ import { SCHEMA_VERSIONS, DATA_FIELD_TYPES, PLATFORM_EVENT_TYPES_V2, nativePlatformCapabilityCatalog, validateDataResource, } from 'openxiangda-contracts';
2
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
3
  import { CronExpressionParser } from 'cron-parser';
3
- import { resolveDataFieldSurfaceWidget } from './field-surface.js';
4
+ import { fieldNullable, isGeneratedField } from './field-codec.js';
5
+ import { supportsFilter, supportsSearch, supportsSort, } from './field-query-plan.js';
6
+ import { physicalPlanForField } from './field-physical-plan.js';
7
+ import { SEMANTIC_FIELD_PATH_PATTERN, semanticFieldPathRoot, validateAuthzSemanticBindings, } from './field-policy-path.js';
8
+ import { compatibleWidgets, isCompatibleFieldWidget, resolveDataFieldSurfaceWidget, } from './field-surface.js';
4
9
  // The compiler owns the application source contract. Devkit and every adapter
5
10
  // re-export this module instead of maintaining separate configuration models.
6
11
  import { validateWorkflowBinding, validateWorkflowDefinition, } from '../internal/workflow.js';
12
+ const DATE_TRIGGER_RESERVED_DATA_FIELDS = new Set([
13
+ 'triggerCode',
14
+ 'resourceCode',
15
+ 'recordId',
16
+ 'recordRevision',
17
+ 'field',
18
+ 'dueAt',
19
+ ]);
7
20
  const APP_DATA_FIELD_TYPES = new Set(DATA_FIELD_TYPES);
8
21
  export const openXiangdaAppConfigSchema = {
9
22
  $id: 'openxiangda.app-config/v3',
@@ -150,6 +163,11 @@ export const openXiangdaAppConfigSchema = {
150
163
  additionalProperties: false,
151
164
  required: ['subscriptions'],
152
165
  properties: {
166
+ schemas: {
167
+ type: 'array',
168
+ maxItems: 100,
169
+ items: { type: 'object' },
170
+ },
153
171
  subscriptions: {
154
172
  type: 'array',
155
173
  maxItems: 100,
@@ -160,6 +178,11 @@ export const openXiangdaAppConfigSchema = {
160
178
  maxItems: 100,
161
179
  items: { type: 'object' },
162
180
  },
181
+ dateTriggers: {
182
+ type: 'array',
183
+ maxItems: 100,
184
+ items: { type: 'object' },
185
+ },
163
186
  },
164
187
  },
165
188
  workflows: {
@@ -235,6 +258,7 @@ export function backendRuntimeRequired(config) {
235
258
  backend.secrets?.length ||
236
259
  config.events?.subscriptions?.length ||
237
260
  config.events?.timers?.length ||
261
+ config.events?.dateTriggers?.length ||
238
262
  config.workflows?.activations?.length ||
239
263
  config.workflows?.providers?.length)));
240
264
  }
@@ -246,6 +270,109 @@ function object(value) {
246
270
  function string(value) {
247
271
  return typeof value === 'string' ? value.trim() : '';
248
272
  }
273
+ function eventSchemaDeclaresPath(schema, path) {
274
+ let current = schema;
275
+ for (const segment of path.split('.')) {
276
+ if (current.type === 'array')
277
+ current = object(current.items);
278
+ const properties = object(current.properties);
279
+ if (!Object.prototype.hasOwnProperty.call(properties, segment)) {
280
+ return false;
281
+ }
282
+ current = object(properties[segment]);
283
+ }
284
+ return true;
285
+ }
286
+ function eventDataMatchesSchema(schema, data) {
287
+ if (!schema)
288
+ return false;
289
+ try {
290
+ const validator = new Ajv2020({
291
+ allErrors: true,
292
+ strict: false,
293
+ validateFormats: false,
294
+ }).compile(schema);
295
+ return Boolean(validator(data));
296
+ }
297
+ catch {
298
+ return false;
299
+ }
300
+ }
301
+ function validDateTriggerOffset(value) {
302
+ const match = /^([+-])?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(string(value));
303
+ if (!match || !match.slice(2).some(part => part !== undefined))
304
+ return false;
305
+ const milliseconds = Number(match[2] || 0) * 24 * 60 * 60 * 1000 +
306
+ Number(match[3] || 0) * 60 * 60 * 1000 +
307
+ Number(match[4] || 0) * 60 * 1000 +
308
+ Number(match[5] || 0) * 1000;
309
+ return (Number.isFinite(milliseconds) &&
310
+ milliseconds <= 10 * 365 * 24 * 60 * 60 * 1000);
311
+ }
312
+ const EVENT_TYPE_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+\.v[1-9][0-9]*$/;
313
+ const EVENT_DATA_SCHEMA_VERSION_PATTERN = /^[1-9][0-9]*\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/;
314
+ const EVENT_FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
315
+ function eventPredicateValid(value) {
316
+ const predicate = object(value);
317
+ const keys = Object.keys(predicate);
318
+ if (keys.length !== 1 ||
319
+ !['eq', 'ne', 'in', 'notIn', 'exists'].includes(keys[0] || '')) {
320
+ return false;
321
+ }
322
+ const operand = predicate[keys[0]];
323
+ if (keys[0] === 'exists' && typeof operand !== 'boolean')
324
+ return false;
325
+ if ((keys[0] === 'in' || keys[0] === 'notIn') &&
326
+ (!Array.isArray(operand) || operand.length < 1 || operand.length > 20))
327
+ return false;
328
+ try {
329
+ const serialized = JSON.stringify(predicate);
330
+ return (typeof serialized === 'string' &&
331
+ Buffer.byteLength(serialized, 'utf8') <= 4096);
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ function validateEventChangeFilter(value, path, allowedFields, diagnostics) {
338
+ const change = object(value);
339
+ const field = string(change.field);
340
+ const keys = Object.keys(change);
341
+ if (!EVENT_FIELD_PATTERN.test(field) ||
342
+ !allowedFields.has(field) ||
343
+ keys.some(key => !['field', 'before', 'after'].includes(key)) ||
344
+ (!('before' in change) && !('after' in change)) ||
345
+ ('before' in change && !eventPredicateValid(change.before)) ||
346
+ ('after' in change && !eventPredicateValid(change.after))) {
347
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_CHANGE_FILTER_INVALID', '字段变化条件必须引用已声明字段,并且 before/after 各只使用一个受支持操作符', path));
348
+ }
349
+ }
350
+ function validateEventFilterCondition(value, path, allowedFields, diagnostics, state, depth = 1) {
351
+ const condition = object(value);
352
+ const keys = Object.keys(condition);
353
+ if (depth > 3 || keys.length !== 1) {
354
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_FILTER_COMPLEXITY_EXCEEDED', '事件过滤条件最多嵌套 3 层,且每层只能声明 change/all/any/not 之一', path));
355
+ return;
356
+ }
357
+ const key = keys[0];
358
+ if (key === 'change') {
359
+ state.atoms += 1;
360
+ validateEventChangeFilter(condition.change, `${path}.change`, allowedFields, diagnostics);
361
+ }
362
+ else if (key === 'all' || key === 'any') {
363
+ const items = Array.isArray(condition[key]) ? condition[key] : [];
364
+ if (items.length < 1 || items.length > 16) {
365
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_FILTER_GROUP_INVALID', '事件过滤 all/any 必须包含 1 到 16 个条件', `${path}.${key}`));
366
+ }
367
+ items.forEach((item, index) => validateEventFilterCondition(item, `${path}.${key}[${index}]`, allowedFields, diagnostics, state, depth + 1));
368
+ }
369
+ else if (key === 'not') {
370
+ validateEventFilterCondition(condition.not, `${path}.not`, allowedFields, diagnostics, state, depth + 1);
371
+ }
372
+ else {
373
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_FILTER_OPERATOR_INVALID', '事件过滤条件只支持 change/all/any/not', path));
374
+ }
375
+ }
249
376
  export function validateAppConfig(value) {
250
377
  const diagnostics = [];
251
378
  const config = object(value);
@@ -318,6 +445,8 @@ export function validateAppConfig(value) {
318
445
  Array.isArray(eventConfig.subscriptions) &&
319
446
  eventConfig.subscriptions.length > 0 ||
320
447
  Array.isArray(eventConfig.timers) && eventConfig.timers.length > 0 ||
448
+ Array.isArray(eventConfig.dateTriggers) &&
449
+ eventConfig.dateTriggers.length > 0 ||
321
450
  Array.isArray(workflowConfig.activations) &&
322
451
  workflowConfig.activations.length > 0 ||
323
452
  Array.isArray(workflowConfig.providers) && workflowConfig.providers.length > 0)) {
@@ -379,22 +508,54 @@ export function validateAppConfig(value) {
379
508
  const fields = object(resourceRecord.schema).fields;
380
509
  (Array.isArray(fields) ? fields : []).forEach((rawField, fieldIndex) => {
381
510
  const field = object(rawField);
382
- const reference = object(field.reference);
383
- if (string(reference.kind) !== 'resource')
511
+ const source = object(field.source);
512
+ if (string(source.kind) !== 'resource')
384
513
  return;
385
- const targetCode = string(reference.resourceCode);
514
+ const targetCode = string(source.resourceCode);
386
515
  const target = resources.find(candidate => string(object(candidate).code) === targetCode);
387
- const referencePath = `data.resources[${index}].schema.fields[${fieldIndex}].reference`;
516
+ const sourcePath = `data.resources[${index}].schema.fields[${fieldIndex}].source`;
388
517
  if (!target) {
389
- diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_REFERENCE_TARGET_NOT_FOUND', `资源引用目标 ${targetCode} 必须在同一应用中声明`, `${referencePath}.resourceCode`));
518
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SOURCE_TARGET_NOT_FOUND', `动态选项来源 ${targetCode} 必须在同一应用中声明`, `${sourcePath}.resourceCode`));
390
519
  return;
391
520
  }
392
521
  const targetFields = object(object(target).schema).fields;
393
- const labelField = string(reference.labelField);
522
+ const targetFieldList = Array.isArray(targetFields) ? targetFields : [];
523
+ const labelField = string(source.labelField);
394
524
  const label = (Array.isArray(targetFields) ? targetFields : []).find(candidate => string(object(candidate).code) === labelField);
395
- if (!label || !['string', 'text'].includes(string(object(label).type))) {
396
- diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_REFERENCE_LABEL_INVALID', `资源引用标签字段 ${labelField} 必须是 string 或 text`, `${referencePath}.labelField`));
525
+ if (!label ||
526
+ !['text.short', 'text.long', 'serial-number'].includes(string(object(label).type))) {
527
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SOURCE_LABEL_INVALID', `动态选项标签字段 ${labelField} 必须是文本或流水号`, `${sourcePath}.labelField`));
397
528
  }
529
+ for (const key of [
530
+ 'searchFields',
531
+ 'descriptionFields',
532
+ 'snapshotFields',
533
+ ]) {
534
+ const values = Array.isArray(source[key]) ? source[key] : [];
535
+ values.forEach((value, valueIndex) => {
536
+ const targetField = targetFieldList.find(candidate => string(object(candidate).code) === string(value));
537
+ if (!targetField) {
538
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SOURCE_FIELD_NOT_FOUND', `动态来源字段 ${String(value)} 未在 ${targetCode} 声明`, `${sourcePath}.${key}[${valueIndex}]`));
539
+ }
540
+ else if (key === 'searchFields' &&
541
+ !supportsSearch(string(object(targetField).type))) {
542
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SOURCE_SEARCH_UNSUPPORTED', `动态来源字段 ${String(value)} 不支持搜索`, `${sourcePath}.${key}[${valueIndex}]`));
543
+ }
544
+ });
545
+ }
546
+ const sourceFilters = Array.isArray(source.filters) ? source.filters : [];
547
+ sourceFilters.forEach((rawFilter, filterIndex) => {
548
+ const filter = object(rawFilter);
549
+ const targetFieldCode = string(filter.field);
550
+ if (!targetFieldList.some(candidate => string(object(candidate).code) === targetFieldCode)) {
551
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SOURCE_FILTER_FIELD_NOT_FOUND', `动态来源筛选字段 ${targetFieldCode} 未在 ${targetCode} 声明`, `${sourcePath}.filters[${filterIndex}].field`));
552
+ }
553
+ const bindingField = string(object(filter.binding).field);
554
+ if (bindingField &&
555
+ !(Array.isArray(fields) ? fields : []).some(candidate => string(object(candidate).code) === bindingField)) {
556
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SOURCE_BINDING_FIELD_NOT_FOUND', `动态来源绑定字段 ${bindingField} 未在当前资源声明`, `${sourcePath}.filters[${filterIndex}].binding.field`));
557
+ }
558
+ });
398
559
  });
399
560
  });
400
561
  }
@@ -514,10 +675,10 @@ export function validateAppConfig(value) {
514
675
  if (!declaredFieldTypes) {
515
676
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_VALUE_SOURCE_RESOURCE_INVALID', 'native_resource valueSource 必须引用同一应用内已声明的 Data Resource', `${valueSourcePath}.resourceCode`));
516
677
  }
517
- else if (!['string', 'text'].includes(declaredFieldTypes.get(labelField) || '') ||
678
+ else if (!['text.short', 'text.long', 'serial-number'].includes(declaredFieldTypes.get(labelField) || '') ||
518
679
  (enabledField !== undefined &&
519
680
  declaredFieldTypes.get(enabledField) !== 'boolean')) {
520
- diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_VALUE_SOURCE_FIELD_INVALID', 'valueSource 标签字段必须为 string/text,启用字段必须为 boolean', valueSourcePath));
681
+ diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DIMENSION_VALUE_SOURCE_FIELD_INVALID', 'valueSource 标签字段必须为文本/流水号,启用字段必须为 boolean', valueSourcePath));
521
682
  }
522
683
  }
523
684
  dimensionCodes.add(code);
@@ -541,7 +702,7 @@ export function validateAppConfig(value) {
541
702
  !string(source.name) ||
542
703
  !/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(resourceCode) ||
543
704
  !['user', 'role_membership'].includes(subjectType) ||
544
- !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(subject.userIdField)) ||
705
+ !SEMANTIC_FIELD_PATH_PATTERN.test(string(subject.userIdField)) ||
545
706
  (subjectType === 'role_membership' && !roleCodes.has(roleCode)) ||
546
707
  (subjectType === 'user' && Boolean(roleCode)) ||
547
708
  grants.length === 0 ||
@@ -554,9 +715,9 @@ export function validateAppConfig(value) {
554
715
  const dimensionCode = string(grant.dimensionCode);
555
716
  if (!dimensionCodes.has(dimensionCode) ||
556
717
  grantDimensions.has(dimensionCode) ||
557
- !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(grant.valueField)) ||
718
+ !SEMANTIC_FIELD_PATH_PATTERN.test(string(grant.valueField)) ||
558
719
  (grant.parentValueField !== undefined &&
559
- !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(string(grant.parentValueField)))) {
720
+ !SEMANTIC_FIELD_PATH_PATTERN.test(string(grant.parentValueField)))) {
560
721
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_GRANT_INVALID', 'scope source grant 必须引用唯一已声明维度及有效字段', `${sourcePath}.grants[${grantIndex}]`));
561
722
  }
562
723
  grantDimensions.add(dimensionCode);
@@ -593,7 +754,7 @@ export function validateAppConfig(value) {
593
754
  .map(string),
594
755
  ];
595
756
  if (!declaredFields ||
596
- referencedFields.some(field => !declaredFields.has(field))) {
757
+ referencedFields.some(field => !declaredFields.has(semanticFieldPathRoot(field)))) {
597
758
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_SCOPE_SOURCE_RESOURCE_INVALID', 'scope source 必须引用已声明 Data Resource 及其真实字段', sourcePath));
598
759
  }
599
760
  sourceCodes.add(code);
@@ -611,6 +772,9 @@ export function validateAppConfig(value) {
611
772
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_INVALID', 'data policy 必须声明唯一 code、name、matchMode 和至少一条规则', `authz.dataPolicies[${index}]`));
612
773
  }
613
774
  policyCodes.add(code);
775
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(string(policy.resourceCode))) {
776
+ diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_POLICY_RESOURCE_REQUIRED', 'data policy 必须声明唯一目标 resourceCode', `authz.dataPolicies[${index}].resourceCode`));
777
+ }
614
778
  if (policy.unrestrictedRoleCodes !== undefined) {
615
779
  const unrestrictedRoleCodes = Array.isArray(policy.unrestrictedRoleCodes)
616
780
  ? policy.unrestrictedRoleCodes
@@ -683,10 +847,80 @@ export function validateAppConfig(value) {
683
847
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_DATA_POLICY_NOT_FOUND', `DataResource 引用了未声明的数据策略: ${policyCode}`, `data.resources[${index}].dataPolicyCode`));
684
848
  }
685
849
  });
850
+ diagnostics.push(...validateAuthzSemanticBindings({
851
+ resources: dataResources,
852
+ dimensions,
853
+ scopeSources,
854
+ policies,
855
+ }));
686
856
  validateAuthorizationTransitions(authz.authorizationTransitions, roleCodes, diagnostics);
687
857
  }
688
858
  validateCapabilityClosure(appCode, config, declaredCapabilities, diagnostics);
689
859
  const events = object(config.events);
860
+ const eventDataResources = Array.isArray(object(config.data).resources)
861
+ ? object(config.data).resources
862
+ : [];
863
+ const eventResourceFields = new Map(eventDataResources.map(rawResource => {
864
+ const resource = object(rawResource);
865
+ const fields = Array.isArray(object(resource.schema).fields)
866
+ ? object(resource.schema).fields
867
+ : [];
868
+ return [
869
+ string(resource.code),
870
+ new Map(fields.map(rawField => {
871
+ const field = object(rawField);
872
+ return [string(field.code), string(field.type)];
873
+ })),
874
+ ];
875
+ }));
876
+ const platformEventTypes = new Set(PLATFORM_EVENT_TYPES_V2);
877
+ const declaredEventTypes = new Set();
878
+ const applicationEventSchemas = new Map();
879
+ const schemaKeys = new Set();
880
+ (Array.isArray(events.schemas) ? events.schemas : []).forEach((item, index) => {
881
+ const schema = object(item);
882
+ const path = `events.schemas[${index}]`;
883
+ const eventType = string(schema.eventType);
884
+ const dataSchemaVersion = string(schema.dataSchemaVersion);
885
+ const jsonSchema = object(schema.jsonSchema);
886
+ const sensitiveFields = Array.isArray(schema.sensitiveFields)
887
+ ? schema.sensitiveFields.map(value => string(value))
888
+ : [];
889
+ const sensitiveFieldsValid = (schema.sensitiveFields === undefined ||
890
+ Array.isArray(schema.sensitiveFields)) &&
891
+ sensitiveFields.length <= 100 &&
892
+ new Set(sensitiveFields).size === sensitiveFields.length &&
893
+ sensitiveFields.every(field => /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,7}$/.test(field) && eventSchemaDeclaresPath(jsonSchema, field));
894
+ const schemaKey = `${eventType}:${dataSchemaVersion}`;
895
+ let schemaBytes = Number.POSITIVE_INFINITY;
896
+ try {
897
+ schemaBytes = Buffer.byteLength(JSON.stringify(schema.jsonSchema), 'utf8');
898
+ }
899
+ catch {
900
+ // Reported by the combined validation below.
901
+ }
902
+ const schemaValid = !(!EVENT_TYPE_PATTERN.test(eventType) ||
903
+ eventType.startsWith('openxiangda.') ||
904
+ !eventType.startsWith(`${appCode}.`) ||
905
+ !EVENT_DATA_SCHEMA_VERSION_PATTERN.test(dataSchemaVersion) ||
906
+ object(schema.jsonSchema) !== schema.jsonSchema ||
907
+ jsonSchema.type !== 'object' ||
908
+ schemaBytes > 256 * 1024 ||
909
+ !sensitiveFieldsValid ||
910
+ schemaKeys.has(schemaKey) ||
911
+ declaredEventTypes.has(eventType));
912
+ if (!schemaValid) {
913
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_SCHEMA_INVALID', '应用事件 Schema 必须使用 appCode 命名空间、major 事件类型、SemVer dataSchemaVersion、不超过 256 KiB 的 object JSON Schema;sensitiveFields 最多 100 个、路径规范且必须指向已声明字段,且版本不可重复', path));
914
+ }
915
+ if (schemaValid) {
916
+ declaredEventTypes.add(eventType);
917
+ applicationEventSchemas.set(eventType, jsonSchema);
918
+ }
919
+ schemaKeys.add(schemaKey);
920
+ });
921
+ const allowedEventTypes = new Set([...platformEventTypes, ...declaredEventTypes]);
922
+ const captureFieldsByResource = new Map();
923
+ const subscriptionCodes = new Set();
690
924
  if (config.events !== undefined && !Array.isArray(events.subscriptions)) {
691
925
  diagnostics.push(diagnostic('APP_CONFIG_EVENT_SUBSCRIPTIONS_REQUIRED', 'events.subscriptions 必须是数组', 'events.subscriptions'));
692
926
  }
@@ -697,34 +931,154 @@ export function validateAppConfig(value) {
697
931
  if ('environmentKey' in subscription) {
698
932
  diagnostics.push(diagnostic('APP_CONFIG_ENVIRONMENT_OVERRIDE_FORBIDDEN', '事件声明属于环境中立 AppVersion,不能声明 environmentKey', `${path}.environmentKey`));
699
933
  }
700
- if (!/^[a-z][a-z0-9-]*$/.test(string(subscription.code))) {
701
- diagnostics.push(diagnostic('APP_CONFIG_EVENT_SUBSCRIPTION_CODE_INVALID', '事件订阅 code 必须是 kebab-case', `${path}.code`));
934
+ const subscriptionCode = string(subscription.code);
935
+ if (!/^[a-z][a-z0-9-]*$/.test(subscriptionCode) ||
936
+ subscriptionCodes.has(subscriptionCode)) {
937
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_SUBSCRIPTION_CODE_INVALID', '事件订阅 code 必须是唯一的 kebab-case', `${path}.code`));
702
938
  }
703
939
  if (!Array.isArray(subscription.eventTypes) ||
704
- subscription.eventTypes.length === 0) {
705
- diagnostics.push(diagnostic('APP_CONFIG_EVENT_TYPES_REQUIRED', '事件订阅必须声明 eventTypes', `${path}.eventTypes`));
940
+ subscription.eventTypes.length === 0 ||
941
+ subscription.eventTypes.length > 20 ||
942
+ new Set(subscription.eventTypes.map(string)).size !==
943
+ subscription.eventTypes.length ||
944
+ subscription.eventTypes.some(eventType => !allowedEventTypes.has(string(eventType)))) {
945
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_TYPES_INVALID', '事件订阅必须声明 1 到 20 个不重复且已注册的 v2 eventTypes', `${path}.eventTypes`));
946
+ }
947
+ if ('endpointPath' in subscription) {
948
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_ENDPOINT_DECLARATION_FORBIDDEN', '事件订阅 endpointPath 由 code 生成,应用不能重复声明', `${path}.endpointPath`));
706
949
  }
707
- const endpointPath = string(subscription.endpointPath);
708
- if (!endpointPath.startsWith('/') ||
709
- endpointPath.startsWith('//') ||
710
- endpointPath.includes('..')) {
711
- diagnostics.push(diagnostic('APP_CONFIG_EVENT_ENDPOINT_INVALID', '事件订阅 endpointPath 必须是应用后端相对路径', `${path}.endpointPath`));
950
+ subscriptionCodes.add(subscriptionCode);
951
+ const filter = object(subscription.filter);
952
+ const filterKeys = Object.keys(filter);
953
+ const resourceCodes = Array.isArray(filter.resourceCodes)
954
+ ? filter.resourceCodes.map(string)
955
+ : [];
956
+ if (filterKeys.some(key => !['resourceCodes', 'subject', 'changedFields', 'changes', 'where'].includes(key)) ||
957
+ resourceCodes.length > 20 ||
958
+ new Set(resourceCodes).size !== resourceCodes.length ||
959
+ resourceCodes.some(code => !eventResourceFields.has(code))) {
960
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_FILTER_RESOURCE_INVALID', '事件过滤 resourceCodes 必须引用最多 20 个不重复的已声明资源', `${path}.filter`));
961
+ }
962
+ const allowedFields = new Set();
963
+ if (resourceCodes.length > 0) {
964
+ const fieldSets = resourceCodes
965
+ .map(code => eventResourceFields.get(code))
966
+ .filter(Boolean);
967
+ for (const field of fieldSets[0]?.keys() || []) {
968
+ if (fieldSets.every(fields => fields.has(field)))
969
+ allowedFields.add(field);
970
+ }
971
+ }
972
+ const subject = object(filter.subject);
973
+ if (Object.keys(subject).length > 1 ||
974
+ Object.keys(subject).some(key => !['equals', 'prefix'].includes(key)) ||
975
+ Object.values(subject).some(value => typeof value !== 'string')) {
976
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_FILTER_SUBJECT_INVALID', 'subject 过滤只能声明 equals 或 prefix 之一', `${path}.filter.subject`));
977
+ }
978
+ const referencedFields = new Set();
979
+ const changedFields = object(filter.changedFields);
980
+ const filterComplexity = {
981
+ atoms: (resourceCodes.length > 0 ? 1 : 0) +
982
+ (Object.keys(subject).length > 0 ? 1 : 0),
983
+ };
984
+ for (const [group, rawFields] of Object.entries(changedFields)) {
985
+ const fields = Array.isArray(rawFields) ? rawFields.map(string) : [];
986
+ fields.forEach(field => referencedFields.add(field));
987
+ if (['anyOf', 'allOf', 'noneOf'].includes(group)) {
988
+ filterComplexity.atoms += 1;
989
+ }
990
+ if (!['anyOf', 'allOf', 'noneOf'].includes(group) ||
991
+ fields.length < 1 ||
992
+ fields.length > 32 ||
993
+ new Set(fields).size !== fields.length) {
994
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_CHANGED_FIELDS_INVALID', 'changedFields 只支持 anyOf/allOf/noneOf,每组 1 到 32 个不重复字段', `${path}.filter.changedFields.${group}`));
995
+ }
996
+ }
997
+ const changes = Array.isArray(filter.changes) ? filter.changes : [];
998
+ filterComplexity.atoms += changes.length;
999
+ if (changes.length > 16) {
1000
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_CHANGE_FILTERS_EXCEEDED', '单个订阅最多声明 16 个字段变化条件', `${path}.filter.changes`));
1001
+ }
1002
+ changes.forEach((change, changeIndex) => {
1003
+ referencedFields.add(string(object(change).field));
1004
+ validateEventChangeFilter(change, `${path}.filter.changes[${changeIndex}]`, allowedFields, diagnostics);
1005
+ });
1006
+ if (filter.where !== undefined) {
1007
+ validateEventFilterCondition(filter.where, `${path}.filter.where`, allowedFields, diagnostics, filterComplexity);
1008
+ }
1009
+ if (filterComplexity.atoms > 16) {
1010
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_FILTER_ATOMS_EXCEEDED', '单个订阅最多包含 16 个原子过滤条件', `${path}.filter`));
1011
+ }
1012
+ const payload = object(subscription.payload);
1013
+ const projectionFields = Array.isArray(payload.fields)
1014
+ ? payload.fields.map(string)
1015
+ : [];
1016
+ projectionFields.forEach(field => referencedFields.add(field));
1017
+ if (Object.keys(payload).some(key => !['includeChanges', 'fields'].includes(key)) ||
1018
+ (payload.includeChanges !== undefined &&
1019
+ typeof payload.includeChanges !== 'boolean') ||
1020
+ projectionFields.length > 32 ||
1021
+ new Set(projectionFields).size !== projectionFields.length ||
1022
+ referencedFields.size > 0 && resourceCodes.length === 0 ||
1023
+ [...referencedFields].some(field => !allowedFields.has(field))) {
1024
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_PROJECTION_INVALID', '字段过滤和 payload.fields 必须绑定 resourceCodes、引用各目标资源共有字段,且单订阅投影最多 32 个字段', `${path}.payload`));
1025
+ }
1026
+ for (const resourceCode of resourceCodes) {
1027
+ const captured = captureFieldsByResource.get(resourceCode) || new Set();
1028
+ referencedFields.forEach(field => captured.add(field));
1029
+ captureFieldsByResource.set(resourceCode, captured);
1030
+ }
1031
+ const delivery = {
1032
+ timeoutMs: 10_000,
1033
+ maxAttempts: 8,
1034
+ initialBackoffMs: 1_000,
1035
+ maxBackoffMs: 300_000,
1036
+ ordering: 'none',
1037
+ concurrency: 10,
1038
+ ...object(subscription.delivery),
1039
+ };
1040
+ if (!Number.isInteger(delivery.timeoutMs) ||
1041
+ Number(delivery.timeoutMs) < 1000 ||
1042
+ Number(delivery.timeoutMs) > 30_000 ||
1043
+ !Number.isInteger(delivery.maxAttempts) ||
1044
+ Number(delivery.maxAttempts) < 1 ||
1045
+ Number(delivery.maxAttempts) > 12 ||
1046
+ !Number.isInteger(delivery.initialBackoffMs) ||
1047
+ Number(delivery.initialBackoffMs) < 1000 ||
1048
+ !Number.isInteger(delivery.maxBackoffMs) ||
1049
+ Number(delivery.maxBackoffMs) < Number(delivery.initialBackoffMs) ||
1050
+ Number(delivery.maxBackoffMs) > 1_800_000 ||
1051
+ !['none', 'record'].includes(string(delivery.ordering)) ||
1052
+ !Number.isInteger(delivery.concurrency) ||
1053
+ Number(delivery.concurrency) < 1 ||
1054
+ Number(delivery.concurrency) > 50) {
1055
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_DELIVERY_POLICY_INVALID', 'delivery 超出 timeout、attempt、backoff、ordering 或 concurrency 的平台上限', `${path}.delivery`));
712
1056
  }
713
1057
  });
714
1058
  }
1059
+ for (const [resourceCode, fields] of captureFieldsByResource) {
1060
+ if (fields.size > 64) {
1061
+ diagnostics.push(diagnostic('APP_CONFIG_EVENT_CAPTURE_PLAN_EXCEEDED', `资源 ${resourceCode} 的订阅捕获字段并集超过 64 个`, 'events.subscriptions'));
1062
+ }
1063
+ }
1064
+ const timerCodes = new Set();
715
1065
  (Array.isArray(events.timers) ? events.timers : []).forEach((item, index) => {
716
1066
  const timer = object(item);
717
1067
  const path = `events.timers[${index}]`;
718
1068
  if ('environmentKey' in timer) {
719
1069
  diagnostics.push(diagnostic('APP_CONFIG_ENVIRONMENT_OVERRIDE_FORBIDDEN', '定时声明属于环境中立 AppVersion,不能声明 environmentKey', `${path}.environmentKey`));
720
1070
  }
721
- if (!/^[a-z][a-z0-9-]*$/.test(string(timer.code))) {
1071
+ if (!/^[a-z][a-z0-9-]*$/.test(string(timer.code)) ||
1072
+ timerCodes.has(string(timer.code))) {
722
1073
  diagnostics.push(diagnostic('APP_CONFIG_TIMER_CODE_INVALID', '定时订阅 code 必须是 kebab-case', `${path}.code`));
723
1074
  }
724
- if (!string(timer.eventType) ||
1075
+ timerCodes.add(string(timer.code));
1076
+ if (!declaredEventTypes.has(string(timer.eventType)) ||
725
1077
  !string(timer.cronExpression) ||
726
- !string(timer.timezone)) {
727
- diagnostics.push(diagnostic('APP_CONFIG_TIMER_SCHEDULE_REQUIRED', '定时订阅必须声明 eventType、六段 cronExpression 和 IANA timezone', path));
1078
+ !string(timer.timezone) ||
1079
+ (timer.misfirePolicy !== undefined &&
1080
+ timer.misfirePolicy !== 'coalesce_one')) {
1081
+ diagnostics.push(diagnostic('APP_CONFIG_TIMER_SCHEDULE_REQUIRED', '定时订阅必须引用应用事件 Schema,并声明六段 cronExpression、IANA timezone 和 coalesce_one misfire policy', path));
728
1082
  }
729
1083
  const cronExpression = string(timer.cronExpression);
730
1084
  const timezone = string(timer.timezone);
@@ -754,6 +1108,49 @@ export function validateAppConfig(value) {
754
1108
  else if (Buffer.byteLength(JSON.stringify(timer.payload || {}), 'utf8') > 64 * 1024) {
755
1109
  diagnostics.push(diagnostic('APP_CONFIG_TIMER_PAYLOAD_TOO_LARGE', '定时事件 payload 不能超过 64 KiB', `${path}.payload`));
756
1110
  }
1111
+ else if (!eventDataMatchesSchema(applicationEventSchemas.get(string(timer.eventType)), timer.payload)) {
1112
+ diagnostics.push(diagnostic('APP_CONFIG_TIMER_PAYLOAD_SCHEMA_INVALID', '定时事件 payload 必须完整满足所引用的应用事件 JSON Schema', `${path}.payload`));
1113
+ }
1114
+ });
1115
+ const dateTriggerCodes = new Set();
1116
+ (Array.isArray(events.dateTriggers) ? events.dateTriggers : []).forEach((item, index) => {
1117
+ const trigger = object(item);
1118
+ const path = `events.dateTriggers[${index}]`;
1119
+ const code = string(trigger.code);
1120
+ const resourceCode = string(trigger.resourceCode);
1121
+ const field = string(trigger.field);
1122
+ const fieldType = eventResourceFields.get(resourceCode)?.get(field);
1123
+ const payload = object(trigger.payload);
1124
+ let payloadBytes = Number.POSITIVE_INFINITY;
1125
+ try {
1126
+ payloadBytes = Buffer.byteLength(JSON.stringify(trigger.payload || {}), 'utf8');
1127
+ }
1128
+ catch {
1129
+ // Reported by the combined validation below.
1130
+ }
1131
+ if (!/^[a-z][a-z0-9-]*$/.test(code) ||
1132
+ dateTriggerCodes.has(code) ||
1133
+ !eventResourceFields.has(resourceCode) ||
1134
+ !['date', 'datetime'].includes(fieldType || '') ||
1135
+ !validDateTriggerOffset(trigger.offset) ||
1136
+ !declaredEventTypes.has(string(trigger.eventType)) ||
1137
+ trigger.payload === null ||
1138
+ typeof trigger.payload !== 'object' ||
1139
+ Array.isArray(trigger.payload) ||
1140
+ payloadBytes > 64 * 1024 ||
1141
+ Object.keys(payload).some(key => DATE_TRIGGER_RESERVED_DATA_FIELDS.has(key)) ||
1142
+ !eventDataMatchesSchema(applicationEventSchemas.get(string(trigger.eventType)), {
1143
+ ...payload,
1144
+ triggerCode: code,
1145
+ resourceCode,
1146
+ recordId: '00000000-0000-4000-8000-000000000000',
1147
+ recordRevision: 1,
1148
+ field,
1149
+ dueAt: '2026-01-01T00:00:00.000Z',
1150
+ })) {
1151
+ diagnostics.push(diagnostic('APP_CONFIG_DATE_TRIGGER_INVALID', 'dateTrigger 必须使用唯一 code、date/datetime 字段、十年内 ISO-8601 offset、已注册应用事件、无保留字段且完整生成数据满足 JSON Schema', path));
1152
+ }
1153
+ dateTriggerCodes.add(code);
757
1154
  });
758
1155
  const workflows = object(config.workflows);
759
1156
  if (config.workflows !== undefined &&
@@ -1288,14 +1685,29 @@ export function currentUserDataPolicy(input) {
1288
1685
  }
1289
1686
  export function materializeDataResource(appCode, declaration) {
1290
1687
  const capabilities = resourceCapabilityCodes(appCode, declaration.code);
1291
- const fields = declaration.fields.map(field => ({
1292
- code: field.code,
1293
- type: field.type,
1294
- nullable: field.required !== true,
1295
- ...(field.indexed !== undefined ? { indexed: field.indexed } : {}),
1296
- ...(field.reference ? { reference: field.reference } : {}),
1297
- ...(field.file ? { file: field.file } : {}),
1298
- }));
1688
+ const fields = declaration.fields.map(field => {
1689
+ const definition = {
1690
+ code: field.code,
1691
+ type: field.type,
1692
+ nullable: fieldNullable({
1693
+ type: field.type,
1694
+ nullable: field.required !== true,
1695
+ }),
1696
+ indexed: field.indexed ??
1697
+ Boolean(field.filter || field.searchable || field.sortable),
1698
+ ...(field.options ? { options: field.options } : {}),
1699
+ ...(field.source ? { source: field.source } : {}),
1700
+ ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
1701
+ ...(field.precision !== undefined ? { precision: field.precision } : {}),
1702
+ ...(field.scale !== undefined ? { scale: field.scale } : {}),
1703
+ ...(field.timePrecision ? { timePrecision: field.timePrecision } : {}),
1704
+ ...(field.file ? { file: field.file } : {}),
1705
+ ...(field.serial ? { serial: field.serial } : {}),
1706
+ ...(field.subtable ? { subtable: field.subtable } : {}),
1707
+ };
1708
+ physicalPlanForField(definition, field.searchable === true);
1709
+ return definition;
1710
+ });
1299
1711
  const visible = declaration.fields
1300
1712
  .filter(field => field.list !== false)
1301
1713
  .slice(0, 8);
@@ -1306,6 +1718,8 @@ export function materializeDataResource(appCode, declaration) {
1306
1718
  .filter(field => field.filter === true)
1307
1719
  .map(field => field.code);
1308
1720
  const access = (field, operation) => {
1721
+ if (isGeneratedField(field.type) && operation !== 'read')
1722
+ return [];
1309
1723
  const override = field.access?.[operation];
1310
1724
  if (override === false)
1311
1725
  return [];
@@ -1319,6 +1733,7 @@ export function materializeDataResource(appCode, declaration) {
1319
1733
  field.code,
1320
1734
  {
1321
1735
  label: field.label,
1736
+ type: field.type,
1322
1737
  widget: resolveDataFieldSurfaceWidget(field),
1323
1738
  ...(field.section ? { section: field.section } : {}),
1324
1739
  ...(field.required === true ? { requiredHint: true } : {}),
@@ -1326,16 +1741,21 @@ export function materializeDataResource(appCode, declaration) {
1326
1741
  readCapabilities: access(field, 'read'),
1327
1742
  createCapabilities: access(field, 'create'),
1328
1743
  updateCapabilities: access(field, 'update'),
1329
- ...(field.reference?.multiple !== undefined
1330
- ? { multiple: field.reference.multiple }
1331
- : field.file?.multiple !== undefined
1332
- ? { multiple: field.file.multiple }
1333
- : {}),
1744
+ ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
1745
+ ...(field.precision !== undefined ? { precision: field.precision } : {}),
1746
+ ...(field.scale !== undefined ? { scale: field.scale } : {}),
1334
1747
  ...(field.file?.maxCount !== undefined
1335
1748
  ? { maxCount: field.file.maxCount }
1336
1749
  : {}),
1750
+ ...(field.file?.maxSizeMb !== undefined
1751
+ ? { maxSizeMb: field.file.maxSizeMb }
1752
+ : {}),
1337
1753
  ...(field.file?.accept ? { accept: field.file.accept } : {}),
1338
1754
  ...(field.options ? { options: field.options } : {}),
1755
+ ...(field.source ? { source: field.source } : {}),
1756
+ ...(field.timePrecision ? { timePrecision: field.timePrecision } : {}),
1757
+ ...(field.serial ? { serial: field.serial } : {}),
1758
+ ...(field.subtable ? { subtable: field.subtable } : {}),
1339
1759
  ...(visible.some(item => item.code === field.code)
1340
1760
  ? { list: true }
1341
1761
  : { list: false }),
@@ -1343,7 +1763,6 @@ export function materializeDataResource(appCode, declaration) {
1343
1763
  ? { searchable: field.searchable }
1344
1764
  : {}),
1345
1765
  ...(field.sortable !== undefined ? { sortable: field.sortable } : {}),
1346
- ...(field.reference ? { reference: field.reference } : {}),
1347
1766
  },
1348
1767
  ])),
1349
1768
  list: {
@@ -1404,8 +1823,15 @@ export function validateAppDeclaration(value) {
1404
1823
  'label',
1405
1824
  'required',
1406
1825
  'indexed',
1407
- 'reference',
1826
+ 'options',
1827
+ 'source',
1828
+ 'maxLength',
1829
+ 'precision',
1830
+ 'scale',
1831
+ 'timePrecision',
1408
1832
  'file',
1833
+ 'serial',
1834
+ 'subtable',
1409
1835
  'widget',
1410
1836
  'section',
1411
1837
  'system',
@@ -1413,7 +1839,6 @@ export function validateAppDeclaration(value) {
1413
1839
  'filter',
1414
1840
  'searchable',
1415
1841
  'sortable',
1416
- 'options',
1417
1842
  'access',
1418
1843
  ]);
1419
1844
  resources.forEach((rawResource, resourceIndex) => {
@@ -1443,7 +1868,34 @@ export function validateAppDeclaration(value) {
1443
1868
  const fieldType = string(field.type);
1444
1869
  if (!APP_DATA_FIELD_TYPES.has(fieldType)) {
1445
1870
  materializable = false;
1446
- diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_TYPE_INVALID', `${fieldPath}.type 必须是 string、text、integer、decimal、boolean、date、datetime、uuid、json file`, `${fieldPath}.type`, '数字字段使用 integer 或 decimal,不存在 number 类型'));
1871
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_TYPE_INVALID', `${fieldPath}.type 必须是 OpenXiangda 2.0 语义字段类型之一: ${DATA_FIELD_TYPES.join('、')}`, `${fieldPath}.type`, '直接声明业务语义类型,不声明 PostgreSQL 物理类型'));
1872
+ }
1873
+ else {
1874
+ const semanticType = fieldType;
1875
+ const widget = string(field.widget);
1876
+ if (widget && !isCompatibleFieldWidget({ type: semanticType, widget })) {
1877
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_WIDGET_INCOMPATIBLE', `${fieldPath}.widget 与 ${semanticType} 不兼容;允许 ${compatibleWidgets(semanticType).join('、')}`, `${fieldPath}.widget`));
1878
+ }
1879
+ if (field.filter === true && !supportsFilter(semanticType)) {
1880
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_FILTER_UNSUPPORTED', `${semanticType} 不支持作为父资源筛选字段`, `${fieldPath}.filter`));
1881
+ }
1882
+ if (field.searchable === true && !supportsSearch(semanticType)) {
1883
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_SEARCH_UNSUPPORTED', `${semanticType} 没有全文/模糊搜索和 trigram 索引计划`, `${fieldPath}.searchable`));
1884
+ }
1885
+ if (field.sortable === true && !supportsSort(semanticType)) {
1886
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_SORT_UNSUPPORTED', `${semanticType} 不支持稳定排序`, `${fieldPath}.sortable`));
1887
+ }
1888
+ if (field.indexed === false &&
1889
+ (field.filter === true ||
1890
+ field.searchable === true ||
1891
+ field.sortable === true)) {
1892
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_INDEX_REQUIRED', '启用筛选、搜索或排序时不能显式关闭索引', `${fieldPath}.indexed`));
1893
+ }
1894
+ if (['radio', 'checkbox'].includes(widget) &&
1895
+ object(field.source).loadMode !== 'all' &&
1896
+ field.source !== undefined) {
1897
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_SOURCE_LOAD_MODE_INVALID', '动态 Radio/Checkbox 必须声明 source.loadMode = all', `${fieldPath}.source.loadMode`));
1898
+ }
1447
1899
  }
1448
1900
  if (!string(field.label)) {
1449
1901
  diagnostics.push(diagnostic('APP_CONFIG_DATA_FIELD_LABEL_REQUIRED', '字段必须同时声明中文或业务 label', `${fieldPath}.label`));
@@ -1493,6 +1945,63 @@ export function validateAppDeclaration(value) {
1493
1945
  })));
1494
1946
  }
1495
1947
  });
1948
+ const declaredResources = new Map(resources.map((rawResource, resourceIndex) => {
1949
+ const resource = object(rawResource);
1950
+ return [
1951
+ string(resource.code),
1952
+ {
1953
+ resource,
1954
+ resourceIndex,
1955
+ fields: new Map((Array.isArray(resource.fields) ? resource.fields : []).map(rawField => {
1956
+ const field = object(rawField);
1957
+ return [string(field.code), field];
1958
+ })),
1959
+ },
1960
+ ];
1961
+ }));
1962
+ for (const [resourceCode, declaration] of declaredResources) {
1963
+ const fields = Array.isArray(declaration.resource.fields)
1964
+ ? declaration.resource.fields
1965
+ : [];
1966
+ const aggregateMaxRows = fields.reduce((total, rawField) => {
1967
+ const field = object(rawField);
1968
+ if (field.type !== 'subtable')
1969
+ return total;
1970
+ const configured = Number(object(field.subtable).maxRows);
1971
+ return total + (Number.isSafeInteger(configured) ? configured : 20);
1972
+ }, 0);
1973
+ if (aggregateMaxRows > 49) {
1974
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_SUBTABLE_AGGREGATE_MAX_ROWS_EXCEEDED', '同一父资源的所有子表 maxRows 总和不能超过 49', `data.resources[${declaration.resourceIndex}].fields`));
1975
+ }
1976
+ fields.forEach((rawField, fieldIndex) => {
1977
+ const field = object(rawField);
1978
+ if (field.type !== 'subtable')
1979
+ return;
1980
+ const path = `data.resources[${declaration.resourceIndex}].fields[${fieldIndex}].subtable`;
1981
+ const subtable = object(field.subtable);
1982
+ const targetCode = string(subtable.resourceCode);
1983
+ const target = declaredResources.get(targetCode);
1984
+ if (!target || targetCode === resourceCode) {
1985
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_SUBTABLE_TARGET_INVALID', '子表必须引用同一应用内不同的子资源', `${path}.resourceCode`));
1986
+ return;
1987
+ }
1988
+ const foreignKey = target.fields.get(string(subtable.foreignKey));
1989
+ if (!foreignKey ||
1990
+ foreignKey.type !== 'uuid' ||
1991
+ foreignKey.required !== true ||
1992
+ object(foreignKey.access).create === false) {
1993
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_SUBTABLE_FOREIGN_KEY_INVALID', '子表外键必须是子资源中可创建写入的必填 uuid 字段', `${path}.foreignKey`));
1994
+ }
1995
+ const orderField = target.fields.get(string(subtable.orderField));
1996
+ if (!orderField ||
1997
+ orderField.type !== 'number.integer' ||
1998
+ orderField.required !== true ||
1999
+ object(orderField.access).create === false ||
2000
+ object(orderField.access).update === false) {
2001
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_SUBTABLE_ORDER_FIELD_INVALID', '子表排序字段必须是子资源中可创建和更新的必填 number.integer 字段', `${path}.orderField`));
2002
+ }
2003
+ });
2004
+ }
1496
2005
  return diagnostics;
1497
2006
  }
1498
2007
  export function defineOpenXiangdaApp(declaration) {