Skip to content

Commit 242cbc6

Browse files
committed
fix(cli): os migrate unmapped-columns refuses a value JSON cannot carry as stored
Binary bytes, a bigint and a non-finite number reach a JSON document as a stand-in a conversion would write as the value. The read now refuses them in both faces, exit 1, naming the column and the record id, and emits no record; a Date passes as its ISO 8601 text. No column the platform creates for a field type answers with one, measured on SQLite and PostgreSQL. The --json refusal path passes exit 1 to emitJson, as the family does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
1 parent 68b8478 commit 242cbc6

5 files changed

Lines changed: 127 additions & 15 deletions

File tree

‎.changeset/21573-migrate-unmapped-columns.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ Clause-②: yes (widening)
99
- **What it reads.** The columns `os migrate plan` reports as `unmapped_column` for one object's table: a column that is still in the table and that no metadata declares, typically one a retired field left behind. The column set is the plan's own findings, from the same differ on the same read-only boot, so the command never reads a column the plan does not report. Each record is emitted as `{ id, values }`. `--json` prints one document, `{ database, object, table, columns, count, records, duration }`. The text face lists the columns and each record's values.
1010
- **Why it exists.** A read or a write through the engine now serves an object's declared fields only, and naming an undeclared column is refused. An app that moves a retired field's values into the field that replaced it reads them once with this command, writes them with its own script, and then drops the columns with `os migrate apply --allow-destructive`. That is the route the read and write narrowing in `@objectstack/objectql` names for this case.
1111
- **Operator-only and read-only.** It runs under the database credentials you pass (`--database-url`, else `OS_DATABASE_URL`, else the project database), and it reads every organization's rows. No REST route, API flag or per-request option serves these values, and the runtime doors are unchanged. It boots the way `os migrate plan` does: no schema DDL, no seed data, and no database file created.
12-
- **Values as stored.** An unmapped column has no declared type, so each value is emitted as the database client returns it, with no field-type decoding.
13-
- **Answers.** An object with no unmapped column, or with no table yet: empty work, exit 0. No SQL driver: `os migrate plan`'s own `no_sql_driver` answer, exit 0. An undeclared object name: `OBJECT_NOT_FOUND`, exit 1. An object the plan does not diff (federated, or bound to another datasource): refused, exit 1. A read that cannot be complete, such as one stopped by `--max-records`: refused, exit 1, and no partial set is emitted.
12+
- **Values as stored.** An unmapped column has no declared type, so each value is emitted as the database client returns it, with no field-type decoding; a PostgreSQL `timestamp` arrives as a date and is emitted as its ISO 8601 text. A value JSON cannot carry as stored (binary bytes, a `bigint`, or a non-finite number) is refused in both faces with exit 1, naming the column and the record id, and no record is emitted: read that column with the database's own client. No column the platform creates for a field type answers with one of these, on SQLite or on PostgreSQL.
13+
- **Answers.** An object with no unmapped column, or with no table yet: empty work, exit 0. No SQL driver: `os migrate plan`'s own `no_sql_driver` answer, exit 0. An undeclared object name: `OBJECT_NOT_FOUND`, exit 1. An object the plan does not diff (federated, or bound to another datasource): refused, exit 1. A read that cannot be complete, such as one stopped by `--max-records`: refused, exit 1, and no partial set is emitted. A value JSON cannot carry as stored: refused, exit 1, as above.
1414
- `MigrateUnmappedColumnsCommand` is exported from `@objectstack/cli` beside the other `os migrate` commands.
1515

1616
Nothing that ran before changes. This is a new command.

