Skip to content

One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122

Description

@hotlong

从下游应用提出(steedos-labs/hotcrm-heimaoobjectstack-ai/hotcrm)。全部结论实测于 @objectstack/* 17.2.0 运行实例,非推断。

修订 1:补入 §5.6「安装时对象名唯一性检查」——放宽命名空间闸的安全前提,初版遗漏;并新增 §6 兼容性一节(对存量客户元数据应用的影响)。


一、业务需求

1.1 标准产品 + 客户定制,今天靠整仓复制

我们有标准 CRM(hotcrm),客户会在它上面做定制。今天的模式是把整个仓库复制一份随便改——黑猫(hotcrm-heimao)就是 hotcrm 的 fork。

fork 税是实测的,不是担心:把 @objectstack/* 从 17.1.0 升到 17.2.0,两个仓库各做一遍,同样的坑(sharing 播种、StackBlitz 双锁文件门)各踩一遍,知识靠人手搬。黑猫对标准 CRM 的改动其实约 95% 是增量——碰到标准对象的 6 个文件是 +1,000/−15 行,15 行删除全是替换而非删能力。也就是说:绝大部分本可以是"扩展",却因为没有边界而变成了"分叉"。

1.2 产品自身的模块也在扩张

不只是客户定制。hotcrm 想在销售模块上扩 CPQ;黑猫加了"订单与毛利测算"(12 个对象 + 8 个 hook)。今天这些都堆在同一个 src/,和标准 CRM 的对象平铺在一起。

1.3 由此产生的三个可度量的业务痛点

① 维护者看不清自己的系统。 Studio 是元数据管理界面,作用域是软件包。黑猫这一个包里:

Studio 分区 内容 是否分组
数据 30 个对象 ❌ 平铺
自动化 29 条 flow ❌ 平铺,且标签截断
界面 导航树 ✅ 有分组
权限 30 对象 × 9 列 CRUD × 6 个权限集 ❌ 平铺

自动化那一栏里,29 条 flow 有两条都截断成 Case Escalation…,肉眼分不出是哪两条。权限矩阵是一张 30 行 × 9 列的网格,把 客户/联系人/商机运费标准/等级政策/工厂成本 混在一起——给一个销售主管配权限,要在这张表里滚完 30 行。

「界面」分区之所以有分组,不是因为它做得更好,而是因为它渲染的恰好是唯一被作者写下来的那份分组app.navigation 的 group 节点)。对象、flow、权限集在 spec 里都没有任何分组键:ObjectSchemaBase 的顶层只有 name / label / pluralLabel / description / icon / isSystemFlowSchema 只有 name / label / description

② 上下文预算没有模块边界。 产品定位是"一个 CRM 销售模块能完整装进 AI 的上下文窗口",并由 scripts/check-source-token-ratchet.mjs 机械守着。黑猫当前:

business semantics  112,746 / 115,000   headroom 2,254
interaction layer    44,495 /  48,000   headroom 3,505
authored total      178,417 / 180,000   headroom 1,583   ← 卡这里

没有模块边界,就没有分模块的预算——所有模块一起顶到同一个天花板,谁也说不清是谁把预算吃掉了。

③ 商业边界不存在。 CPQ 希望能单独售卖,但它和销售模块在同一个包里,没有可售的单元。


二、为什么三条显而易见的路都走不通(实测)

2.1 给对象/flow 加 module 标签 —— 预算不允许

ratchet 计入 190 个授权文件。每处 module: 'order_margin', 约 25 字符 ≈ 6 tokens,190 处 ≈ 1,100–1,200 tokens,占总量剩余额度的 70–75%。而抬 ceiling 是维护者的 floor item,不能由 agent 动。

一个纯分组键吃掉四分之三的剩余预算,性价比不成立。

2.2 拆成各自命名空间的独立包 —— 要给每个对象改名

SchemaRegistry.installPackage()(ADR-0048 §3.2):

if (manifest.namespace && !isShareableNamespace(manifest.namespace)) {
  const conflictOwner = getNamespaceOwners(ns).find(o => o !== manifest.id);
  if (conflictOwner) throw new NamespaceConflictError(...)
}

判据是**"不是同一个包 id"**,豁免只有 RESERVED_NAMESPACES = {base, system}sys。加上 defineStackvalidateNamespacePrefix 要求 object.name === ${namespace}_${shortName}——N 个包就是 N 个命名空间,就是把 crm_account 改成 sales_account。表名、API 路径、公式、筛选器、集成、客户存的视图全断。

ADR-0048 §5 自己把这条列为已接受的非目标:"Namespace rename-on-install is an explicit non-goal for now (deep rewrite of every object name, cross-reference, and formula)."

2.3 用现成的 composeStacks 合并编译 —— 子包身份会丢

composeStacks 的关键选项:

manifest: z.union([z.enum(['first', 'last']), z.number().int().min(0)]).default('last')

它在 N 份 manifest 里挑一份,扔掉其余的。 现成的合并机制是"压平",不是"保留"。走完整个重构会落回一个 30 对象的平包——收益恰好在它该出现的地方蒸发。


三、最终方案:一个发布物,物内 N 个包

编译按包,产物仍是一个 JSON,JSON 里保留 N 份 manifest,装载时按依赖拓扑序注册 N 个包。

HotCRM 发布物(一个 JSON,一个版本号,应用市场照旧发一个文件)
├── app.objectstack.hotcrm            type: app     ← 唯一的消费者单元,拥有应用与导航
├── app.objectstack.hotcrm.cpq        type: module  ← 共用 crm 命名空间,navigationContributions 注入导航
└── app.objectstack.hotcrm.order      type: module  ← 同上

3.1 为什么这样不违反 ADR-0019

ADR-0019 D2 已经分好层:app 是消费者单元,plugin/module/driver 是"内部贡献——.app bundle 里的框架,随 App 一起装,消费者从不单独浏览或安装"。D4 更是原文写着 "An App declares and owns a set of namespaces"——是 set,不是单个

所以消费者仍然装一个、开一个、卸一个,D1/D2/D3 原样成立。validateSingleApp 也不咬——它只约束 type: app

不能跨的线:这一层必须停在控制面。一旦让"发布物"成为消费者要浏览、要安装、要卸载的东西,那就是 ADR-0019 D3 点名禁掉的 suite,苹果那段论证原样适用。

3.2 共用命名空间的所有权证明就是"同一个物"

同一个物里来的包,是同一个发布者原子交付的。物本身就是共同所有权的声明,因此安装闸的判据从"同一个包 id"改成"同一个物内"即可,manifest 不需要新增 owner 字段

(owner 字段只在 CPQ 哪天单独成物、单独售卖时才必须——那时它要向外声明"我和 HotCRM 同属一个发布者"。ADR-0048 的 2026-08-08 addendum D2 已经把发布侧的保留权定给了 publisher,届时两侧对齐即可。本 issue 把这件事推迟,不前置。)


四、实测支撑:注册层本来就是多包的

已经支持、不用改的:

事实 位置
registerApp(manifest)按包函数:用该 manifest 的 id/namespace 给每个对象打 'own' objectql/src/engine.ts:4745
命名空间存储本来就是多所有者(namespaceRegistry 是 namespace → packageId 集合的 Map),getNamespaceOwners() 返回数组 objectql/src/registry.ts:1456
卸载已经按包清,不是按命名空间扫 registry.unregisterObjectsByPackage(packageId)
对象已有逐条 owner:contributors[{ packageId, ownership: 'own' | 'extend' }] 同上
这个装机今天就注册着 23 个包,全走同一条路径 /api/v1/meta/package

编写侧今天就通过: 子包声明 namespace: 'crm'、定义 crm_* 对象、用 navigationContributions 注入 crm_enterprise,三种 type 全部 defineStack ACCEPTED(plugin / module / app)。唯一的闸在 installPackage

跨包能力矩阵(逐条实测):

跨包做什么 结果
lookup / 关系字段指向外包对象 ✅ ACCEPTED
navigationContributions 注入外包 app 且指向外包对象 ✅ ACCEPTED
本包 app 的 navigation 指向外包对象 App 'x' navigation references object 'core_account' which is not defined in objects.
hook 挂到外包对象上 Hook 'h' references object 'core_account' which is not defined in objects.

后两条的含义很具体:导航必须走 contributions(平台自己六个插件往 setup 注入就是这个形状);拆的缝必须顺着 hook 归属切。黑猫这条缝是干净的——它新增的 8 个 hook 全部挂在它自己新增的对象上。

产物现状: os buildos compile 的别名,产出一个 JSON(默认 dist/objectstack.json)。黑猫已构建的那份:manifest单个 dictapp.objectstack.hotcrm, ns crm, v3.0.0),objects 30 个平铺,2.6 MB,manifest.engines.protocol"^17.2.0"


五、要改的(应用市场链路不动)

不管物里装几个包,市场发的还是一个文件——分发、下载、上架、安装入口的传输形态全部不变。

# 改动 规模
1 产物 schema:manifest 单数 → packages: [...] 列表。必须做成新增可选键、两种都读(见 §6) 唯一实质的一处
2 装载处循环:objectql/src/plugin.ts:405ql.registerApp(manifest) → 遍历 一个循环
3 composeStacks 增加保留模式(今天 'first'/'last'/index 是刻意的挑选语义),默认值不变
4 installPackage 命名空间闸认"同一个物内的共同所有者" 一行判据
5 Studio 包选择器列出 project 域的 module 包(今天只显示 app.objectstack.hotcrm,22 个系统插件被过滤) objectui 侧,另开
6 安装时逐对象名唯一性检查 —— 放宽 §5.4 那道闸的安全前提,见下 小,但不可省

5.6 为什么对象名唯一性检查不是可选项

命名空间独占今天在代理另一件事。ADR-0048 原文:

objects dodge collisions because their names are namespace-prefixed (crm_account) and map to physical tables; a clash fails loudly at the DB

也就是说,"两个包不能共用命名空间"这道闸,顺带保证了"不会有两个包定义同名对象"。一旦按 §5.4 放宽它而不补显式检查,同一个物里两个包都定义 crm_account 时:

  • 安装期不报错
  • 拖到 DB 层才炸(重复建表),或者按驱动实现不同,可能一个包静默覆盖另一个包的表定义 —— 这是唯一会真正伤到客户数据的形态

所以 §5.4 与 §5.6 必须同批落地,不能拆开发布。


六、兼容性:对存量客户元数据应用的影响

结论:不拆模块的存量客户零影响——前提是 §5.1 做成增量而非替换。

6.1 核心原因:本方案不改名

这既是它相对 §2.2 的根本优势,也正是兼容性的答案:

  • 对象名不变 → 表名、REST 路径、公式、筛选器、客户存的视图全部不变
  • org 级元数据覆盖(ADR-0005 overlay)按对象名/字段名键控 → 不变
  • 不拆模块的包仍是一个包,_packageId 不变
  • Studio 路由 /_console/studio/<packageId>/... 不变
  • 客户不需要做任何数据迁移

6.2 逐条

改动 对存量客户
§5.1 产物 schema 前提:新增可选键、两种都读(有 packages 就遍历,没有就把 manifest 当单元素列表)。这样存量产物行为逐位不变
§5.2 装载循环 内部,无感
§5.3 composeStacks 新增可选项,默认仍是 'last' → 现有调用方无感
§5.4 闸放宽 放宽从不打破原本能跑的东西 —— 但必须与 §5.6 同批
§5.5 Studio 选择器 UI 增量
§5.6 对象名唯一性 新增的拒绝:只会拒掉今天本就会在 DB 层炸的配置,且拒得更早、更清楚

6.3 前向兼容已有机制,不用新建

产物里已有 manifest.engines.protocol(黑猫那份是 "^17.2.0")。新格式产物声明新的 protocol range,旧运行时会干净地拒绝而不是错误解析。

6.4 建议的验收条件

存量单 manifest 产物经新装载路径后,注册结果逐位相同(包记录、对象 FQN、_packageId 标记、命名空间所有者集合)。

这条应当写成测试,而不是靠 review 判断。


七、待决策

① 这条路走不走。 这是主问题。替代方案是 §2 的三条,各自的否决理由已实测列出。

② 拓扑序是必须项,不是优化项。 一个包用 defineObjectExtension 扩另一个包的对象,必须在被扩的包之后注册。今天跨物安装靠 manifest.dependenciesresolvePluginOrder 定序;N 个包塞进一个物之后,装载处必须拓扑排序,不能直接 for 数组。这是这套改动里唯一会静默出错的地方——顺序错了不报错,只是扩展没生效。请把它作为验收条件,不要留给实现者判断。

③ 一个物一个版本号,取舍认不认。 好处:版本矩阵直接消失,客户不可能装出 core 3.2 + cpq 1.4 这种没人测过的组合,defineObjectExtension 也不会因为被扩的包单独升级而悬空。代价:不能单独热修一个模块,任何一处修都要重发整物。对内部模块这个取舍是对的;对要单独售卖的模块不成立,那种应当独立成物。

④ 产物体积。 黑猫 30 个对象已经 2.6 MB。模块继续往一个 JSON 里加,市场传输和启动解析都会涨。不是拦路虎,但该在 schema 定下来的时候就决定要不要分段加载,而不是等它到 20 MB 再回头改格式。

⑤ 确认 owner 字段推迟。 本方案不加 manifest 的 owner/publisher 字段,理由见 §3.2。如果维护者认为应当与 ADR-0048 addendum D2 同批落地,请在此说明——那会让 §5 多一项。


八、下游可以先做什么(不阻塞本 issue)

黑猫和 hotcrm 的交付链路在我们手里,可以先按包拆源码结构、锁步发布(N 个包永远同版本、整批安装),等产物 schema 落地再切过去,源码结构不用返工。

如实标注这条中间路的缺陷:锁步是约定,不是闸——没有任何机制阻止装成错配的一对。对自建交付可控,一旦上架市场就不成立。这正是本 issue 要解决的部分。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions