Skip to content

Commit 9b4fa6c

Browse files
feat(cli): os migrate option-codes rewrites option values still stored as a number, a boolean or SQLite "N.0" text to the code string (#29643) (#29700)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8VQJupmprD1XpcbZZYuS4Kh)_ Fixes #29643 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:新增 `os migrate option-codes`,把 #29562 之前写进选项字段的旧值(内存驱动的原始数字/布尔、SQLite 的 `"3.0"`/`"1.0"`/`"0.0"`、多值数组里的原始元素)一次性改写成写入口如今存的选项码字符串。 - 为什么改:旧行与新行存成两种形态,`{ f: "3" }` 筛不到旧行、读出来是旧形态、唯一字段上新 `"3"` 与旧 `"3.0"` 不冲突(#29643 实测)。 - 风险与回滚:默认只读演练;`--apply` 只改能判定的值,判不了的(无选项字段上的 `"3.0"`、`"1"` 与 `"true"` 同时声明时的 `"1.0"`、读成未声明码的值)只列出不改;回滚即撤 PR,已改写的值与新写入同形。 - 先看:① `packages/cli/src/commands/migrate/option-codes.ts` 的 `decideOptionCode`(判定表);② 同文件 `migrateOptionCodes` 的整行改写与逐行失败;③ `option-codes.integration.test.ts`(真实 SQLite)。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/migrate/option-codes.integration.test.ts`,应见 26/26 通过。 - 需要你做的:无,席位落地。 ## What changes - **New command `os migrate option-codes`** (`packages/cli/src/commands/migrate/option-codes.ts`), the data half of #29562: a sibling of `os migrate blank-single-values`, not an arm of `os migrate value-shapes`. `value-shapes` documents that it rewrites nothing, and the server seat's ruling on #29431 (record 6588116815) put the last rewriting step beside it rather than in it, so this one follows that precedent. It walks every option-family field (`SINGLE_OPTION_TYPES` ∪ `MULTI_OPTION_TYPES` from `@objectstack/spec/data`, the sets the #29562 door reads; a `multiple: true` select or radio is walked as an array), `readonly` ones included, by keyset on `id`. - **The decision (`decideOptionCode`)**, per stored scalar or per array element: - a raw number or boolean is decidable by type and becomes `String(value)`; on a field declaring codes, only when that string is one, else it is reported `undeclared`; - a SQLite `"N.0"` that is not itself a declared code is decidable only against the declared codes: its readings are `String(Number(s))`, plus `"true"` for `"1.0"` and `"false"` for `"0.0"`; exactly one declared reading → that code, none → `undeclared`, two → `ambiguous`, no declared codes → `free-field`; - a picklist-bound field whose options did not resolve is reported `picklist-unresolved`; - every other string and `null` is left alone. A multi-value array is rewritten whole only when every old element in it is decidable, so no row ends half-migrated. - **Dry run by default**, as the family does: counts per object and field, a few `stored → code` samples, no row written, read-only boot. `--apply` writes each affected row once through the engine in the system context; a failure (a unique collision with a row that already holds the code) is a per-row failure and the walk goes on. `--object` narrows and refuses an undeclared name; `--apply` keeps the SQLite occupancy gate (`--force`); `--json` keeps stdout parseable. No deployment flag: a re-run that reports nothing to rewrite is the verification. - **Rosters**: the new command joins the three family pins its sibling sits in (`schema-migrate.one-shot-family.integration.test.ts`, `migrate-confirmation-exit.pin.test.ts`, `json-stdout-purity.e2e.test.ts`) and gets a row and a section in `content/docs/deployment/cli.mdx`. - **Changeset** `.changeset/29643-migrate-option-codes.md`: `@objectstack/cli` patch, both declaration lines. - No `packages/spec/**` hunk, no write-door change, no `protocol.ts`, no new error code. ## Why Wanted 1 asked to measure first. Rows written below the engine's door (what the pre-#29562 door handed the driver; PR #29638's recording-driver pin shows it passed the raw scalar through), then a fresh engine on the same store, at `origin/main` `7dae3b3c`: | question | SQLite (`SqlDriver`, file) | driver-memory (`persistence: 'file'`) | |:--|:--|:--| | stored `3` / `true` / `false` on a single select | `"3.0"` / `"1.0"` / `"0.0"` | `3` / `true` / `false` | | stored `[3, true]` on a multiselect | JSON `[3,true]` | `[3, true]` | | engine read | the old form as stored | the old form as stored | | `{ coded: "3" }`, `{ coded: "true" }`, `{ free: "3" }` | no row | no row | | `{ free: 3 }` | the `"3.0"` row | the `3` row | | `{ multi: { $contains: "3" } }` | the `[3,true]` row | the `[3,true]` row | | unique `uq`: insert `"3"` beside an old `3` / `"3.0"` | admitted beside it | admitted beside it | | unique `uq`: then insert `3` | 409 (collides with the new `"3"`) | 409 | | text field `42` (#29241, item 3) | `"42.0"` | `42` | So one option splits into two stored values on both drivers, which is what this rewrite closes. A raw scalar is decidable by its type; a SQLite `"N.0"` is decidable only when the field declares exactly one of its readings, which is why the free-field case is reported and never rewritten. **Item 3, the text family (#29241): not folded in; its own card is named in the report.** Its decidable part is only driver-memory's raw `42` / `true`: on SQLite a text field holds `"42.0"`, and with no declared codes nothing can tell an old `42` from text someone typed, so a text pass could rewrite nothing on SQLite and would have to stay silent there. The text family's set (`BOUNDED_STRING_FIELD_TYPES`) also takes in `password`, whose reads a walk would have to treat differently. That makes it a different decision from this one, so it gets its own card rather than an arm here. ## Verification Head `70a9ce67`: `541cafa9` (command, tests, rosters, docs, changeset) and `70a9ce67` (the walk test on SQLite only, see Acceptance note 1). Workspace built with `turbo run build --filter=./packages/** --filter=./examples/**` under `os-verify-lock` (VERDICT command-exit 0) before the gates. <details> <summary>Tests</summary> ```text on 70a9ce6, under os-verify-lock, VERDICT command-exit 0: pnpm --filter @objectstack/cli typecheck OK vitest run option-codes.integration + blank-single-values.integration + schema-migrate.one-shot-family.integration + migrate-confirmation-exit.pin + vitest-tiers-partition Test Files 5 passed (5) · Tests 241 passed (241) on 541cafa (the roster edit; unchanged since), under the lock, VERDICT command-exit 0: OS_TEST_TIERS=nightly vitest run test/json-stdout-purity.e2e.test.ts Test Files 1 passed (1) · Tests 89 passed (89) option-codes.integration.test.ts alone on 70a9ce6 Tests 26 passed (26) ``` </details> <details> <summary>Ablation (restored: blob == HEAD, `git diff HEAD` empty)</summary> ```text each through scripts/ablation-replace.mjs (anchor 1 -> 0, blob 16ad735 -> mutated), restored by the tool, blob after restore 16ad735 == HEAD, git diff HEAD empty A sqlite-real arm off: matches.length === 1 never rewrites option-codes.integration.test.ts Tests 8 failed | 18 passed (26) e.g. "select codes 3/true/false · '3.0' -> rewrite '3'" B free field rewrites instead of reporting: codes.length === 0 -> rewrite String(Number(stored)) option-codes.integration.test.ts Tests 3 failed | 23 passed (26) "{ type: 'select' } · '3.0' -> report free-field", "--apply rewrites to the code …", "a free field and an ambiguous 1.0 are reported …" ``` </details> <details> <summary>Gates and lint (after the last commit, on 70a9ce6)</summary> ```text node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 92 derived (7 paths vs merge base 7dae3b3) each run on 70a9ce6, exit captured before any pipe; --ran reconciliation: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN (dispatch-gates --ran exit 0) first pass (on 541cafa) found one real red and five prerequisite exits: check:driver-memory-census exit 1: the walk test bound @objectstack/driver-memory -> fixed in 70a9ce6, re-run exit 0 check-plugin-teardown-shape --self-test exit 3 (fixture commit 621a487 fetched), check:skill-examples, check:dual-build-cjs-loads, check:i18n, check:i18n-coverage exit 3 (built output missing) -> full build, all exit 0 pnpm lint (eslint . --no-inline-config) exit 0 ``` </details> ## Acceptance notes 1. **driver-memory is measured, not a test backend.** The walk test runs on SQLite only: a first version also ran on `InMemoryDriver` and `check:driver-memory-census` refused the new consumer (exit 1, `x LEDGERED: … option-codes.integration.test.ts:26`). The ruling there is the maintainer's, so the consumer was dropped rather than ledgered. driver-memory's face (a raw scalar on a single-value field) is the `scalar` arm of `decideOptionCode`, pinned by the decision table, and the same arm runs end to end on SQLite through the raw JSON elements of `multi` / `tags`. 2. **Postgres / MySQL: NOT MEASURED** (no server in this container). A JS number bound into a `text` column there is sent as its decimal string, so the `"N.0"` arm is not expected to fire; the raw-element arm in JSON arrays applies on every driver. 3. **Filter face, observed and not changed**: on base, `{ free: 3 }` (a number comparand) matches an old SQLite `"3.0"` row through SQLite's own numeric comparison, and the old raw `3` on driver-memory. How a number comparand should read against option codes belongs to #29486; this PR does not touch the filter door. ## Human Minimum - [x] 1. No `packages/spec` change. - [x] 2. Names follow the casing for their kind (`option-codes`, `decideOptionCode`, `migrateOptionCodes`). - [x] 3. No formatter ran; `pnpm lint` result above. - [x] 4. One issue, one branch off `main`, one pull request. - [x] 5. Changeset carries both declaration lines. - [x] 6. n/a (no `packages/spec`). - [x] 7. No hand edits to references, CHANGELOGs or releases. - [ ] 8. The maintainer quick-read section is in Chinese by the fleet's dev contract; everything else is English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbYbcN3M7eorMnGovXG --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYbcN3M7eorMnGovXG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b55ea9 commit 9b4fa6c

7 files changed

Lines changed: 896 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os migrate option-codes` rewrites option values still stored as a number, a boolean or SQLite `"N.0"` text to the option code string
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Since #29562 the write door stores a number or boolean written to an option field (`select`, `radio`, `multiselect`, `checkboxes`, `tags`, a `multiple: true` select or radio) as the code string it was judged by. Rows written before keep the old form: the raw `3` / `true` on driver-memory with persistence, the TEXT `"3.0"` / `"1.0"` / `"0.0"` on SQLite, and raw elements in a multi-value array on both. A filter on `"3"` misses them, a read hands back the old form, and a unique field admits a new `"3"` beside an old `"3.0"`. This new command is the one-time data step, a sibling of `os migrate blank-single-values`, not part of `os migrate value-shapes`, which rewrites nothing.
11+
12+
- **Decidable values are rewritten.** A raw number or boolean becomes `String(value)` (on a field declaring codes, only when that string is one). A SQLite `"N.0"` becomes the declared code it reads as, when exactly one reading (`"3"`, or `"1"` / `"true"` for `"1.0"`, `"0"` / `"false"` for `"0.0"`) is declared.
13+
- **The rest is reported, never rewritten.** A `"N.0"` on a field with no declared codes, an ambiguous `"1.0"` / `"0.0"`, a reading the field does not declare, and an unresolved picklist.
14+
- **Dry run by default.** `--apply` rewrites each affected row through the engine; a unique collision is a per-row failure and the walk goes on. `--object` narrows the walk and refuses a name the deployment does not declare. `--apply` refuses a SQLite file another process holds unless `--force`.
15+
- **No flag.** Nothing is gated on it. A re-run that reports nothing to rewrite is the verification.

‎content/docs/deployment/cli.mdx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,7 @@ where the data lives.
13041304
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
13051305
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
13061306
| `os migrate blank-single-values` | Rewrite the empty strings (`''`) still stored on optional single-value option, reference and file columns to `null`, the form a cleared value is stored in today, so `$empty` finds those rows. Repairs values; no flag, nothing depends on it having run. See [below](#os-migrate-blank-single-values) |
1307+
| `os migrate option-codes` | Rewrite option values still stored as a number, a boolean or SQLite `"N.0"` text (rows written before option fields stored the code string) to that code string, so filters, reads and unique keys see one value. Values it cannot decide are listed, never rewritten. No flag; idempotent. See [below](#os-migrate-option-codes) |
13071308
| `os migrate record-currency-codes` | Write the currency code you give into the rows of a record-mode currency field that hold an amount and no code, the rows stored before the field was switched to `currencyMode: 'record'`. The code is never derived. No flag; idempotent. See [below](#os-migrate-record-currency-codes) |
13081309
| `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column |
13091310
| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. It upgrades a database 17.x or an 18.0 pre-release (`18.0.0-next.*`) created; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) |
@@ -1529,6 +1530,33 @@ column and a text-like column are never touched. Idempotent: a second run
15291530
finds nothing and writes nothing, and re-running until it reports zero is the
15301531
verification, so it records no flag.
15311532
1533+
#### `os migrate option-codes`
1534+
1535+
A number or a boolean written to a `select`, `radio`, `multiselect`,
1536+
`checkboxes` or `tags` field is stored as the option code string it was judged
1537+
by: `3` as `"3"`, `true` as `"true"`. Rows written before that rule keep the
1538+
form the database stored: the raw number or boolean on driver-memory with
1539+
persistence, the text `"3.0"`, `"1.0"` or `"0.0"` on SQLite, and raw elements
1540+
inside a multi-value array on every driver. A filter on `"3"` does not find
1541+
those rows, a read hands back the old form, and a unique field admits a new
1542+
`"3"` beside an old `"3.0"`.
1543+
1544+
```bash
1545+
os migrate option-codes # Dry run: counts per object and field, writes no rows
1546+
os migrate option-codes --apply # Rewrite the decidable values (prompts)
1547+
os migrate option-codes --apply --yes --json # CI / scripts
1548+
os migrate option-codes --object account # Restrict to one object (repeatable)
1549+
```
1550+
1551+
A raw number or boolean becomes its string. A SQLite `"3.0"` becomes the
1552+
declared code it reads as, only when exactly one reading is declared. These are
1553+
listed and left as stored, for you to fix by hand: a `"3.0"` on a field with no
1554+
declared options (it may be text someone typed), a `"1.0"` on a field declaring
1555+
both `"1"` and `"true"`, and a value whose reading the field no longer
1556+
declares. A row that would collide with a unique value already stored is
1557+
listed as a failure and the walk goes on. Idempotent: re-running until it
1558+
reports nothing to rewrite is the verification, so it records no flag.
1559+
15321560
#### `os migrate record-currency-codes`
15331561
15341562
Switching an existing currency field to `currencyMode: 'record'` leaves every row
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate option-codes` (#29643): the walk on a real engine (`ObjectQL` +
5+
* sqlite `SqlDriver`).
6+
*
7+
* Since #29562 (PR #29638) the write door stores a number or boolean written
8+
* to an option field as its code string. Rows written before keep the form
9+
* the driver stored: SQLite TEXT `"3.0"` / `"1.0"` / `"0.0"` for a single
10+
* value, raw JSON elements in a multi-value array on both drivers, and the raw
11+
* `3` / `true` on driver-memory. The legacy rows here are written through the
12+
* DRIVER, below the engine's door, which is exactly what the pre-#29562 door
13+
* handed it; the physical value is read with the driver's own query builder.
14+
*
15+
* driver-memory's face (a raw `3` / `true` on a single-value field) is the
16+
* `scalar` arm of `decideOptionCode`, pinned by the decision table below and,
17+
* end to end, by the raw JSON elements every driver keeps in a multi-value
18+
* array. driver-memory itself is not a test backend here: its test consumers
19+
* are a closed census (`check:driver-memory-census`).
20+
*
21+
* Pinned: the decision table (`decideOptionCode`); a dry run counts and
22+
* writes nothing; `--apply` makes `{ f: "3" }` find every legacy row and a
23+
* unique field's new `"3"` collide; undecidable values (SQLite `"N.0"` on a
24+
* free field, an undeclared reading, an ambiguous `"1.0"`) are reported and
25+
* left as stored; a unique collision is a per-row failure, not an abort; a
26+
* re-run reads zero to rewrite.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { decideOptionCode, migrateOptionCodes, optionCodeFields } from './option-codes.js';
33+
34+
const opts = (...codes: string[]) => codes.map((value) => ({ value, label: value }));
35+
const OBJ = {
36+
name: 'oc_obj', label: 'Option codes', systemFields: false,
37+
fields: {
38+
id: { name: 'id', type: 'text' as const, primaryKey: true },
39+
coded: { name: 'coded', type: 'select' as const, options: opts('3', 'true', 'false', 'x') },
40+
radio: { name: 'radio', type: 'radio' as const, options: opts('1', '2') },
41+
free: { name: 'free', type: 'select' as const },
42+
both: { name: 'both', type: 'select' as const, options: opts('1', 'true') },
43+
gone: { name: 'gone', type: 'select' as const, options: opts('a', 'b') },
44+
uq: { name: 'uq', type: 'select' as const, options: opts('3', '7'), unique: true },
45+
multi: { name: 'multi', type: 'multiselect' as const, options: opts('3', 'true', 'x') },
46+
tags: { name: 'tags', type: 'tags' as const },
47+
title: { name: 'title', type: 'text' as const },
48+
},
49+
};
50+
const OTHER = {
51+
name: 'oc_other', label: 'Other', systemFields: false,
52+
fields: { id: { name: 'id', type: 'text' as const, primaryKey: true }, o: { name: 'o', type: 'select' as const, options: opts('3') } },
53+
};
54+
55+
const silent = { info: () => {}, warn: () => {} };
56+
const live: ObjectQL[] = [];
57+
afterEach(async () => {
58+
while (live.length) {
59+
try { await live.pop()?.destroy(); } catch { /* noop */ }
60+
}
61+
});
62+
63+
async function boot() {
64+
const engine = new ObjectQL();
65+
live.push(engine);
66+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
67+
engine.registerDriver(driver, true);
68+
await engine.init();
69+
for (const o of [OBJ, OTHER]) engine.registry.registerObject(o as any);
70+
await engine.syncSchemas();
71+
const knex = (driver as any).knex;
72+
const stored = async (object: string, id: string) =>
73+
(await knex(object).where({ id }).first()) as Record<string, unknown>;
74+
return { engine, driver, stored };
75+
}
76+
type Boot = Awaited<ReturnType<typeof boot>>;
77+
const parse = (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : v);
78+
79+
const ids = async (ctx: Boot, where: Record<string, unknown>) =>
80+
((await ctx.engine.find('oc_obj', { where, fields: ['id'] })) as Array<{ id: string }>).map((r) => r.id).sort();
81+
82+
/** The pre-#29562 door: the raw scalar handed to the driver. */
83+
async function plantLegacy(ctx: Boot) {
84+
await ctx.driver.create('oc_obj', { id: 'n3', coded: 3, radio: 2, free: 3, uq: 3, multi: [3, true], tags: [0, 'k'], title: 42 });
85+
await ctx.driver.create('oc_obj', { id: 'b1', coded: true, free: true, both: true, multi: ['x'] });
86+
await ctx.driver.create('oc_obj', { id: 'f0', coded: false, gone: 3 });
87+
await ctx.driver.create('oc_obj', { id: 'held', coded: 'x', radio: '1', free: 'hello', uq: '7', multi: ['x'], tags: ['k'] });
88+
await ctx.driver.create('oc_other', { id: 'o1', o: 3 });
89+
}
90+
91+
describe('os migrate option-codes: the decision (#29643)', () => {
92+
const coded = { type: 'select', options: opts('3', 'true', 'false') };
93+
const free = { type: 'select' };
94+
it.each([
95+
// raw scalars: decidable by type
96+
[coded, 3, { kind: 'rewrite', to: '3', arm: 'scalar' }],
97+
[coded, true, { kind: 'rewrite', to: 'true', arm: 'scalar' }],
98+
[free, 3, { kind: 'rewrite', to: '3', arm: 'scalar' }],
99+
[free, -1.5, { kind: 'rewrite', to: '-1.5', arm: 'scalar' }],
100+
[coded, 7, { kind: 'report', reason: 'undeclared' }],
101+
// SQLite REAL text: decidable only against the declared codes
102+
[coded, '3.0', { kind: 'rewrite', to: '3', arm: 'sqlite-real' }],
103+
[coded, '1.0', { kind: 'rewrite', to: 'true', arm: 'sqlite-real' }],
104+
[coded, '0.0', { kind: 'rewrite', to: 'false', arm: 'sqlite-real' }],
105+
[{ type: 'radio', options: opts('0', '1') }, '0.0', { kind: 'rewrite', to: '0', arm: 'sqlite-real' }],
106+
[{ type: 'select', options: opts('1', 'true') }, '1.0', { kind: 'report', reason: 'ambiguous' }],
107+
[coded, '9.0', { kind: 'report', reason: 'undeclared' }],
108+
[free, '3.0', { kind: 'report', reason: 'free-field' }],
109+
[{ type: 'select', picklist: 'p' }, 3, { kind: 'report', reason: 'picklist-unresolved' }],
110+
// left alone
111+
[{ type: 'select', options: opts('3.0') }, '3.0', { kind: 'ok' }],
112+
[coded, '3', { kind: 'ok' }],
113+
[free, 'hello', { kind: 'ok' }],
114+
[free, '-1.5', { kind: 'ok' }],
115+
[coded, null, { kind: 'ok' }],
116+
[coded, undefined, { kind: 'ok' }],
117+
] as const)('%o · %o → %o', (def, value, verdict) => {
118+
expect(decideOptionCode(def as any, value)).toEqual(verdict);
119+
});
120+
121+
it('walks the option family only: select, radio, multiselect, checkboxes, tags, a multiple select', () => {
122+
const fields = optionCodeFields({
123+
fields: {
124+
a: { type: 'select' }, b: { type: 'radio' }, c: { type: 'multiselect' }, d: { type: 'checkboxes' },
125+
e: { type: 'tags' }, f: { type: 'select', multiple: true }, g: { type: 'text' }, h: { type: 'lookup' },
126+
i: { type: 'number' }, j: { type: 'boolean' },
127+
},
128+
});
129+
expect(fields.map((f) => [f.name, f.multi])).toEqual([
130+
['a', false], ['b', false], ['c', true], ['d', true], ['e', true], ['f', true],
131+
]);
132+
});
133+
});
134+
135+
describe('os migrate option-codes: the walk on sqlite (#29643)', () => {
136+
it('red before: the legacy form is what is stored, and { f: "3" } misses it', async () => {
137+
const ctx = await boot();
138+
await plantLegacy(ctx);
139+
expect((await ctx.stored('oc_obj', 'n3')).coded).toBe('3.0');
140+
expect((await ctx.stored('oc_obj', 'b1')).coded).toBe('1.0');
141+
expect((await ctx.stored('oc_obj', 'f0')).coded).toBe('0.0');
142+
expect(parse((await ctx.stored('oc_obj', 'n3')).multi)).toEqual([3, true]);
143+
expect(await ids(ctx, { coded: '3' })).toEqual([]);
144+
// A unique field's new "3" does not collide with the old form.
145+
await ctx.engine.insert('oc_obj', { id: 'new', uq: '3' });
146+
expect(await ids(ctx, { uq: '3' })).toEqual(['new']);
147+
});
148+
149+
it('a dry run counts what it would rewrite and report, and writes nothing', async () => {
150+
const ctx = await boot();
151+
await plantLegacy(ctx);
152+
const before = await ctx.stored('oc_obj', 'n3');
153+
const report = await migrateOptionCodes(ctx.engine as any, silent, {});
154+
expect(report.applied).toBe(false);
155+
expect(report.scannedObjects.sort()).toEqual(['oc_obj', 'oc_other']);
156+
const byCol = Object.fromEntries(report.columns.map((c) => [`${c.object}.${c.field}`, [c.rewriteRows, c.reported]]));
157+
expect(byCol['oc_obj.coded']).toEqual([3, {}]);
158+
expect(byCol['oc_obj.uq']).toEqual([1, {}]);
159+
expect(byCol['oc_obj.multi']).toEqual([1, {}]);
160+
expect(byCol['oc_obj.tags']).toEqual([1, {}]);
161+
expect(byCol['oc_obj.gone']).toEqual([0, { undeclared: 1 }]);
162+
expect(byCol['oc_other.o']).toEqual([1, {}]);
163+
expect(report.rewritten).toBe(0);
164+
expect(report.failures).toEqual([]);
165+
expect(await ctx.stored('oc_obj', 'n3')).toEqual(before);
166+
});
167+
168+
it('--apply rewrites to the code: filters find the rows, a unique "3" collides, a re-run reads zero', async () => {
169+
const ctx = await boot();
170+
await plantLegacy(ctx);
171+
const report = await migrateOptionCodes(ctx.engine as any, silent, { apply: true });
172+
expect(report.failures).toEqual([]);
173+
expect(report.rewritten).toBe(report.rewriteValues);
174+
175+
expect((await ctx.stored('oc_obj', 'n3')).coded).toBe('3');
176+
expect((await ctx.stored('oc_obj', 'b1')).coded).toBe('true');
177+
expect((await ctx.stored('oc_obj', 'f0')).coded).toBe('false');
178+
expect(await ids(ctx, { coded: '3' })).toEqual(['n3']);
179+
expect(await ids(ctx, { coded: 'true' })).toEqual(['b1']);
180+
expect(await ids(ctx, { radio: '2' })).toEqual(['n3']);
181+
// A free field's SQLite "3.0" is not decidable: reported below, still stored as it was.
182+
expect(await ids(ctx, { free: '3' })).toEqual([]);
183+
const n3 = (await ctx.engine.findOne('oc_obj', { where: { id: 'n3' } })) as Record<string, unknown>;
184+
expect(n3.multi).toEqual(['3', 'true']);
185+
expect(n3.tags).toEqual(['0', 'k']);
186+
expect(parse((await ctx.stored('oc_obj', 'n3')).multi)).toEqual(['3', 'true']);
187+
// Undecidable / out of scope: as stored.
188+
expect((await ctx.stored('oc_obj', 'f0')).gone).toEqual('3.0');
189+
expect((await ctx.stored('oc_obj', 'n3')).title).toEqual('42.0');
190+
expect((await ctx.stored('oc_obj', 'held')).free).toBe('hello');
191+
// The unique key now sees one value.
192+
await expect(ctx.engine.insert('oc_obj', { id: 'dup', uq: '3' })).rejects.toMatchObject({ code: expect.stringMatching(/UNIQUE|DUPLICATE/i) });
193+
194+
const again = await migrateOptionCodes(ctx.engine as any, silent, { apply: true });
195+
// Reported values stay reported: free "3.0" / "1.0", ambiguous "1.0", undeclared "3.0".
196+
expect([again.rewriteValues, again.rewritten, again.reportedValues]).toEqual([0, 0, 4]);
197+
});
198+
199+
it('a unique collision with a row already holding the code is a per-row failure, not an abort', async () => {
200+
const ctx = await boot();
201+
await plantLegacy(ctx);
202+
await ctx.engine.insert('oc_obj', { id: 'new', uq: '3' });
203+
const report = await migrateOptionCodes(ctx.engine as any, silent, { apply: true });
204+
expect(report.failures.map((f) => [f.recordId, f.fields.includes('uq')])).toEqual([['n3', true]]);
205+
// The rest of the walk landed.
206+
expect((await ctx.stored('oc_obj', 'b1')).coded).toBe('true');
207+
expect((await ctx.stored('oc_other', 'o1')).o).toBe('3');
208+
});
209+
210+
it('objects narrows the walk to the named objects', async () => {
211+
const ctx = await boot();
212+
await plantLegacy(ctx);
213+
const report = await migrateOptionCodes(ctx.engine as any, silent, { apply: true, objects: ['oc_other'] });
214+
expect(report.scannedObjects).toEqual(['oc_other']);
215+
expect(report.rewritten).toBe(1);
216+
expect((await ctx.stored('oc_obj', 'n3')).coded).toBe('3.0');
217+
});
218+
});
219+
220+
describe('os migrate option-codes: what is reported, not rewritten (#29643)', () => {
221+
it('a free field and an ambiguous "1.0" are reported and left as stored', async () => {
222+
const ctx = await boot();
223+
await plantLegacy(ctx);
224+
const report = await migrateOptionCodes(ctx.engine as any, silent, { apply: true });
225+
const byCol = Object.fromEntries(report.columns.map((c) => [`${c.object}.${c.field}`, c.reported]));
226+
expect(byCol['oc_obj.free']).toEqual({ 'free-field': 2 });
227+
expect(byCol['oc_obj.both']).toEqual({ ambiguous: 1 });
228+
expect((await ctx.stored('oc_obj', 'n3')).free).toBe('3.0');
229+
expect((await ctx.stored('oc_obj', 'b1')).both).toBe('1.0');
230+
});
231+
});

0 commit comments

Comments
 (0)