Skip to content

Commit 08a63c8

Browse files
committed
merge origin/main into claude/issue-18520-nightly-pin-source-readers
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
2 parents 7df682f + 625db0e commit 08a63c8

30 files changed

Lines changed: 2486 additions & 142 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": patch
5+
---
6+
7+
The package-install request contract now names the door that actually serves it, declares the two body forms that door accepts, and the door honours `enableOnInstall` instead of ignoring it (#18058).
8+
9+
`PackageInstallRequestSchema` was declared, published and bound to `POST /api/v1/packages/install` — a path the composed runtime mounts nowhere: the dispatcher answers `handled=false` and `@objectstack/rest`'s registrar mounts only `POST /api/v1/packages/publish`. Meanwhile `POST /api/v1/packages`, the door that answers `201`, had no declared request contract at all, so the read contract was strictly more truthful than the write contract producing the rows it describes.
10+
11+
Clause-②: yes (widening)
12+
13+
**What moved on the published surface**
14+
15+
- `PackageApiContracts.installPackage.path` — `'/api/v1/packages/install'` → `'/api/v1/packages'`. A caller that read the constant to build a URL was building one nothing serves; a caller that hard-coded the old string gets a `404` today and should send `POST /api/v1/packages`. The method (`POST`) is unchanged and is what distinguishes this entry from `listPackages`.
16+
- `PackageApiContracts.installPackage.input` — `PackageInstallRequestSchema` → the new `PackageInstallBodySchema`. The wrapped schema is still exported and still parses the wrapped form; the new export is a union that also parses a bare manifest.
17+
- `PackageInstallRequestSchema` gains **`overwrite?: boolean`**. This is a declaration of behaviour that already shipped: the door reads `overwrite` from the body (or `?overwrite=true`) to opt back in to replacing an already-installed id instead of answering `409 Conflict`, the first-party SDK sends it, and no schema declared it — so any parse at that door would have silently stripped it and turned a deliberate re-install into a conflict.
18+
- **`PackageInstallBodySchema`** / `PackageInstallBody` / `PackageInstallBodyParsed` are new. The door reads `body.manifest || body`, and first-party callers really do post a bare manifest as the whole body, so the contract declares both forms as a union — every parse is a full parse of one coherent form, never a tolerant shape. The two branches are disjoint, but only the BARE one is CLOSED: `PackageInstallRequestSchema` is a plain `z.object`, so an unknown key on the wrapped form is DROPPED (`{ manifest, bogus: 1 }` parses and `bogus` is gone) while the same key on a bare manifest is refused by name. That asymmetry matches the door, which reads four keys off the wrapper and ignores the rest — closing the wrapped branch would refuse bodies the door answers `201` to. The bare form carries no install options: `settings`, `enableOnInstall` and `overwrite` are not manifest keys and the manifest surface is closed, so a bare-form caller reaches `overwrite` through the query string alone.
19+
20+
**What moved at the runtime**
21+
22+
`POST /api/v1/packages` now honours `enableOnInstall: false` in the wrapped body: the package installs `disabled`, through the same registry flip and durable state write `PATCH /packages/:id/disable` uses, so a restart does not re-enable what the caller switched off. `true` and absent install enabled, which is the declared default. Previously the key was declared in three schemas, sent by the SDK, and read by no handler at all.
23+
24+
The durable write happens on **both** arms, not just the disable. `POST /packages` is a create that an already-installed id reaches through `overwrite`, and `DELETE /packages/:id` does not clear this record either, so an install could answer `201` with `enabled: true` while the state file still listed the id as disabled — and `SchemaRegistry.installPackage` reads that file at boot, re-installing the package DISABLED one restart later with nothing red in between. The mirror of that risk is why the write follows the ROW this door returned rather than the request's intent: `SchemaRegistry.installPackage` lands an id in the boot-seeded `initialDisabledPackageIds` DISABLED whatever the request says, and `enableOnInstall` defaults to `true`, so persisting the request would clear an operator's earlier disable off disk on the SDK's default call while the row being served says `enabled: false`. A flag-absent install of a seeded id therefore answers `enabled: false` and records it disabled — wire, registry and disk agree, and the next boot reads the same. Every install now persists the state it returned.
25+
26+
**What the declaration does NOT cover — the measured residual**
27+
28+
This is a subset description of the live door, deliberately, and it is recorded rather than implied. Measured through `HttpDispatcher.handlePackages`, the door also answers `201` to: a manifest missing `type` and/or `version` (both of the runtime's own door drives post one); unknown keys on either form (refused by name on the bare branch, dropped on the wrapped one, `201` either way); a string-typed `enableOnInstall` / `overwrite`, which is compared against `true`/`false`/`'true'` and therefore treated as absent — `enableOnInstall: 'false'` installs ENABLED; and install options spelled on the bare form, which are ignored. In the opposite direction the door answers `400` to a whitespace-only `id` this declaration admits. `ManifestSchema` is not relaxed to close any of that.
29+
30+
**Documentation**
31+
32+
`packages/client`'s README install example could not parse against the manifest contract — no `id`, no `type`, and a `label` key the closed manifest surface refuses by name — and the live door answered it `400 Package id is required`. It is now a manifest that parses, and the example names the `overwrite` opt-in beside it.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
The per-package author-time de-duplication key ignores the top-level collection index, so a package-local finding no longer survives as an echo of the union finding it duplicates
6+
7+
`runPerPackageAuthoringRules` runs the author-time rule table once per
8+
`packages[]` entry and drops anything the union run already reported. Its key
9+
was `rule` + `where` + `path` + `message`, and `path` is **positional**: a
10+
package body re-bases every collection from 0, while the flattened union numbers
11+
that same entry wherever `authoringRuleUnionStack` placed it.
12+
`objects[0].fields.industry` and `objects[1].fields.industry` are ONE finding
13+
under two spellings, so the `Set` never matched them and the echo survived the
14+
filter that exists to remove it.
15+
16+
Measured on `origin/main` a43b9d0654 over the repo's own two-package fixture
17+
`examples/app-multi-package`, at every door, before and after:
18+
19+
| | before | after |
20+
|---|---|---|
21+
| `os build --json` | warnings 4, exit 0 | warnings 3, exit 0 |
22+
| `os validate --json` | warnings 4, exit 0 | warnings 3, exit 0 |
23+
| `os lint --json` | total 4, failing 0, exit 0 | total 3, failing 0, exit 0 |
24+
| `os lint --json --strict` | total 4, failing 4, exit 1 | total 3, failing 3, exit 1 |
25+
26+
The one warning that stops being reported is `field-no-consumers` on
27+
`crm_account.industry` re-reported at the package-local index — the union run's
28+
own finding, printed a second time. Its twin is still reported, which is why no
29+
verdict moves.
30+
31+
**No input's verdict changes, and that is structural rather than a property of
32+
this fixture.** Every finding the de-duplication drops has, by construction, a
33+
finding carrying the same key already in the reported set: the seed is the union
34+
run's findings, which every door reports, and it grows only with per-package
35+
findings that themselves survived. So a door's refusal cannot flip — `os build`
36+
already exits 1 on a union error before this pass runs, and `os lint --strict`
37+
fails on `errors + warnings`, a count that could only reach zero if the twin
38+
went unreported too.
39+
40+
Only the **top-level** index is neutralised. Nested positions (`.indexes[1]`,
41+
`.columns[0]`) address the author's own document and read identically in both
42+
views, so they stay in the key and keep discriminating. A finding's own `path`
43+
is never modified — every door still prints the location it always printed.
44+
45+
What this does **not** buy: the key becomes position-insensitive, not
46+
collision-proof. Two entries that render the same `where` still share a key,
47+
exactly as they already did whenever their indices happened to match. Measured
48+
over every example stack in this repo that parses today (`app-multi-package`'s
49+
built artifact, `app-crm`, `app-showcase`, `app-todo`), 45 registry rules
50+
produced 103 findings and 103 distinct neutralised keys — zero collisions.
51+
52+
Also corrected: the sentence "what survives the filter is exactly the set the
53+
union could not see", which was false for as long as the key was positional and
54+
had been copied from `compile.ts` into the `os validate` and `os lint` doors as
55+
each was wired. It is now stated at the bound the pass can actually hold, in
56+
every file that carried it.
57+
58+
Clause-②: no

0 commit comments

Comments
 (0)