‎content/docs/deployment/cli.mdx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,14 +1082,19 @@ them into the declared fields with your own script, then run
10821082
- **Values as stored.** An unmapped column has no declared type, so each value is
10831083
emitted as the database client returns it, with no field-type decoding: a
10841084
retired `json` field on SQLite reads as its stored text, a retired `boolean`
1085-
as `0` or `1`.
1085+
as `0` or `1`, and a retired `datetime` on PostgreSQL arrives as a date and is
1086+
emitted as its ISO 8601 text. A value JSON cannot carry as stored (binary
1087+
bytes, a `bigint`, or a non-finite number) is refused with exit 1, naming the
1088+
column and the record id, and no record is emitted; read that column with
1089+
the database's own client.
10861090
- **Empty work, exit 0**, for an object with no unmapped column, or with no table
10871091
in this database yet. `--json` prints one document:
10881092
`{ object, table, columns, count, records: [{ id, values }] }`.
10891093
- **Refused, exit 1**: an object name the deployment does not declare
10901094
(`OBJECT_NOT_FOUND`); an object `plan` does not diff (federated, or bound to
1091-
another datasource), whose empty answer would be unmeasured; and a read that
1092-
cannot be complete, such as one stopped by `--max-records`. A partial set is
1095+
another datasource), whose empty answer would be unmeasured; a read that
1096+
cannot be complete, such as one stopped by `--max-records`; and a value JSON
1097+
cannot carry as stored, described above. A partial set is
10931098
never emitted, because a conversion over part of a table, followed by the
10941099
drop, loses the rest.
10951100

‎packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
* 4. an object name the registry does not hold: `OBJECT_NOT_FOUND`, exit 1,
2626
* one document;
2727
* 5. a row cap the table exceeds: refused, exit 1, and no records emitted;
28-
* 6. the door writes nothing: the schema and every row are byte-identical
28+
* 6. a value JSON cannot carry as stored (bytes in a BLOB column added by
29+
* hand to `um_blob`; the platform creates no binary column for any field
30+
* type): refused in both faces, exit 1, naming the column and the record
31+
* id, no record emitted;
32+
* 7. the door writes nothing: the schema and every row are byte-identical
2933
* after it ran.
3034
*
3135
* The runtime half, that the engine's data door never serves these columns, is
@@ -64,6 +68,7 @@ const RELEASE_ONE = {
6468
fields: { name: { type: 'text' }, mailing_street: { type: 'text' }, mailing_city: { type: 'text' } },
6569
},
6670
{ name: 'um_account', fields: { name: { type: 'text' } } },
71+
{ name: 'um_blob', fields: { name: { type: 'text' } } },
6772
],
6873
};
6974

@@ -73,6 +78,7 @@ const RELEASE_TWO = {
7378
objects: [
7479
{ name: 'um_contact', fields: { name: { type: 'text' } } },
7580
{ name: 'um_account', fields: { name: { type: 'text' } } },
81+
{ name: 'um_blob', fields: { name: { type: 'text' } } },
7682
],
7783
};
7884

@@ -95,7 +101,8 @@ function childEnv(overrides: Record<string, string | undefined>): Record<string,
95101

