Skip to content

Commit de11458

Browse files
dmealingclaude
andcommitted
fix(conformance): SP-A close-out — C# ms-truncation + sub-ms gate row + TS recover decimal->string + hygiene
Review findings: C# Normalization truncates sub-second to ms (was 7-digit, a cross-port divergence); a .123456->.123 corpus row now proves ms-truncation on all five ports; TS runtime recover-object maps field.decimal to STRING (matching its codegen sibling + C#); genericized a home path in the plan doc; decimal filter value type -> string (operator band stays numeric); dead java.sql.Time branches carry the fractional suffix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5520e9e commit de11458

12 files changed

Lines changed: 128 additions & 20 deletions

File tree

docs/superpowers/plans/2026-05-31-sp-a-type-fidelity-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
**Tech stack:** TS (bun, migrate-ts, codegen-ts), C# (EF Core, xUnit, Testcontainers), Java (OMDB, JUnit), Python (SQLAlchemy Core, pytest), Kotlin (Exposed, KotlinPoet). Design: `docs/superpowers/specs/2026-05-31-sp-a-type-fidelity-design.md`.
1010

11-
**Worktree:** `/home/doug/Development/metaobjects/.claude/worktrees/sp-a-type-fidelity` (branch `sp-a-type-fidelity`, off origin/main).
11+
**Worktree:** `<repo-root>/.claude/worktrees/sp-a-type-fidelity` (branch `sp-a-type-fidelity`, off origin/main).
1212

1313
---
1414

fixtures/persistence-conformance/queries/normalization-wire-types.yaml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ description: |
1010
recordedAt's -05:00 offset must read back as 14:30:00.123Z, while observedAt's
1111
bare wall-clock 14:30:00.123 must read back unshifted with no Z.
1212
13-
SP-A: this row carries a sub-second component (millisecond resolution). The
14-
fractional digits carry no trailing zeros (.120 → .12). The whole-second
15-
omit-the-dot rule is proven separately by asset-uuid-roundtrip.yaml, whose
16-
rows stay at "...:00" / "...:00Z".
13+
SP-A: this row carries a sub-second component. recordedAt (TIMESTAMPTZ) and
14+
atTime (TIME) are seeded with SIX fractional digits (.123456) whose 4th-6th
15+
digits are non-zero, so every port must TRUNCATE to millisecond resolution
16+
(.123456 → .123) rather than round or pass through — a port emitting .123456
17+
fails the gate. observedAt (TIMESTAMP) keeps .120 to exercise the
18+
no-trailing-zeros rule (.120 → .12). The whole-second omit-the-dot rule is
19+
proven separately by asset-uuid-roundtrip.yaml, whose rows stay at
20+
"...:00" / "...:00Z".
1721
seed-data: |
1822
INSERT INTO "assets"
1923
("id","ownerId","externalId","payload","recordedAt","observedAt","asOfDate","atTime")
@@ -22,10 +26,10 @@ seed-data: |
2226
'BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB',
2327
NULL,
2428
'{"b": 2, "a": 1}',
25-
'2026-05-31T09:30:00.123-05:00',
29+
'2026-05-31T09:30:00.123456-05:00',
2630
'2026-05-31T14:30:00.120',
2731
'2026-05-31',
28-
'14:30:00.123');
32+
'14:30:00.123456');
2933
queries:
3034
- name: wire-types-roundtrip
3135
op: get

server/csharp/MetaObjects.IntegrationTests/Runner/Normalization.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,18 @@ private static string CanonicalFloat(double d)
119119
// as its UTC equivalent, proving normalization (not just formatting).
120120
private static string FormatDateTime(DateTime dt)
121121
{
122-
var s = TrimSubseconds(dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture));
122+
// Truncate (NOT round) sub-second to millisecond resolution — `fff` — to match
123+
// the other four ports' integer-division truncation (TS slice(dot+1,dot+4),
124+
// Java/Kotlin nanos/1_000_000, Python microsecond//1000). A 7-digit `fffffff`
125+
// here would surface `.123456` where every other port emits `.123`.
126+
var s = TrimSubseconds(dt.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture));
123127
return dt.Kind == DateTimeKind.Utc ? s + "Z" : s;
124128
}
125129

126-
// TIME → "HH:MM:SS[.fff]" with trailing zero subseconds (and a bare decimal
127-
// point) stripped. Phase B seeds whole seconds, so this yields "HH:MM:SS".
130+
// TIME → "HH:MM:SS[.fff]" — truncated to millisecond resolution then trailing-zero
131+
// subseconds (and a bare decimal point) stripped, matching the other ports.
128132
private static string FormatTimeOnly(TimeOnly time) =>
129-
TrimSubseconds(time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture));
133+
TrimSubseconds(time.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture));
130134

