openxiangda 1.0.77 → 1.0.78

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 (25) hide show
  1. package/README.md +3 -1
  2. package/lib/cli.js +320 -10
  3. package/openxiangda-skills/SKILL.md +11 -9
  4. package/openxiangda-skills/references/automation-v3.md +17 -1
  5. package/openxiangda-skills/references/connector-resources.md +4 -1
  6. package/openxiangda-skills/references/data-views.md +23 -9
  7. package/openxiangda-skills/references/pages/page-sdk.md +15 -0
  8. package/openxiangda-skills/references/resource-manifest-cheatsheet.md +88 -19
  9. package/openxiangda-skills/references/workflow-v3.md +17 -1
  10. package/openxiangda-skills/skills/openxiangda-app/SKILL.md +7 -5
  11. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +7 -4
  12. package/openxiangda-skills/skills/openxiangda-form/SKILL.md +9 -9
  13. package/openxiangda-skills/skills/openxiangda-inspect/SKILL.md +2 -2
  14. package/openxiangda-skills/skills/openxiangda-page/SKILL.md +19 -16
  15. package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +4 -4
  16. package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +27 -8
  17. package/package.json +1 -1
  18. package/packages/sdk/dist/runtime/index.cjs +20 -0
  19. package/packages/sdk/dist/runtime/index.cjs.map +1 -1
  20. package/packages/sdk/dist/runtime/index.d.mts +18 -1
  21. package/packages/sdk/dist/runtime/index.d.ts +18 -1
  22. package/packages/sdk/dist/runtime/index.mjs +20 -0
  23. package/packages/sdk/dist/runtime/index.mjs.map +1 -1
  24. package/templates/sy-lowcode-app-workspace/scripts/build-js-code.mjs +8 -2
  25. package/templates/sy-lowcode-app-workspace/tsconfig.js-code-nodes.json +5 -1
package/README.md CHANGED
@@ -170,7 +170,9 @@ const affiliatedDepartmentExternalId = user?.affiliatedDepartment?.externalId
170
170
 
171
171
  工程化资源放在工作区 `src/resources/` 下,由 `openxiangda resource validate|plan|publish|pull` 管理。`workspace publish` 会先构建并注册 workspace 表单/页面,再执行非破坏性资源 upsert,这样菜单、权限组、流程和表单设置可以解析最新的 profile-local ID。需要删除平台中 manifest 未声明的资源时,显式传 `--prune`。连接器页面运行时通过 `sdk.connector.invoke()` / `sdk.connector.call("connector.api")` 调用平台运行时接口,第三方密钥只保存在后端连接器配置中。
172
172
 
173
- 多表只读查询和固定口径统计优先声明 `src/resources/data-views/*.json` 数据视图,而不是在页面里手写多次单表查询再拼数据。默认数据视图是行级联表视图,适合工单+客户、订单+商品、项目+成员、报表列表、跨页面复用查询等读多写少场景;`viewType: "aggregate"` 是统计聚合视图,适合按客户、状态、月份等维度预聚合 count/sum/avg/min/max。发布时 CLI 会把 `formCode` 解析为当前 profile 的 `formUuid`,平台创建 PostgreSQL materialized view;页面通过 `sdk.dataView.query(code, params)` 查询行级视图,通过 `sdk.dataView.stats(code, params)` 查询聚合视图,也可以用 `sdk.dataSource.run()` 路由 `dataView.query` / `dataView.stats`。发布前应为常用筛选、排序、统计维度和时间桶声明 `indexes`,并确认用户能接受的刷新延迟;默认不要设置低于 5 分钟的定时刷新。数据视图只读,刷新后才反映源表变化,不适合单表 CRUD、写回源表、强实时状态、临时 BI 查询或简单 linkedForm 下拉。
173
+ 多表只读查询和固定口径统计优先声明 `src/resources/data-views/*.json` 数据视图,而不是在页面里手写多次单表查询再拼数据。默认 `storageMode: "materialized"` 会创建 PostgreSQL materialized view,适合读多写少和可接受刷新延迟的列表/报表;`storageMode: "live"` 每次查询实时编译逻辑视图,适合强实时但数据量可控的复杂查询。`viewType: "aggregate"` 是统计聚合视图,适合按客户、状态、月份等维度聚合 count/sum/avg/min/max。发布时 CLI 会把 `formCode` 解析为当前 profile 的 `formUuid`;页面通过 `sdk.dataView.query(code, params)` 查询行级视图,通过 `sdk.dataView.stats(code, params)` 查询聚合视图,也可以用 `sdk.dataSource.run()` 路由 `dataView.query` / `dataView.stats`。materialized 模式应为常用筛选、排序、统计维度和时间桶声明 `indexes`,并确认用户能接受的刷新延迟;live 模式忽略 `indexes`,不需要刷新。
174
+
175
+ 后端业务逻辑优先声明为 App Function:源码放在 `src/functions/<functionCode>/index.ts`,资源 manifest 放在 `src/resources/functions/<functionCode>.json`。函数运行在 trusted_node 中,通过 `ctx.form`、`ctx.dataView`、`ctx.connector`、`ctx.notification`、`ctx.platform.api` 等受控 API 访问平台能力;自动化、流程和运行时接口都可以调用同一个 function。JS_CODE V2 仍兼容,但新逻辑建议写成 function,再由 `function_call` 节点、`sdk.function.invoke(code, { input })` 或 `/:appType/v1/functions/:code/invoke.json` 调用。当前 MVP 中直接运行时接口要求调用者具备应用自动化管理权限,自动化/流程内部调用走服务端受控上下文。
174
176
 
175
177
  常用数据视图命令:
176
178
 
