Skip to content

Commit 1cbb7ba

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16173-shard-timings-stale-cli
2 parents 3593acd + 618f70d commit 1cbb7ba

16 files changed

Lines changed: 1481 additions & 72 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os init -t app`, `os init -t plugin` and `os g object` now emit an object file that compiles. All three wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`, so the first command a new user runs produced a project that failed its own `pnpm typecheck`.
6+
7+
Measured against the **published** package a real user installs (`npm pack @objectstack/spec@17.3.0`, extracted and linked into a driven emission), not against the workspace:
8+
9+
```
10+
error TS2694: Namespace '.../@objectstack/spec/dist/data/index' has no exported member 'Object'
11+
tsc exit 2
12+
```
13+
14+
Identical at TypeScript 5.3.3, 5.8.3 and 6.0.3, so it was never a compiler-version effect. `os create example` type-checked clean on the same tarball in the same run — the failure was specific to these emissions.
15+
16+
The annotation is now `Data.ServiceObject`. That name was not chosen here — it is what [ADR-0122](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0122-schema-type-alias-naming-convention.md) D1 already ruled: for a schema `XSchema`, the **bare** alias denotes the author state (`z.input<typeof XSchema>`), and it is "the name documentation, examples, skills and AI authoring surfaces use for the thing an author writes". An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention — `UI.View`, `UI.Action`, `UI.Dashboard` and `Automation.Flow` are each the bare alias of their own schema — and only the object emitters had drifted off it.
17+
18+
**Nothing was added to `@objectstack/spec`**: `ServiceObject` has been exported from `@objectstack/spec/data` throughout.
19+
20+
The parsed-state alias is not an alternative here. Annotating the same emitted literal `Data.ServiceObjectParsed` fails all three cases with `error TS2740`, because every field literal is then missing the keys the schema supplies by default — which is exactly the author-state/parsed-state distinction ADR-0122 D2 draws.
21+
22+
`content/docs/deployment/cli.mdx` taught the broken spelling too, and is corrected with them — a reader copying from the docs wrote the same uncompilable line.
23+
24+
The whole emitter roster was swept rather than the three reported sites: driving every `os init` template and every `os g` generator through `tsc --noEmit` under the tsconfig the scaffolder itself writes, `Data.Object` was the only non-existent member any of them named. In particular `UI.View` and `Automation.Flow` — named alongside `Data.Object` in the docs line and explicitly not swept when this was reported — are genuinely exported, and their generators compile at exit 0.
25+
26+
Why nothing caught this: both existing scaffold sweeps are runtime pins that load the emitted TypeScript through esbuild, which erases type annotations **without checking them**, so a broken annotation transpiles to byte-identical JavaScript and is invisible to them by construction. The scaffolds parsed, validated and loaded; they simply did not compile. A new pin runs the emitted projects through a real `tsc` program, with a canary that must fail with TS2694 so the harness cannot pass by resolving nothing.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
A dashboard bound to a dataset you just saved now publishes, without restarting the runtime.
6+
7+
The author-time gate that runs on every `active` metadata publish resolves a widget's `dataset` (and a `type: 'page'` view's `pageName`, and the sibling collections the cross-collection security rules compare against) against a resolution universe the host gathers per write. That gather read the SchemaRegistry alone. The registry is filled at boot by code packages, and for every metadata type except `object` a runtime write does not reach it — so a dataset saved through `PUT /api/v1/meta/dataset` was invisible to the gate until the process restarted, while `GET /api/v1/meta/dataset` returned it in the same instant with `_diagnostics.valid: true`.
8+
9+
Measured on the reported shape, in one process with no restart between the steps: the row is in `sys_metadata`, the read API lists six datasets, the registry lists the five code-package ones, and a three-widget board bound to the new dataset was refused `422` with three `widget-dataset-unknown` issues whose hint enumerated every dataset except the one just authored. The same request answered `200` after a restart, nothing else changed.
10+
11+
The gather now folds the stored half onto the registry half for every collection it carries. What that does and does not do:
12+
13+
- **Additive.** A stored row contributes a name the registry does not already carry and never displaces a registry entry — an object's registry copy is its resolved schema (base plus `extend` contributors) and a raw `sys_metadata` row is the base layer alone, so replacing it would trade this phantom for a subtler one. Where an org overlay redefines a code-package item, the gate still judges that item's content from the registry's version.
14+
- **Active rows only.** A draft does not resolve. The refuse-at-publish ruling exists so an author can write the widget first and the dataset second; a draft dataset that satisfied a published board would invert it.
15+
- **Scoped to the write's own partition** — environment-wide rows plus, when the write has one, its own organization. No other organization's overlays are visible to the gate, on any kernel.
16+
- **A failed store read is reported, not swallowed.** Context gathering still never fails a write, but a read that fails for any reason other than an unprovisioned `sys_metadata` now says so once, naming the consequence — a gather that silently shrinks is how a phantom refusal is manufactured in the first place.
17+
18+
The rules themselves are unchanged: a reference that resolves in neither home is still refused, with the same code, status and key path.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(platform-objects): 21 zh-CN metadata-form leaves say what their source says
6+
7+
`zh-CN.metadata-forms.generated.ts` carries 615 leaves that differ from `en` and hold no
8+
digest in `zh-CN.source-hashes.generated.ts` — LEGACY-TRUSTED values carried in from a
9+
pre-consolidation hand vocabulary (`e0077ea36` deleted a 746-line
10+
`src/metadata-translations/zh-CN.ts` and imported its strings) and never reconciled
11+
against the English the same commit range seeded. A census of all 613 (as the population
12+
then stood) found 26 that assert something the source does not, or drop a distinct concept
13+
the source names. Five of the 26 — the whole `dashboard` subtree — landed in `9f57f1e31`.
14+
These are the remaining 21.
15+
16+
They are not stale fills and no gate can see them: a stale fill is a byte copy of a
17+
previous source revision, detectable by cross-locale agreement or a recorded digest, and
18+
these are neither. Nor does re-extraction correct them — bundle merge fills gaps only, and
19+
a present-but-wrong leaf is not a gap.
20+
21+
Three defect kinds, all decided against this bundle's own usage:
22+
23+
- **Asserts an input that does not exist.** `skill.sections.triggers.description` promised
24+
「触发关键词」 for a section holding only `triggerConditions` (`triggerPhrases` was
25+
removed with the key); `email_template.fields.variables.helpText` promised a per-variable
26+
「默认值」 that `EmailTemplateDefinitionVariableSchema` does not declare;
27+
`action.sections.advanced.description` promised 「批量」 after `bulkEnabled` was removed
28+
from that section.
29+
- **Names the wrong technology.** `action.fields.body.helpText` said the body is
30+
「JavaScript 代码」; an L1 expression is not JavaScript. It now reads
31+
「L1 表达式或 L2 沙箱 JS 体」 — verbatim the sibling `hook.fields.body.helpText`, which
32+
translates the identical source sentence correctly.
33+
- **Drops a distinct concept the source names.** `object.fields.isSystem.helpText` dropped
34+
「共享默认为公开」; `view.fields.filter.helpText` reduced a sentence about the shared
35+
visual builder to 「筛选规则」; `permission.sections.identity.description` dropped both
36+
sentences explaining how permission sets stack on profiles.
37+
38+
zh-CN only: es-ES and ja-JP are untouched here. The 18 looser paraphrases the census
39+
excluded are also untouched.

