Skip to content

Commit d18d078

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14238-org-hierarchy-timezone
2 parents 910ce78 + 7b68254 commit d18d078

70 files changed

Lines changed: 6233 additions & 395 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice
6+
7+
`AppPlugin.start()` collects seed data from two locations — the top-level
8+
`data` field, then the legacy `manifest.data` for backward compatibility. The
9+
legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a
10+
FLAT bundle — manifest fields written directly on the bundle rather than nested
11+
under `manifest:`, a shape `AppPlugin` supports by design and this repo's own
12+
tests construct — it re-read the very array the top-level read had just
13+
contributed. Every dataset landed in the collection twice.
14+
15+
`mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies
16+
reached the shared `seed-datasets` registry, the inline boot seed, and every
17+
later per-org replay. For an `upsert` dataset with an `externalId` the second
18+
pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset
19+
it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls
20+
for one record.
21+
22+
The legacy read now carries the same reference guard its sibling collector has
23+
always carried: `loadTranslations()` performs the identical two-location read
24+
and skips the legacy half when `manifest.translations` IS the array the top
25+
level already contributed. That asymmetry between the two collectors was the
26+
whole defect, so the repair is the sibling's guard rather than a third spelling
27+
of the same idea.
28+
29+
⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a
30+
genuinely different array from its top-level `data` still contributes both, and
31+
a bundle that nests its manifest is unaffected either way. Nothing is added to
32+
or removed from any published surface.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) nothing is renamed, retired or respelled: no metadata key, export or operator changes shape, so `objectstack migrate meta` has nothing to rewrite; an author's remedy is to drop or retarget the mistaken filter condition, which is a change to the query they meant, not to a spelling -->
8+
9+
**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny.
10+
11+
The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES``BOOLEAN_VALUE_TYPES``CALENDAR_DATE_TYPES``INSTANT_TYPES``CLOCK_TIME_TYPES``STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares.
12+
13+
New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes.
14+
15+
The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set.
16+
17+
What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
A self-registration grant is refused, not silently redirected, when a permission-set row is malformed — and the fourteen dead `{ records }` / `{ data }` normalizer limbs behind that code are gone.
6+
7+
`plugin-auth` carried fourteen array-or-envelope normalizer blocks of the shape `Array.isArray(x) ? x : x.records ?? []` (thirteen on a `records` limb, one on a `data` limb, four of them written as a guard clause rather than a ternary). All fourteen read the same concrete engine — the `ObjectQL` instance the kernel registers as the `objectql` / `data` service — which answers a bare array on every path, populated or empty. The envelope limb was unreachable code that read as a contract, so the next author writing a defensive normalizer here believed an envelope was possible. The limbs are removed, and the three local engine ports that declared `Promise<unknown>` (`BootProbeEngine`, `DevAdminSeedProbeEngine`, `PhoneSmsTemplateEngine`) now declare the array they always returned.
8+
9+
The user-visible change is in `settleSelfRegistrationGrant`, which carried the opposite defect. Its candidate filter dropped any permission-set row whose `id` was missing or blank, silently, before choosing which row to grant:
10+
11+
- When the malformed row was the only one, the operator was told `no active sys_permission_set row named 'X' resolves` — false, since an active row named exactly that was present. That report is the only signal this path emits, and nothing retries it.
12+
- When the malformed row was the **organization-scoped** one and a global row also carried the declared name, dropping it let the `organization_id == null` arm match instead, and the self-registrant was granted the **global** permission set their organization never declared — with a success log and no other trace.
13+
14+
`active !== false` remains a selection predicate: a deactivated set still reports the ordinary "does not resolve". A malformed row is no longer a selection at all — the grant is refused and the report names the malformed row, so the ambiguity is surfaced instead of resolved by accident. A well-formed family grants exactly as before.
15+
16+
**Upgrade note — one family now gets a refusal where it previously got a grant.** If a deployment's `sys_permission_set` already contains a row that is active and carries the declared name but whose `id` is missing or blank, self-registration grants against that name now stop and report, including the case where the malformed row is one nobody was relying on: a malformed **global** row sitting alongside a well-formed **organization-scoped** row used to be dropped silently, letting the org row be granted, and is now refused. This is deliberate — the old behaviour could not tell that family apart from the one where the silent drop granted the *wrong* set — and it is fully reversible without a code change: repair or delete the malformed row and the grant proceeds exactly as before. The refusal is loud and names the row, so it is visible rather than something to discover later; nothing is written while it stands.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row.
6+
7+
`applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`".
8+
9+
That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type).
10+
11+
The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries.
12+
13+
One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064)
7+
8+
A roll-up value has three producers — the insert-time seed, the child-write
9+
recompute, and the one-off backfill — and **declaring a summary field on an
10+
object that already has rows reaches none of them**. For `count`/`sum` the
11+
backfill repairs that as a side effect (every `NULL` is a hole to it). For
12+
`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the
13+
function alone, so "never computed" and "no child rows" were indistinguishable,
14+
the column stayed `NULL` on every pre-existing parent, and the report said
15+
`filled: 0` — a false all-clear that a timed flow built on the column then
16+
turned into "matches nothing" (the customer case behind cloud#1908).
17+
18+
**What changes** — maintainer ruling on #15064, option A: the caller who holds
19+
the fact gets a way to say it; the predicate and the default run do not move.
20+
21+
- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]``object.field`
22+
roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is
23+
walked like a `count`: every `NULL` parent is recomputed through the same
24+
`aggregateSummaryValue` the engine writes. A parent whose aggregate is the
25+
empty-set reading (`null` — no child rows) already holds the engine's own
26+
value, so it is neither counted as a hole nor written; the scoped run is
27+
therefore idempotent in the same "re-run until it reports zero" sense.
28+
Naming a `count`/`sum` is accepted and changes nothing, so a publish path can
29+
pass every column it just declared without knowing the empty-set list.
30+
- A name that resolves to no roll-up owned by an object the run walks — a typo,
31+
a plain field, or an object `objects` left out — is **refused before any row
32+
is read**, dry run or apply, with an ADR-0112 envelope (`code:
33+
'INVALID_FIELD'`, `status: 400` — the code the projection and write axes
34+
that name a field already answer, while sorting keeps `INVALID_SORT`;
35+
`field` names the first unresolved entry, `fields` all of them). A silent
36+
no-op there would be the same false all-clear this option exists to end.
37+
- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement
38+
of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an
39+
unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'`
40+
to every roll-up function, since a named `max` now appears in `fields`.
41+
- `os migrate summary-nulls --recompute-undefined-on-empty object.field`
42+
(repeatable) passes the scope through; the confirmation prompt names the
43+
columns; `formatSummaryBackfillReport` lists them under "Recomputed on
44+
request" and explains a `NULL` that remains.
45+
46+
**What does not change:** without the option the walk, the writes, every
47+
counter and the human-readable report are byte-for-byte what they were (pinned
48+
against output captured on `main` before this change); `min`/`max`/`avg` stay
49+
out of scope and keep being reported under `skippedUndefinedOnEmpty`; the
50+
predicate `summaryNullIsBackfillable` is untouched, so `os migrate
51+
summary-nulls` keeps its meaning on every deployment. The only visible delta on
52+
an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`.
53+
54+
`minor` for both packages: an optional parameter on a published exported
55+
function, a new report key, and a new CLI flag are each a purely additive
56+
widening of a published surface, which takes at least `minor` (bump-level rule,
57+
2026-09-04); the `fix`-shaped motivation does not lower it.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition.
6+
7+
The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back).
8+
9+
The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none.
10+
11+
The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition.
12+
13+
Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question.

.claude/skills/dogfood-verification/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@ dev 工作树、dev-server 端口、preview 浏览器全是**共享的**:并行
132132
`git -C /tmp/pr apply /tmp/fix.patch` → 确认 `git -C /tmp/pr status` **列你的文件。
133133
- [ ]**changeset**(`.changeset/<slug>.md`,`"@objectstack/<pkg>": patch`)——
134134
发布包的改动过不了 CI 的「Check Changeset」门。
135-
- [ ] 提交(信息末尾带 `Co-Authored-By:` trailer)、推送、`gh pr create`,然后
136-
`gh pr merge --squash --auto --delete-branch`(先移除 worktree,本地分支才不被
137-
锁:`git worktree remove /tmp/pr --force`)
135+
- [ ] 提交(信息末尾带 `Co-Authored-By:` trailer)、推送、**draft** PR(dev 容器走 REST 端点;
136+
人手上 `gh pr create --draft`),PR 报给派发你的席位;ready / 入队 / 人工合并归该席位。
137+
- [ ] `git worktree remove /tmp/pr`:⛔ 不带 `--force`,被拒说明有东西没提交,先读 `git status`
138138

139139
## 6. Shell 卫生
140140

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
104104

105105
| issue 上的信号 | 含义 |
106106
|---|---|
107-
| open + 队列标签 + 无 assignee | 可派发(同卡带 `pm:retriage` 的除外) |
108-
| assignee 已设 | 已认领/在飞,不是你的就永不碰 |
109-
| `pm:dispatched` | 已派发(派发评论记轮次);与摘 `pm:queue` 同一次标签写入成对落地 |
107+
| open + 队列标签 + 无 assignee | 可派发(同卡带 `pm:retriage` 的除外);`pm:queue` 卡恒无 assignee,有即半态 |
108+
| assignee 已设 | 已认领/在飞,不是你的就永不碰;离手恒走释放 —— 四因 = 改路由、前提证伪、弃飞无接管、跨车道移交,去向 = 新标签态或车道;⛔ 不静默摘 assignee |
109+
| `pm:dispatched` | 已派发(派发评论记轮次),恒带 assignee;与摘 `pm:queue` 同一次标签写入成对落地 |
110110
| `needs-user-decision` | 决定待做:永不派发、除代裁通道外永不代答;维护者的收件箱 |
111111
| `pm:on-hold` | 决定已做且答案是暂不做:不派发不催;仅当带机器可读 `Restart-when:` 行才合法 |
112112
| `pm:blocked` + 正文行 `Blocked-by: #N` | 等上游:选择期跳过,#N 关闭时由解锁扫描放回;工已完、PR 被外部门禁卡住的同用本态 |
@@ -462,8 +462,8 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
462462