131135
// Strip trailing zeros from a subsecond fraction, then drop the decimal point
132136
// if the subsecond portion was entirely zero.

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/Normalization.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ object Normalization {
5858
// "14:30:00", not java.time's elided "14:30"); millisecond fractional suffix
5959
// appended when sub-second is non-zero (SP-A).
6060
is LocalTime -> v.format(timeFmt) + fractionalSuffix(v.nano)
61-
is Time -> v.toLocalTime().format(timeFmt)
61+
is Time -> v.toLocalTime().let { it.format(timeFmt) + fractionalSuffix(it.nano) }
6262
// TIMESTAMPTZ → UTC instant + "Z" suffix. The runner surfaces tz-aware columns as
6363
// OffsetDateTime (NOT collapsed to LocalDateTime), so the zone discriminator survives:
6464
// re-anchor to UTC and append "Z". A plain TIMESTAMP arrives as Timestamp/LocalDateTime

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/NormalizationFloatTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,13 @@ class NormalizationFloatTest {
6161
@Test fun `whole-second time omits the dot`() {
6262
assertEquals("14:30:00", Normalization.normalizeValue(LocalTime.of(14, 30, 0, 0)))
6363
}
64+
// SP-A close-out: sub-millisecond fractional seconds TRUNCATE to ms (.123456 → .123),
65+
// never round to .124 or pass through 6 digits.
66+
@Test fun `sub-millisecond time truncates to millis`() {
67+
assertEquals("14:30:00.123", Normalization.normalizeValue(LocalTime.of(14, 30, 0, 123_456_000)))
68+
}
69+
@Test fun `sub-millisecond timestamp truncates to millis`() {
70+
assertEquals("2026-05-31T14:30:00.123",
71+
Normalization.normalizeValue(LocalDateTime.of(2026, 5, 31, 14, 30, 0, 123_456_000)))
72+
}
6473
}

server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public static Object normalizeValue(Object v) {
6161
if (v instanceof byte[] bytes) return Base64.getEncoder().encodeToString(bytes);
6262
if (v instanceof LocalDate d) return d.format(DATE_FMT);
6363
if (v instanceof LocalTime t) return t.format(TIME_FMT) + fractionalSuffix(t.getNano());
64-
if (v instanceof Time t) return t.toLocalTime().format(TIME_FMT);
64+
if (v instanceof Time t) return t.toLocalTime().format(TIME_FMT) + fractionalSuffix(t.toLocalTime().getNano());
6565
// TIMESTAMPTZ → UTC instant + "Z" suffix. The runner surfaces tz-aware
6666
// timestamp columns as OffsetDateTime (normalized to UTC in
6767
// ObjectManagerDbAdapter) so the zone discriminator survives — a plain

server/java/integration-tests/src/test/java/com/metaobjects/integration/NormalizationFloatTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import org.junit.jupiter.api.Test;
44

5+
import java.time.LocalDateTime;
6+
import java.time.LocalTime;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
59
import static org.junit.jupiter.api.Assertions.assertThrows;
610

711
/**
@@ -22,4 +26,15 @@ class NormalizationFloatTest {
2226
assertThrows(IllegalArgumentException.class,
2327
() -> Normalization.normalizeValue(1.0e-10f)); // Float.toString -> "1.0E-10"
2428
}
29+
30+
// SP-A close-out: sub-millisecond fractional seconds TRUNCATE to ms (123456 us of
31+
// a second = 123_456_000 ns → ".123", NOT ".123456" or a rounded ".124").
32+
@Test void subMillisecondTime_truncatesToMillis() {
33+
assertEquals("14:30:00.123",
34+
Normalization.normalizeValue(LocalTime.of(14, 30, 0, 123_456_000)));
35+
}
36+
@Test void subMillisecondTimestamp_truncatesToMillis() {
37+
assertEquals("2026-05-31T14:30:00.123",
38+
Normalization.normalizeValue(LocalDateTime.of(2026, 5, 31, 14, 30, 0, 123_456_000)));
39+
}
2540
}

server/typescript/packages/codegen-ts/src/templates/filter-type.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,27 @@ import {
1313
FIELD_SUBTYPE_LONG,
1414
FIELD_SUBTYPE_DOUBLE,
1515
FIELD_SUBTYPE_FLOAT,
16-
FIELD_SUBTYPE_DECIMAL,
1716
opsForSubType,
1817
} from "@metaobjectsdev/metadata";
1918
import { isSortableField } from "./filter-shared.js";
2019

21-
const NUMBER_SUBTYPES = new Set<string>([
20+
// VALUE-type classification only (distinct from the OPERATOR band, which comes from
21+
// opsForSubType). decimal is deliberately NOT here: its operator band stays NUMERIC
22+
// (eq/ne/gt/gte/lt/lte/in/isNull, see OPS_BY_SUBTYPE) but its VALUE type is `string`,
23+
// matching the entity field representation (exact decimal string, not lossy number).
24+
const NUMBER_VALUE_SUBTYPES = new Set<string>([
2225
FIELD_SUBTYPE_INT,
2326
FIELD_SUBTYPE_SHORT,
2427
FIELD_SUBTYPE_BYTE,
2528
FIELD_SUBTYPE_LONG,
2629
FIELD_SUBTYPE_DOUBLE,
2730
FIELD_SUBTYPE_FLOAT,
28-
FIELD_SUBTYPE_DECIMAL,
2931
]);
3032

31-
/** Maps a field subtype to its TS value type name for operator union codegen. */
33+
/** Maps a field subtype to its TS VALUE type name for operator union codegen. */
3234
function tsNameFor(fieldSubType: string): string {
3335
if (fieldSubType === FIELD_SUBTYPE_BOOLEAN) return "boolean";
34-
if (NUMBER_SUBTYPES.has(fieldSubType)) return "number";
36+
if (NUMBER_VALUE_SUBTYPES.has(fieldSubType)) return "number";
3537
return "string";
3638
}
3739

server/typescript/packages/codegen-ts/test/templates/filter-type.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, test, expect } from "bun:test";
22
import { renderFilterType } from "../../src/templates/filter-type.js";
33
import { resolve } from "node:path";
4-
import { MetaDataLoader } from "@metaobjectsdev/metadata";
4+
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
55
import { FileSource } from "@metaobjectsdev/metadata/core";
66

77
const FIXTURE = resolve(import.meta.dir, "..", "fixtures", "filter-fixture.json");
@@ -86,4 +86,36 @@ describe("renderFilterType", () => {
8686
// price should NOT appear as a filterable field (no @filterable)
8787
expect(out).not.toContain("price?:");
8888
});
89+
90+
// SP-A close-out: a filterable decimal field's VALUE type is `string` (exact-decimal
91+
// wire form, matching the entity field) while its OPERATOR band stays NUMERIC
92+
// (gt/gte/lt/lte/in — NOT the string `like`).
93+
test("decimal filterable field has string value type but numeric operator band", async () => {
94+
const meta = {
95+
"metadata.root": {
96+
package: "shop::money",
97+
children: [
98+
{
99+
"object.entity": {
100+
name: "Invoice",
101+
children: [
102+
{ "field.decimal": { name: "total", "@filterable": true, "@precision": 12, "@scale": 2 } },
103+
],
104+
},
105+
},
106+
],
107+
},
108+
};
109+
const { root } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(meta))]);
110+
const entity = root.objects().find((c) => c.name === "Invoice")!;
111+
const out = renderFilterType(entity).toString();
112+
// value type is string (gt?: string, not gt?: number)
113+
expect(out).toMatch(/total\?:\s*string\s*\|\s*\{/);
114+
expect(out).toMatch(/total\?:[\s\S]*?gt\?:\s*string/);
115+
// numeric operator band present...
116+
expect(out).toMatch(/total\?:[\s\S]*?gte\?:/);
117+
expect(out).toMatch(/total\?:[\s\S]*?lte\?:/);
118+
// ...and the string-only `like` operator absent.
119+
expect(out).not.toMatch(/total\?:\s*string\s*\|\s*\{[^}]*like\?:/);
120+
});
89121
});