.github/actions/setup-pnpm/action.yml

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535
# build after a `packageManager` bump, when every job misses the cache at once.
3636
# Restoring the cache is what removes the steady-state exposure.
3737
#
38+
# The store is RESTORED at the top and SAVED explicitly at the bottom, rather
39+
# than handed to `actions/cache`'s post-job save. That save runs after EVERY
40+
# step of the job and writes back whatever those steps left in COREPACK_HOME --
41+
# which on 2026-09-05 included a pnpm nobody pinned: one step ran a package
42+
# manager from a directory carrying no `packageManager` field, so Corepack
43+
# resolved `latest` off the registry into the shared store and wrote its sticky
44+
# `lastKnownGood.json`. Later jobs restored that store; it reddened `main`'s own
45+
# push build and four unrelated PRs, and the signature was flaky ACROSS jobs but
46+
# byte-identical WITHIN one. Saving from a fixed point in the job -- right after
47+
# this action materialised the pin, with an assertion in between -- is what
48+
# makes a job structurally unable to donate an unpinned manager to the cache.
49+
#
3850
# Deliberately NOT in here: `actions/setup-node`. `scripts/check-node-version.mjs`
3951
# scans `.github/workflows/*.yml` ONLY, and reports how many setup-node steps it
4052
# audited. Moving those steps into this composite would drop them from its census
@@ -74,11 +86,23 @@ runs:
7486
# a store for a different pnpm version cannot satisfy this pin, and a partial
7587
# hit would only mask a cold start. A cache-service failure is non-fatal here
7688
# and degrades to a download, which the retry below then covers.
89+
#
90+
# `cache/restore`, not `cache` -- see the write-back paragraph in the header.
91+
# The matching `cache/save` is the LAST step of this action.
92+
#
93+
# The `-v2-` in the key is a ONE-TIME ROTATION, not a widening: entries
94+
# written by the old post-job save may already hold an unpinned manager, and
95+
# the assertion below would then (correctly) fail every job that restored
96+
# one. Rotating abandons those entries. It costs exactly one cold build --
97+
# the same cost as a `packageManager` bump, which the retry in "Materialise
98+
# pnpm" is the backstop for. It is not a restore-keys prefix and it does not
99+
# widen what can satisfy the pin.
77100
- name: Restore the Corepack store
78-
uses: actions/cache@v6
101+
id: restore
102+
uses: actions/cache/restore@v6
79103
with:
80104
path: ${{ runner.temp }}/corepack
81-
key: ${{ runner.os }}-corepack-${{ steps.pin.outputs.key }}
105+
key: ${{ runner.os }}-corepack-v2-${{ steps.pin.outputs.key }}
82106

