Repository navigation
Commit 0cba0ce
feat(rest, client): a tenant-admin REST door for the record-currency code backfill (#30809)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DVR4GMtAuGamfk3RpvPfBRN)_
domain:server#1 · os-elon-musk · session_016WV4cyrhYJgbkZHbbqud3F
Fixes #30675
Clause-②: yes (widening) — a new route `POST
/api/v1/meta/_migrate-record-currency-codes` (behind
`metadata.endpoints.maintenance`), a new SDK method
`client.meta.migrateRecordCurrencyCodes`, and two type exports on the
`@objectstack/objectql` entry (`RecordCurrencyBackfillOrganization`,
`RecordCurrencyBackfillOrganizationsRefusal`).
Metadata-protocol: none
Claim: 6675312689 (amended 6675340866, 6675450053) · Branch:
`claude/project-thread-1lyw0v` · Ruling-ref: 6670707546 (letter C, item
B)
## 维护者速读
- 改了什么:组织管理员没有 shell 也能补币种码了。新增 `POST
/api/v1/meta/_migrate-record-currency-codes`,请求体 `{ object, field, code,
apply? }`,只补调用者当前组织的无码行;不带 `apply: true` 是预览、不写;SDK 加
`client.meta.migrateRecordCurrencyCodes`。409 `destructive_change`
的补救文字现在同时点名 CLI 和这个门。
- 为什么改:金额字段切到 `currencyMode: 'record'` 后,老行有金额没币种,补救只有 `os migrate
record-currency-codes`,云上租户管理员碰不到数据库 shell(裁决 6670707546 C 的 B 项)。
- 风险与回滚:小。新门需要当前组织的 `setup.write`,请求体不收任何组织参数(`organizationId` 等一律
400),无当前组织 403,`maintenance` 开关关掉就不挂载。顺带修了一处泄漏:被沙箱钩子拒写的行,报告里原来是 `hook
'<name>' threw:` 调试包装,现在是钩子自己的那句话。回滚即撤 PR。
-
先看:`packages/rest/src/meta-record-currency-backfill-route.ts`(门禁顺序、组织取自
`ctx.tenantId`、请求体白名单),`packages/objectql/src/record-currency-backfill.ts`
的
`rowFailureSentence`,`packages/rest/src/meta-write-door-capability-enumeration.test.ts`
新增的 `DATA_DOORS` 一节。
- 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run
src/meta-record-currency-backfill-route.test.ts`,预期 12 条全绿,其中「the other
organization's admin sees and writes only its own row」断言 B 组织管理员只碰到
`b1`、A 组织的行一行未动。
- 需要你做的:无,席位落地。
## What changes
**`@objectstack/rest`: the door**
(`meta-record-currency-backfill-route.ts`, new; registered from
`registerMetadataEndpointsInner` beside the object state read)
- `POST {metaPath}/_migrate-record-currency-codes`, on every mount base
the `/meta` family is mounted on, behind
`metadata.endpoints.maintenance` (the `_migrate-stored` switch). It sits
inside the `/meta` umbrella, so an anonymous caller gets the umbrella's
401.
- The order inside the handler:
1. The gate: `setup.write` in `systemPermissions` (resolved for the
current organization), or `isSystem`. Otherwise `403 FORBIDDEN`. This is
asked first, before the body or the engine, so a refused caller learns
nothing about objects or kernels.
2. The organization: `ctx.tenantId`. None means `403 FORBIDDEN` ("no
current organization … Nothing was read.").
3. The body: the closed set `{ object, field, code, apply? }`. Any other
key is `400 VALIDATION_ERROR` naming it. `organizationId` and
`allOrganizations` get their own sentence ("this door fills the rows of
your current organization only"). Blank names and a non-boolean `apply`
are refused the same way.
4. The engine: the request's environment's, through `wiredEngineOrLoud`.
`@objectstack/objectql` stays a devDependency and is loaded by a guarded
dynamic import. No engine, or no function, means `501 NOT_IMPLEMENTED`.
5. `backfillRecordCurrencyCodes(ql, logger, { object, field, code,
apply, organizationId })`. The answer is `{ success: true, data:
<report> }`, the report the CLI's `--json` prints.
- Errors use only codes already in the registry. A 4xx the backfill
declares is answered with its own status and code (`INVALID_FIELD` with
`details.fields`, `OBJECT_NOT_FOUND` 404); anything else goes to
`sendServerFault`.
- Registered in: `REST_ROUTE_LEDGER` (`disposition: 'sdk'`, no `authz`
field, because there is no conformance coverage yet),
`REST_SERVER_REGISTRAR_FILES` and `REST_SERVER_META_ROUTE_FILES`, and
the `scripts/check-undeclared-dep-imports.mjs` LEDGER (one
`optional-runtime-probe` row, seat amendment 6675450053).
**`@objectstack/client`**: `client.meta.migrateRecordCurrencyCodes({
object, field, code, apply? })` returns `unknown` (see Acceptance
notes).
**`@objectstack/metadata-protocol`**: the `currency_mode_to_record`
finding of the 409 `destructive_change` now names the route and its body
beside the CLI command.
**`@objectstack/objectql`**
- The entry re-exports `RecordCurrencyBackfillOrganization` and
`RecordCurrencyBackfillOrganizationsRefusal` (carried item 1 from the
#30674 ACCEPT).
- A truncated walk says it is truncated (carried item 2). The report
already carried `truncated`. The text no longer prints "✓ Every …
nothing to backfill" for rows it never examined. An unscoped run adds
that its organization check saw only the rows examined. The CLI prints a
`Walk incomplete` warning. REST and CLI answer the same report.
- A failed row's `error` reads a sandboxed hook as the write doors do
(`rowFailureSentence`, the import row's rule). A refusal gives the
hook's own sentence. A crash, a declared 5xx or the sandbox's own fault
gives the `/data` door's withheld sentence. Any other throw keeps its
message. The operator's `logger.warn` line keeps the whole text.
**Docs**
- `content/docs/deployment/cli.mdx`: a truncation paragraph, then
"Without shell access, use the route" with the HTTP and SDK forms. It
covers the caller's organization only, the refusals, the capability and
the switch, and says there is no Setup button yet.
- `content/docs/data-modeling/currency.mdx`: one sentence.
- `content/docs/permissions/system-context.mdx`: row 50b and the
regenerated register.
## Tests
- `packages/rest/src/meta-record-currency-backfill-route.test.ts` (new,
12 cases, real `ObjectQL` over an in-memory driver, two organizations):
- The org A admin's preview counts only A's rows and writes nothing;
every walk read carries A's filter.
- The org B admin sees and writes only `b1`.
- Apply writes only the caller's codeless rows, never a coded row or
another organization's; `isSystem` with a current organization passes
the gate and stays in that organization.
- The refusals: missing `setup.write` gives 403 with the provider never
called; no current organization gives 403; `organizationId` or
`allOrganizations` in the body gives 400; a malformed body gives 400; a
non-record-mode field gives `INVALID_FIELD` with `details.fields`; an
unknown object gives 404; no engine gives 501.
- The `maintenance` switch off unmounts the route and the other switches
leave it; the scoped base mounts it.
- `meta-write-door-capability-enumeration.test.ts`: the door joins the
closed set of `/meta` write doors as a `DATA_DOORS` entry. It is not a
`manage_metadata` door: it reaches no protocol method. It has its own
cases. Anonymous gets 401. A capability-less caller, a `manage_metadata`
holder and `setup.access` alone each get 403. `setup.write` and
`isSystem` pass the gate on both bases, reaching 501 with no engine.
- `rest-write-route-hook-refusal-sentence.ledger.test.ts`: the door is
driven per row (§2 and §6, 12 shapes), with the failure entry as the
answer. The harness caller gained `setup.write` and an organization, and
its engine double gained `getObject`. The whole file is green.
- The other census rows: the #8497 tripwire (`no-record-echo`), the
`objectQLProvider` consumer census (6 → 7 sites, all through
`wiredEngineOrLoud`), the query-slot census (0 slots), the docblock
floors (7 blocks), and `rest-config-mount-table.pin.test.ts` (the
maintenance row gates four routes).
- `packages/objectql/src/record-currency-backfill.test.ts`: a walk cut
short by `maxRecords` says so, and the new describe "a failed row reads
a sandboxed hook as the write doors do" (a refusal, a crash and an own
fault, and a plain-throw control).
- `packages/cli/src/commands/migrate/record-currency-codes.test.ts`:
`--org org_a --max-records 2` prints the incomplete-walk warning.
- `packages/client`: the method POSTs the body and unwraps `data`, and
`apply` is sent only when true; its return type is pinned as `unknown`.
- `protocol-destructive-currency-mode.test.ts`: the remedy names the
route.
## Acceptance notes
- **The maintenance switch's describe(), with `domain:spec` consent.**
`packages/spec/src/api/rest-server.zod.ts` (L538–541) said the switch
mounts `GET /meta/diagnostics`, `GET /meta/_drafts` and `POST
/meta/_migrate-stored`, and did not name this door. Commit `12af728f`
adds `POST /meta/_migrate-record-currency-codes` in the sentence's own
list shape and nothing else under `packages/spec/src`. It also carries
the regenerated `content/docs/references/api/rest-server.mdx` row and
`'@objectstack/spec': patch` in the card changeset, under
`domain:spec#2`'s consent 6675431663 (mailbox PR #30557). The hunk
raises this PR's review to CONTRACT_REVIEW_TIER.
- **The SDK method answers `unknown`, not the report type.** Ruling 2C
and the seat's amendment keep it untyped like `migrateStored`. An `any`
return would red the shrink-only
`packages/client/exported-any-returns.json`, so it is `unknown`. Callers
who want the type import it from `@objectstack/objectql`.
- **The shared envelope, where `_migrate-stored` answers bare.** The new
module is born on `sendOk` / `sendError` / `sendServerFault`
(`check:route-envelope` asks new modules for the shared pair). So the
report is `data`, and refusals are `{ success: false, error: { code,
message } }`.
- **No actor on the writes.** The backfill takes no user. Its writes run
under `{ isSystem: true, tenantId: <current organization> }` as the
CLI's do, so `modified_by` is not the admin's id. The door's gate is the
attribution point.
- **A tenancy-disabled object** (no `organization_id`) is refused
`INVALID_FIELD` / 400 by the backfill. Its sentence says to run it
without an organization, which is the CLI's form; over REST there is no
such form, by design.
- **Commit trailers.** Two local commits were first written with a
model-named co-author trailer. They were reworded to the model-free pair
before any push, so every pushed commit carries `Co-authored-by: Claude`
plus `Claude-Session`.
## Verification
<details><summary>Local tests and type-checks (head b56a12d)</summary>
- `pnpm --filter @objectstack/rest exec vitest run` (whole package): 396
files passed; 7710 tests passed, 354 skipped.
- `pnpm --filter @objectstack/objectql exec vitest run` (whole package):
557 files, 11089 tests passed.
- `pnpm --filter @objectstack/client exec vitest run`: 54 files, 703
tests passed.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 319
files passed (4 skipped); 22480 tests passed.
- `pnpm --filter @objectstack/cli exec vitest run --project integration
src/commands/migrate/`: 49 files passed (1 skipped); 375 tests passed.
The migrate tests live in `integration`.
- `pnpm --filter @objectstack/dogfood exec vitest run
test/authz-probe-blind-spot.test.ts test/authz-conformance.test.ts`
(they read the registrar lists): 70 passed.
- `tsc --noEmit` for rest, client, metadata-protocol and objectql: exit
0.
- Whole-tree `turbo run build` under the verify lock: 119/119 tasks,
VERDICT command-exit 0.
- `pnpm lint`: exit 0.
</details>
<details><summary>Ablation</summary>
The failure-sentence fix was measured as follows.
`rowFailureSentence(err, object, message)` was replaced by `message` in
`record-currency-backfill.ts`, and `@objectstack/objectql` was rebuilt,
because rest resolves it through `dist`.
`rest-write-route-hook-refusal-sentence.ledger.test.ts` then gave 12
failed and 613 passed. After restoring the file and rebuilding, it gave
625 passed.
</details>
<details><summary>Derived gates (dispatch-gates --commands, reconciled
with --ran)</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 116 families on head `b56a12d6`.
Each was run with its exit code captured before any pipe.
```
✓ dispatch-gates --ran: 116 derived famil(ies) accounted for — 116 run, 0 NOT-MEASURED (a DERIVED zero — all 116 recorded an exit code and none of them is 3).
```
The first pass, at an earlier head, had one finding:
`check:system-context-census` exited 1, because the door's `isSystem`
capability bypass was a read no row owned. Row 50b now owns it, and the
generated register was rewritten with `--write`.
The second pass had one prerequisite miss: `pnpm --filter
@objectstack/spec run check:docs` exited 1 with
"packages/spec/json-schema is older than packages/spec/src". That is a
gitignored local artifact, made stale by preparing the held spec hunk.
After `pnpm --filter @objectstack/spec gen:schema`, it exited 0 ("241
generated files in sync"). The CI families outside the derivation are
read from the PR's check-runs, not rerun here.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(`packages/spec` gains one describe string only, at `12af728f`.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: one describe hunk, under `domain:spec`
consent 6675431663; the generated docs row comes from `gen:docs`, and
`check:generated` passes all 14 artifacts.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4cyrhYJgbkZHbbqud3F)_
<!-- signed: domain:server#1 · os-elon-musk ·
session_016WV4cytLmJxfAmgPVjUbbW -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fd749a4 commit 0cba0ce
29 files changed
Lines changed: 1031 additions & 18 deletions
File tree
- .changeset
- content/docs
- data-modeling
- deployment
- permissions
- references/api
- packages
- client/src
- cli/src/commands/migrate
- metadata-protocol/src
- objectql/src
- rest/src
- spec/src/api
- scripts
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
151 | 155 | | |
152 | 156 | | |
153 | 157 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1663 | 1663 | | |
1664 | 1664 | | |
1665 | 1665 | | |
| 1666 | + | |
| 1667 | + | |
| 1668 | + | |
| 1669 | + | |
| 1670 | + | |
| 1671 | + | |
| 1672 | + | |
| 1673 | + | |
| 1674 | + | |
| 1675 | + | |
| 1676 | + | |
| 1677 | + | |
| 1678 | + | |
| 1679 | + | |
| 1680 | + | |
| 1681 | + | |
| 1682 | + | |
| 1683 | + | |
| 1684 | + | |
| 1685 | + | |
| 1686 | + | |
| 1687 | + | |
| 1688 | + | |
| 1689 | + | |
| 1690 | + | |
| 1691 | + | |
| 1692 | + | |
| 1693 | + | |
| 1694 | + | |
| 1695 | + | |
| 1696 | + | |
| 1697 | + | |
| 1698 | + | |
| 1699 | + | |
1666 | 1700 | | |
1667 | 1701 | | |
1668 | 1702 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
| 200 | + | |
200 | 201 | | |
201 | 202 | | |
202 | 203 | | |
| |||
622 | 623 | | |
623 | 624 | | |
624 | 625 | | |
| 626 | + | |
625 | 627 | | |
626 | 628 | | |
627 | 629 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
228 | | - | |
| 228 | + | |
229 | 229 | | |
230 | 230 | | |
231 | 231 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
316 | 316 | | |
317 | 317 | | |
318 | 318 | | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
319 | 333 | | |
320 | 334 | | |
321 | 335 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
304 | 304 | | |
305 | 305 | | |
306 | 306 | | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
307 | 314 | | |
308 | 315 | | |
309 | 316 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2206 | 2206 | | |
2207 | 2207 | | |
2208 | 2208 | | |
| 2209 | + | |
| 2210 | + | |
| 2211 | + | |
2209 | 2212 | | |
2210 | 2213 | | |
2211 | 2214 | | |
| |||
2275 | 2278 | | |
2276 | 2279 | | |
2277 | 2280 | | |
| 2281 | + | |
| 2282 | + | |
| 2283 | + | |
| 2284 | + | |
| 2285 | + | |
| 2286 | + | |
| 2287 | + | |
| 2288 | + | |
| 2289 | + | |
| 2290 | + | |
| 2291 | + | |
| 2292 | + | |
| 2293 | + | |
| 2294 | + | |
| 2295 | + | |
| 2296 | + | |
| 2297 | + | |
| 2298 | + | |
| 2299 | + | |
| 2300 | + | |
| 2301 | + | |
| 2302 | + | |
| 2303 | + | |
| 2304 | + | |
| 2305 | + | |
| 2306 | + | |
| 2307 | + | |
| 2308 | + | |
| 2309 | + | |
| 2310 | + | |
| 2311 | + | |
| 2312 | + | |
| 2313 | + | |
| 2314 | + | |
| 2315 | + | |
| 2316 | + | |
| 2317 | + | |
| 2318 | + | |
| 2319 | + | |
| 2320 | + | |
| 2321 | + | |
| 2322 | + | |
| 2323 | + | |
| 2324 | + | |
2278 | 2325 | | |
2279 | 2326 | | |
2280 | 2327 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
428 | 428 | | |
429 | 429 | | |
430 | 430 | | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
431 | 435 | | |
432 | 436 | | |
433 | 437 | | |
| |||
0 commit comments