463463
- 同账号多会话共享 GitHub 身份:assignee 只回答有 agent 认领了,认领评论承载身份。
464464
- assignee 字段归 PM:原子对 step 1 设,dev 席恒不写它;跨账号 assignee 不是你 ⇒ 永不碰。
465-
- 选中的单派发前按序执行一个原子对
466-
- ① Assign @me,并把 `pm:dispatched` 与摘 `pm:queue` 放进同一次标签写入。
465+
- 释放是显式动作:让卡离手者同笔清 assignee + `Release:` 行(会话/因/去向);下一任重新认领
466+
- 派发前按序执行原子对:① Assign @me,并把 `pm:dispatched` 与摘 `pm:queue` 放进同一次标签写入。
467467
- step ① 之后获得 assignee 的直接弃出本批。
468468
- ② 认领评论(英文),固定形状见 模板与表 节;首行以字面 `Claim:` 开头是机器判据。
469469
- 巡查谓词只认 `Claim:` 这一个拼写且保持严格,修法是全舰队向它收敛,⛔ 不放宽谓词。
@@ -490,7 +490,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
490490
- 过栏后,派发 worktree 的未提交改动先 WIP commit 到派发分支并 push,sha 记进回收评论。
491491
- WIP commit 标 INCOMPLETE AND UNREVIEWED;续派者 diff 它,⛔ 不无审续建。
492492
- WIP 信息只写观察到的(脏路径/行数/sha),⛔ 不写席位行为的现在时断言。
493-
- 再评论询问,静默一窗后摘 assignee(注明原因)回队;有带提交活分支的认领永不回收。
493+
- 再评论询问,静默一窗后释放回队(`Release:` 行载因);有带提交活分支的认领永不回收。
494494
- 误伤活席位 ⇒ 令其追加式更正,落 PR 正文不落分支历史。
495495

496496
### 派发

0 commit comments

Comments
 (0)