83107
- name: Enable Corepack
84108
shell: bash
@@ -109,3 +133,91 @@ runs:
109133
- name: Verify pnpm version
110134
shell: bash
111135
run: pnpm --version
136+
137+
# The store is about to be saved under the PIN's key, so it must hold the
138+
# pin and nothing else. Measured with the Corepack that Node 22 ships
139+
# (0.34.6): `corepack install` in this repo produces exactly
140+
# <store>/v1/pnpm/<pinned version>/ and NO lastKnownGood.json, while a single
141+
# `pnpm` call from a directory with no `packageManager` field adds a second
142+
# version directory (the registry's `latest`) and writes that file.
143+
#
144+
# This step must POSITIVELY LOCATE the pin. A Corepack that changes its
145+
# layout would otherwise make the scan enumerate nothing and report clean --
146+
# a check that has retired itself while still reading green. So "the pin is
147+
# not where this expects it" is a FAILURE here, not a pass.
148+
#
149+
# It also runs on a cache HIT, where nothing is saved: that is the alarm the
150+
# whole incident lacked. A restored store holding an unpinned manager means
151+
# the channel is armed again somewhere, and this says so by name in the job
152+
# that restored it.
153+
- name: Assert the Corepack store holds only the pinned package manager
154+
shell: bash
155+
env:
156+
PM_SPEC: ${{ steps.pin.outputs.spec }}
157+
STORE: ${{ runner.temp }}/corepack
158+
run: |
159+
set -euo pipefail
160+
# `pnpm@10.31.0+sha512.<hash>` -> manager `pnpm`, version `10.31.0`.
161+
manager="${PM_SPEC%%@*}"
162+
rest="${PM_SPEC#*@}"
163+
version="${rest%%+*}"
164+
165+
problems=''
166+
note() { problems="${problems}${problems:+$'\n'}$1"; }
167+
168+
if [ "${COREPACK_HOME:-}" != "$STORE" ]; then
169+
note "::error::COREPACK_HOME is '${COREPACK_HOME:-<unset>}' but the cached store is '$STORE' -- this assertion and the job's own pnpm would be reading different directories."
170+
fi
171+
172+
found=0
173+
extra=''
174+
while IFS= read -r dir; do
175+
[ -n "$dir" ] || continue
176+
v="$(basename "$dir")"
177+
m="$(basename "$(dirname "$dir")")"
178+
if [ "$m" = "$manager" ] && [ "$v" = "$version" ]; then
179+
found=1
180+
else
181+
extra="${extra}${extra:+, }${m}@${v}"
182+
fi
183+
done < <(find "$STORE" -mindepth 3 -maxdepth 3 -type d | sort)
184+
185+
if [ "$found" -ne 1 ]; then
186+
note "::error::Corepack store '$STORE' does not hold the pinned ${manager}@${version} at <store>/v1/${manager}/${version}. Either the store is not where this action thinks it is, or Corepack's layout changed and this assertion is no longer measuring anything -- fix the assertion, do not delete it."
187+
fi
188+
189+
if [ -n "$extra" ]; then
190+
note "::error::Corepack store holds a package manager nobody pinned: ${extra} (the pin is ${manager}@${version}). Some step ran a package manager from a directory carrying no \"packageManager\" field, so Corepack resolved a version off the registry into this shared store."
191+
fi
192+
193+
while IFS= read -r f; do
194+
[ -n "$f" ] || continue
195+
note "::error::Corepack wrote $f -- a sticky default that outlives this job inside the cached store and then applies to every invocation made outside a pinned directory."
196+
done < <(find "$STORE" -maxdepth 2 -name 'lastKnownGood.json' -type f | sort)
197+
198+
if [ -n "$problems" ]; then
199+
printf '%s\n' "$problems"
200+
echo "::error::Refusing to cache this Corepack store: a store saved under the ${manager}@${version} key must hold that version and nothing else. See .github/actions/setup-pnpm/action.yml for the incident this guards."
201+
exit 1
202+
fi
203+
echo "Corepack store holds exactly ${manager}@${version}, and no lastKnownGood.json."
204+
205+
# Save from HERE, not from a post-job step, so what lands in the cache is
206+
# what the steps above just produced rather than whatever the rest of the
207+
# job leaves behind. This step carries no `if: always()` and no status
208+
# function beyond the cache-hit test, so it runs only when every step before
209+
# it -- the assertion included -- succeeded: a store that failed the
210+
# assertion is never written back.
211+
#
212+
# Only a cold restore saves; an exact hit has nothing new to write. One
213+
# entry per (OS, pin): the key is content-addressed by the pin, so this
214+
# cannot churn the repo's 10 GB cache pool the way a per-sha key does. When
215+
# several cold jobs race, the losers log "Unable to reserve cache with
216+
# key ..., another job may be creating this cache" -- a warning, not a
217+
# failure, and every racer is saving byte-identical content anyway.
218+
- name: Save the Corepack store
219+
if: steps.restore.outputs.cache-hit != 'true'
220+
uses: actions/cache/save@v6
221+
with:
222+
path: ${{ runner.temp }}/corepack
223+
key: ${{ runner.os }}-corepack-v2-${{ steps.pin.outputs.key }}

