Skip to content

Commit 0cba0ce

Browse files
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
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/client': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/cli': patch
6+
'@objectstack/metadata-protocol': patch
7+
'@objectstack/spec': patch
8+
---
9+
10+
feat(rest, client): a tenant-admin REST door for the record-currency code backfill
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: none
14+
15+
An organization admin with no shell on the deployment can now run the record-currency code backfill (`os migrate record-currency-codes`) over HTTP: `POST /api/v1/meta/_migrate-record-currency-codes` with `{ object, field, code, apply? }`, or `client.meta.migrateRecordCurrencyCodes(...)` from the SDK.
16+
17+
- It fills the codeless rows of **the caller's current organization only**. There is no environment-wide form, and the body takes no organization: `organizationId`, `allOrganizations` or any other key outside the four is refused with `VALIDATION_ERROR` / 400. A request with no current organization is refused `403 FORBIDDEN`.
18+
- It requires `setup.write` in that organization (`403 FORBIDDEN` otherwise), and it is mounted behind `metadata.endpoints.maintenance` with the other maintenance routes.
19+
- Preview unless `apply` is `true`. The answer is the report the CLI prints under `--json`, and the code, field and object refusals are the CLI's.
20+
- The SDK method answers `unknown`: the report's named type lives in `@objectstack/objectql`.
21+
- The 409 `destructive_change` finding `currency_mode_to_record` now names this route beside the CLI command.
22+
- `@objectstack/objectql` now exports `RecordCurrencyBackfillOrganization` and `RecordCurrencyBackfillOrganizationsRefusal` from its entry.
23+
- A backfill walk stopped by `--max-records` no longer prints "nothing to backfill" for rows it did not examine. It says the walk is incomplete, and an unscoped run says its organization check saw only the rows examined.
24+
- A backfill row whose write a sandboxed hook refused now reports the hook's own sentence, not the `hook '<name>' threw:` debug wrapper, and a crashed hook's row reports the write doors' withheld fault sentence. The operator's log line keeps the whole text.