server/typescript/packages/integration-tests/test/temporal-parsers.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,19 @@ describe("temporal canonical parsers — fractional milliseconds", () => {
5858
expect(canonicalTimestamp("2026-05-31 14:30:00.123456")).toBe("2026-05-31T14:30:00.123");
5959
});
6060

61+
test("TIMESTAMPTZ truncates beyond milliseconds (and converts to UTC Z)", () => {
62+
// SP-A close-out: the corpus seeds .123456 here; the pinned contract truncates to ms.
63+
expect(canonicalTimestamptz("2026-05-31 10:30:00.123456-04")).toBe("2026-05-31T14:30:00.123Z");
64+
});
65+
6166
test("TIME carries milliseconds, strips trailing zeros, omits dot when zero", () => {
6267
expect(canonicalTime("14:30:00.123")).toBe("14:30:00.123");
6368
expect(canonicalTime("14:30:00.120")).toBe("14:30:00.12");
6469
expect(canonicalTime("14:30:00.000")).toBe("14:30:00");
6570
});
71+
72+
test("TIME truncates beyond milliseconds", () => {
73+
// SP-A close-out: the corpus seeds .123456 here; truncate to ms (not round, not passthrough).
74+
expect(canonicalTime("14:30:00.123456")).toBe("14:30:00.123");
75+
});
6676
});

0 commit comments

Comments
 (0)