content/docs/deployment/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1299,7 +1299,7 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
12991299
- `--dry-run` — Preview without writing files
13001300

13011301
**What it does:**
1302-
1. Creates a typed TypeScript file using `Data.Object`, `UI.View`, `Automation.Flow`, etc.
1302+
1. Creates a typed TypeScript file using `Data.ServiceObject`, `UI.View`, `Automation.Flow`, etc.
13031303
2. Creates or updates the barrel `index.ts` in the target directory
13041304
3. Shows a hint to run `objectstack validate`
13051305

packages/cli/src/commands/generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const GENERATORS: Record<string, {
5757
/**
5858
* ${toTitleCase(name)} Object
5959
*/
60-
const ${toCamelCase(name)}: Data.Object = {
60+
const ${toCamelCase(name)}: Data.ServiceObject = {
6161
name: '${toSnakeCase(name)}',
6262
label: '${toTitleCase(name)}',
6363
pluralLabel: '${toTitleCase(name)}s',

packages/cli/src/commands/init.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ export default defineStack({
562562
`,
563563
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
564564
565-
const ${toCamelCase(namespace)}Item: Data.Object = {
565+
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
566566
name: '${namespace}_item',
567567
label: '${toTitleCase(namespace)} Item',
568568
fields: {
@@ -650,7 +650,7 @@ export default defineStack({
650650
`,
651651
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
652652
653-
const ${toCamelCase(namespace)}Item: Data.Object = {
653+
const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
654654
name: '${namespace}_item',
655655
label: '${toTitleCase(namespace)} Item',
656656
fields: {

0 commit comments

Comments
 (0)