Repository navigation
Commit a3d24c9
feat(objectql,cli,metadata-protocol): backfill the currency code of rows stored before an amount switched to record mode, and acknowledge the switch with a 409 at the save (#28128)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg9xV1B9ukhcbUkqcsAi8vpW)_
Fixes #27577
Ruling-ref: 6492000994
⛔ lands before R2 (#27191 / #27192 / #27193). Not release-paired: ships
in the next Version Packages.
Clause-②: yes (widening) — a new `@objectstack/objectql` export
(`backfillRecordCurrencyCodes` and its report helpers), a new command
`os migrate record-currency-codes`, and a new finding
`currency_mode_to_record` registered in the existing 409
`destructive_change` face, lifted by its existing `force`
Metadata-protocol: none
## 维护者速读
- **改了什么:** 已有数据的 `dynamic` / `fixed` 金额字段切到 `record` 后,旧行有金额无币种码。新增显式回填
`os migrate record-currency-codes --object --field
--code`:只给「有金额、码为空」的行写运维给的码,默认只报告,`--apply` 才写,可重复跑;元数据保存这种切换时返回现有 409
`destructive_change`(finding
`currency_mode_to_record`),点名三个后果和命令,`force` 放行。
- **为什么改:** 不回填,这些行显示为纯数字、分组落进 null 组、币种筛选看不到,R2 合并会漏算。
- **风险与回滚:** 码从不推断;有码的行从不改;非 ISO 4217 码在读任何行前拒绝;写入走 `engine.update`,受 R1
写入守卫与 `valueDomain` 判;409 不读数据行、不加启动拒绝。回滚即撤 PR。
- **先看:** `packages/objectql/src/record-currency-backfill.ts`,再看
`protocol.ts` 里 `detectDestructiveObjectChanges` 新增的一段。
- **两分钟验证:** 跑
`record-currency-backfill.test.ts`(干跑、写入、幂等、有码不动、空金额跳过、坏码拒绝)与
`protocol-destructive-currency-mode.test.ts`(dynamic/fixed→record 返回
409、新字段不触发、`force` 放行)。
- **需要您做的:** 无,已裁 B;本 PR 不触及受治理路径,skills 一行与 ADR 开放项由 epic PM 另开 Tier H
PR。
## What changes
**`@objectstack/objectql` — `backfillRecordCurrencyCodes(engine, logger,
{ object, field, code, apply, maxRecords })`**
(`record-currency-backfill.ts`, modelled on `summary-backfill.ts`),
exported with `formatRecordCurrencyBackfillReport` and
`recordCurrencyBackfillComplete`.
- Before any row is read: the object must resolve (`OBJECT_NOT_FOUND` /
404, `objectNotFoundError`), the field must be a `record`-mode
`currency` field (`recordCurrencyFieldOf`, the write guard's own
reading; else `INVALID_FIELD` / 400 naming the field), and the code must
be an `iso_4217_currency` member (`isValueDomainMember`, the predicate
the code field's `valueDomain` uses, exact spelling, no trim or case
fold; else `INVALID_FIELD` / 400 naming the code field). Each refusal
ends "Refused before the walk read or wrote any row."
- The walk: a keyset walk over `id` reading `id`, the amount and the
code, under the unscoped system context (`{ isSystem: true }`, as every
`os migrate` data run). Per row, in this order: a code that is not blank
→ `skippedCodePresent`, never written; a blank amount →
`skippedAmountBlank`; otherwise `codeless`, and on `--apply` one write.
"Blank" is `isRecordCurrencyBlank` (absent, `null`, or white space
only), now exported from `validation/record-currency-code.ts` so the
backfill and the write guard judge one predicate: the rows filled are
exactly the rows the guard would refuse on their next write of the pair.
An amount of `0` is an amount.
- ⛔ The code is the caller's, never derived: nothing reads
`defaultCurrency`, `localization.currency` or any other source (ADR-0144
D8 rule 4).
- Dry run unless `apply`. Idempotent: every write turns a blank code
into a member code, so a second run counts zero `codeless`. One row's
failure is recorded in `failures` and the walk continues; `maxRecords`
(default 100 000) truncates and says so. No `sys_migration` flag.
**Why `engine.update`, not a driver write** (the card leaves this to the
precedent picked): `summary-backfill.ts` itself writes through
`engine.update` with the system context, as do `comment-parent-pair`,
`activity-changed-fields`, `audit-key-material`, `audit-mail-bodies` and
`audit-metadata-bodies`; no `os migrate` data run writes at the driver.
The engine path is also the one that already judges this exact write:
the R1 guard sees the payload touch the code beside the stored amount,
and the code field's `valueDomain` judges the code. A write the backfill
makes is therefore one the platform would accept from any caller,
audited and hooked like any other; a driver write would step around both
and record nothing.
**`@objectstack/cli` — `os migrate record-currency-codes --object <o>
--field <amount> --code <ISO> [--apply] [--yes] [--json] [--force]
[--max-records] [--database-url]`**
(`commands/migrate/record-currency-codes.ts`). The shell of `os migrate
summary-nulls`, one for one: SQLite occupancy gate, `--yes` for a
non-interactive apply, read-only boot for the dry run (`deferSchemaDdl`
+ `readOnlyProbe`), the "no app objects loaded" refusal,
`refuseUndeclaredObjects` for `--object`, the journal-mode note after
the dry-run line and after an `INVALID_FIELD` refusal on a SQLite file,
`--json` envelope with `errorCodeFields`, exit 1 on any failure.
`--object`, `--field` and `--code` are required. Everything else is the
engine function's.
**`@objectstack/metadata-protocol` — `detectDestructiveObjectChanges`
gains `currency_mode_to_record`** (Phase 3a-destructive,
`saveMetaItem`): the previous field is stored, its
`currencyConfig.currencyMode` is not `record` (an absent config is the
default `dynamic`), and the next field is a `currency` field with
`currencyMode: 'record'`. The finding answers as the existing 409
`DESTRUCTIVE_CHANGE` with the existing `force` remedy; its message names
the code field, the three consequences (plain number, the `null` group,
outside every currency-paired filter) and the command with `--object`
and `--field` filled in. It reads no row, like `field_type_change` and
`field_required_no_default`. A new field declared in `record` mode, a
field already in `record` mode, and a move out of `record` raise
nothing. ⛔ No boot-time refusal: the gate runs only where a previous
version exists (REST `PUT /meta`, Studio, package publish).
**Not in this diff** (governed paths, kept with the epic PM's Tier H PR
per the card): the `skills/**` line and the ADR-0144 open item with its
dated note. The epic PM also adds the blank-code class to #27193.
#27193's code is not touched.
**Declarations, confirmed against the tree.** `Clause-②: yes
(widening)`: new exports and a new command widen the public face; the
finding is a new member of the existing `destructive_change` refusal
face, which is the reading the card and ruling take from #27339's review
6490309706. One thing for the contract review to weigh: a save of
exactly this switch that succeeded without `force` now answers 409 until
re-sent with `force`; it is a risk acknowledgement on an existing face,
not a refusal of what an author can write, so `Metadata-protocol: none`
(every body that saved still saves). One changeset, `patch` for all
three packages.
## Why
Ruling 6492000994 on #8345 (letter B): the switch is allowed and owes an
explicit, audited backfill, and the R2 home projection cannot convert a
codeless row, so this lands before R2.
## Verification
Head `1636d8c3`: the branch's four commits on `1ed33609` (all four R1
PRs on `main`) plus a merge of `main` at `549203d0` (including #27991),
no conflicts. After the merge: `pnpm install --frozen-lockfile && pnpm
build` (whole workspace), then everything below on that head.
- `packages/objectql`: `record-currency-backfill.test.ts` 8 / 8 (dry run
counts every kind of row and writes nothing; `--apply` fills only the
codeless rows through the engine, `0` and white-space codes included,
codes and amounts otherwise untouched; idempotent; five non-member codes
refused with no read; three non-record fields refused with no read;
unknown object `OBJECT_NOT_FOUND`; one failed write isolated, re-run
finishes it; the dry-run report text).
`protocol-destructive-currency-mode.test.ts` 7 / 7 (dynamic, fixed and
unset → record: 409 with the finding, the message's three consequences
and command, and no data read of the object; `force` lifts it; a new
record-mode field, record → record, and record → dynamic / fixed raise
nothing). `protocol-destructive.test.ts` 7 / 7 unchanged.
- `packages/cli`: `record-currency-codes.test.ts` 9 / 9 (dry run
default; the three flags required and `--code` missing refused; names
handed through; `--apply --yes`; undeclared object refused before the
backfill; a real `INVALID_FIELD` code refusal reaches the `--json`
envelope with `httpStatus`; the dry-run line with the journal-mode note
on SQLite and without it on PostgreSQL).
- `pnpm --filter` typecheck: `@objectstack/objectql`,
`@objectstack/metadata-protocol`, `@objectstack/cli` pass
(test-typecheck ledgers held). eslint on every touched file: clean.
- **Ablations** (`scripts/ablation-replace.mjs`, mutation landed and
restored byte-identical each time):
1. the backfill's code-present test `if
(!isRecordCurrencyBlank(row[codeField]))` → `if (false && …)`: 5 / 8 red
(rows holding EUR and JPY overwritten).
2. the same test → `row[codeField] != null` (a null-only blank): 4 / 8
red (the white-space code row skipped as "present").
3. the finding's condition → `false && …` (metadata-protocol rebuilt
under the mutation): the three 409 cases red, 3 / 7; the four non-firing
cases stay green, as they must.
- Full suites on `1636d8c3`: `@objectstack/objectql` 496 files / 9615
tests passed; `@objectstack/metadata-protocol` 284 files / 21857 passed
(26 skipped, pre-existing); `packages/cli/src/commands/migrate/` 59
files / 429 passed (8 skipped, pre-existing).
- Derived gates: `dispatch-gates --commands` on `1636d8c3` derives 65
families (9 paths vs merge base `549203d0`, 1254 changed lines); each
ran with its exit captured before any pipe, all 65 exited 0.
`dispatch-gates --ran` reads: "65 derived famil(ies) accounted for — 65
run, 0 NOT-MEASURED". On an earlier head, `check:objectql-double-limit`
caught the backfill test's driver double counting reads inside `find`
(its probe could not drive it); the count moved to spies beside the
double (`3c340f77`).
**CI.** Head `5d2f5efd`: 35 check runs success, 13 skipped
(path-filtered), none failed; `mergeable_state: clean`. Two reds on the
way there, both this PR's own and fixed:
- `check:error-code-casing` (not in the path-derived list) read the
test's `{ code: 'currency_mode_to_record' }` as a lowercase `error.code`
emission; the test now reads the raised finding codes as a list
(`0f5c7f1f`). A destructive-change finding code is the issue's own
field-addressed vocabulary, as `field_type_change` is.
- Test Core: two CLI rosters that discover their members from the tree
(`test/migrate-confirmation-exit.pin.test.ts`,
`src/utils/schema-migrate.one-shot-family.integration.test.ts`) did not
yet name the new command; both rows added (`5d2f5efd`). The one-shot
fixture declares no record-mode amount, so its two cases reach the boot
and then the backfill's `INVALID_FIELD` refusal, the same shape as the
`resume` and `plugin trust revoke` rows. Full `@objectstack/cli` suite
locally: 535 files / 6680 tests passed (9 skipped, pre-existing).
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; eslint on the touched files 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: n/a.
- [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 with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Utp6CbJa6hKdimkAau1nbA
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CbJa6hKdimkAau1nbA)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3cc45cc commit a3d24c9
11 files changed
Lines changed: 1273 additions & 2 deletions
File tree
- .changeset
- packages
- cli
- src
- commands/migrate
- utils
- test
- metadata-protocol/src
- objectql/src
- validation
| 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 | + | |
| 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 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
0 commit comments