‎content/docs/data-modeling/currency.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ naming no organization there is refused before any write and lists each organiza
148148
codeless row count, and `--all-organizations` writes the one code into every
149149
organization's rows when it really is right for all of them. See
150150
[`os migrate record-currency-codes`](/docs/deployment/cli#os-migrate-record-currency-codes).
151+
With no shell on the deployment (a hosted organization), an organization admin holding
152+
`setup.write` runs the same backfill for their current organization's rows with
153+
`POST /api/v1/meta/_migrate-record-currency-codes` and the body
154+
`{ "object", "field", "code", "apply" }`, a preview unless `apply` is `true`.
151155

152156
### Aggregating a record-mode amount
153157

‎content/docs/deployment/cli.mdx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,6 +1663,40 @@ every write turns a blank code into a code, so a second run finds nothing to wri
16631663
Re-running until the report says zero *is* the verification, which is why this
16641664
command records no flag.
16651665
1666+
A walk that reaches `--max-records` stops there: the report says `truncated: true`, the
1667+
text says the walk is incomplete, and the command never prints "nothing to backfill"
1668+
for rows it did not examine. Re-run without the cap to finish.
1669+
1670+
**Without shell access, use the route.** An organization admin on a hosted deployment
1671+
cannot reach its database from a shell. The same backfill is exposed over HTTP, for the
1672+
rows of **the caller's current organization only**:
1673+
1674+
```http
1675+
POST /api/v1/meta/_migrate-record-currency-codes
1676+
Content-Type: application/json
1677+
1678+
{ "object": "opportunity", "field": "amount", "code": "USD", "apply": true }
1679+
```
1680+
1681+
or from the SDK:
1682+
1683+
```ts
1684+
const preview = await client.meta.migrateRecordCurrencyCodes({ object: 'opportunity', field: 'amount', code: 'USD' });
1685+
const result = await client.meta.migrateRecordCurrencyCodes({ object: 'opportunity', field: 'amount', code: 'USD', apply: true });
1686+
```
1687+
1688+
It answers `{ "success": true, "data": … }` with, as `data`, the report this command
1689+
prints under `--json` (its `report` member), and
1690+
takes the same posture: **preview unless `apply` is literally `true`**. It is the
1691+
`--org` run for the caller's current organization: there is no environment-wide form,
1692+
and the body takes no organization (`organizationId` or `allOrganizations` in it is
1693+
refused with `VALIDATION_ERROR` / 400, as is any other key outside `object`, `field`,
1694+
`code` and `apply`). A request with no current organization is refused `403`. It
1695+
requires the `setup.write` capability in that organization and answers `403`
1696+
otherwise; the code, field and object refusals are the command's. It is mounted with
1697+
the other maintenance routes, so `metadata.endpoints.maintenance: false` takes it away.
1698+
There is no Setup button for it yet: that is separate UI work.
1699+
16661700
#### `os migrate recompute-home-amounts`
16671701
16681702
A record-mode amount that declares a home amount is converted into the organization's home

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ One of the largest consumers of the flag;
197197
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes. The gate is `refuseUnexposedDataAction`, which `callData` asks first and the `/data` domain asks before its request gate; `callData` reads the same bypass once more for each nested-relation condition's target | `callData`, `refuseUnexposedDataAction` |
198198
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `actionPermissionError` |
199199
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `handleMetadataRequest`, `registerMetaCollectionRoutes`, `registerMetaItemWriteRoutes`, `registerMetaItemHistoryRoutes`, `metaSaveVerdict`, `handlePermissionSetCloneWrite` |
200+
| 50b | `setup.write` bypassed on the record-currency code backfill door | rest | Get: `POST /meta/_migrate-record-currency-codes` without the capability. The door still fills only the caller's current organization's rows: a system context with no organization is refused like any other caller | `registerMetaRecordCurrencyBackfillRoute` |
200201
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `metaWriteCapabilityVerdict` |
201202
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `handleActionsRequest`, `handleAIRequest`, `handleAutomationRequest`, `handleMetadataRequest`, `handleSecurityRequest`, `handlePackagesRequest`, `handleAnalyticsRequest`, `handleI18nRequest`, `registerExternalDatasourceRoutes`, `refusePackageRequest` |
202203
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `handleMcpRequest` |
@@ -622,6 +623,7 @@ and CI refuses one that differs from what the tree produces.
622623
| `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` | 1 | 50 |
623624
| `packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes` | 2 | 50 |
624625
| `packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes` | 1 | 50 |
626+
| `packages/rest/src/meta-record-currency-backfill-route.ts#registerMetaRecordCurrencyBackfillRoute` | 1 | 50b |
625627
| `packages/rest/src/package-routes.ts#refusePackageRequest` | 2 | 52, 54 |
626628
| `packages/rest/src/rest-server.ts#enforceAuth` | 1 | 18 |
627629
| `packages/rest/src/rest-server.ts#mayReadAuthoringTrail` | 1 | 8b |

‎content/docs/references/api/rest-server.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
225225
| **types** | `boolean` | optional (default: `true`) | Mount the metadata type list — `GET /meta` and `GET /meta/types` (one handler, two paths) |
226226
| **items** | `boolean` | optional (default: `true`) | Mount the per-type item list — `GET /meta/:type`, and nothing else |
227227
| **item** | `boolean` | optional (default: `true`) | Mount the whole per-item face — `GET`, `PUT` and `DELETE /meta/:type/:name`, its `/references` and `/layers` reads, the history family (`GET` `/history`, `/audit`, `/diff`, `/published`; `POST` `/publish`, `/rollback`), `GET /meta/book/:name/tree` and the app metadata bundle `GET /meta/app/:appId/bundle`, which registers through the same per-item registrar because every member it serves is the item read's own answer |
228-
| **maintenance** | `boolean` | optional (default: `true`) | Mount the whole-store maintenance operations — `GET /meta/diagnostics`, `GET /meta/_drafts` and the `POST /meta/_migrate-stored` write door |
228+
| **maintenance** | `boolean` | optional (default: `true`) | Mount the whole-store maintenance operations — `GET /meta/diagnostics`, `GET /meta/_drafts`, the `POST /meta/_migrate-stored` write door and the `POST /meta/_migrate-record-currency-codes` record-currency code backfill |
229229
| **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` / `maintenance` are the switches that gate real mounts. |
230230

231231

‎packages/cli/src/commands/migrate/record-currency-codes.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,20 @@ describe('os migrate record-currency-codes', () => {
316316
expect(lines.find((line) => line.startsWith('Dry run —'))).toBe(`${DRY_RUN_LINE} ${JOURNAL_MODE_SWITCH_NOTE}`);
317317
}, RUN_TIMEOUT);
318318

319+
it('a walk --max-records cut short says it is incomplete, never "nothing to backfill"', async () => {
320+
boot(POSTGRES_CONFIG);
321+
vi.mocked(backfillRecordCurrencyCodes).mockResolvedValue({
322+
...REPORT, organizationId: 'org_a', allOrganizations: false, organizations: [],
323+
scannedRecords: 2, codeless: 0, skippedCodePresent: 2, sampleRecordIds: [], truncated: true,
324+
} as any);
325+
await MigrateRecordCurrencyCodes.run([...NAMING, '--org', 'org_a', '--max-records', '2'], { root: CLI_ROOT });
326+
const text = printedLines().join('\n');
327+
expect(text).toContain('No codeless opportunity.amount amount among the 2 row(s) examined.');
328+
expect(text).toContain('Walk incomplete — 2 row(s) examined, 0 codeless.');
329+
expect(text).not.toContain('Nothing to backfill');
330+
expect(text).not.toContain('nothing to backfill');
331+
}, RUN_TIMEOUT);
332+
319333
it('on PostgreSQL the dry-run line names no switch', async () => {
320334
boot(POSTGRES_CONFIG);
321335
await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT });

‎packages/cli/src/commands/migrate/record-currency-codes.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ export default class MigrateRecordCurrencyCodes extends Command {
304304

305305
if (report.failures.length > 0) {
306306
printError(`${report.failures.length} row(s) could not be written — re-run to finish them.`);
307+
} else if (report.truncated) {
308+
// The cap cut the walk short: neither "done" nor "nothing to do" is known.
309+
printWarning(
310+
`Walk incomplete — ${report.scannedRecords} row(s) examined, ${report.codeless} codeless` +
311+
(apply ? `, ${report.written} written` : '') +
312+
'. Re-run without --max-records (or with a higher one) to finish.',
313+
);
307314
} else if (apply && report.written > 0) {
308315
printSuccess(
309316
`Wrote ${report.code} on ${report.written} row(s). ` +

‎packages/client/src/index.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,9 @@ export class ObjectStackClient {
22062206
* which is not a dependency of this package, and a second declaration in
22072207
* spec would drift against the CLI that renders the same report. Its two
22082208
* ledger rows carry the same note. ⛔ Do not re-declare it here.
2209+
* `migrateRecordCurrencyCodes`, added after the ruling, stays unbound for
2210+
* the same reason (its report type lives in `@objectstack/objectql`) and
2211+
* answers `unknown`, which the caller narrows.
22092212
*/
22102213

22112214
/**
@@ -2275,6 +2278,50 @@ export class ObjectStackClient {
22752278
return this.unwrapResponse<any>(res);
22762279
},
22772280

2281+
/**
2282+
* ADR-0144: write a currency code into the rows of a `record`-mode amount
2283+
* that hold an amount beside a blank code — the rows stored before the
2284+
* field was switched to `currencyMode: 'record'`. The server-side form of
2285+
* `os migrate record-currency-codes`, for an organization admin with no
2286+
* shell on the deployment's database.
2287+
*
2288+
* **Your current organization only.** The server fills the rows of the
2289+
* caller's current organization and no other; there is no
2290+
* environment-wide form and no organization parameter (each organization
2291+
* has its own home currency). `code` is the one you give, never derived.
2292+
*
2293+
* **Preview by default.** Without `apply: true` it reports what it would
2294+
* write and writes nothing; the report is the one the CLI's `--json`
2295+
* prints (`@objectstack/objectql`'s `RecordCurrencyBackfillReport`:
2296+
* `scannedRecords` / `codeless` / `written` / the two skip counts,
2297+
* `sampleRecordIds`, `applied`, `truncated`, `failures`, `organizations`).
2298+
* `truncated: true` means the walk stopped at its row cap and some rows
2299+
* were not examined.
2300+
*
2301+
* Requires `setup.write` in the current organization (403 otherwise).
2302+
*
2303+
* Unbound for the reason `migrateStored` above is (ruling 2C): the
2304+
* report's named type lives in `@objectstack/objectql`, which this
2305+
* package does not depend on. ⛔ Do not re-declare it here. Answered as
2306+
* `unknown`, not `any`: a new `any` return is refused by
2307+
* `check:exported-any-returns` (`exported-any-returns.json` is
2308+
* shrink-only), and `unknown` makes the caller narrow, as `getPublished`
2309+
* does under ruling 1C.
2310+
*/
2311+
migrateRecordCurrencyCodes: async (opts: { object: string; field: string; code: string; apply?: boolean }) => {
2312+
const route = this.getRoute('metadata');
2313+
const res = await this.fetch(`${this.baseUrl}${route}/_migrate-record-currency-codes`, {
2314+
method: 'POST',
2315+
body: JSON.stringify({
2316+
object: opts.object,
2317+
field: opts.field,
2318+
code: opts.code,
2319+
...(opts.apply === true ? { apply: true } : {}),
2320+
}),
2321+
});
2322+
return this.unwrapResponse<unknown>(res);
2323+
},
2324+
22782325
/**
22792326
* ADR-0020 D3.3 FSM introspection: the legal next states for `field`
22802327
* from state `from`, per the object's `state_machine` validation rule.

‎packages/client/src/meta-automation-descriptors.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,27 @@ describe('client.automation descriptors (#3563 PR-5)', () => {
110110
expect(out.flows[0].bound).toBe(true);
111111
});
112112
});
113+
114+
describe('client.meta.migrateRecordCurrencyCodes (ADR-0144 record-currency code backfill)', () => {
115+
it('POSTs the three names to the backfill door, a preview unless apply is true', async () => {
116+
const report = { object: 'deal', organizationId: 'org_a', codeless: 2, written: 0, applied: false, truncated: false };
117+
const { client, fetchMock } = createMockClient({ success: true, data: report });
118+
const out = await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD' });
119+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/meta/_migrate-record-currency-codes');
120+
expect(fetchMock.mock.calls[0][1]?.method).toBe('POST');
121+
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ object: 'deal', field: 'amount', code: 'USD' });
122+
// The REST door answers the report as the `data` of the shared envelope; the caller gets the report.
123+
expect(out).toEqual(report);
124+
});
125+
126+
it('sends apply only when it is true, and never an organization', async () => {
127+
const { client, fetchMock } = createMockClient({ success: true, data: { applied: true } });
128+
await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD', apply: true });
129+
await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD', apply: false });
130+
const bodies = fetchMock.mock.calls.map((c: unknown[]) => JSON.parse(String((c[1] as { body?: unknown })?.body)));
131+
expect(bodies).toEqual([
132+
{ object: 'deal', field: 'amount', code: 'USD', apply: true },
133+
{ object: 'deal', field: 'amount', code: 'USD' },
134+
]);
135+
});
136+
});

‎packages/client/src/return-type-precision.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,10 @@ export async function returnTypePrecisionPins12038(): Promise<void> {
428428
// which this package does not depend on. Pinned so a well-meaning sweep
429429
// does not "fix" it against the ruling.
430430
expectTypeOf(await client.meta.migrateStored()).toBeAny();
431+
// `migrateRecordCurrencyCodes` is unbound for the same reason (its report
432+
// type lives in `@objectstack/objectql`), but answers `unknown`, not `any`:
433+
// the caller narrows, and `exported-any-returns.json` stays shrink-only.
434+
expectTypeOf(await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD' })).toEqualTypeOf<unknown>();
431435

432436
// ── the packages.* eight ──────────────────────────────────────────────
433437
expectTypeOf(await client.packages.publish('com.acme.crm')).toEqualTypeOf<PackagePublishResult>();

0 commit comments

Comments
 (0)