96102
/**
97103
* Release one, served: DDL performed, three contacts and an account written
98-
* while the mailing fields are declared. Then the hash shadow, by hand.
104+
* while the mailing fields are declared. Then, by hand, the hash shadow and a
105+
* BLOB column on `um_blob` holding three bytes.
99106
*/
100107
const SEED_CHILD = `
101108
const rt = await import('@objectstack/runtime');
@@ -115,11 +122,14 @@ await ql.insert('um_contact', { id: 'c1', name: 'Ann', mailing_street: '1 Retire
115122
await ql.insert('um_contact', { id: 'c2', name: 'Bob', mailing_street: '2 Retired Way', mailing_city: 'Newtown' }, SYSTEM);
116123
await ql.insert('um_contact', { id: 'c3', name: 'Cy' }, SYSTEM);
117124
await ql.insert('um_account', { id: 'a1', name: 'Acme' }, SYSTEM);
125+
await ql.insert('um_blob', { id: 'b1', name: 'Bin' }, SYSTEM);
118126
await kernel.shutdown();
119127
const { SqlDriver } = await import('@objectstack/driver-sql');
120128
const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true });
121129
await raw.knex.raw('ALTER TABLE um_contact ADD COLUMN ${HASH_SHADOW} text');
122130
await raw.knex('um_contact').update({ ${HASH_SHADOW}: 'shadow' });
131+
await raw.knex.raw('ALTER TABLE um_blob ADD COLUMN legacy_bytes blob');
132+
await raw.knex('um_blob').update({ legacy_bytes: Buffer.from([1, 2, 255]) });
123133
await raw.disconnect();
124134
process.stderr.write('[fixture] seeded\\n');
125135
process.exit(0);
@@ -205,6 +215,8 @@ let noneJson: Run;
205215
let noneHuman: Run;
206216
let unknownJson: Run;
207217
let cappedJson: Run;
218+
let bytesJson: Run;
219+
let bytesHuman: Run;
208220

209221
beforeAll(async () => {
210222
dir = mkdtempSync(join(tmpdir(), 'os-21573-'));
@@ -227,6 +239,8 @@ beforeAll(async () => {
227239
noneHuman = await runCli(door('um_account'));
228240
unknownJson = await runCli(door('um_nope', '--json'));
229241
cappedJson = await runCli(door('um_contact', '--max-records', '2', '--json'));
242+
bytesJson = await runCli(door('um_blob', '--json'));
243+
bytesHuman = await runCli(door('um_blob'));
230244
after = readState();
231245
planJson = await runCli(['migrate', 'plan', '--json']);
232246
}, HOOK_TIMEOUT_MS);
@@ -302,4 +316,16 @@ describe('os migrate unmapped-columns: the other answers', () => {
302316
expect(doc.error).toMatch(/stopped at 2 row\(s\)[\s\S]*--max-records/);
303317
expect(doc).not.toHaveProperty('records');
304318
});
319+
320+
it('a value JSON cannot carry as stored: refused in both faces, exit 1, naming the column and the record id', () => {
321+
const says = /Record b1 of um_blob holds binary bytes in the column legacy_bytes[\s\S]*database's own client/;
322+
expect(bytesJson.code).toBe(1);
323+
const doc = JSON.parse(bytesJson.stdout);
324+
expect(doc.error).toMatch(says);
325+
expect(doc).not.toHaveProperty('records');
326+
expect(bytesHuman.code).toBe(1);
327+
expect(bytesHuman.stdout).toMatch(says);
328+
// Neither face carries the stand-in a JSON serialisation of the bytes would be.
329+
for (const out of [bytesJson.stdout, bytesHuman.stdout]) expect(out).not.toContain('"type":"Buffer"');
330+
});
305331
});

‎packages/cli/src/commands/migrate/unmapped-columns.test.ts‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,24 @@
2222
* reported column (the SQL driver answers a projection naming a missing column
2323
* with the whole row, measured on SQLite and PostgreSQL) and an answer that is
2424
* not rows each REFUSE, and none of them returns records.
25+
*
26+
* ## …and rather than emit a value JSON cannot carry as stored
27+
*
28+
* Binary bytes, a `bigint` and a non-finite number each reach a JSON document
29+
* as a stand-in (an object shaped like the bytes, a thrown serialisation, a
30+
* `null`), which a conversion would write as the value. One case per class,
31+
* each naming the column and the record id, and a control that a `Date`, a
32+
* parsed json object and a string pass unchanged. What the drivers hand back
33+
* for the columns the platform itself creates is measured on the pull request:
34+
* none of them is in the refused set.
2535
*/
2636

2737
import { describe, it, expect } from 'vitest';
2838
import type { ManagedDriftEntry } from '@objectstack/driver-sql';
2939
import {
3040
unmappedColumnsOf,
3141
readUnmappedColumnValues,
42+
unrepresentableKind,
3243
type UnmappedColumnReader,
3344
} from './unmapped-columns.js';
3445

@@ -124,14 +135,52 @@ describe('readUnmappedColumnValues — every row, keyed by record id, values as
124135
expect(Object.keys(records[0]!.values)).not.toContain('name');
125136
});
126137

127-
it('decodes nothing: a JSON-looking string, a 0/1 and a Buffer reach the record as the driver handed them', async () => {
128-
const blob = Buffer.from([1, 2, 255]);
129-
const reader = rowsReader([{ id: 'c1', legacy_flags: '{"a":[1,2]}', legacy_on: 1, legacy_blob: blob }]);
130-
const [record] = await readUnmappedColumnValues(reader, 'contact', ['legacy_flags', 'legacy_on', 'legacy_blob']);
138+
it('decodes nothing: a JSON-looking string, a 0/1, a Date, a parsed json object and a string pass unchanged', async () => {
139+
const at = new Date('2026-01-02T03:04:05.000Z');
140+
const parsed = { a: [1, 2], b: 'x' };
141+
const reader = rowsReader([
142+
{ id: 'c1', legacy_flags: '{"a":[1,2]}', legacy_on: 1, legacy_at: at, legacy_json: parsed, legacy_note: 'kept' },
143+
]);
144+
const [record] = await readUnmappedColumnValues(reader, 'contact', [
145+
'legacy_flags', 'legacy_on', 'legacy_at', 'legacy_json', 'legacy_note',
146+
]);
131147

132148
expect(record!.values.legacy_flags).toBe('{"a":[1,2]}');
133149
expect(record!.values.legacy_on).toBe(1);
134-
expect(record!.values.legacy_blob).toBe(blob);
150+
// The same instances: nothing is converted on the way through.
151+
expect(record!.values.legacy_at).toBe(at);
152+
expect(record!.values.legacy_json).toBe(parsed);
153+
expect(record!.values.legacy_note).toBe('kept');
154+
// …and each one JSON carries unambiguously: a Date as its ISO 8601 text.
155+
expect(JSON.parse(JSON.stringify(record!.values))).toEqual({
156+
legacy_flags: '{"a":[1,2]}', legacy_on: 1, legacy_at: '2026-01-02T03:04:05.000Z', legacy_json: parsed, legacy_note: 'kept',
157+
});
158+
});
159+
160+
it.each([
161+
['a Buffer (a SQLite blob, a PostgreSQL bytea)', Buffer.from([1, 2, 255]), /holds binary bytes/],
162+
['another ArrayBufferView', new Float64Array([1.5]), /holds binary bytes/],
163+
['a bigint', 9007199254740993n, /holds a bigint/],
164+
['NaN', Number.NaN, /holds a non-finite number \(NaN\)/],
165+
['Infinity', Number.POSITIVE_INFINITY, /holds a non-finite number \(Infinity\)/],
166+
['-Infinity', Number.NEGATIVE_INFINITY, /holds a non-finite number \(-Infinity\)/],
167+
])('REFUSES %s, naming the column and the record id, and emits no record', async (_label, value, says) => {
168+
// The bad value sits on the SECOND row: the first is never handed back on its own.
169+
const reader = rowsReader([
170+
{ id: 'c1', legacy: 'fine' },
171+
{ id: 'c2', legacy: value },
172+
]);
173+
const read = readUnmappedColumnValues(reader, 'contact', ['legacy']);
174+
await expect(read).rejects.toThrow(says);
175+
await expect(readUnmappedColumnValues(reader, 'contact', ['legacy'])).rejects.toThrow(
176+
/Record c2 of contact holds [^\n]* in the column legacy, which JSON cannot carry as the database stored it/,
177+
);
178+
});
179+
180+
it('the predicate answers null for every value JSON carries as stored', () => {
181+
for (const value of [null, 'text', '', 0, -1.5, 42, true, false, new Date(0), { a: 1 }, [1, 'x']]) {
182+
expect(unrepresentableKind(value), String(value)).toBeNull();
183+
}
135184
});
136185

137186
it('walks past one page by seeking on id, and reads every row once', async () => {

‎packages/cli/src/commands/migrate/unmapped-columns.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@ export interface UnmappedColumnReader {
6262
find(object: string, query: Record<string, unknown>): Promise<unknown>;
6363
}
6464

65+
/**
66+
* The class of a value JSON cannot carry as the database stored it, or `null`
67+
* when it can.
68+
*
69+
* Each of the three arrives in a JSON document as something else, with nothing
70+
* to say so: binary bytes as an object shaped `{ type, data }` (a `Buffer`) or
71+
* as an index-keyed object (another typed array), a `bigint` as a thrown
72+
* serialisation error, and `NaN` / `±Infinity` as `null`. A conversion script
73+
* would write the stand-in into the replacing field and report success, so the
74+
* door refuses these instead of emitting them. ⛔ No codec: choosing a
75+
* representation would make a representation part of this door's contract.
76+
*
77+
* A `Date` is not in the set: PostgreSQL hands a `timestamp` column back as
78+
* one, and it serialises to its unambiguous ISO 8601 text.
79+
*/
80+
export function unrepresentableKind(value: unknown): string | null {
81+
if (ArrayBuffer.isView(value)) return 'binary bytes';
82+
if (typeof value === 'bigint') return 'a bigint';
83+
if (typeof value === 'number' && !Number.isFinite(value)) return `a non-finite number (${String(value)})`;
84+
return null;
85+
}
86+
6587
/**
6688
* Read every row's unmapped values, keyed by record id.
6789
*
@@ -83,6 +105,8 @@ export interface UnmappedColumnReader {
83105
* - a row comes back without a column the differ reported: the SQL driver
84106
* answers a projection naming a missing column with the whole row instead,
85107
* and a record emitted without its value would read as converted;
108+
* - a value is one JSON cannot carry as stored ({@link unrepresentableKind}):
109+
* it would be emitted as a stand-in a conversion would write as the value;
86110
* - the driver answers something other than an array of rows.
87111
*/
88112
export async function readUnmappedColumnValues(
@@ -122,6 +146,14 @@ export async function readUnmappedColumnValues(
122146
'the one asked for. Refusing rather than emitting the record without that value.',
123147
);
124148
}
149+
const kind = unrepresentableKind(row[column]);
150+
if (kind !== null) {
151+
throw new Error(
152+
`Record ${String(row.id)} of ${object} holds ${kind} in the column ${column}, which JSON cannot ` +
153+
'carry as the database stored it. Read that column with the database\'s own client. Refusing ' +
154+
'rather than emitting a stand-in a conversion would write as the value; no record was emitted.',
155+
);
156+
}
125157
values[column] = row[column];
126158
}
127159
records.push({ id: row.id, values });
@@ -182,8 +214,8 @@ export async function readUnmappedColumnValues(
182214
* - an object the plan does not diff (federated, or bound to another
183215
* datasource): refused, exit 1, because an empty answer there would be
184216
* unmeasured rather than clean;
185-
* - a read that cannot be complete: refused, exit 1
186-
* ({@link readUnmappedColumnValues}).
217+
* - a read that cannot be complete, or a value JSON cannot carry as stored:
218+
* refused, exit 1, no record emitted ({@link readUnmappedColumnValues}).
187219
*/
188220
export default class MigrateUnmappedColumns extends Command {
189221
// No tracker id in this string: a command description reaches operators,
@@ -396,7 +428,7 @@ export default class MigrateUnmappedColumns extends Command {
396428
// a second document after the first.
397429
if (isExitSignal(error)) throw error;
398430
if (flags.json) {
399-
await emitJson({ error: error?.message ?? String(error), ...errorCodeFields(error) }, 0, { compact: true });
431+
await emitJson({ error: error?.message ?? String(error), ...errorCodeFields(error) }, 1, { compact: true });
400432
this.exit(1);
401433
}
402434
printError(error?.message ?? String(error));

0 commit comments

Comments
 (0)