Skip to content

Commit 4a94f41

Browse files
dmealingclaude
andcommitted
feat(migrate-ts): R6 — field.float emits REAL; stringify REAL/DOUBLE on the wire
Add a distinct `real4` SqlType variant for single-precision (REAL / float4) to separate it from `real` (DOUBLE PRECISION / float8). Route field.float to real4 and field.double to real. Render real4 as REAL in both postgres and sqlite emitters. Split pgTypeToSqlType so float4/real → real4 and float8/double precision → real, ensuring no phantom diffs after a round-trip. Update the persistence-conformance normalization rule: integer-valued JS numbers stay as JSON numbers; non-integer JS numbers (REAL/DOUBLE columns) stringify via canonicalFloat() — plain decimal, trailing zeros stripped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9b7e2a5 commit 4a94f41

7 files changed

Lines changed: 37 additions & 10 deletions

File tree

server/typescript/packages/integration-tests/src/normalization.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0
2020
export function normalizeValue(v: unknown): unknown {
2121
if (v === null || v === undefined) return null;
2222
if (typeof v === "boolean") return v;
23-
if (typeof v === "number") return v;
23+
// INTEGER/SMALLINT come back as integer-valued JS numbers → keep as JSON number.
24+
// REAL/DOUBLE come back as non-integer JS numbers → stringify (plain decimal, strip zeros).
25+
// (The runner sees an already-mapped row with no column-type metadata; fixtures pin
26+
// float/double values to be non-integer so this value-route is exact — see normalization.md.)
27+
if (typeof v === "number") return Number.isInteger(v) ? v : canonicalFloat(v);
2428
// node-postgres returns BIGINT as string by default — leave as string (contract).
2529
if (typeof v === "bigint") return v.toString();
2630
if (typeof v === "string") {
@@ -60,6 +64,16 @@ function canonicalDecimal(s: string): string {
6064
return out;
6165
}
6266

67+
function canonicalFloat(n: number): string {
68+
// In-band dyadic values (per normalization.md) render plain + shortest via String().
69+
let out = String(n);
70+
if (out.includes(".")) {
71+
out = out.replace(/0+$/, "");
72+
if (out.endsWith(".")) out = out.slice(0, -1);
73+
}
74+
return out;
75+
}
76+
6377
// Format a Date as YYYY-MM-DDTHH:MM:SS[.fff] using UTC getters so the output
6478
// is independent of the runner's host timezone. Trailing zero subseconds are
6579
// trimmed; the decimal point goes too if the subseconds were entirely zero.

server/typescript/packages/migrate-ts/src/emit/postgres.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ function pgType(t: SqlType): string {
147147
case "text": return t.maxLength !== undefined ? `VARCHAR(${t.maxLength})` : "TEXT";
148148
case "integer": return t.bits === 64 ? "BIGINT" : "INTEGER";
149149
case "real": return "DOUBLE PRECISION";
150+
case "real4": return "REAL";
150151
case "numeric": {
151152
if (t.precision !== undefined && t.scale !== undefined) return `NUMERIC(${t.precision},${t.scale})`;
152153
if (t.precision !== undefined) return `NUMERIC(${t.precision})`;

server/typescript/packages/migrate-ts/src/emit/sqlite.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ function sqliteType(t: SqlType, identity: ColumnDescriptor["identity"]): string
278278
// in pragma_table_info, enabling round-trip fidelity (see introspect/sqlite.ts).
279279
case "integer": return t.bits === 64 ? "INTEGER" : "INT";
280280
case "real": return "REAL";
281+
case "real4": return "REAL";
281282
case "numeric": {
282283
if (t.precision !== undefined && t.scale !== undefined) return `NUMERIC(${t.precision},${t.scale})`;
283284
return "NUMERIC";

server/typescript/packages/migrate-ts/src/expected-schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,8 @@ function subtypeToSqlType(field: MetaData): SqlType {
380380
case FIELD_SUBTYPE_BYTE: return { kind: "integer", bits: 32 };
381381
case FIELD_SUBTYPE_LONG:
382382
case FIELD_SUBTYPE_CURRENCY: return { kind: "integer", bits: 64 };
383-
case FIELD_SUBTYPE_DOUBLE:
384-
case FIELD_SUBTYPE_FLOAT: return { kind: "real" };
383+
case FIELD_SUBTYPE_DOUBLE: return { kind: "real" };
384+
case FIELD_SUBTYPE_FLOAT: return { kind: "real4" };
385385
case FIELD_SUBTYPE_DECIMAL: return { kind: "numeric" };
386386
case FIELD_SUBTYPE_BOOLEAN: return { kind: "boolean" };
387387
case FIELD_SUBTYPE_DATE: return { kind: "date" };

server/typescript/packages/migrate-ts/src/introspect/postgres.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ export function pgTypeToSqlType(dataType: string, maxLength?: number | null): Sq
103103
return { kind: "integer", bits: 32 };
104104
}
105105

106-
// Floating-point
107-
if (dt === "float4" || dt === "real") return { kind: "real" };
106+
// Floating-point: float4/real is single precision; float8/double precision is double.
107+
if (dt === "float4" || dt === "real") return { kind: "real4" };
108108
if (dt === "float8" || dt === "double precision") return { kind: "real" };
109109

110110
// Arbitrary-precision numeric — "numeric(p,s)" or bare "numeric"/"decimal"

server/typescript/packages/migrate-ts/src/sql-type.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
export type SqlType =
99
| { kind: "text"; maxLength?: number }
1010
| { kind: "integer"; bits: 32 | 64 }
11-
| { kind: "real" }
11+
| { kind: "real" } // DOUBLE PRECISION (float8) — field.double
12+
| { kind: "real4" } // REAL (float4, single precision) — field.float
1213
| { kind: "numeric"; precision?: number; scale?: number }
1314
| { kind: "boolean" }
1415
| { kind: "timestamp"; withTimezone: boolean }
@@ -32,6 +33,7 @@ export function sqlTypeEquals(a: SqlType, b: SqlType): boolean {
3233
case "timestamp":
3334
return a.withTimezone === (b as Extract<SqlType, { kind: "timestamp" }>).withTimezone;
3435
case "real":
36+
case "real4":
3537
case "boolean":
3638
case "date":
3739
case "json":
@@ -76,10 +78,11 @@ export function isWidening(from: SqlType, to: SqlType): boolean {
7678
// widening iff p2 ≥ p1 AND s2 = s1 AND (p2 - s2) ≥ (p1 - s1)
7779
return tp >= fp && ts === fs && (tp - ts) >= (fp - fs);
7880
}
79-
// real/boolean/date/json/blob/uuid: same kind already handled by sqlTypeEquals;
81+
// real/real4/boolean/date/json/blob/uuid: same kind already handled by sqlTypeEquals;
8082
// any difference here means the discriminant matched but structural equality failed
8183
// (impossible given SqlType has no other variants for these kinds). Defensive false.
8284
case "real":
85+
case "real4":
8386
case "boolean":
8487
case "date":
8588
case "json":

server/typescript/packages/migrate-ts/test/integration/postgres-introspect.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,14 +419,22 @@ describe("pgTypeToSqlType normalization", () => {
419419
expect(pgTypeToSqlType("numeric")).toEqual({ kind: "numeric" });
420420
});
421421

422-
test("float4 → real", () => {
423-
expect(pgTypeToSqlType("float4")).toEqual({ kind: "real" });
422+
test("float4 → real4 (single-precision)", () => {
423+
expect(pgTypeToSqlType("float4")).toEqual({ kind: "real4" });
424424
});
425425

426-
test("double precision → real", () => {
426+
test("real (pg alias for float4) → real4 (single-precision)", () => {
427+
expect(pgTypeToSqlType("real")).toEqual({ kind: "real4" });
428+
});
429+
430+
test("double precision → real (double-precision)", () => {
427431
expect(pgTypeToSqlType("double precision")).toEqual({ kind: "real" });
428432
});
429433

434+
test("float8 (pg alias for double precision) → real (double-precision)", () => {
435+
expect(pgTypeToSqlType("float8")).toEqual({ kind: "real" });
436+
});
437+
430438
test("unknown type falls back to text", () => {
431439
expect(pgTypeToSqlType("citext")).toEqual({ kind: "text" });
432440
});

0 commit comments

Comments
 (0)