package/lib/cli.js CHANGED
@@ -1098,6 +1098,7 @@ async function workspace(args) {
1098
1098
  pages: {},
1099
1099
  workflows: {},
1100
1100
  automations: {},
1101
+ functions: {},
1101
1102
  menus: {},
1102
1103
  roles: {},
1103
1104
  connectors: {},
@@ -3129,6 +3130,7 @@ function ensureResourceBuckets(bound) {
3129
3130
  bound.resources.pages = bound.resources.pages || {};
3130
3131
  bound.resources.workflows = bound.resources.workflows || {};
3131
3132
  bound.resources.automations = bound.resources.automations || {};
3133
+ bound.resources.functions = bound.resources.functions || {};
3132
3134
  bound.resources.menus = bound.resources.menus || {};
3133
3135
  bound.resources.roles = bound.resources.roles || {};
3134
3136
  bound.resources.connectors = bound.resources.connectors || {};
@@ -3327,8 +3329,16 @@ function saveAutomationResource(target, automationCode, automationId, extra = {}
3327
3329
  }, keys);
3328
3330
  }
3329
3331
 
3332
+ function saveFunctionResource(target, functionCode, functionId, extra = {}) {
3333
+ const keys = ['functionId', 'status'];
3334
+ saveStateResource(target, 'functions', functionCode, {
3335
+ ...pickStateFields(extra, keys),
3336
+ functionId,
3337
+ }, keys);
3338
+ }
3339
+
3330
3340
  function saveDataViewResource(target, dataViewCode, dataViewId, extra = {}) {
3331
- const keys = ['dataViewId', 'materializedViewName', 'status'];
3341
+ const keys = ['dataViewId', 'materializedViewName', 'status', 'storageMode'];
3332
3342
  saveStateResource(target, 'dataViews', dataViewCode, {
3333
3343
  ...pickStateFields(extra, keys),
3334
3344
  dataViewId,
@@ -3440,6 +3450,7 @@ const RESOURCE_SPECS = [
3440
3450
  { key: 'menus', dir: 'menus', topFiles: ['menus.json'], pluralKeys: ['menus'] },
3441
3451
  { key: 'workflows', dir: 'workflows', topFiles: ['workflows.json'], pluralKeys: ['workflows'] },
3442
3452
  { key: 'automations', dir: 'automations', topFiles: ['automations.json'], pluralKeys: ['automations'] },
3453
+ { key: 'functions', dir: 'functions', topFiles: ['functions.json'], pluralKeys: ['functions'] },
3443
3454
  { key: 'dataViews', dir: 'data-views', topFiles: ['data-views.json'], pluralKeys: ['dataViews', 'data-views'] },
3444
3455
  {
3445
3456
  key: 'pagePermissionGroups',
@@ -3631,6 +3642,8 @@ function generateResourceTypes(manifest, outputFile) {
3631
3642
  const pagePermissionGroupCodes = unique(
3632
3643
  pagePermissionGroups.map(item => item.code).filter(Boolean)
3633
3644
  );
3645
+ const dataViewCodes = unique((manifest.dataViews || []).map(item => item.code).filter(Boolean));
3646
+ const functionCodes = unique((manifest.functions || []).map(item => item.code).filter(Boolean));
3634
3647
  const content = [
3635
3648
  '/* eslint-disable */',
3636
3649
  '// Generated by openxiangda resource typegen. Do not edit manually.',
@@ -3644,6 +3657,12 @@ function generateResourceTypes(manifest, outputFile) {
3644
3657
  `export const pagePermissionGroupCodes = ${JSON.stringify(pagePermissionGroupCodes, null, 2)} as const`,
3645
3658
  'export type PagePermissionGroupCode = typeof pagePermissionGroupCodes[number]',
3646
3659
  '',
3660
+ `export const dataViewCodes = ${JSON.stringify(dataViewCodes, null, 2)} as const`,
3661
+ 'export type DataViewCode = typeof dataViewCodes[number]',
3662
+ '',
3663
+ `export const functionCodes = ${JSON.stringify(functionCodes, null, 2)} as const`,
3664
+ 'export type FunctionCode = typeof functionCodes[number]',
3665
+ '',
3647
3666
  `export const runtimeMenus = ${JSON.stringify(menus, null, 2)} as const`,
3648
3667
  '',
3649
3668
  `export const pagePermissionGroups = ${JSON.stringify(pagePermissionGroups, null, 2)} as const`,
@@ -3656,6 +3675,8 @@ function generateResourceTypes(manifest, outputFile) {
3656
3675
  menuCodes: menuCodes.length,
3657
3676
  routeCodes: routeCodes.length,
3658
3677
  pagePermissionGroups: pagePermissionGroupCodes.length,
3678
+ dataViews: dataViewCodes.length,
3679
+ functions: functionCodes.length,
3659
3680
  };
3660
3681
  }
3661
3682
 
@@ -3702,9 +3723,20 @@ function validateResourceItem(kind, item, errors, warnings) {
3702
3723
  errors.push(`${label}: 缺少 definitionJson 或 definitionFile`);
3703
3724
  }
3704
3725
  }
3726
+ if (kind === 'functions') {
3727
+ if (!item.name) errors.push(`${label}: 缺少 name`);
3728
+ if (!item.definitionJson && !item.definitionFile) {
3729
+ errors.push(`${label}: 缺少 definitionJson 或 definitionFile`);
3730
+ }
3731
+ }
3705
3732
  if (kind === 'dataViews') {
3706
3733
  const definition = item.definition || item;
3707
3734
  const viewType = String(definition.viewType || 'row').toLowerCase();
3735
+ const storageMode = normalizeDataViewStorageMode(
3736
+ item.storageMode || definition.storageMode || 'materialized',
3737
+ label,
3738
+ errors
3739
+ );
3708
3740
  if (!item.name && !definition.name) errors.push(`${label}: 缺少 name`);
3709
3741
  if (!definition.base) errors.push(`${label}: 缺少 base`);
3710
3742
  if (viewType === 'aggregate') {
@@ -3721,7 +3753,7 @@ function validateResourceItem(kind, item, errors, warnings) {
3721
3753
  } else {
3722
3754
  errors.push(`${label}: 不支持的 viewType: ${definition.viewType}`);
3723
3755
  }
3724
- validateDataViewPerformance(label, definition, errors, warnings);
3756
+ validateDataViewPerformance(label, definition, errors, warnings, storageMode);
3725
3757
  }
3726
3758
  if (kind === 'pagePermissionGroups' && !item.name) errors.push(`${label}: 缺少 name`);
3727
3759
  if (kind === 'formPermissionGroups') {
@@ -3743,7 +3775,7 @@ function validateResourceItem(kind, item, errors, warnings) {
3743
3775
  }
3744
3776
  }
3745
3777
 
3746
- function validateDataViewPerformance(label, definition, errors, warnings) {
3778
+ function validateDataViewPerformance(label, definition, errors, warnings, storageMode = 'materialized') {
3747
3779
  const viewType = String(definition.viewType || 'row').toLowerCase();
3748
3780
  if (viewType !== 'row' && viewType !== 'aggregate') return;
3749
3781
  const outputAliases = collectDataViewOutputAliases(definition, viewType);
@@ -3760,6 +3792,13 @@ function validateDataViewPerformance(label, definition, errors, warnings) {
3760
3792
  }
3761
3793
  }
3762
3794
 
3795
+ if (storageMode === 'live') {
3796
+ if (indexes.length) {
3797
+ warnings.push(`${label}: live 数据视图不会创建物化索引,indexes 仅在 materialized 模式生效`);
3798
+ }
3799
+ return;
3800
+ }
3801
+
3763
3802
  if (!indexes.length) {
3764
3803
  warnings.push(
3765
3804
  viewType === 'aggregate'
@@ -3794,6 +3833,13 @@ function validateDataViewPerformance(label, definition, errors, warnings) {
3794
3833
  }
3795
3834
  }
3796
3835
 
3836
+ function normalizeDataViewStorageMode(value, label, errors) {
3837
+ const storageMode = String(value || 'materialized').trim().toLowerCase();
3838
+ if (storageMode === 'materialized' || storageMode === 'live') return storageMode;
3839
+ errors.push(`${label}: 不支持的 storageMode: ${value}`);
3840
+ return 'materialized';
3841
+ }
3842
+
3797
3843
  function collectDataViewOutputAliases(definition, viewType) {
3798
3844
  if (viewType === 'aggregate') {
3799
3845
  return [
@@ -4109,6 +4155,7 @@ async function buildResourcePlan(config, target, manifest) {
4109
4155
  addPlanActions(actions, 'connector', manifest.connectors, existing.connectors, connectorEquals);
4110
4156
  addNotificationPlanActions(target, actions, manifest.notifications || [], existing);
4111
4157
  await addWorkflowPlanActions(config, target, actions, manifest.workflows, existing.workflows);
4158
+ addPlanActions(actions, 'function', manifest.functions, existing.functions, (item, current) => functionEquals(target, item, current));
4112
4159
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
4113
4160
  addPlanActions(actions, 'dataView', manifest.dataViews, existing.dataViews, (item, current) => dataViewEquals(target.bound, item, current));
4114
4161
  addPlanActions(actions, 'pagePermissionGroup', manifest.pagePermissionGroups, existing.pagePermissionGroups, (item, current) => pagePermissionGroupEquals(target.bound, item, current));
@@ -4143,6 +4190,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
4143
4190
  await publishConnectorResources(config, target, manifest.connectors || [], result);
4144
4191
  await publishNotificationResources(config, target, manifest.notifications || [], result);
4145
4192
  await publishWorkflowResources(config, target, manifest.workflows || [], result);
4193
+ await publishFunctionResources(config, target, manifest.functions || [], result);
4146
4194
  await publishAutomationResources(config, target, manifest.automations || [], result);
4147
4195
  await publishDataViewResources(config, target, manifest.dataViews || [], result);
4148
4196
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result);
@@ -4165,6 +4213,7 @@ async function fetchExistingResourceMaps(config, target, manifest) {
4165
4213
  notificationTemplates: new Map(),
4166
4214
  notificationTypeConfigs: new Map(),
4167
4215
  workflows: new Map(),
4216
+ functions: new Map(),
4168
4217
  automations: new Map(),
4169
4218
  dataViews: new Map(),
4170
4219
  pagePermissionGroups: new Map(),
@@ -4245,6 +4294,31 @@ async function fetchExistingResourceMaps(config, target, manifest) {
4245
4294
  );
4246
4295
  indexByCode(maps.automations, normalizeItems(data), item => item.resourceCode);
4247
4296
  }
4297
+ if ((manifest.functions || []).length > 0) {
4298
+ const data = await requestWithAuth(
4299
+ config,
4300
+ target.profileName,
4301
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions`, {
4302
+ page: 1,
4303
+ pageSize: 1000,
4304
+ })
4305
+ );
4306
+ indexByCode(maps.functions, normalizeItems(data), item => item.code || item.functionCode || item.resourceCode);
4307
+ for (const item of manifest.functions || []) {
4308
+ const code = item.code || item.functionCode || item.resourceCode;
4309
+ const existing = maps.functions.get(code);
4310
+ if (!existing) continue;
4311
+ const detail = await requestOptionalWithAuth(
4312
+ config,
4313
+ target.profileName,
4314
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions/${encodeURIComponent(code)}`
4315
+ );
4316
+ maps.functions.set(code, {
4317
+ ...existing,
4318
+ ...(detail || {}),
4319
+ });
4320
+ }
4321
+ }
4248
4322
  if ((manifest.dataViews || []).length > 0) {
4249
4323
  const data = await requestWithAuth(
4250
4324
  config,
@@ -4782,6 +4856,7 @@ async function publishAutomationResources(config, target, automations, result) {
4782
4856
  'definitionJson',
4783
4857
  'definitionFile'
4784
4858
  );
4859
+ applyResourceBindingsToRuntimeDefinition(target.bound, automationItem, definitionJson);
4785
4860
  const viewJson =
4786
4861
  (await resolveManifestJson(
4787
4862
  config,
@@ -4845,6 +4920,58 @@ async function publishAutomationResources(config, target, automations, result) {
4845
4920
  }
4846
4921
  }
4847
4922
 
4923
+ async function publishFunctionResources(config, target, functions, result) {
4924
+ for (const functionItem of functions) {
4925
+ const code = functionItem.code || functionItem.functionCode;
4926
+ const existing = await findExistingFunction(config, target, code);
4927
+ const definitionJson = await resolveManifestJson(
4928
+ config,
4929
+ target.profileName,
4930
+ functionItem,
4931
+ 'definitionJson',
4932
+ 'definitionFile'
4933
+ );
4934
+ applyResourceBindingsToRuntimeDefinition(target.bound, functionItem, definitionJson);
4935
+ const body = stripUndefinedValues({
4936
+ code,
4937
+ name: functionItem.name || definitionJson.name || code,
4938
+ description:
4939
+ functionItem.description !== undefined
4940
+ ? functionItem.description
4941
+ : definitionJson.description || '',
4942
+ definitionJson,
4943
+ resourceBindings: definitionJson.resourceBindings,
4944
+ inputSchema: functionItem.inputSchema || definitionJson.inputSchema,
4945
+ outputSchema: functionItem.outputSchema || definitionJson.outputSchema,
4946
+ status: functionItem.status,
4947
+ });
4948
+ const data = existing
4949
+ ? await requestWithAuth(
4950
+ config,
4951
+ target.profileName,
4952
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions/${encodeURIComponent(code)}`,
4953
+ { method: 'PUT', body }
4954
+ )
4955
+ : await requestWithAuth(
4956
+ config,
4957
+ target.profileName,
4958
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions`,
4959
+ { method: 'POST', body }
4960
+ );
4961
+ if (data?.id) {
4962
+ saveFunctionResource(target, code, data.id, {
4963
+ status: data.status,
4964
+ });
4965
+ }
4966
+ result.published.push({
4967
+ kind: 'function',
4968
+ code,
4969
+ action: existing ? 'update' : 'create',
4970
+ id: data?.id,
4971
+ });
4972
+ }
4973
+ }
4974
+
4848
4975
  async function publishDataViewResources(config, target, dataViews, result) {
4849
4976
  for (const dataViewItem of dataViews) {
4850
4977
  const existing = await findExistingDataView(config, target, dataViewItem.code);
@@ -4866,6 +4993,7 @@ async function publishDataViewResources(config, target, dataViews, result) {
4866
4993
  saveDataViewResource(target, dataViewItem.code, data.id, {
4867
4994
  materializedViewName: data.materializedViewName,
4868
4995
  status: data.status,
4996
+ storageMode: data.storageMode,
4869
4997
  });
4870
4998
  }
4871
4999
  result.published.push({
@@ -4877,6 +5005,18 @@ async function publishDataViewResources(config, target, dataViews, result) {
4877
5005
  }
4878
5006
  }
4879
5007
 
5008
+ async function findExistingFunction(config, target, code) {
5009
+ const stateId = target.bound.resources?.functions?.[code]?.functionId;
5010
+ const byCode = await requestOptionalWithAuth(
5011
+ config,
5012
+ target.profileName,
5013
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions/${encodeURIComponent(code)}`
5014
+ );
5015
+ if (byCode?.id) return byCode;
5016
+ if (stateId) return { id: stateId, code };
5017
+ return null;
5018
+ }
5019
+
4880
5020
  async function findExistingDataView(config, target, code) {
4881
5021
  const stateId = target.bound.resources?.dataViews?.[code]?.dataViewId;
4882
5022
  const byCode = await requestOptionalWithAuth(
@@ -5026,6 +5166,7 @@ async function pruneResourceManifest(config, target, manifest, result) {
5026
5166
  await pruneMenus(config, target, desiredCodes(manifest.menus), result);
5027
5167
  await pruneWorkflows(config, target, desiredCodes(manifest.workflows), result);
5028
5168
  await pruneAutomations(config, target, desiredCodes(manifest.automations), result);
5169
+ await pruneFunctions(config, target, desiredCodes(manifest.functions), result);
5029
5170
  await pruneDataViews(config, target, desiredCodes(manifest.dataViews), result);
5030
5171
  await prunePagePermissionGroups(
5031
5172
  config,
@@ -5198,6 +5339,29 @@ async function pruneDataViews(config, target, desired, result) {
5198
5339
  }
5199
5340
  }
5200
5341
 
5342
+ async function pruneFunctions(config, target, desired, result) {
5343
+ const data = await requestWithAuth(
5344
+ config,
5345
+ target.profileName,
5346
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions`, {
5347
+ page: 1,
5348
+ pageSize: 1000,
5349
+ })
5350
+ );
5351
+ for (const fn of normalizeItems(data)) {
5352
+ const code = fn.code || fn.functionCode || fn.resourceCode;
5353
+ if (!code || desired.has(code)) continue;
5354
+ await pruneOne(config, target, result, 'function', code, fn.id, async () => {
5355
+ await requestWithAuth(
5356
+ config,
5357
+ target.profileName,
5358
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions/${encodeURIComponent(code)}`,
5359
+ { method: 'DELETE' }
5360
+ );
5361
+ });
5362
+ }
5363
+ }
5364
+
5201
5365
  async function prunePagePermissionGroups(config, target, desired, result) {
5202
5366
  const data = await requestWithAuth(
5203
5367
  config,
@@ -5272,6 +5436,7 @@ function removeStateResource(target, kind, code) {
5272
5436
  menu: 'menus',
5273
5437
  workflow: 'workflows',
5274
5438
  automation: 'automations',
5439
+ function: 'functions',
5275
5440
  dataView: 'dataViews',
5276
5441
  pagePermissionGroup: 'pagePermissionGroups',
5277
5442
  formPermissionGroup: 'formPermissionGroups',
@@ -5441,6 +5606,35 @@ async function pullResources(config, target) {
5441
5606
  written.push(path.relative(process.cwd(), filePath));
5442
5607
  }
5443
5608
 
5609
+ const functions = await requestWithAuth(
5610
+ config,
5611
+ target.profileName,
5612
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions`, {
5613
+ page: 1,
5614
+ pageSize: 1000,
5615
+ })
5616
+ );
5617
+ for (const fn of normalizeItems(functions)) {
5618
+ const code = fn.code || fn.functionCode || fn.resourceCode || fn.id;
5619
+ const detail = await requestWithAuth(
5620
+ config,
5621
+ target.profileName,
5622
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/functions/${encodeURIComponent(code)}`
5623
+ );
5624
+ const filePath = path.join(baseDir, 'functions', `${code}.json`);
5625
+ writeResourceJsonFile(filePath, {
5626
+ code,
5627
+ name: detail.name,
5628
+ description: detail.description || '',
5629
+ definitionJson: detail.definitionJson,
5630
+ resourceBindings: detail.resourceBindings || undefined,
5631
+ inputSchema: detail.inputSchema || undefined,
5632
+ outputSchema: detail.outputSchema || undefined,
5633
+ status: detail.status || undefined,
5634
+ });
5635
+ written.push(path.relative(process.cwd(), filePath));
5636
+ }
5637
+
5444
5638
  const dataViews = await requestWithAuth(
5445
5639
  config,
5446
5640
  target.profileName,
@@ -5649,6 +5843,7 @@ function toPulledDataView(dataView, permissionGroups, lookups) {
5649
5843
  code: dataView.code || dataView.resourceCode,
5650
5844
  name: dataView.name,
5651
5845
  description: dataView.description || '',
5846
+ storageMode: dataView.storageMode || 'materialized',
5652
5847
  definition,
5653
5848
  refreshConfig: dataView.refreshConfig || undefined,
5654
5849
  permissionGroups: groups.length > 0 ? groups : undefined,
@@ -5818,12 +6013,15 @@ function normalizeDataViewManifest(bound, dataView) {
5818
6013
  definition.description = dataView.description;
5819
6014
  }
5820
6015
  const normalizedDefinition = normalizeDataViewDefinitionFormRefs(bound, definition, dataView);
6016
+ const storageMode = String(dataView.storageMode || normalizedDefinition.storageMode || 'materialized').toLowerCase();
6017
+ delete normalizedDefinition.storageMode;
5821
6018
  return stripUndefinedValues({
5822
6019
  code: dataView.code || dataView.resourceCode || normalizedDefinition.code,
5823
6020
  name: dataView.name || normalizedDefinition.name || dataView.code || normalizedDefinition.code,
5824
6021
  description: dataView.description !== undefined
5825
- ? dataView.description
5826
- : normalizedDefinition.description || '',
6022
+ ? dataView.description
6023
+ : normalizedDefinition.description || '',
6024
+ storageMode,
5827
6025
  definition: normalizedDefinition,
5828
6026
  refreshConfig: dataView.refreshConfig || dataView.refresh || normalizedDefinition.refresh,
5829
6027
  permissionGroups: Array.isArray(dataView.permissionGroups)
@@ -5836,6 +6034,7 @@ function withoutDataViewManifestMeta(dataView) {
5836
6034
  const next = withoutResourceMeta(dataView);
5837
6035
  delete next.permissionGroups;
5838
6036
  delete next.refreshConfig;
6037
+ delete next.storageMode;
5839
6038
  return next;
5840
6039
  }
5841
6040
 
@@ -5872,6 +6071,93 @@ function resolveDataViewSourceFormUuid(bound, source, dataView, label) {
5872
6071
  delete source.form;
5873
6072
  }
5874
6073
 
6074
+ function applyResourceBindingsToRuntimeDefinition(bound, item, definitionJson) {
6075
+ if (!definitionJson || typeof definitionJson !== 'object') return definitionJson;
6076
+ const topBindings = resolveRuntimeResourceBindings(
6077
+ bound,
6078
+ item.resources || definitionJson.resources,
6079
+ item
6080
+ );
6081
+ if (!isEmptyResourceBindings(topBindings)) {
6082
+ definitionJson.resourceBindings = mergeResourceBindings(
6083
+ definitionJson.resourceBindings,
6084
+ topBindings
6085
+ );
6086
+ }
6087
+ applyNestedRuntimeResourceBindings(bound, definitionJson, item);
6088
+ return definitionJson;
6089
+ }
6090
+
6091
+ function applyNestedRuntimeResourceBindings(bound, value, owner) {
6092
+ if (!value || typeof value !== 'object') return;
6093
+ if (Array.isArray(value)) {
6094
+ for (const item of value) applyNestedRuntimeResourceBindings(bound, item, owner);
6095
+ return;
6096
+ }
6097
+ if (value.resources && typeof value.resources === 'object') {
6098
+ const bindings = resolveRuntimeResourceBindings(bound, value.resources, owner);
6099
+ if (!isEmptyResourceBindings(bindings)) {
6100
+ value.resourceBindings = mergeResourceBindings(value.resourceBindings, bindings);
6101
+ }
6102
+ }
6103
+ for (const child of Object.values(value)) {
6104
+ if (child && typeof child === 'object') {
6105
+ applyNestedRuntimeResourceBindings(bound, child, owner);
6106
+ }
6107
+ }
6108
+ }
6109
+
6110
+ function resolveRuntimeResourceBindings(bound, resources, item) {
6111
+ if (!resources || typeof resources !== 'object') return {};
6112
+ const label = resourceLabel('resource', item);
6113
+ const forms = {};
6114
+ for (const formCode of normalizeResourceCodeList(resources.forms)) {
6115
+ const formUuid = bound.resources?.forms?.[formCode]?.formUuid;
6116
+ if (!formUuid) {
6117
+ fail(`${label}: resources.forms 未绑定: ${formCode}`);
6118
+ }
6119
+ forms[formCode] = formUuid;
6120
+ }
6121
+ const dataViews = {};
6122
+ for (const dataViewCode of normalizeResourceCodeList(resources.dataViews)) {
6123
+ dataViews[dataViewCode] = dataViewCode;
6124
+ }
6125
+ return stripEmptyResourceBindings({ forms, dataViews });
6126
+ }
6127
+
6128
+ function normalizeResourceCodeList(value) {
6129
+ if (!value) return [];
6130
+ if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean);
6131
+ if (typeof value === 'object') return Object.keys(value).map(item => String(item || '').trim()).filter(Boolean);
6132
+ return [String(value).trim()].filter(Boolean);
6133
+ }
6134
+
6135
+ function mergeResourceBindings(left, right) {
6136
+ return stripEmptyResourceBindings({
6137
+ ...(left || {}),
6138
+ ...(right || {}),
6139
+ forms: {
6140
+ ...((left && left.forms) || {}),
6141
+ ...((right && right.forms) || {}),
6142
+ },
6143
+ dataViews: {
6144
+ ...((left && left.dataViews) || {}),
6145
+ ...((right && right.dataViews) || {}),
6146
+ },
6147
+ });
6148
+ }
6149
+
6150
+ function stripEmptyResourceBindings(bindings) {
6151
+ const next = { ...(bindings || {}) };
6152
+ if (!next.forms || Object.keys(next.forms).length === 0) delete next.forms;
6153
+ if (!next.dataViews || Object.keys(next.dataViews).length === 0) delete next.dataViews;
6154
+ return next;
6155
+ }
6156
+
6157
+ function isEmptyResourceBindings(bindings) {
6158
+ return Object.keys(stripEmptyResourceBindings(bindings)).length === 0;
6159
+ }
6160
+
5875
6161
  async function resolveManifestJson(config, profileName, item, objectKey, fileKey, optional = false) {
5876
6162
  let value = item[objectKey];
5877
6163
  if (value === undefined && item[fileKey]) {
@@ -6166,6 +6452,7 @@ function workflowEquals(bound, desired, existing) {
6166
6452
  function automationEquals(target, desired, existing) {
6167
6453
  if (!existing) return false;
6168
6454
  const desiredDefinition = resolveManifestPlainJson(desired, 'definitionJson', 'definitionFile');
6455
+ applyResourceBindingsToRuntimeDefinition(target.bound, desired, desiredDefinition);
6169
6456
  const desiredView = resolveManifestPlainJson(desired, 'viewJson', 'viewFile', true);
6170
6457
  const automationPayload = resolveAutomationManifestPayload(target, desired);
6171
6458
  const desiredTags =
@@ -6195,6 +6482,23 @@ function automationEquals(target, desired, existing) {
6195
6482
  );
6196
6483
  }
6197
6484
 
6485
+ function functionEquals(target, desired, existing) {
6486
+ if (!existing) return false;
6487
+ const desiredDefinition = resolveManifestPlainJson(desired, 'definitionJson', 'definitionFile');
6488
+ applyResourceBindingsToRuntimeDefinition(target.bound, desired, desiredDefinition);
6489
+ const desiredStatus = desired.status === undefined ? 'active' : String(desired.status || 'active');
6490
+ return (
6491
+ String(existing.code || existing.functionCode || existing.resourceCode || '') === String(desired.code || desired.functionCode || '') &&
6492
+ String(existing.name || '') === String(desired.name || desiredDefinition.name || desired.code || '') &&
6493
+ String(existing.description || '') === String(
6494
+ desired.description !== undefined ? desired.description : desiredDefinition.description || ''
6495
+ ) &&
6496
+ jsonEqualsForPlan(existing.definitionJson, desiredDefinition, desired.__dir) &&
6497
+ jsonEqualsForPlan(existing.resourceBindings || {}, desiredDefinition.resourceBindings || {}, desired.__dir) &&
6498
+ String(existing.status || 'active') === desiredStatus
6499
+ );
6500
+ }
6501
+
6198
6502
  function dataViewEquals(bound, desired, existing) {
6199
6503
  if (!existing) return false;
6200
6504
  const expected = normalizeDataViewManifest(bound, desired);
@@ -6205,6 +6509,7 @@ function dataViewEquals(bound, desired, existing) {
6205
6509
  String(existing.code || existing.resourceCode || '') === String(expected.code || '') &&
6206
6510
  String(existing.name || '') === String(expected.name || '') &&
6207
6511
  String(existing.description || '') === String(expected.description || '') &&
6512
+ String(existing.storageMode || 'materialized') === String(expected.storageMode || 'materialized') &&
6208
6513
  jsonEqualsForPlan(existing.definition || {}, expected.definition || {}, desired.__dir) &&
6209
6514
  jsonEqualsForPlan(existing.refreshConfig || {}, expected.refreshConfig || {}, desired.__dir) &&
6210
6515
  permissionGroupsEqual
@@ -6538,26 +6843,31 @@ function resolveJsCodeBundlePath(localPath, scriptCode) {
6538
6843
  const extension = path.extname(localPath).toLowerCase();
6539
6844
  if (['.js', '.cjs', '.mjs'].includes(extension)) {
6540
6845
  fail(
6541
- `JS_CODE V2 要求 AI 源码使用 TypeScript,请把 sourceFile.localPath 指向 src/js-code-nodes/<scriptCode>/index.ts,而不是已构建的 ${extension} 文件: ${localPath}`
6846
+ `JS_CODE V2 要求 AI 源码使用 TypeScript,请把 sourceFile.localPath 指向 src/js-code-nodes/<scriptCode>/index.ts、src/automations/<scriptCode>/index.ts 或 src/functions/<functionCode>/index.ts,而不是已构建的 ${extension} 文件: ${localPath}`
6542
6847
  );
6543
6848
  }
6544
6849
  if (extension !== '.ts') {
6545
6850
  fail(
6546
- `JS_CODE V2 sourceFile.localPath 必须指向 TypeScript 源文件 src/js-code-nodes/<scriptCode>/index.ts: ${localPath}`
6851
+ `JS_CODE V2 sourceFile.localPath 必须指向 TypeScript 源文件 src/js-code-nodes/<scriptCode>/index.ts、src/automations/<scriptCode>/index.ts 或 src/functions/<functionCode>/index.ts: ${localPath}`
6547
6852
  );
6548
6853
  }
6549
6854
 
6550
6855
  const normalized = localPath.replace(/\\/g, '/');
6551
6856
  const jsCodeMatched = normalized.match(/src\/js-code-nodes\/([^/]+)\/index\.tsx?$/);
6552
6857
  const automationMatched = normalized.match(/src\/automations\/([^/]+)\/index\.tsx?$/);
6553
- const matched = jsCodeMatched || automationMatched;
6858
+ const functionMatched = normalized.match(/src\/functions\/([^/]+)\/index\.tsx?$/);
6859
+ const matched = jsCodeMatched || automationMatched || functionMatched;
6554
6860
  const resolvedScriptCode = scriptCode || matched?.[1];
6555
6861
  if (!resolvedScriptCode) {
6556
6862
  fail(
6557
- `无法从 sourceFile.localPath 推断 scriptCode,请使用 src/js-code-nodes/<scriptCode>/index.ts 或 src/automations/<scriptCode>/index.ts: ${localPath}`
6863
+ `无法从 sourceFile.localPath 推断 scriptCode,请使用 src/js-code-nodes/<scriptCode>/index.ts、src/automations/<scriptCode>/index.ts 或 src/functions/<functionCode>/index.ts: ${localPath}`
6558
6864
  );
6559
6865
  }
6560
- const sourceKind = automationMatched ? 'automations' : 'js-code-nodes';
6866
+ const sourceKind = functionMatched
6867
+ ? 'functions'
6868
+ : automationMatched
6869
+ ? 'automations'
6870
+ : 'js-code-nodes';
6561
6871
 
6562
6872
  const workspaceRoot = findWorkspaceRoot(localPath);
6563
6873
  const result = spawnSync('pnpm', ['build-js-code', '--script', resolvedScriptCode, '--source', sourceKind], {
@@ -26,10 +26,11 @@ OpenXiangda supports two workspace modes. Classic `sy-lowcode-app-workspace` pub
26
26
  | 创建 / 修改自定义代码页 / portal / dashboard | `openxiangda-page` | edit `src/pages/<code>/` → `workspace publish --page <code>` |
27
27
  | 审批流程 / workflow / 流程节点 / JS_CODE | `openxiangda-workflow-automation` | `openxiangda workflow validate / create / publish` |
28
28
  | 自动化 / 定时任务 / 提交触发 / cron | `openxiangda-workflow-automation` | `openxiangda automation validate / create / publish / enable` |
29
+ | App Function / function_call / 可复用后端逻辑 | `openxiangda-workflow-automation` | declare `src/resources/functions/<code>.json` + `src/functions/<code>/index.ts` |
29
30
  | 角色 / 权限组 / 字段权限 / 数据范围 / 公开访问 | `openxiangda-permission-settings` | `openxiangda permission ...` / `openxiangda settings ...` |
30
31
  | 看应用结构 / 快照 / 对比 / 诊断 / 排查 / 报错 | `openxiangda-inspect` | `openxiangda app snapshot <APP_XXX> --profile <name> --json` |
31
32
  | 登录 / 切换平台 / profile / token / whoami | `openxiangda-core` | `openxiangda env --profile <name>` / `openxiangda auth status` |
32
- | 多表只读联表 / 固定口径统计 / 看板指标 | `openxiangda-form` (data view) | declare `src/resources/data-views/<code>.json` → `resource publish` |
33
+ | 多表只读联表 / 固定口径统计 / 强实时复杂查询 / 看板指标 | `openxiangda-form` (data view) | declare `src/resources/data-views/<code>.json` with `storageMode` → `resource publish` |
33
34
  | 调外部 / 第三方 API / 钉钉 / 自建系统 | `openxiangda-page` (connector) | declare `src/resources/connectors/<code>.json` → `sdk.connector.invoke` |
34
35
 
35
36
  ### Hard rules — always
@@ -133,14 +134,15 @@ When the user provides a root domain such as `https://yida.wisejob.cn/`, use it
133
134
  - All visible copy in forms and pages must be written for end users. Do not put implementation notes, developer explanations, schema descriptions, or "this area is generated by..." text into page sections, cards, labels, tips, or empty states.
134
135
  - Use logical resource codes in local files. Platform-specific IDs such as `formUuid`, `pageId`, `workflowId`, and `automationId` must be isolated by profile.
135
136
  - Put engineering-managed resources in `src/resources/` and use `openxiangda resource validate|plan|publish|pull`. `workspace publish` publishes workspace forms/pages first, then runs non-destructive resource upsert. Only pass `--prune` when the user explicitly wants local manifests to delete platform-side extras.
136
- - For repeated read-only multi-form queries or predefined dashboard metrics, create a data view manifest in `src/resources/data-views/`. Use row views plus `sdk.dataView.query` for joined lists/lookups; use `viewType: "aggregate"` plus `sdk.dataView.stats` for count/sum/avg/min/max statistics. Before choosing scheduled refresh, confirm the user's freshness tolerance; default to manual or 10 minutes and above unless fresher data is explicitly required. Add `indexes` for common runtime filters/order fields, aggregate dimensions, and time buckets. Do not use data views for single-form CRUD, simple linkedForm selects, real-time writes, write-back, or ad-hoc BI. Read `references/data-views.md` before designing one.
137
+ - For repeated read-only multi-form queries or predefined dashboard metrics, create a data view manifest in `src/resources/data-views/`. Use `storageMode: "materialized"` for refreshed lists/reports that tolerate delay; use `storageMode: "live"` for bounded real-time joins where source changes must appear immediately. Use row views plus `sdk.dataView.query` for joined lists/lookups; use `viewType: "aggregate"` plus `sdk.dataView.stats` for count/sum/avg/min/max statistics. Add `indexes` only for materialized views. Do not use data views for single-form CRUD, simple linkedForm selects, writes, write-back, unbounded realtime joins, or ad-hoc BI. Read `references/data-views.md` before designing one.
137
138
  - For external APIs, create a connector manifest in `src/resources/connectors/` and call it from pages through `sdk.connector`; never put third-party API keys in page source.
139
+ - For reusable backend logic shared by pages, automations, and workflows, create an App Function in `src/resources/functions/<functionCode>.json` plus `src/functions/<functionCode>/index.ts`. Call it from pages with `sdk.function.invoke`, from automation/workflow graphs with `function_call`, or from the runtime endpoint when the caller has app automation management permission. Current App Function MVP exposes controlled helpers such as `ctx.resources`, `ctx.form`, `ctx.dataView`, `ctx.connector`, `ctx.notification`, and `ctx.platform.api`; it does not expose raw SQL or Redis.
138
140
  - Before scaffolding a real app, complex page, data management page, portal shell, status lifecycle, role governance, or automation, read `references/best-practices.md` and pick the architecture first. Prefer copying the relevant pattern from `examples/best-practices/` into `src/` instead of generating a single large page file.
139
141
  - Before publishing to another platform, verify the workspace is bound for that profile. Resource IDs from one profile must not be reused for another profile.
140
142
  - Use `openxiangda app snapshot <APP_XXX> --profile <name> --json` for diagnosis before changing an existing app.
141
143
  - Run write commands that update `.openxiangda/state.json` sequentially within the same workspace. Read-only commands can run in parallel.
142
144
  - JS_CODE is backend-executed workflow/automation logic, not frontend page code. Use it when logic must run after a backend trigger such as fixed cron schedules, form date-field schedules, form submit/update/delete/field-change events, or workflow approval/process events.
143
- - Use JS_CODE for cross-form data queries, create/update/batch update operations, process termination, platform API calls, external HTTP calls, and complex orchestration that the frontend cannot handle reliably. Do not use it for simple UI interactions, ordinary form validation, or display-only page behavior.
145
+ - Use JS_CODE for node-local cross-form data queries, create/update/batch update operations, process termination, platform API calls, external HTTP calls, and backend trigger orchestration. For logic that pages, automations, and workflows should reuse through a stable backend entry, prefer App Function. Do not use JS_CODE for simple UI interactions, ordinary form validation, or display-only page behavior.
144
146
  - For workflow/automation JS_CODE nodes, prefer V2 `runtimeMode: "trusted_node"`. AI-authored source must be TypeScript under `sy-lowcode-app-workspace/src/js-code-nodes/<scriptCode>/index.ts`. `pnpm build-js-code --script <scriptCode>` runs TypeScript validation before bundling, and `sourceFile.localPath` should point to the TS source; the CLI builds, uploads, and replaces it with snapshot metadata during validate/create.
145
147
 
146
148
  ## Subskills
@@ -169,9 +171,9 @@ Core CLI / state:
169
171
 
170
172
  - `references/openxiangda-api.md` — `/openxiangda-api/v1` request and response fields.
171
173
  - `references/workspace-state.md` — `.openxiangda/state.json` shape and profile isolation rules.
172
- - `references/connector-resources.md` — `src/resources` manifests, connector schema, and SDK connector calls.
173
- - `references/resource-manifest-cheatsheet.md` — 复制即用的 connector / data-view / notification / workflow / automation / JS_CODE / role / permission group / settings / menu manifest 骨架与运行时调用示例。在创建任何 `src/resources/` 资源前先看这份。
174
- - `references/data-views.md` — `src/resources/data-views` materialized view resources, DSL, permissions, refresh, CLI commands, and runtime SDK usage.
174
+ - `references/connector-resources.md` — `src/resources` manifests, connector schema, App Function boundary, and platform runtime calls.
175
+ - `references/resource-manifest-cheatsheet.md` — 复制即用的 connector / data-view / notification / App Function / workflow / automation / JS_CODE / role / permission group / settings / menu manifest 骨架与运行时调用示例。在创建任何 `src/resources/` 资源前先看这份。
176
+ - `references/data-views.md` — `src/resources/data-views` materialized/live data view resources, DSL, permissions, refresh, CLI commands, and runtime SDK usage.
175
177
  - `references/notifications.md` — `src/resources/notifications`, notification templates/type bindings, and `sdk.notification` / `ctx.notification`.
176
178
  - `references/best-practices.md` — initialized examples for modular pages, state lifecycles, role governance, permission isolation, high-performance queries, portal shells, workflow boundaries, and automation patterns.
177
179
 
@@ -184,13 +186,13 @@ Form authoring:
184
186
  Page authoring:
185
187
 
186
188
  - `references/pages/workspace-structure.md` — `src/pages/<pageCode>/` layout and config.
187
- - `references/pages/page-sdk.md` — `openxiangda/runtime` data and runtime APIs.
189
+ - `references/pages/page-sdk.md` — `openxiangda/runtime` data, connector, data view, notification, and App Function APIs.
188
190
  - `references/pages/publish-flow.md` — workspace publish steps for code pages.
189
191
 
190
192
  Workflow / automation / permissions:
191
193
 
192
- - `references/workflow-v3.md` — workflow v3 definitions, JS_CODE nodes, trusted_node mode.
193
- - `references/automation-v3.md` — automation triggers, conditions, and publishing.
194
+ - `references/workflow-v3.md` — workflow v3 definitions, JS_CODE nodes, App Function `function_call`, and trusted_node mode.
195
+ - `references/automation-v3.md` — automation triggers, conditions, App Function `function_call`, and publishing.
194
196
  - `references/permissions-settings.md` — roles, page/form permission groups, settings.
195
197
 
196
198
  Platform domain knowledge (read these first when generating forms or pages so the output matches the live platform behavior):