fix(sharing): key the territory rules off a flat billing_country column (#621) - #637
Merged
Merged
Conversation
…mn (#621) Both territory sharing rules were declared against `record.billing_address.country`, a path that reaches inside the composite `address` value. A sharing rule's condition is compiled to a pushdown-able query filter by `compileCelToFilter`, which rejects every such path, and `plugin-sharing` then refuses to seed the rule rather than degrade it to match-all. The result was silent: `seeded: 7, skipped: 2, total: 9` on every boot, with `na_sales_team` / `eu_sales_team` receiving no criteria-based account access at all while the metadata and the admin docs said they did. Measured, the blocker is the NESTED PATH and not the `in [...]` operator: `in`, `==`, `!=`, ordering, `&&`, `||`, `!`, `startsWith()` and `== null` all compile against a flat field, while `==` on the nested path fails exactly like `in` does. Issue #621's option A (rewriting `in [...]` as a disjunction of `==`) could therefore never have worked; only a flat column can. `crm_account` now carries `billing_country`, a readonly projection of `billing_address.country` (trimmed, upper-cased) maintained by `account_protection` on every write that carries the address and left untouched by writes that do not. Only the `country` slot is read — `countryCode` holds ISO 3166-1 alpha-2, where the UK is `GB`, and the Europe rule is authored against `UK`, so preferring the ISO slot would silently evict UK accounts from their own territory. Territory membership is unchanged; only the column the rules read from is. `test/sharing-seeding.test.ts` asserts the seeded OUTCOME rather than the declared shape — it compiles every declared rule with the platform's own compiler, requires `seeded + 0 skipped`, and requires every field a rule filters on to be a real flat column of its object. It also pins the measured operator matrix, including that `has()` does not compile in a sharing condition (load-bearing for #633). Verified on a fresh boot: `{"seeded":9,"skipped":0,"total":9}`, and the seeded `europe_territory` row carries `criteria_json: {"billing_country":{"$in":["UK","DE","FR","IT","ES"]}}`. An account POSTed with `billing_address.country = " de "` reads back `billing_country: "DE"`, and an unrelated PATCH leaves it intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…ependency
`package-lock.json` is maintained separately from `pnpm-lock.yaml` for the
StackBlitz demo (WebContainers cannot run this repo's pnpm), and
`scripts/check-stackblitz-lock.mjs` compares its root entry against
package.json. Adding `@objectstack/formula` to devDependencies made it stale.
Regenerated with the exact command the gate prints:
d=$(mktemp -d) && cp package.json "$d" \
&& (cd "$d" && npm install --package-lock-only --ignore-scripts) \
&& cp "$d/package-lock.json" .
The root `devDependencies` delta is exactly the intended addition. The command
also re-resolves every floating `^` range against the registry, so ten
transitive entries moved to newer patch/minor versions (@babel/parser and
@babel/types 7.29.7 to 7.29.8, @napi-rs/wasm-runtime, jose, js-yaml x3,
magicast, tinyexec, tsx). No entry was added or removed, and no @objectstack/*
package moved. That churn is inherent to the documented regeneration step —
the script's own header notes a regenerated lockfile "shifts with the npm
version", which is why it compares the root entry rather than diffing the
file — so it is left as generated rather than hand-trimmed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
os-zhuang
marked this pull request as ready for review
August 2, 2026 15:42
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #621
先摸清翻译器到底支持什么(issue 的第一句要求)
issue 猜测障碍是
in [...]成员运算符,实测证明猜错了。用平台自己的compileCelToFilter(即plugin-sharing播种时调用的那个函数)逐条测量,得到下面这张矩阵——两个嫌疑对象是分开验证的:
crm_account)record.type == "customer" && record.is_active == true{"$and":[{"type":"customer"},{"is_active":true}]}record.type in ["customer","partner"]{"type":{"$in":[…]}}record.billing_country in ["US","CA","MX"]{"billing_country":{"$in":[…]}}record.type != "customer"{"type":{"$ne":"customer"}}record.annual_revenue > 1000/>=$gt/$gtea == x || b == y$or!(record.type == "customer")$notrecord.type == null{"type":{"$null":true}}record.name.startsWith("A")$startsWithrecord.billing_address.country == "US"unsupported: cross-object/nested field path … is not pushdown-ablerecord.billing_address.country in ["US","CA","MX"]has(record.type) && record.type == "customer"unsupported operator "call"结论:障碍是
address复合值上的嵌套路径,不是in [...]。address字段整体存成一列(
{street, city, state, postalCode, country, countryCode, formatted}),查询无法下推到它内部。于是:
in [...]改写成==的析取,在同一条嵌套路径上照样编译失败(表里已验证)。crm_account上加扁平的billing_country,规则改为读它。改了什么
crm_account.billing_country:billing_address.country的只读投影(去空格 + 转大写),由account_protection钩子在每一次携带地址的写入时重算,不携带地址的写入则完全不动它(否则任何无关编辑都会把这一列清空、把客户悄悄踢出区域)。
只读
country这一个槽位,故意不读countryCode:后者装 ISO 3166-1 alpha-2,英国是GB,而 Europe 规则写的是
UK——优先用 ISO 槽位会让英国客户悄无声息地掉出自己的区域。因此本 PR只改变"国家从哪一列读",不改变"哪些客户属于哪个区域"。
投影逻辑写成内联而非模块级 helper:钩子体必须能降级成 metadata-only(无自由标识符),
test/action-sandbox.test.ts对每个注册钩子强制这一点——第一版抽成 helper 时它直接报红。其余:4 个语系的标签 + help、账户表单 Locations 区段(管理员能直接看到区域匹配用的值)、
crm_account上的billing_country索引、管理员文档(英文 + zh-Hans + zh-Hant)与销售文档、changeset。测试(issue 点名要求的那条)
新增
test/sharing-seeding.test.ts——现有test/sharing-coverage.test.ts断言的是声明的形状,所以它在 9 条规则里有 2 条什么都不做时依然全绿。新文件断言的是播种的结果,并且用平台自己的
编译器而不是正则:
seeds all of them — seeded + 0 skipped,复刻 seeder 的每一条跳过分支(名称/对象缺失、recipient 不可映射、
type: 'owner'、条件不可翻译、条件不收窄任何字段);isMatchAllCriteria更严,只会误伤平台本来也会丢弃的规则,不会放过平台会丢弃的);
billing_country就可以重新考虑。另外在
hooks-runtime-sales.test.ts加了投影的逐形状回归(大小写、空白、缺 country、null 地址、非对象值、非字符串 country、无关更新不清空、清空地址则清空投影、系统写入同样投影),
在
action-sandbox.test.ts加了在真实 QuickJS 沙箱里跑降级后钩子体的验证——直接调 handler证明不了降级形态还能工作。
验证
真机复现与验证(
objectstack dev --fresh -p … --seed-admin):修复前 —
修复后 —
播种出来的行确实带着可下推的过滤器:
{"name":"europe_territory","object_name":"crm_account", "criteria_json":"{\"billing_country\":{\"$in\":[\"UK\",\"DE\",\"FR\",\"IT\",\"ES\"]}}", "recipient_type":"position","recipient_id":"eu_sales_team","access_level":"edit","active":true}走真实 REST 写入路径建一条客户,
billing_address.country = " de "→ 读回billing_country: "DE"(落在上面的 criteria 内),随后一次无关的 PATCH(只改 phone)后仍是
"DE"。需要维护者知道的两件事
src/data/index.ts(归 Forecast seed upserts onperiod_label, which is no longer a unique key #613),但实测发现:种子数据里没有任何一条客户带billing_address。所以在 demo 数据集上,这两条区域规则现在虽然被正确安装、条件也能真的求值,却仍然匹配 0 条记录。已另行开 issue 记录,需要串到后续轮次。
has()在 sharing 条件里编译不过(unsupported operator "call")。Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633 想给 9 条 sharing rule加
has()守卫——那会让每一条被守卫的规则都变成不可翻译、从而被静默跳过,正是本 issue 的缺陷被放大到全部 9 条。这一点已作为断言钉在
test/sharing-seeding.test.ts里,并已评论到 Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633。本 PR 未触碰 #633 的守卫问题,也未编辑
content/docs/releases/。🤖 Generated with Claude Code
https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
Generated by Claude Code