Repository navigation
Commit 3aa0c7b
fix(runtime): the @objectstack/hono catch-all's PUT /meta honours If-Match, If-None-Match and ?mode=draft (#22206)
Fixes #22141
Clause-②: yes
## What this changes
Behind `@objectstack/hono`'s `${prefix}/*` catch-all, `PUT
/meta/:type/:name` is answered by the runtime dispatcher's `/meta`
domain (`handleMetadataRequest`). That branch handed `saveMetaItem` no
`parentVersion` and no `mode`. So through the catch-all a stale
`If-Match` wrote (200, not 409), `If-None-Match: *` over an existing row
wrote, and a `?mode=draft` save landed on the ACTIVE row. A draft went
live without a publish, and the client read a 200.
- **One mapping, two doors.** `RestServer`'s private precondition parser
moves to `@objectstack/rest` as `metaSaveRequestOptions`
(`packages/rest/src/meta-save-request.ts`). It now also reads
`?mode=draft`. `RestServer`'s `PUT` door and the dispatcher's `PUT`
branch both call it, and spread its `request` (`parentVersion` and
`mode`, each present only when asked) into `saveMetaItem`. It reads a
Fetch `Headers` (`get`) or a plain header record, so it takes the raw
`Request` the catch-all hands `dispatch()` and the record the
`plugin-hono-server` adapter hands `RestServer`.
- **Dispatcher refusals.** A pin that can never be honoured (both
headers, or an `If-None-Match` other than `*`) is `400
VALIDATION_ERROR`, after the capability gate and before anything is
written, as on `RestServer`. The dispatcher alone has a fallback writer
(`metadata.saveItem(type, name, item)`, used when the protocol has no
`saveMetaItem`). It cannot carry a pin or a lifecycle, so a save that
asks for one is refused `501 NOT_IMPLEMENTED` there instead of written
unguarded or active.
- **`RestServer` answers are unchanged.** One ordering moved: its
multiplicity guard (`refuseRepeatedQueryParams` for `force` / `package`
/ `mode`) now runs just before the shared read, because the guard
unwraps a single-element `?mode` array in place and the mapping must see
the unwrapped string. A request that carries both a malformed pin and a
repeated parameter now gets the repeated-parameter `400` first. Both are
`400 VALIDATION_ERROR`.
- **Widened public surface (`Clause-②: yes`).** `@objectstack/rest`'s
package entry now exports `metaSaveRequestOptions` and the types
`MetaSaveRequestHttp`, `MetaSaveRequestMembers` and
`MetaSaveRequestOptions`. That widens its public surface, so its
changeset is `minor` with `Clause-②: yes (widening)`, the shape the
read-side twin of this parity declared when it published
`createMetaItemReadGate`. `@objectstack/runtime`'s public surface does
not change: its changeset stays `patch` with `Clause-②: no`.
- `@objectstack/hono` itself is unchanged: the catch-all already hands
`dispatch()` `{ request: c.req.raw }`, so the headers were on
`context.request` all along (H3). The fix ships in
`@objectstack/runtime` (`patch`) and `@objectstack/rest` (`minor`).
## Readings (on `origin/main` `6ed0c0f3`, this branch's base)
- **H1, reach.** In this repository `objectstack serve` / `os dev`
compose `createRestApiPlugin` and `createDispatcherPlugin`. The
dispatcher plugin mounts no `/meta` route (its explicit mounts are
`/notifications*`, `/keys`, `/analytics/*`, `/i18n/*`, `/health`,
`/ready`, `/discovery`, plus AI routes), so `/meta` writes there are
`RestServer`'s. `packages/adapters/` now holds only `hono`, and
`createHonoApp` is called by no app, example or package outside tests.
**Whether the hosted runtime's `PUT /meta` reaches this catch-all, and
on which cloud pin: NOT MEASURED from this session**
(objectstack-ai/cloud is not readable here). The grade is the seat's.
- **H2, reproduced on `main`, through the real `createHonoApp`.** A
scratch probe (not committed) mounted `createHonoApp({ kernel, prefix:
'/api/v1', cors: false })` from this checkout's adapter source over a
`LiteKernel` carrying a real `ObjectStackProtocolImplementation` on
ObjectQL + better-sqlite3 `:memory:`, with identity stubbed to
`manage_metadata`. Requests went through `app.request`. Same probe,
`main`'s `meta.ts` then this branch's:
| request through the catch-all | `main` | this branch |
|:--|:--|:--|
| `PUT /api/v1/meta/view/case_grid` (v1, then v2) | 200, 200 | 200, 200
|
| `PUT` with `If-Match` = v1's token (stale) | **200, active row now `v3
stale`** | 409 `METADATA_CONFLICT`, active row `v2` |
| `PUT` with `If-None-Match: *` over an existing row | **200, active row
now `second`** | 409 `METADATA_CONFLICT`, active row `first` |
| `PUT ?mode=draft` | **200, receipt `state: 'active'`, active row
`staged`, no draft row** | 200, receipt `state: 'draft'`, active row
`v2`, draft row `staged` |
- **H3, where.** Confirmed: `handleMetadataRequest`'s `PUT` branch read
neither headers nor `mode`. The catch-all's `dispatcher.dispatch(method,
subPath, body, queryParams, { request: c.req.raw }, prefix)` already
carries the Fetch `Request`, so `context.request.headers.get(...)`
reaches them. No adapter change is needed.
- **H4, one mapping.** Dependency direction measured:
`@objectstack/runtime` depends on `@objectstack/rest` (`dependencies`),
and `rest` does not depend on `runtime`. So the shared parser lives in
`@objectstack/rest`, beside the `/meta` read chain the runtime already
imports from there. No `packages/spec` edit.
- **H5, every `/meta` write verb.** Measured on both doors (catch-all
rows driven through `dispatch()` with the catch-all's arguments, every
header and parameter sent):
| write verb | parameter | `RestServer` | catch-all before | catch-all
after |
|:--|:--|:--|:--|:--|
| `PUT /meta/:type/:name` | `If-Match` | → `parentVersion`; stale → 409
`METADATA_CONFLICT` | **dropped**: stale token writes, 200 | as
`RestServer` |
| | `If-None-Match: *` | → `parentVersion: null`; over a row → 409.
Non-`*` or beside `If-Match` → 400 `VALIDATION_ERROR` | **dropped**:
writes, 200 | as `RestServer` |
| | `?mode=draft` | → `mode: 'draft'` | **dropped**: lands active | as
`RestServer` |
| `DELETE /meta/:type/:name` (reset) | `If-Match` → `parentVersion`;
`?state=draft` → `state`; `If-None-Match` not read | as listed | verb
not served: 405 `METHOD_NOT_ALLOWED` (`Allow: GET, HEAD, PUT`), no
protocol call | unchanged |
| `POST /meta/:type/:name/publish` | none of the three is read | — |
route not served: 404 `ROUTE_NOT_FOUND`, no protocol call | unchanged |
| `POST /meta/:type/:name/rollback` | none of the three is read | — |
404 `ROUTE_NOT_FOUND`, no protocol call | unchanged |
| `POST /meta/_migrate-stored` | none of the three is read | — | served;
reads none of the three | unchanged |
The three verbs the catch-all does not serve answer loudly and write
nothing. The comment on the 405 branch records that mounting a real
`DELETE` there "needs its own card". `RestServer`'s rows where a
parameter is not read stay as they are (not widened).
- **H6, same answer.** Partly holds. Same status, same `code`, same
refusal sentence, and the draft receipt says `state: 'draft'` on both
doors. Not byte-identical envelopes: each transport answers in its own
dialect, and the spec declares `MetadataConflictErrorSchema` as "the
REST door's flat ADR-0112 dialect". `RestServer`'s 409 is `{ error:
SENTENCE, code: 'METADATA_CONFLICT', currentVersion: TOKEN }`. The
catch-all's is `{ success: false, error: { code: 'METADATA_CONFLICT',
message: SENTENCE, httpStatus: 409 } }` (measured through the real
`createHonoApp`). **`currentVersion` is not carried as data on the
catch-all.** That is an open question for the seat (see the report), not
decided here.
- **H7, neighbour.** `rest-server.ts`'s `?package` reads (the `PUT`
door's `packageRaw` / `packageId` lines and the publish door's) are
untouched. Edits there are the import, the removed private parser, and
the `PUT` door's precondition / guard / spread lines.
## Pins
(`packages/runtime/src/domains/meta-save-preconditions-parity.test.ts`)
Each door over its own real store (better-sqlite3 `:memory:`, the real
`sys_metadata*` objects, a real `ObjectStackProtocolImplementation`).
The store is read after every write. The `RestServer` leg calls its
registered `PUT` handler. The catch-all leg repeats the catch-all's four
statements over a real Fetch `Request` (path below the prefix, JSON
body, query flattened from the URL, the raw `Request` as
`context.request`) into the real `HttpDispatcher.dispatch()`. It cannot
import `createHonoApp`: this package cannot depend on
`@objectstack/hono` (that package depends on it), and
`packages/adapters/hono`'s suite aliases `@objectstack/runtime` to a
stub. The H2 probe above is the real-`createHonoApp` reading.
Per door: control (an unguarded save writes the active row, last writer
wins); a stale `If-Match` → 409 `METADATA_CONFLICT`, nothing written,
and the current token still saves; `If-None-Match: *` writes the first
row and over an existing row → 409, nothing written; `?mode=draft`
stages a draft and leaves the active row untouched; both unhonourable
pins → 400 `VALIDATION_ERROR`, nothing written. Plus one side-by-side
row (same status, code and token-masked sentence on both doors), and the
dispatcher's fallback writer (three asks → 501 and `saveItem` never
called; control: an unguarded save still reaches it).
**Reverse verification and ablations** (fix committed first; each leg
restored from `HEAD` and proven by blob hash and an empty `git diff
HEAD`; the subject resolves by relative source import and
`@objectstack/rest` by the runtime vitest alias to source, so no `dist/`
leg applies):
| leg | result | which rows went red |
|:--|:--|:--|
| this branch | 15 passed | — |
| `main`'s `meta.ts` in the tree | 8 failed, 7 passed | all on the
catch-all side (4 case rows, the side-by-side row, 3 fallback rows).
`RestServer`'s 5 rows and both controls green. |
| ablation A: catch-all reads `headers: undefined` | 6 failed, 9 passed
| stale token, `If-None-Match: *`, the 400 row, the side-by-side row, 2
fallback header rows. `RestServer` green; the catch-all's draft row
green. |
| ablation B: catch-all reads `query: undefined` | 2 failed, 13 passed |
the catch-all's draft row and the fallback draft row. `RestServer`
green. |
Ablations went through `node scripts/ablation-replace.mjs` (anchor hit
x1 → x0, blob `e90328952e70` → `18cb795a909f` / `88f9509a61df`, restored
to `e90328952e70` == HEAD, `git diff HEAD` empty).
## Verification
All at this branch's head `bba46774` (`git rev-parse --short HEAD`),
base `6ed0c0f3`. Heavy runs went through `scripts/pm/os-verify-lock.sh`.
- **Build.** `pnpm --filter '@objectstack/runtime^...' build`
(dependency closure), then `@objectstack/rest` and
`@objectstack/runtime` rebuilt at head: exit 0. `dist/` checked to carry
`metaSaveRequestOptions`.
- **Typecheck.** `pnpm --filter @objectstack/rest typecheck`: exit 0.
`pnpm --filter @objectstack/runtime typecheck`: exit 0. The test layer
printed `check:test-typecheck: OK — 27 file(s) / 190 error(s) / 68
pinned signature(s) held`, ledger unchanged. The new test file is in
that program: it was refused once, for one error, before the fix in
`bba46774`.
- **Unit tiers.** `pnpm --filter @objectstack/rest exec vitest run
--project local`: 261 files passed, 4929 passed, 326 skipped. `pnpm
--filter @objectstack/runtime exec vitest run --project local`: 335
files passed, 4736 passed, 19 skipped. The `repo` projects (`test:repo`)
were not run locally; they are left to CI.
- **Gates.** The order's 64 commands plus the full `pnpm lint`, each
exit recorded. `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` re-derived at head gives the same
64 (8 paths, 653 changed lines, under the 5000 threshold). `--ran`
reconciliation: `64 derived famil(ies) accounted for — 63 run, 1
NOT-MEASURED`, 0 unrun. 63 exit 0, and `pnpm lint` (`eslint .
--no-inline-config`, whole repo) exit 0 with no findings.
- **NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: exit 3
`PREREQUISITE NOT MET`.** It reads every package's `dist/`, and 38
packages are unbuilt in this worktree. Declared narrowing in its place:
the `require` entries of the two packages whose shipped code changed
load at head. `require('packages/rest/dist/index.cjs')` gives 34 exports
including `metaSaveRequestOptions`, and
`require('packages/runtime/dist/index.cjs')` gives 312 exports. CI runs
the full gate.
- **Upstream.** `origin/main` is 6 commits past the base (`13aea189`).
None of them touches `meta.ts`, `rest-server.ts`, `rest/src/index.ts`,
`query-multiplicity.ts`, `http-dispatcher.ts` or
`metadata-protocol/src/protocol.ts`, so no merge of `main` was taken.
- **Round 1 head `6d5f6a5f`.** Only
`.changeset/22141-rest-meta-save-request-options.md` changed (`patch` →
`minor`, `Clause-②: no` → `Clause-②: yes (widening)`), so the code
verification above stands. `dispatch-gates --commands` for that path
derives 20 gates, and all 20 exit 0 at `6d5f6a5f` (`--ran`: 20 run, 0
NOT-MEASURED, 0 UNRUN). The branch union is unchanged (the same 64).
Also exit 0: `check:type-check-coverage`, `check:type-check-debt`, and
`check-changeset-no-major.mjs --base origin/main --event` over this
body, whose level axis reads `Clause-②: yes` and finds
`@objectstack/rest: minor`.
## Acceptance notes
- **Reproduced, not fixed here (reported to the seat): `PUT
?package=all` through the catch-all binds the row to a package literally
named `all`.** The dispatcher reads `query?.package || undefined`, while
`RestServer` treats `all` and empty as the env-local overlay. Measured
on the real store at this branch's head: catch-all → row `package_id:
'all'`; `RestServer` → `package_id: null`. This is the same write-parity
family, but the shared fix would edit `RestServer`'s `?package` read,
the region #22128's claim may edit. #22128 is not addressed here.
- The catch-all's 409 does not carry `currentVersion` as data (H6). Open
question in the report.
- `RestServer`'s `PUT` treats any `?mode` value other than `draft` as an
active save (no closed value set). Unchanged here: a row where
`RestServer` itself does not refuse stays as it is.
- The dispatcher's `/meta` domain does not judge query multiplicity. The
catch-all flattens its query to one string per name, so a repeated
`?mode` there is read as its last value. `meta-save-request.ts`'s header
records that a door calls the mapping after its own multiplicity gate.
- `meta-draft-read-door-census.test.ts` loses its `PUT ?mode=draft` row:
that `write` switch is no longer read in `rest-server.ts`. Its header
now says where it went and which tests hold it.
Written by session `session_01RWZbGvPFcRKvUqASZtunCU`. Round 1 changed
only the `Clause-②` declaration (the `@objectstack/rest` changeset
regrade and this body), with no code change.
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 11e6fc5 commit 3aa0c7b
8 files changed
Lines changed: 585 additions & 68 deletions
File tree
- .changeset
- packages
- rest/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
168 | 168 | | |
169 | 169 | | |
170 | 170 | | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
38 | 45 | | |
39 | 46 | | |
40 | 47 | | |
| |||
160 | 167 | | |
161 | 168 | | |
162 | 169 | | |
163 | | - | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | 170 | | |
169 | 171 | | |
170 | 172 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
213 | 213 | | |
214 | 214 | | |
215 | 215 | | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
216 | 219 | | |
217 | 220 | | |
218 | 221 | | |
| |||
1717 | 1720 | | |
1718 | 1721 | | |
1719 | 1722 | | |
1720 | | - | |
1721 | | - | |
1722 | | - | |
1723 | | - | |
1724 | | - | |
1725 | | - | |
1726 | | - | |
1727 | | - | |
1728 | | - | |
1729 | | - | |
1730 | | - | |
1731 | | - | |
1732 | | - | |
1733 | | - | |
1734 | | - | |
1735 | | - | |
1736 | | - | |
1737 | | - | |
1738 | | - | |
1739 | | - | |
1740 | | - | |
1741 | | - | |
1742 | | - | |
1743 | | - | |
1744 | | - | |
1745 | | - | |
1746 | | - | |
1747 | | - | |
1748 | | - | |
1749 | | - | |
1750 | | - | |
1751 | | - | |
1752 | | - | |
1753 | | - | |
1754 | | - | |
1755 | | - | |
1756 | | - | |
1757 | | - | |
1758 | | - | |
1759 | | - | |
1760 | | - | |
1761 | | - | |
1762 | | - | |
1763 | | - | |
1764 | | - | |
1765 | | - | |
1766 | | - | |
1767 | | - | |
1768 | | - | |
1769 | | - | |
1770 | | - | |
1771 | 1723 | | |
1772 | 1724 | | |
1773 | 1725 | | |
| |||
7160 | 7112 | | |
7161 | 7113 | | |
7162 | 7114 | | |
7163 | | - | |
7164 | | - | |
7165 | | - | |
7166 | | - | |
7167 | | - | |
| 7115 | + | |
| 7116 | + | |
| 7117 | + | |
| 7118 | + | |
| 7119 | + | |
| 7120 | + | |
| 7121 | + | |
| 7122 | + | |
| 7123 | + | |
| 7124 | + | |
| 7125 | + | |
| 7126 | + | |
| 7127 | + | |
| 7128 | + | |
| 7129 | + | |
| 7130 | + | |
| 7131 | + | |
7168 | 7132 | | |
7169 | 7133 | | |
7170 | | - | |
7171 | 7134 | | |
7172 | 7135 | | |
7173 | 7136 | | |
| |||
7181 | 7144 | | |
7182 | 7145 | | |
7183 | 7146 | | |
7184 | | - | |
7185 | | - | |
| 7147 | + | |
| 7148 | + | |
7186 | 7149 | | |
7187 | 7150 | | |
7188 | 7151 | | |
| |||
7291 | 7254 | | |
7292 | 7255 | | |
7293 | 7256 | | |
7294 | | - | |
| 7257 | + | |
| 7258 | + | |
| 7259 | + | |
| 7260 | + | |
7295 | 7261 | | |
7296 | 7262 | | |
7297 | 7263 | | |
7298 | | - | |
7299 | | - | |
7300 | | - | |
7301 | 7264 | | |
7302 | 7265 | | |
7303 | 7266 | | |
| |||
0 commit comments