diff --git a/agent-context/skills/metaobjects-codegen/references/java.md b/agent-context/skills/metaobjects-codegen/references/java.md index 1b105224d..7021005f5 100644 --- a/agent-context/skills/metaobjects-codegen/references/java.md +++ b/agent-context/skills/metaobjects-codegen/references/java.md @@ -89,9 +89,10 @@ concrete imports and signatures so you don't have to guess them. ## `codegen-spring` generators -All live in `metaobjects-codegen-spring` under +Most live in `metaobjects-codegen-spring` under `com.metaobjects.generator.spring.*`; wire any subset, typically all three of the -first group together: +first group together. (`JavaObjectCodeGenerator`, last row below, lives in the +separate `metaobjects-codegen-base` module instead.) | Generator | Output | |---|---| @@ -105,6 +106,7 @@ first group together: | `SpringRenderHelperGenerator` | the typed render helper for a `template.prompt` payload | | `LlmTraceHelperGenerator` | `TraceHelper.java` per concrete entity — the LLM-trace helper | | `SpringFilterAllowlistGenerator` | per-entity filter allowlist | +| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. | **Projections (read-only views).** An `object.projection` (read-only `source.rdb` `@kind: view` child) is served read-only through OMDB at the ObjectManager layer @@ -153,3 +155,69 @@ polymorphic + per-subtype-scoped repository seam the consumer implements against Spring Data JPA / JDBC. Conformance-gated by `fixtures/api-contract-conformance/tph` (HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table runtime semantics). + +## Serializing generated objects + +Two paths hand you a `MetaObjectAware` instance: (a) `JavaObjectCodeGenerator`'s +flavored codegen above (a `pojoAware` or `valueObject` class), and (b) the om/omdb +runtime (`ObjectManager.getObjects(...)` / `MetaObject.newInstance()` — see the +runtime-ui reference). **A default Jackson/Gson mapper over a `PojoObject` subtype +fails on the `MetaObject` back-reference** — the inherited `getMetaData()` getter +leads a bean-style mapper into the metadata graph, and on a modular JVM into +`InaccessibleObjectException`. This is expected, not a bug to work around. If you +want a type that serializes cleanly with a bare default mapper, use the +`codegen-spring` record surface (`SpringDtoGenerator` / `SpringPayloadGenerator` / +`SpringValueObjectGenerator`) instead — never `pojoAware`. + +Serialize any `MetaObjectAware` instance through the MetaObjects JSON layer's +`JsonObjectWriter`/`JsonObjectReader`, not a bare mapper — it applies the temporal +wire form below, and read/write round-trip through the same pair of calls: + +```java +import com.metaobjects.io.object.json.JsonObjectWriter; +import com.metaobjects.io.object.json.JsonObjectReader; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; + +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Path; + +MetaDataLoader loader = MetaDataLoader.fromDirectory("app", Path.of("src/main/metaobjects")); +MetaObject mo = loader.getMetaObjectByName("acme::blog::Author"); + +// pojoAware-flavor generated class: public Author(MetaObject mo) { super(mo); } +Author author = new Author(mo); +author.setName("Ada"); +author.setBirthDate(new java.util.Date()); // field.date + +// Write +StringWriter out = new StringWriter(); +JsonObjectWriter.writeObject(author, out); +String json = out.toString(); +// {"@type":"acme::blog::Author","name":"Ada","birthDate":"2026-06-03"} + +// Read +Author roundTripped = JsonObjectReader.readObject(Author.class, mo, new StringReader(json)); +``` + +**Wire form** (`field.date` / `field.timestamp`): + +| Field | Wire form | Example | +|---|---|---| +| `field.date` | calendar date of the instant at UTC — `YYYY-MM-DD` | `"2026-06-03"` | +| `field.timestamp` + `@localTime: true` | wall clock of the instant at UTC, no `Z` | `"2026-06-03T14:30:00.123"` | +| `field.timestamp` (default, tz-aware) | UTC instant, with `Z` | `"2026-06-03T14:30:00.123Z"` | + +Fraction is millisecond resolution, trailing zeros stripped, and the `.` plus +fraction omitted entirely when zero (`.123`→`.123`, `.120`→`.12`, `.100`→`.1`, +`.000`→omitted). A `null` value writes JSON `null`. Readers are tolerant and +backward-compatible: a JSON **number** is still read as **legacy epoch +milliseconds**; a JSON **string** is tried in order as an ISO instant (the `Z` +form) → a local date-time (no `Z`) → a date-only form, failing with a message +naming all three accepted forms. + +**Known bounded caveat:** a hand-constructed `field.date` value carrying a +sub-day time component writes as the calendar date only (truncated on first +write, stable thereafter) — this matches the shipped OMDB DATE codec, which +anchors DATE columns at midnight UTC. diff --git a/agent-context/skills/metaobjects-prompts/references/java.md b/agent-context/skills/metaobjects-prompts/references/java.md index f312b2bc7..f86c24d6b 100644 --- a/agent-context/skills/metaobjects-prompts/references/java.md +++ b/agent-context/skills/metaobjects-prompts/references/java.md @@ -53,6 +53,14 @@ rather than a throw. The payload record itself comes from `SpringPayloadGenerato — the parser is a companion to it, so the parser and payload VO can't silently drift. +Both `parse()` and `extractLenient(...)` here return **plain Java 21 records** — +safe with any mapper, nothing special needed. That's specific to this +`codegen-spring` extract tier: the codegen-base flavored `Extractor` and the +raw `MetaObjectExtractor` (the alternative extraction path, see the codegen +reference) return `MetaObjectAware` instances instead, and those need +`JsonObjectWriter`/`MetaObjectSerializer` — not a bare mapper — to serialize +correctly (see the codegen reference's "Serializing generated objects" section). + ## The output-format prompt fragment (FR-010) For every json/xml-format `template.output`, `codegen-spring`'s diff --git a/agent-context/skills/metaobjects-runtime-ui/references/java.md b/agent-context/skills/metaobjects-runtime-ui/references/java.md index 393d7969a..5018454a8 100644 --- a/agent-context/skills/metaobjects-runtime-ui/references/java.md +++ b/agent-context/skills/metaobjects-runtime-ui/references/java.md @@ -65,6 +65,22 @@ try { taking a `QueryOptions` (built from an `Expression`). `ValueObject` is the map-backed runtime carrier. +## Serializing a row + +A `ValueObject` **is** a `Map`, so a default Jackson +`ObjectMapper` map-serializes it without special configuration — you may not +hit a hard failure at all. The hard failure other shapes hit is the +**`pojoAware`** codegen flavor's bean shape (a public `getMetaData()` +back-reference a bean-style mapper walks into) and any direct Gson field walk +over a `MetaObjectAware` instance — an OMDB `ValueObject` row sidesteps both. + +Even so, the MetaObjects JSON layer (`JsonObjectWriter`/`JsonObjectReader`, +`com.metaobjects.io.object.json`) is the sanctioned path for an OMDB row +regardless of mapper friendliness — it's what applies the temporal wire form +(`field.date`/`field.timestamp` render per the cross-port contract; a default +mapper has no idea what shape those should take). See the codegen reference's +"Serializing generated objects" section for the write+read snippet. + ## Spring wiring `metaobjects-core-spring` (or the Spring Boot starter) declares an diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index cb24017ad..c2c61a91d 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `{{codegenComm - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/docs/ports/java.md b/docs/ports/java.md index 1d129f9b1..331d90fa6 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -172,10 +172,13 @@ auto-create path was removed per ADR-0015. OMDB reads the same metadata at runtime and drives CRUD; no per-entity ORM boilerplate. -The Java port generates **no typed entity POJO** — the only entity-shaped Java -output is the immutable `Dto` record (from `codegen-spring`). OMDB drives -CRUD against the loaded metadata plus generic `ValueObject` instances, and its API -is connection-first (you pass an `ObjectConnection` to each call): +`codegen-spring`'s only entity-shaped output is the immutable `Dto` +record — it generates no typed entity POJO. (A typed `MetaObjectAware` class +is available separately, from `JavaObjectCodeGenerator`'s flavored codegen — +see [Serializing generated objects](#serializing-generated-objects) below.) +OMDB drives CRUD against the loaded metadata plus generic `ValueObject` +instances, and its API is connection-first (you pass an `ObjectConnection` to +each call): ```java import com.metaobjects.loader.MetaDataLoader; @@ -264,13 +267,78 @@ into a Maven test (e.g. a JUnit assertion in the `test` phase). | `SpringControllerGenerator` | `metaobjects-codegen-spring` | One `Controller.java` per writable entity (`source.rdb @kind="table"`). Spring Boot 3.x / Spring Web MVC. Five CRUD endpoints (GET list / GET by id / POST / PATCH + PUT / DELETE) matching the cross-port [REST API contract](../features/api-contract.md). `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Filter operators (`eq/ne/gt/gte/lt/lte/in/like/isNull`) ship via the generated `FilterAllowlist` (`SpringFilterAllowlistGenerator`) + the runtime `FilterParser`, wired directly into the list handler. | | `SpringDtoGenerator` | `metaobjects-codegen-spring` | One `Dto.java` per entity as a Java 21 `record`. Wrapped-primitive components (`Long`, `Integer`, `Boolean`) so missing JSON properties deserialise to `null`. Currency = `Long` (integer minor units cross-port invariant). Used as both request and response body. | | `SpringRepositoryGenerator` | `metaobjects-codegen-spring` | One `Repository.java` per writable entity as a hand-stubbed Java `interface` the consumer implements with their preferred persistence layer (Spring Data JPA / jOOQ / plain JDBC — all out of MetaObjects' concern). Nests the `SortClause` record the controller calls into. | +| `JavaObjectCodeGenerator` | `metaobjects-codegen-base` | Flavor-selected via the `flavor` generator arg (`com.metaobjects.generator.direct.object.javacode`). `flavor=pojoAware` emits `class extends PojoObject` — a concrete `MetaObjectAware` class whose inherited `getMetaData()` back-reference breaks a default Jackson/Gson mapper (see [Serializing generated objects](#serializing-generated-objects) below). `flavor=valueObject` emits a map-backed `class extends ValueObject` instead. Either flavor also emits a `Extractor` and a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. | -Wire any of them via the Maven plugin's `` entry pointing at -`com.metaobjects.generator.spring.SpringControllerGenerator` / +Wire any of the three Spring generators via the Maven plugin's `` +entry pointing at `com.metaobjects.generator.spring.SpringControllerGenerator` / `SpringDtoGenerator` / `SpringRepositoryGenerator`. The three are independently configurable; typical use is all three together (controller + DTO + repository). +## Serializing generated objects + +Two paths hand you a `MetaObjectAware` instance: the `JavaObjectCodeGenerator` +flavored codegen above (a `pojoAware` or `valueObject` class), and the OMDB +runtime (`ObjectManagerDB.getObjects(...)` / `MetaObject.newInstance()`, see +[Use](#use) above). Serialize either through the MetaObjects JSON layer +(`com.metaobjects.io.object.json`) — `JsonObjectWriter` for the write side, +`JsonObjectReader` for the read side — rather than a bare Jackson/Gson mapper: + +```java +import com.metaobjects.io.object.json.JsonObjectWriter; +import com.metaobjects.io.object.json.JsonObjectReader; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; + +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Path; + +MetaDataLoader loader = MetaDataLoader.fromDirectory("app", Path.of("src/main/metaobjects")); +MetaObject mo = loader.getMetaObjectByName("acme::blog::Author"); + +// pojoAware-flavor generated class: public Author(MetaObject mo) { super(mo); } +Author author = new Author(mo); +author.setName("Ada"); + +StringWriter out = new StringWriter(); +JsonObjectWriter.writeObject(author, out); +String json = out.toString(); +// {"@type":"acme::blog::Author","name":"Ada"} + +Author roundTripped = JsonObjectReader.readObject(Author.class, mo, new StringReader(json)); +``` + +A default Jackson/Gson mapper pointed directly at a `pojoAware`-flavor class +fails on the `MetaObject` back-reference every generated `PojoObject` subtype +carries (the inherited `getMetaData()` getter leads a bean-style mapper into +the metadata graph, and on a modular JVM into `InaccessibleObjectException`) +— **this is expected, not a bug to work around.** If you want a type that +serializes cleanly with a bare default mapper, generate the `codegen-spring` +record surface instead (`SpringDtoGenerator` / `SpringPayloadGenerator` / +`SpringValueObjectGenerator`) — never `pojoAware`. + +**Wire form** (`field.date` / `field.timestamp`) — a Java rendering of the cross-port contract in [`normalization.md`](../../fixtures/persistence-conformance/normalization.md) (the single source of truth): + +| Field | Wire form | Example | +|---|---|---| +| `field.date` | calendar date of the instant at UTC — `YYYY-MM-DD` | `"2026-06-03"` | +| `field.timestamp` + `@localTime: true` | wall clock of the instant at UTC, no `Z` | `"2026-06-03T14:30:00.123"` | +| `field.timestamp` (default, tz-aware) | UTC instant, with `Z` | `"2026-06-03T14:30:00.123Z"` | + +The fraction is millisecond resolution, trailing zeros stripped, and the `.` +plus fraction omitted entirely when zero (`.123`→`.123`, `.120`→`.12`, +`.100`→`.1`, `.000`→omitted). A `null` value writes JSON `null`. Readers stay +tolerant and backward-compatible: a JSON **number** is still read as **legacy +epoch milliseconds**; a JSON **string** is tried in order as an ISO instant +(the `Z` form) → a local date-time (no `Z`) → a date-only form, and the error +message names all three accepted forms if none match. + +A hand-constructed `field.date` value carrying a sub-day time component +writes as the calendar date only (truncated on first write, stable +thereafter) — this matches the shipped OMDB DATE codec, which anchors DATE +columns at midnight UTC. + ## Universal Angular 18 client The browser-side Angular 18 client (`@metaobjectsdev/angular` + diff --git a/docs/superpowers/plans/2026-08-08-serializer-date-recursion-and-java-json-docs.md b/docs/superpowers/plans/2026-08-08-serializer-date-recursion-and-java-json-docs.md index 7f71f6dc9..fba04146c 100644 --- a/docs/superpowers/plans/2026-08-08-serializer-date-recursion-and-java-json-docs.md +++ b/docs/superpowers/plans/2026-08-08-serializer-date-recursion-and-java-json-docs.md @@ -13,16 +13,31 @@ verified at the baseline SHA but must be **re-derived from code** before acting ## STATUS — update as you go (edit this file, commit the checkbox flips with the work) -- [ ] Phase 0 — setup, premise recon -- [ ] Unit A — wire-form implementation: serializer DATE branch + deserializer DATE split + streaming-reader split + `TemporalWireFormat` + gate tests (TDD) -- [ ] Unit B — Gson wiring siblings: `JsonObjectReader` registers serializers-only; initializer's add-flags are dead code (fix TOGETHER — they mask each other) -- [ ] Unit C — serializer write-side `@isArray` asymmetry (bounded; **maintainer checkpoint before widening**) -- [ ] Unit D — #273 docs (5 files; gated on Unit A being merged-or-on-the-same-branch) -- [ ] Free-text sweep (hazard discipline — member VALUES, spelling-agnostic) -- [ ] Independent review (branch + `no-mistakes` gate) → merge to `main` → local-ci green +- [x] Phase 0 — setup, premise recon +- [x] Unit A — wire-form implementation: serializer DATE branch + deserializer DATE split + streaming-reader split + `TemporalWireFormat` + gate tests (TDD) — `94a9f400` +- [x] Unit B — Gson wiring siblings: `JsonObjectReader` registers serializers-only; initializer's add-flags are dead code (fix TOGETHER — they mask each other) — `daa8d677` +- [x] Unit C — serializer write-side `@isArray` asymmetry (bounded; **maintainer checkpoint before widening**) — `026bc342`, `0ba2e030`. Stayed inside its bound (3 files, zero `MetaField`/`DataConverter` change); the escalation clause fired as designed — see "Carry-forward" below. +- [x] Unit D — #273 docs (5 files; gated on Unit A being merged-or-on-the-same-branch) — `30e8946c`, `7138e580` +- [x] Free-text sweep (hazard discipline — member VALUES, spelling-agnostic) — two passes, code side + doc side, clean +- [x] Independent review (branch) — final whole-branch review clean after one fix wave (`f8e10c39`); 25 deferred findings triaged, 1 parked +- [ ] `no-mistakes` gate → merge to `main` → local-ci green - [ ] Release — coordinated PATCH: npm `0.21.1` · PyPI `0.21.1` · NuGet `0.21.1` · Maven `7.21.1` (**checkpoint with the maintainer first**) - [ ] Close #275 + #273 with receipts +**Carry-forward out of this batch** (deliberately NOT fixed; Unit C's bounded-scope clause names both +almost verbatim as scope-creep triggers). Recommended as ONE future unit, which would also close +deferred findings A6/C1/C3/C4/C5 and the `Apple.worms` fixture question: +1. `MetaField.setObject(Object,Object)` converts via the field's **scalar** `getDataType()` instead of + the array-aware `getEffectiveDataType()`, corrupting any `isArray` primitive before storage. +2. `DataConverter` has **no `DATE_ARRAY` implementation** (`case DATE_ARRAY:` → `unsupported()`), so + no entry point can store a `List` on an `isArray` DATE field. + Net: Unit C fixed the array **write** side while array **storage** stays broken. Verified coherent + to ship — the read half already threw at baseline, so Unit C introduces no regression; it converts + silent write corruption into correct output, and leaves two unreachable-but-correct code paths. +3. The OMDB jsonb-temporal gap — the motivating blast-radius claim for this whole fix still has no + test at any level. A metadata-local or omdb-local regression test is in-repo scope and does not + require touching the shared five-port `labels` fixture. + --- ## Meta-lesson (read before every unit) diff --git a/docs/superpowers/specs/2026-08-08-temporal-identity-timezone-flexibility-research.md b/docs/superpowers/specs/2026-08-08-temporal-identity-timezone-flexibility-research.md new file mode 100644 index 000000000..0105d49e6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-temporal-identity-timezone-flexibility-research.md @@ -0,0 +1,644 @@ +# Temporal types, identity generation, and timezone handling — flexibility research + +**Date:** 2026-08-08 · **Status:** research only (no code changed) · **Scope:** the three +domains the maintainer flagged — dates/times, auto-increment ids, and the DB timezone +boundary — across all five ports, classified per finding as **(A) over-forcing**, +**(B) under-documenting**, **(C) wrong default**, or **deliberate-and-correct**. + +**Method.** Every claim below is anchored to code or a committed doc (`file:line`, +repo-relative). Where code and prose disagree, the disagreement is itself listed as a +finding. Two upstream issue drafts from a real dogfooding adoption session (an existing +TypeScript/Postgres service with a hand-written Drizzle schema, `serial` PKs, and +`.defaultNow()` timestamps) were used as live field evidence and re-verified against the +code; both check out, and one is materially **reframed** below (§1, finding T2). Claims +not empirically executed are labeled *(verified by reading, not run)*. + +--- + +## Executive summary — the thesis, answered per domain + +The maintainer's suspicion was: *"Either we force too hard into one pattern, or we don't +have the documentation explaining how to do it other ways. And maybe our default ways are +not the norm we should be doing."* + +| Domain | Verdict | One-line evidence | +|---|---|---| +| **1. Temporal types** | **Mostly (B) under-documenting + implementation bugs. The default is right.** | Instant-by-default (`timestamptz`) is the deliberate, evidence-backed ADR-0036 choice and matches the unanimous best-practice recommendation. But the escape hatches that exist (`@localTime`, `timestampMode`, `@default: "now"`) are documented wrongly or not at all, and the TS `@autoSet` path ignores `timestampMode` (a bug, not a vocabulary gap). | +| **2. Identity generation** | **Mostly (B) + one sharp adoption bug. The vocabulary is sufficient.** | `increment \| uuid \| assigned` + composite `@fields` + natural keys covers every real shape. The failure is at the *adoption seam*: the diff engine treats a legacy Postgres `serial` PK — the single most common pre-adoption shape — as default-drift and emits a destructive `DROP DEFAULT` (draft 01, verified). `assigned` and composite-PK behavior are effectively undocumented. | +| **3. Timezone at the DB boundary** | **The storage model is deliberate-and-correct; the *wire* contract is under-enforced (a conformance gap, not a vocabulary gap).** | Every port's DB codec is properly UTC-pinned. But the documented Tier-1 REST wire form (`...sssZ`) is only enforced in the *persistence* harness; the generated REST surfaces genuinely diverge per port (Python emits `+00:00`/microseconds, C# depends on host JSON options, TS depends on driver parsers), and the api-contract corpus deliberately sidesteps timestamp literals. Issue #275's unfinished Gson `DATE` branch is the same symptom in the Java JSON layer. | + +**Direct answer:** we are **not** meaningfully over-forcing, and our defaults are **not** +wrong — `timestamptz`-by-default and identity-column emission are both the modern +recommendation. The dominant failure mode is **(B)**: the "other ways" either exist and +are undocumented/misdocumented, or exist in one layer (migrate) and not the other +(codegen). The second-order failure is a **cross-cutting adoption-seam pattern** (§5): +the tool is opinionated about what it *emits* (good) but also refuses equally-valid +physical encodings of the same logical declaration when it *reads reality back* +(serial-vs-identity, `CURRENT_TIMESTAMP`-vs-`now()`, `+00:00`-vs-`Z`) — which is exactly +where an adopter following the repo's own "metadata FOLLOWS the code" doctrine +(`agent-context/skills/metaobjects-authoring/SKILL.md:63-71`) gets hurt. + +--- + +## 1. Temporal types + +### 1.1 Current model + +**Vocabulary** (`fixtures/registry-conformance/expected-registry.json`, the true registry): +three subtypes — `field.date`, `field.time`, `field.timestamp`. `@localTime` (boolean) is +registered on `field.timestamp` **only**; `@autoSet` (`onCreate | onUpdate`) is registered +on all three; `@default` on all three. There is **no** zone-name attr, no precision attr, +no offset-preserving attr, and `@dbColumnType`'s allowed values are `uuid | jsonb` only — +the physical escape hatch does **not** cover temporals (ADR-0036 §1 deliberately retired +`timestamp_with_tz` from it: `spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md:32`). + +**The instant-by-default decision** is ADR-0036 §1 (`:26-34`): `field.timestamp` = instant +→ `timestamptz`; `@localTime: true` = naive wall-clock → `timestamp without time zone`. +The rationale explicitly cites Postgres "Don't Do This", Ecto, and Django, and records +that an earlier `field.localDateTime` subtype draft was corrected to an attribute under +the ADR-0037 framework. Adopter data drove it: 76% / 62% of two production adopters' +timestamp fields carried the retired tz annotation (`:15`). + +**Per-port native bindings** (all verified in code): + +| | `field.date` | `field.time` | `field.timestamp` | `@localTime: true` | anchor | +|---|---|---|---|---|---| +| TS | ISO string¹ | ISO string¹ | ISO string¹ (`z.string()`, Drizzle `mode:"string"`) | ISO string, no `Z` | `codegen-ts/src/templates/zod-validators.ts:512-515`, `column-mapper.ts:446-463` | +| Java | `LocalDate` | `LocalTime` | `Instant` | `LocalDateTime` | `codegen-spring/.../SpringTypeMapper.java:94,99,108-110` | +| Kotlin | `LocalDate` | `LocalTime` | `Instant` | `LocalDateTime` | `codegen-kotlin/.../KotlinTypeMapper.kt:254-266` | +| C# | `DateOnly` | `TimeOnly` | `DateTimeOffset` | `DateTime` | `MetaObjects.Codegen/CSharpNaming.cs:46-52,113-115` | +| Python | `datetime.date` | `datetime.time` | aware `datetime`² | naive `datetime`² | `codegen/type_map.py:26-28`; `runtime/object_manager.py:796-804` | + +¹ TS's string binding is per ADR-0019 (`spec/decisions/ADR-0019-runtime-return-type-contract.md:27`) +and per ADR-0036 (`:30`). The `timestampMode: "date"` codegen config +(`codegen-ts/src/metaobjects-config.ts:111-117`) opts the *Drizzle column* into JS `Date`. +² Python's *annotation* is bare `datetime.datetime` either way; `@localTime` acts at the +runtime coercion layer only (`object_manager.py:800-804`, `885-901`) — an asymmetry with C#, +where the declared CLR type bifurcates. + +**Column types per dialect:** PG `date`/`time`/`timestamptz`(default)/`timestamp`(@localTime) +(`migrate-ts/src/expected-schema.ts:997,1073`, `emit/postgres.ts:223`; C# EF `HasColumnType` +twin at `MetaObjects.Codegen/Generators/DbContextGenerator.cs:380-387`). SQLite/D1: one +declared `TIMESTAMP` text-affinity column for both (`emit/sqlite.ts:346`), and the drift +check collapses temporal distinctions there **deliberately** — `buildExpectedSchema` takes +a `dialect` and normalizes expected temporals to what SQLite introspection can see +(`expected-schema.ts:76-82`; gate: `migrate-ts/test/integration/sqlite-roundtrip.test.ts:63-68`). + +**Wire form (the pinned cross-port contract)** — `fixtures/persistence-conformance/normalization.md:36-43`: +`DATE`→`YYYY-MM-DD`; `TIME`→`HH:MM:SS[.fff]`; `TIMESTAMP`→no `Z`; `TIMESTAMPTZ`→UTC, +always `Z`; **millisecond** resolution, trailing zeros stripped, fraction omitted when +zero (`:45-59`). The corpus proves genuine normalization (a `-05:00`-seeded value must +read back as `Z`: `fixtures/persistence-conformance/queries/normalization-wire-types.yaml:4-10,29`). + +### 1.2 What is and is not expressible + +| Shape | Expressible? | How / why not | +|---|---|---| +| Instant (absolute point in time) | ✅ default | bare `field.timestamp` | +| Naive wall-clock date+time | ✅ | `@localTime: true` | +| Date-only, no zone | ✅ | `field.date` (inherently naive — ADR-0036 §1 rationale) | +| Time-of-day, no zone | ✅ | `field.time` | +| Server-stamped created/updated | ✅ | `@autoSet` (+ migrate emits a real `DEFAULT now()` — see T3) | +| DB-side default (`now()`/`CURRENT_TIMESTAMP`) | ✅ | `@default: "now"` → dialect-aware `.defaultNow()` (`column-mapper.ts:571-578`, `drizzle-schema.ts:318-326`) | +| TS native-`Date` binding | ⚠️ half | `timestampMode: "date"` affects the Drizzle column only; Zod/API types and the `@autoSet` stamp ignore it (T2) | +| **Naive column + UTC-instant semantics** (the Prisma/Rails/stock-Drizzle convention) | ❌ | no combination: instant ⇒ `timestamptz` column; `@localTime` ⇒ wall-clock semantics in JVM/C# native types (T5) | +| Wall-clock time **with a named zone** (`America/New_York`) | ❌ | no zone attr; matches PG (no such column type) — pattern is a second column. Deliberate non-goal, but undocumented | +| Zoned timestamp **preserving the original offset** | ❌ | `timestamptz` discards offset (PG semantics); norm everywhere is an extra column. Deliberate non-goal, undocumented | +| `timetz` | ❌ | deliberately excluded — "timetz is discouraged" (ADR-0036 §1 `:34`) | +| Partial dates (year-only, year-month) | ❌ | no subtype; industry norm is string/custom. Fine | +| Sub-millisecond wire precision | ❌ | wire is pinned at ms (`normalization.md:47-52`); PG stores µs — precision above ms is truncated at the wire tier | +| Physical precision override (`timestamp(3)`) | ➖ tolerated | introspection doesn't read `datetime_precision` (`introspect/postgres.ts:436-446` selects neither), so a live `timestamp(3)` neither drifts nor is declarable. Silent leniency | + +### 1.3 Industry-norm comparison + +*(from general knowledge of these tools' current documented behavior; each is stated as +the tool's default, not its best-practice guidance)* + +| System | Timestamp default | Aware or naive? | +|---|---|---| +| Postgres wiki ("Don't Do This") | **recommends `timestamptz`** | aware | +| Drizzle | `timestamp()` → `withTimezone: false`, `mode: "date"` | **naive** | +| Prisma | `DateTime` → `timestamp(3)` | **naive** (UTC-by-convention) | +| EF Core / Npgsql (6+) | `DateTime`/`DateTimeOffset` → `timestamptz` (UTC-enforced) | aware | +| JPA / Hibernate 6 | `Instant` → TIMESTAMP_UTC; `LocalDateTime` → naive | aware for `Instant` | +| SQLAlchemy | `DateTime` → `timezone=False` | **naive** | +| Django (PG) | always `timestamp with time zone`; `USE_TZ` default True since 5.0 | aware | +| Rails (PG) | `datetime` → naive column, UTC-by-convention in AR | **naive** storage | +| Pydantic / FastAPI encoder | `datetime.isoformat()` → `+00:00`, µs | aware value, non-`Z` text | + +Conclusion: `timestamptz`-by-default is the **prevailing recommendation** and the default +in the .NET/Django half of the world — MetaObjects' default is correct and should be +defended, not flipped. But the **TS ecosystem's de-facto default is naive storage with a +UTC convention** (Prisma, stock Drizzle), which is why TS-side adopters are the ones who +feel friction: their working tables often disagree with our (better) default, and the +adoption path for that disagreement is undocumented (T5). + +### 1.4 Findings + +**T1 — docs/features/field-types.md contradicts the code and the ADRs on the TS binding, +and omits `field.time`, `@localTime`, and `@autoSet` entirely. Class: (B).** +`docs/features/field-types.md:18-19` says TS binds `field.date`/`field.timestamp` to +`Date`. The shipped default is ISO **string** (`zod-validators.ts:512-515`; +`column-mapper.ts:453-457`'s comment explains why; ADR-0019 `:27` and ADR-0036 `:30` both +say "string" for TS). The same table has no `field.time` row (both JVM ports fully +support it — `SpringTypeMapper.java:99`, `KotlinTypeMapper.kt:257`), no +`field.float`/`decimal`/`uri`/`inet` rows, and the file never mentions `@localTime` or +`@autoSet` — the two attributes that answer "how do I do it another way". `@localTime`'s +only adopter-facing documentation is the 0.x→1.0 migration guide +(`docs/features/migrations/0.x-to-1.0.md:44-62`) and the authoring skill +(`SKILL.md:342-352`). This single stale page is likely a major *source* of the +"inflexible dates" perception: it presents one binding per port and no knobs. + +**T2 — the `timestampMode` escape hatch is real but half-integrated and mis-documented; +draft 02 reframed. Class: bug + (B).** +Settling the flagged unverified claim: `timestampMode` **is** threaded into the base +column mapping for *every* field, `@autoSet` included +(`drizzle-schema.ts:80,101` → `mapColumnType(..., ctx.timestampMode)` → +`column-mapper.ts:460-463`). The draft's observed `mode: "string"`-despite-config is best +explained not by a second column-mapper bug but by the config never being read: the +draft's repro sets `codegen: { timestampMode: "date" }`, and **no `codegen` block exists** +— the key is top-level (`metaobjects-config.ts:111-117`), `normalizeConfig` silently +defaults anything it doesn't find (`metaobjects-config.ts:277-292`, no unknown-key +detection), and the *internal doc comment itself* says "Opt in via +`codegen.timestampMode`" (`render-context.ts:44-48`) — the likely origin of the wrong +shape. The only correct documentation is one line in an agent skill +(`agent-context/skills/metaobjects-codegen/references/typescript.md:48`). +What remains true and is a **real bug**: with the key set correctly, the `@autoSet` +suffix still hardcodes a string stamp (`.$defaultFn(() => new Date().toISOString())`, +`drizzle-schema.ts:375-381`) into a now-`Date`-mode column, and the generated Zod layer +is entirely `timestampMode`-blind (`zod-validators.ts:512-515` plus the `@autoSet` sites +`:192-194,257-259,287-292`) — so `timestampMode: "date"` yields a Drizzle layer typed +`Date` and an API/validator layer typed `string`. Severity is therefore **one coherence +bug + one config-UX/docs bug**, not two compounding column-mapper bugs. + +**T3 — `@autoSet` already produces a DB-side `DEFAULT now()` in the schema; nobody says +so, and codegen doesn't mirror it. Class: (B).** +Draft 02's premise — "no combination gives DB-side default + native typing + optional on +insert" — is two-thirds false on the migrate side: an `@autoSet` column's expected schema +carries `default: { kind: "expr", value: "now()" }` (`expected-schema.ts:938-947`; +sqlite/d1-canonicalized to `CURRENT_TIMESTAMP` at `:242-252`, gated by +`migrate-ts/test/integration/sqlite-autoset-default.test.ts`). So a MetaObjects-managed +database **has** the real DB default, insert-optionality exists in the Zod insert schema +(`zod-validators.ts:240-259`), and any non-generated writer gets stamped by the DB. What +the generated Drizzle schema shows is only the app-side `$defaultFn` belt-and-suspenders +— which reads as "no DB default exists" to anyone auditing generated code. Also +adoption-relevant: a live hand-written `.defaultNow()` column diffs clean against +`@autoSet` (expected `now()` == introspected `now()`), which is exactly what an adopter +wants — and is documented nowhere. + +**T4 — the expected-side vs introspect-side SQL-default-expression classifiers disagree +on any function call with arguments. Class: bug (adoption false-drift).** +*(verified by reading, not run.)* The expected side classifies a string `@default` as an +expression only for the fixed keywords or a literal `()` +(`EXPR_DEFAULT_PATTERNS`, `expected-schema.ts:903-909` — `/\(\)/` matches only *empty* +parens despite its "anything function-like" comment). The introspect side classifies +**any** leading-identifier function call as an expression +(`introspect/postgres.ts:211-227`, whose own comment claims lockstep with the expected +side). Consequence: an authored `@default` like `timezone('utc', now())` or +`nextval('seq')` is a *literal* on the expected side (quoted on emit — wrong DDL) and an +*expr* on the actual side (`columnDefaultsEqual` compares kind+value strictly, +`diff/index.ts:806-810`) → perpetual false drift. This also kills the one conceivable +metadata-level workaround for the draft-01 serial bug (declaring `@default: +"nextval(...)"` on the PK field). Same family, second instance: a hand-written +`DEFAULT CURRENT_TIMESTAMP` column vs `@autoSet`'s expected `now()` fails the strict +string compare and should false-drift on adoption *(needs a repro; parsePgDefault keeps +the raw spelling)*. + +**T5 — the "naive column + UTC convention" adoption cell is inexpressible, and the +documented alternative (convert the column) has a silent data hazard. Class: (A)-adjacent, +but the recommended remedy is a bugfix + docs, not vocabulary.** +An adopter with Prisma/stock-Drizzle-shaped naive `timestamp` columns holding +UTC-by-convention instants must choose: declare instant (metadata says `timestamptz`, +migrate wants to convert the column) or declare `@localTime` (column matches, but +JVM/C#/Python native types become wall-clock — semantics lost; TS alone is unaffected +since its binding is string either way). The conversion path is the *right* long-term +answer — but `meta migrate` emits `ALTER TABLE ... ALTER COLUMN ... TYPE TIMESTAMPTZ;` +with **no `USING ... AT TIME ZONE 'UTC'` clause** (`emit/postgres.ts:75` — one generic +arm for every type change), and Postgres's implicit timestamp→timestamptz cast interprets +the naive values **in the session's `TimeZone`** — so applying the migration from a +non-UTC session silently reinterprets every stored instant. Fixing that emission (special- +case the naive→tz temporal conversion with an explicit `USING "col" AT TIME ZONE 'UTC'`, +or refuse-with-hint like #226/#258 precedent) plus a documented adoption recipe covers +this cell without new vocabulary. A physical `@dbColumnType: "timestamp"` escape (instant +semantics over naive storage) is ADR-0037-classifiable as step 1 physical-only, but it is +the exact inverse of what ADR-0036 just retired and would need all five ports' codecs to +implement "read naive as UTC" — recommend **not** doing it unless a second adopter asks +(see §7 gauntlet). + +**T6 — `@localTime` inheritance is read own-only in the Java Spring mapper but resolving +in Kotlin, C#, Python, TS. Class: bug (ADR-0039 violation).** +`SpringTypeMapper.java:192-196` reads `hasMetaAttr(LOCAL_TIME)` without the parent flag; +Kotlin resolves (`KotlinTypeMapper.kt:568-569,607-611`), C# resolves +(`CSharpNaming.cs:89-90`), TS/migrate resolve with explicit ADR-0039 comments +(`expected-schema.ts:996`). A base-entity-declared `@localTime` therefore binds +`LocalDateTime` in Kotlin but `Instant` in Java for the same metadata. The OMDB codec has +the same own-only read (`JdbcCodecs.java:480-488`). Exactly the "silently drops +everything inherited via extends" bug class ADR-0039 documents. + +**T7 — smaller port divergences.** (i) C#'s `@autoSet` stamp for a `@localTime` field is +`System.DateTime.Now` — host-local wall clock — where Python uses UTC +(`RoutesGenerator.cs:515` vs `router_generator.py:154`); same metadata, different stored +value on any non-UTC host. Class: bug. (ii) Generated Kotlin `field.time` columns use +stock Exposed `time()` which truncates sub-seconds (the conformance reference had to +hand-write `PreciseLocalTimeColumnType.kt:11-34`; the generator never emits it — +`KotlinTypeMapper.kt:473`). Class: bug, corpus-invisible (the generated-table lane +doesn't run the ms-bearing rows through stock `time()`). (iii) +`server/java/codegen-kotlin/README.md:26` documents an Exposed `timestampWithTimeZone` +that the code doesn't emit (it emits a generated file-local `instantWithTimeZone` — +`KotlinTypeMapper.kt:482`). Class: (B). + +**Deliberate-and-correct (say it louder):** instant-by-default; `@localTime` as attribute +not subtype; no `timetz`; date/time inherently naive; ms wire resolution; SQLite temporal +collapse in drift checks. All have written rationale (ADR-0036 §1, `normalization.md`) +and survived adversarial review; none should be relaxed. + +--- + +## 2. Identity generation + +### 2.1 Current model + +**Vocabulary:** `identity.primary` with `@fields` (string-array — composite is +first-class) and optional `@generation: increment | uuid | assigned` +(`expected-registry.json` identity.primary entry; +`metadata/src/core/identity/identity-constants.ts:41-50`). Absent `@generation` = natural +key. `assigned` additionally interacts with FR-013: an `@readOnly` field may participate +in an *assigned* primary identity (`metadata/src/core/field/validate-field-readonly.ts:134-154`). + +**What each port emits per value** (schema is TS-owned per ADR-0015, so DDL applies to TS +only; other ports do data-access/API): + +| | `increment` | `uuid` | `assigned` / absent | +|---|---|---|---| +| TS Drizzle schema | PG: `serial()`/`bigserial()` by field width; SQLite: `.primaryKey({autoIncrement:true})` (`drizzle-schema.ts:273-286`) | PG `.defaultRandom()`; SQLite `$defaultFn(crypto.randomUUID())` (`:288-293`) | bare `.primaryKey()` — natural key (`:294-297`) | +| TS migrate DDL | PG: `GENERATED BY DEFAULT AS IDENTITY` (`emit/postgres.ts:201`); SQLite `AUTOINCREMENT` (`emit/sqlite.ts:320`) | PG `DEFAULT gen_random_uuid()` (`emit/postgres.ts:202`); SQLite `lower(hex(randomblob(16)))` (`emit/sqlite.ts:326`) | plain PK | +| Kotlin Exposed | `.autoIncrement()` (`KotlinExposedTableGenerator.kt:625-630`) | server DEFAULT `gen_random_uuid()` **and** client `UUID.randomUUID()` in the repo insert (`:863-869`; `KotlinRepositoryGenerator.kt:186-193`) | write `dto.pk` verbatim (`:194-200`) | +| Java (codegen-spring) | **no dispatch** — repository is an interface the consumer implements (`SpringRepositoryGenerator.java:108-120`); only the PK *type* is derived (`SpringTypeMapper.java:166-183`) | same | same | +| Java OMDB runtime | `AUTO_LAST_ID`, per-dialect read-back (`SimpleMappingHandlerDB.java:346-375`; `PostgresDriver.java:104-111`) | app-side `UUID.randomUUID()` (`GenericSQLDriver.java:1513-1519`) | caller supplies | +| C# | **no dispatch anywhere** — bare `[Key]`, EF's `ValueGeneratedOnAdd` convention does everything (`EntityGenerator.cs:1030-1031`; the `MetaPrimaryIdentity.Generation` property is dead code, `MetaIdentity.cs:47-56`) | same (EF client-side Guid generator) | caller supplies | +| Python | omit PK from `Create` model (`entity_model.py:360-372`); runtime relies on `RETURNING` (`object_manager.py:217-220,284-290`) | same | PK kept in create body | + +Notably, the **project's own two TS layers use different physical mechanisms for +`increment` on Postgres**: codegen's Drizzle schema says legacy `serial` +(`drizzle-schema.ts:283-284`) while migrate's DDL says modern `IDENTITY` +(`emit/postgres.ts:201`). Harmless today (Drizzle never emits DDL here), but it proves +the point of finding I1: both encodings are the same logical declaration. + +### 2.2 What is and is not expressible + +| Shape | Expressible? | Notes | +|---|---|---| +| DB auto-increment PK | ✅ | `@generation: increment` | +| UUID PK, DB- or app-generated | ✅ | `@generation: uuid` (which side generates varies by port — undocumented; see I4) | +| Caller-supplied / app-generated id (ULID, snowflake, anything) | ✅ | `@generation: assigned` — but see I3 (Kotlin controller bug) and I4 (undocumented) | +| Natural key, no generation | ✅ | omit `@generation` | +| Composite PK | ⚠️ half | schema + Drizzle + EF `[PrimaryKey]` all support it (`drizzle-schema.ts:120-121`; `EntityGenerator.cs:319-323`), but the generated query/route layer keys **only on the first PK field** (`codegen-ts/src/templates/queries.ts:29-35` — explicit comment; only the #214 re-read uses all fields). `findById`/`update`/`DELETE /:id` on a composite-PK entity silently address by one component. Undocumented anywhere (`composite` appears once in all docs, `SKILL.md:557`, about FK targets) | +| **Adopting a legacy `serial` PK** | ❌ today | the draft-01 bug (I1) — the metadata is right, the diff refuses reality | +| DB-side PK default the tool doesn't own (trigger, custom function) | ⚠️ | `assigned` + the T4 classifier bug blocks declaring the default; zero-arg functions (`gen_random_uuid()`) squeak through the `/\(\)/` pattern, argument-bearing ones don't | +| New generation *strategies* as first-class vocab (ulid, uuidv7, snowflake) | ❌ deliberately | `assigned` covers them app-side; ADR-0007 Amendment 2's re-entry bar (a shipping consumer must dispatch on it) is the right precedent — same treatment as `@role` | +| Moving a PK on an existing DB | refused explicitly | detect-and-refuse with a clear error, by design (#258; `docs/features/migrations-and-drift.md:100-115`) | + +### 2.3 Industry-norm comparison + +Postgres itself has recommended identity columns over `serial` since v10, and the +ecosystem is mid-migration: EF Core/Npgsql and Django (4.1+) emit `IDENTITY`; Drizzle +(`serial()`), Prisma (`autoincrement()` → SERIAL), Rails, and SQLAlchemy's default still +create `serial`-family columns. **Both encodings are everywhere.** Emitting `IDENTITY` +on fresh DDL (what migrate does) is the modern choice; treating live `serial` as +*equivalent* on read-back is what every coexisting tool must do — and is precisely what +the diff engine fails to do. + +### 2.4 Findings + +**I1 — legacy `serial` adoption emits a destructive `DROP DEFAULT`. Class: bug (the +sharpest single finding of this research); root cause verified exactly as drafted.** +Introspection correctly detects `nextval(...)` and sets `identity = "increment"` on the +actual side (`introspect/postgres.ts:462-468`) — but it also (correctly) records the real +`DEFAULT nextval(...)` (`:459-460`), and the default-diff guard at `diff/index.ts:384-394` +skips identity-driven defaults only for `uuid`, on the stated-in-comment assumption that +"an AUTOINCREMENT column has no DEFAULT" — true for SQLite and modern PG `IDENTITY`, +**false for `serial`**. Expected `undefined` vs actual `nextval(...)` → +`change-column-default` → `ALTER ... DROP DEFAULT;` with no replacement mechanism (the +`GENERATED ... AS IDENTITY` emission exists only in the CREATE TABLE path, +`emit/postgres.ts:201`). Draft 01's suggested fix (extend the guard: `identity === +"increment"` + the already-detected serial pattern ⇒ not default-drift) is right, small, +and matches how the tool already treats modern IDENTITY columns (whose +`column_default` is NULL, so they diff clean today; note the introspection SELECT reads +neither `is_identity` — `:436-446` — identity is simply never diffed as a dimension, +`diff/index.ts:362-403`). A `serial`→`IDENTITY` modernization, if ever wanted, should be +its own opt-in migration — not fallout from adoption. + +**I2 — the `@generation` semantic is under-specified: "increment" means *a* DB-side +auto-increment mechanism, but nothing says which, or that adoption accepts any. Class: (B).** +`docs/features/entities.md` shows only `increment` (`:31,62`); `assigned` appears in **no** +feature doc (its only doc surface is loader validation errors); which side mints a `uuid` +(DB default vs app) is not documented and *differs by port* (Kotlin repo mints app-side +with an explanatory comment, `KotlinRepositoryGenerator.kt:36-38,186-193`; TS PG uses the +DB default; Java OMDB mints app-side, `GenericSQLDriver.java:1513-1519`; C# delegates to +EF's client-side Guid generator — with one stale comment claiming the DB default fires, +`RoundtripWriter.cs:14-15`). None of this is wrong per se — per-port idiom is chartered — +but an adopter cannot currently learn any of it from docs. + +**I3 — `assigned` is broken in the generated Kotlin controller. Class: bug.** +`KotlinSpringControllerGenerator.kt:433` unconditionally skips the PK column on insert +("the 95% case") — so an `assigned` entity's generated controller drops the +caller-supplied id, while the same entity's generated *repository* handles it correctly +(`KotlinRepositoryGenerator.kt:194-200`, whose header comment `:40-41` even names the +controller gap). Also: `PrimaryIdentity.java:86` treats absent-`@generation` as +`assigned` while `MetaIdentity.isAssigned` (`:185`) requires the literal string — a +latent base/subclass inconsistency. + +**I4 — composite PKs are a silent half-support cliff. Class: (A)-lite + (B).** +Fully expressible and correctly emitted at the schema tier; silently degraded at the +generated-API tier to first-field addressing (anchors in §2.2). Either the API tier +should refuse-or-support (a `GET /:id1/:id2` surface is real work; a load-time *warning* +that generated CRUD for a composite-PK entity addresses by first component is cheap), or +the limitation must be documented. Today an adopter finds out via wrong rows. + +**Deliberate-and-correct:** the three-value `@generation` vocabulary itself (new +strategies gated on a shipping consumer, per the `@role`/ADR-0040 reserved-not-registered +precedent); natural keys via omission; the moved-PK refusal (#258); Java's +consumer-implements-repository stance (documented in code, `SpringRepositoryGenerator.java:108-110`). + +--- + +## 3. Timezone handling at the database boundary + +### 3.1 The round-trip, per port — verified + +The **DB codec tier is in good shape**: every port pins UTC explicitly rather than +trusting process defaults. + +- **Java/OMDB** — the model citizen: `Calendar.getInstance(TimeZone.getTimeZone("UTC"))` + per access (`omdb/.../JdbcCodecs.java:151-153`), DATE read/write through UTC calendars + (`:173-201`), instant writes as `OffsetDateTime` at `ZoneOffset.UTC` with + `Types.TIMESTAMP_WITH_TIMEZONE` (`:362-368`), naive via UTC calendar (`:353,376-384`), + with comments naming the JVM-default-zone bug each overload closes (`:155-171,329-335`). + No default-zone dependence found in the runtime paths. (One test-harness asymmetry: + Kotlin's normalizer uses default-zone `Timestamp.toLocalDateTime()` where Java uses the + UTC-anchored form — `integration-tests-kotlin/.../Normalization.kt:79` vs + `integration-tests/.../Normalization.java:84-87`.) +- **C#** — `DateTimeOffset` normalized via `.UtcDateTime`, parse with + `AssumeUniversal|AdjustToUniversal` (`Normalization.cs:71`, `WriteCoercion.cs:151-156`); + EF `HasColumnType` emitted precisely because Npgsql rejects `Kind=Unspecified` on + `timestamptz` (`DbContextGenerator.cs:333-336`). +- **Python** — driver-native pass-through per ADR-0019 (`object_manager.py:7-13,90-166`); + aware-vs-naive is the tz discriminator at the boundary (`normalization.py:78-86`); + inbound `Z`→`+00:00`, offset-less instants defaulted to UTC (`object_manager.py:885-901`). +- **TS** — the interesting one. node-postgres hands TIMESTAMP and TIMESTAMPTZ to JS as + the same `Date`, and a naive TIMESTAMP is **shifted by the host timezone** on the way + in — the harness closes both hazards by parsing raw wire text keyed by column OID + (`integration-tests/src/temporal-parsers.ts:4-17`) and pinning the session zone + (`query-scenario.ts:57-62`, `options: "-c timezone=UTC"`). **But that is + test-harness-only by explicit design** (`pg-pristine-default-types.ts:6-13`: "that + override is a TEST-HARNESS/boundary concern, NOT part of runtime-ts"). Production + generated code ships no parser registration, no session-TZ pin, and no serialization + canonicalizer (`registerTemporalParsers`/`setTypeParser` appear nowhere under + `runtime-ts/src`, `codegen-ts/src`, or `cli/src`). + +**Session-timezone assumptions:** none in Java/C#/Python runtime paths; TS production is +*exposed* to them (Drizzle string-mode `mapFromDriverValue` falls back to +host-offset arithmetic when the driver yields a `Date`), which today is masked in CI by +UTC hosts and the harness parsers. SQLite/D1: no native temporal type; declared +`TIMESTAMP` text columns, values are whatever the app writes (TS writes ISO strings), and +the drift checker deliberately collapses temporal kinds there (§1.1). `verify --db` +drift-checks PG temporals exactly (`timestamptz` vs `timestamp` is a strict dimension, +`sql-type.ts:37`, faithful introspection `introspect/postgres.ts:166-170`) and +SQLite leniently — both correct. + +### 3.2 The wire tiers — where the contract actually stops being enforced + +Three tiers exist, and only the first is gated: + +1. **Persistence-conformance wire** — pinned and enforced byte-identical in all five + ports (`normalization.md`; the roundtrip corpus incl. non-UTC-offset and + whole-second proofs; each port's normalizer verified in §1/§3 anchors). +2. **Documented REST wire** — `docs/features/api-contract.md:158` declares + `YYYY-MM-DDTHH:mm:ss.sssZ` a **Tier-1 invariant**. +3. **Actual generated REST wire** — port-divergent: + - **Python**: generated FastAPI handlers return raw dicts, no `response_model`, no + encoder (`router_generator.py:368-425`) → FastAPI's default `datetime.isoformat()` + → **`2026-06-03T14:30:00.123000+00:00`** — microseconds, `+00:00`, never `Z`. + - **C#**: generated routes *fetch the host's* JSON options (`RoutesGenerator.cs:236-241,706-711`); + the UTC `"o"`-format converter that makes the harness emit `Z` is **test-host code**, + not codegen output (`GeneratedAuthorServerFactory.cs:118-119,343-368`) → an adopter's + default host emits System.Text.Json's default (`"o"`-like, 7-digit fraction, offset + as stored). + - **TS**: whatever the driver parser + Drizzle mode produce, JSON-stringified — with + stock node-postgres, not the canonical form. + - The api-contract corpus cannot see any of this: its `@autoSet` assertions + deliberately compare fields **to each other, never to a literal** — "so timestamp + non-determinism is a non-issue" (`ApiContractAssertions.cs:7-10`). + +**This is the systemic finding of domain 3 (Z1): the documented cross-port REST temporal +wire form is asserted nowhere, and it is not currently true.** It is also the frame for +**issue #275**: the Gson `MetaObjectSerializer` DATE branch serializes the *container* +instead of the field value (`metadata/.../MetaObjectSerializer.java:76-78` — every other +branch reads `mf.getX(vo)`; DATE passes `vo` to a context that re-enters the same +adapter, `MetaObjectGsonInitializer.java:53-73` → unbounded recursion), with the +`// TODO: consider custom DATE serialization` sitting inline since the branch was +written. `DataTypes.DATE` backs both `TimestampField` (`TimestampField.java:89`) and +`DateField` (`DateField.java:47`), so both subtypes hit it. Nothing caught it because +nothing anywhere gates a JSON temporal literal — the same reason the port-divergent REST +wire survives. **Guidance for the tactical #275 plan:** the fix's wire form should be the +already-pinned contract, not a new choice — ISO-8601 UTC with `Z`, millisecond +resolution, fraction omitted when zero (`normalization.md:38-52`), i.e. serialize +`mf.getDate(vo)` to the same string the Jackson harness config produces +(`GeneratedAuthorControllerHarness.java:135-138`) — **not** epoch millis, and not a +locale/zone-dependent `toString()`. + +### 3.3 Findings summary + +- **Z1 — REST temporal wire is documented as invariant but unenforced and divergent. + Class: (B) + conformance gap** (remedy: pin it in the api-contract corpus with literal + assertions on a deterministic seeded read path, then fix the three ports to it; the + `fieldsEqual` design can stay for `@autoSet` non-determinism). +- **Z2 — TS production path has no owned canonicalization seam.** ADR-0019 assigns wire + canonicalization to "the serialization layer" (`ADR-0019:41`), but the TS generated + stack never implements one; the harness parsers stand in for it. Class: (B)/design + debt — either the generated routes serialize through a small canonicalizer in + `runtime-ts`, or the scaffolded db.ts pins parsers + session TZ, and either way the + choice gets documented. +- **Z3 — migrate's naive→timestamptz ALTER lacks `USING ... AT TIME ZONE 'UTC'`** (T5) — + the one place a *timezone* bug can corrupt data at rest. Class: bug. +- **Deliberate-and-correct:** UTC-pinned codecs everywhere (JVM's especially well + commented); aware/naive as the boundary discriminator; SQLite temporal-collapse + leniency; `TIMESTAMP` no-`Z` / `TIMESTAMPTZ` always-`Z` discrimination. + +--- + +## 4. The two field-evidence drafts — disposition + +| Draft | Verdict | Notes | +|---|---|---| +| 01 (`migrate` drops `serial` default) | **Confirmed exactly as written; fix as suggested.** | Root cause at `diff/index.ts:394`; the guard's comment is wrong for legacy serial. Extend the skip to `identity === "increment"` + the serial pattern `introspect/postgres.ts:465-467` already computes. Also fold in the `CURRENT_TIMESTAMP`-vs-`now()` sibling (T4) while in that code. | +| 02 (`@autoSet` × `timestampMode`) | **One real bug + one docs/config-UX bug — not two compounding codegen bugs.** | The base column *does* honor `timestampMode` (`drizzle-schema.ts:80,101`); the repro's `codegen:`-nested key was silently ignored (no such block; no unknown-key warning; the internal comment at `render-context.ts:48` teaches the wrong path). Real bug: the `@autoSet` suffix + entire Zod tier are `timestampMode`-blind. Its "fix 2" (delegate `@autoSet` to the DB `DEFAULT now()` on PG) is already half-true — migrate emits that DEFAULT (T3) — so the remaining work is codegen-side coherence + documentation, all additive. | + +--- + +## 5. The cross-cutting pattern — real, and it has a name + +The maintainer suspected one pattern; the evidence supports it, with a precise shape: + +**Every logical concept in these domains has one blessed physical realization per layer, +and the layers disagree with each other and with the world — while the metamodel itself +is fine.** Concretely: *one* logical `@generation: increment` is realized as `serial` +(TS codegen), `IDENTITY` (TS migrate DDL), `autoIncrement()` (Kotlin), EF conventions +(C#) — and the adoption diff accepts only the realization migrate itself emits. *One* +logical instant is realized as `Z`-string (persistence wire), `+00:00`-microseconds +(Python REST), host-configured (C# REST), driver-dependent (TS prod). *One* logical +`@autoSet` is realized as app-stamp (codegen) *and* DB default (migrate), each unaware of +the other. + +The corollary is the actionable rule: **be conservative in what you emit, liberal in +what you accept at the adoption/introspection seam, and *pinned* at every serialization +seam.** The project already applies rule 1 rigorously (one blessed emission per dialect, +byte-identical gates). Rule 2 is where draft 01, T4, and the `CURRENT_TIMESTAMP` sibling +live — the diff engine currently treats "not the encoding I would have emitted" as drift. +Rule 3 is where Z1/Z2/#275 live — the wire is pinned only where the persistence harness +happens to stand. + +This also explains why the failures cluster on *adopters* rather than greenfield users: +greenfield never leaves the blessed path, so the conformance corpora (which are all +greenfield-shaped: fresh schema from the tool's own DDL, canonical seeds) structurally +cannot see any of it. The one corpus that fights this — the migrate idempotence gate +(apply → re-diff EMPTY) — is also the one that has caught the most bugs of this class +(0.15.21, 0.20.2). The missing sibling is an **adoption-idempotence gate**: create a +schema the way *other tools* create it (`serial`, `DEFAULT CURRENT_TIMESTAMP`, naive +timestamps), point `--from-db` metadata at it per the "metadata FOLLOWS the code" +doctrine, and require an empty diff. + +--- + +## 6. Recommendations, prioritized + +### Tier 0 — documentation (free; do first) + +1. **Rewrite `docs/features/field-types.md`**: correct the TS temporal binding to string + (+ the `timestampMode: "date"` opt-in and its current limits), add the missing + `field.time`/`float`/`decimal`/`uri`/`inet` rows, add `@localTime` and `@autoSet` to + the attr table with the instant-by-default rationale in one sentence (T1). +2. **Fix the `render-context.ts:44-48` comment** (`codegen.timestampMode` → top-level + `timestampMode`) and document `timestampMode` in the `@metaobjectsdev/cli` README next + to `columnNamingStrategy` (T2). +3. **Write the adoption recipes** the drafts prove are needed — a "Adopting an existing + Postgres schema" section (in `migrations-and-drift.md` or the authoring skill): + `serial` PK → `@generation: increment` (once I1 lands); `.defaultNow()`/`DEFAULT + now()` timestamp → `@autoSet` or `@default: "now"` and *when to pick which* (@autoSet = + optional-on-insert semantics + app stamp + DB default; @default = DB default only); + naive-UTC-convention columns → the conversion path (after Z3 lands) or `@localTime` + with its native-type consequences stated; `@generation: assigned`/ULID; composite-PK + API limitation (I4). +4. **Document the `@autoSet` ⇒ `DEFAULT now()` schema fact** (T3) and per-port `uuid` + minting side (I2) — one table in `generated-mutations.md`. +5. **Fix `server/java/codegen-kotlin/README.md:21,26`** (varchar default; the + `instantWithTimeZone` generated helper) (T7iii). +6. **Say the deliberate constraints out loud**: a short "temporal design stance" note + (could live in field-types.md): no `timetz`, no zone-name attr, no offset + preservation, ms wire resolution — each with its one-line rationale and the + workaround pattern (extra column). Turning silent refusals into stated stances is the + cheapest possible answer to "we force too hard". + +### Tier 1 — bug fixes, in place (additive, non-breaking) + +7. **I1 / draft 01**: extend the `diff/index.ts:394` guard — `increment` + live serial + pattern ⇒ not default-drift. Add the adoption-idempotence test (serial table → + `--from-db` → empty diff). +8. **T4**: reconcile the expected-side expression classifier with the introspect side + (any leading-identifier call = expr on both), and cover `CURRENT_TIMESTAMP` ≡ `now()` + equivalence in `columnDefaultsEqual` (PG-spelling normalization). Repro first. +9. **T2 / draft 02 fix 1**: make `autoSetSuffix` and the Zod `@autoSet` sites honor + `timestampMode` (emit `new Date()` when mode is `date`; keep string otherwise). + Consider a config-load warning for unknown top-level keys while there + (`normalizeConfig` currently swallows them silently). +10. **Z3 / T5**: the naive↔tz temporal `change-column-type` arm emits + `USING "col" AT TIME ZONE 'UTC'` (or refuses with a hint, matching the #226/#258 + refuse-don't-corrupt precedent). +11. **T6**: Java `SpringTypeMapper.localTimeOptIn` + OMDB `isLocalTime` → resolving reads + (ADR-0039); pin with a base-entity-`@localTime` conformance fixture. +12. **T7i**: C# `@localTime` `@autoSet` stamp → UTC wall clock + (`DateTime.UtcNow`-derived), matching Python; **I3**: Kotlin controller honors + `assigned` (delegate to the repository's existing branch); **T7ii**: emit the + precise time column type the conformance reference already hand-wrote. +13. **I4**: load-time or gen-time warning when generated CRUD is requested for a + composite-PK entity (until/unless the API tier supports composite addressing). + +### Tier 2 — the wire-contract consolidation (additive but cross-port; its own unit) + +14. **Z1/Z2**: pin the REST temporal wire form to the persistence form (`Z`, ms) — + add literal-assertion scenarios to the api-contract corpus on deterministic seeded + reads (both lanes), then bring the three divergent surfaces to it: Python routes gain + an encoder (or `response_model`), C# codegen registers the UTC converter it currently + only gets from the test host, TS generated stack owns a canonicalization seam + (scaffolded db.ts parser pin or a `runtime-ts` serializer). **The #275 fix should + target this same form now** (§3.2) so Java's JSON layer doesn't need a second pass. + This closes the docs-vs-reality gap on `api-contract.md:158` in the direction of the + docs. + +### Tier 3 — breaking or vocabulary (explicitly deferred; the 0.21.0 slot just closed) + +15. **No default flips are recommended.** `timestamptz`-by-default: keep (it is the + recommendation the naive-default ORMs are slowly migrating toward). TS + `timestampMode: "string"`: keep as default (it is what makes the TS wire coherent; + `date` becomes a *fully supported* opt-in after #9). +16. **No new vocabulary is proposed.** The one candidate examined — a physical + `@dbColumnType: "timestamp"` naive-storage escape for instant fields (the + Prisma-shaped adoption cell, T5) — passes ADR-0037 mechanically (§7) but fails the + ADR-0023 economics today: recipes #3 + the `USING` fix #10 cover the need with zero + vocabulary. Revisit only if a real adopter cannot take the column conversion. + +--- + +## 7. ADR-0037 gauntlet for the one vocabulary candidate (recorded so it isn't re-litigated) + +**Candidate:** `@dbColumnType: "timestamp"` on `field.timestamp` — instant native +semantics over naive physical storage (read-as-UTC convention). + +- **Step 0, derivable?** No — nothing in existing metadata distinguishes "naive column + holding UTC instants" from "naive column holding wall-clock" (that is `@localTime`'s + meaning). +- **Step 1, physical-only?** **Yes** — the native type and meaning stay instant + (`Instant`/`DateTimeOffset`/aware `datetime`/`Z`-string); only the column type changes. + So per ADR-0037 it is `@dbColumnType` territory, *not* a subtype or new attr — and + ADR-0039 already makes `@dbColumnType` the one deliberately own-only attr, which fits + (a physical storage concession should not inherit). +- **Steps 2a-c:** not reached (step 1 disposes of it). +- **ADR-0023 cost class:** registered-value extension on an existing attr + a + registry-conformance fixture + **five ports of codec work** (each must read naive as + UTC when the override is present) + it reverses the *spirit* of ADR-0036 §1's + retirement of `timestamp_with_tz` (which removed the physical-knob-for-tz-ness in the + aware direction). Verdict: mechanically legal, economically unjustified while the + conversion path exists. **Do not add without a named adopter who cannot convert.** + +No other candidate survives step 0/1: zone-name and offset-preservation are "second +column" patterns (derivable structure, not new semantics); precision is physical and +currently silently tolerated; new `@generation` values are blocked by the +shipping-consumer bar the project already ratified for `@role`. + +--- + +## 8. Non-goals and open questions for the maintainer + +**Non-goals (constraints this research confirms as correct and worth defending louder):** +instant-by-default; `@localTime` as attribute; no `timetz`/zone-attr/offset-preservation/ +partial dates; ms wire resolution; three-value `@generation`; moved-PK refusal; +schema-is-TS-owned (no port re-grew DDL — verified clean in Java/C#/Python). + +**Open questions needing a ruling:** + +1. **Composite-PK API stance (I4):** document-as-unsupported + warn, or commit to + composite addressing in generated CRUD? (Warn is cheap; support touches every port's + route/query generators.) +2. **REST wire pinning (Z1) sequencing:** pin-then-fix in one coordinated cut, or fix + ports individually behind the existing loose assertions first? A coordinated cut is + honest (the corpus change is what makes it real) but touches all five ports — likely + a MINOR by the versioning doctrine if any adopter-visible wire byte changes + (Python's `+00:00`→`Z` is adopter-visible). +3. **TS production canonicalization seam (Z2):** scaffolded driver-parser pin (smallest, + but per-driver) vs a serializer in `runtime-ts` (cleaner ADR-0019 fit, more surface)? +4. **Adoption-idempotence gate:** bless as a standing corpus (foreign-DDL fixtures → + `--from-db` → empty diff) alongside the migrate idempotence gate? This is the + structural fix for the whole §5 pattern, not just its two known instances. +5. **Draft 02 "fix 2"** (PG `@autoSet` delegating to `.defaultNow()` in the generated + Drizzle column too): worth the codegen churn given the migrate-side DEFAULT already + exists (T3), or is documenting the belt-and-suspenders reality enough? diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md index 1b105224d..7021005f5 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md @@ -89,9 +89,10 @@ concrete imports and signatures so you don't have to guess them. ## `codegen-spring` generators -All live in `metaobjects-codegen-spring` under +Most live in `metaobjects-codegen-spring` under `com.metaobjects.generator.spring.*`; wire any subset, typically all three of the -first group together: +first group together. (`JavaObjectCodeGenerator`, last row below, lives in the +separate `metaobjects-codegen-base` module instead.) | Generator | Output | |---|---| @@ -105,6 +106,7 @@ first group together: | `SpringRenderHelperGenerator` | the typed render helper for a `template.prompt` payload | | `LlmTraceHelperGenerator` | `TraceHelper.java` per concrete entity — the LLM-trace helper | | `SpringFilterAllowlistGenerator` | per-entity filter allowlist | +| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. | **Projections (read-only views).** An `object.projection` (read-only `source.rdb` `@kind: view` child) is served read-only through OMDB at the ObjectManager layer @@ -153,3 +155,69 @@ polymorphic + per-subtype-scoped repository seam the consumer implements against Spring Data JPA / JDBC. Conformance-gated by `fixtures/api-contract-conformance/tph` (HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table runtime semantics). + +## Serializing generated objects + +Two paths hand you a `MetaObjectAware` instance: (a) `JavaObjectCodeGenerator`'s +flavored codegen above (a `pojoAware` or `valueObject` class), and (b) the om/omdb +runtime (`ObjectManager.getObjects(...)` / `MetaObject.newInstance()` — see the +runtime-ui reference). **A default Jackson/Gson mapper over a `PojoObject` subtype +fails on the `MetaObject` back-reference** — the inherited `getMetaData()` getter +leads a bean-style mapper into the metadata graph, and on a modular JVM into +`InaccessibleObjectException`. This is expected, not a bug to work around. If you +want a type that serializes cleanly with a bare default mapper, use the +`codegen-spring` record surface (`SpringDtoGenerator` / `SpringPayloadGenerator` / +`SpringValueObjectGenerator`) instead — never `pojoAware`. + +Serialize any `MetaObjectAware` instance through the MetaObjects JSON layer's +`JsonObjectWriter`/`JsonObjectReader`, not a bare mapper — it applies the temporal +wire form below, and read/write round-trip through the same pair of calls: + +```java +import com.metaobjects.io.object.json.JsonObjectWriter; +import com.metaobjects.io.object.json.JsonObjectReader; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; + +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Path; + +MetaDataLoader loader = MetaDataLoader.fromDirectory("app", Path.of("src/main/metaobjects")); +MetaObject mo = loader.getMetaObjectByName("acme::blog::Author"); + +// pojoAware-flavor generated class: public Author(MetaObject mo) { super(mo); } +Author author = new Author(mo); +author.setName("Ada"); +author.setBirthDate(new java.util.Date()); // field.date + +// Write +StringWriter out = new StringWriter(); +JsonObjectWriter.writeObject(author, out); +String json = out.toString(); +// {"@type":"acme::blog::Author","name":"Ada","birthDate":"2026-06-03"} + +// Read +Author roundTripped = JsonObjectReader.readObject(Author.class, mo, new StringReader(json)); +``` + +**Wire form** (`field.date` / `field.timestamp`): + +| Field | Wire form | Example | +|---|---|---| +| `field.date` | calendar date of the instant at UTC — `YYYY-MM-DD` | `"2026-06-03"` | +| `field.timestamp` + `@localTime: true` | wall clock of the instant at UTC, no `Z` | `"2026-06-03T14:30:00.123"` | +| `field.timestamp` (default, tz-aware) | UTC instant, with `Z` | `"2026-06-03T14:30:00.123Z"` | + +Fraction is millisecond resolution, trailing zeros stripped, and the `.` plus +fraction omitted entirely when zero (`.123`→`.123`, `.120`→`.12`, `.100`→`.1`, +`.000`→omitted). A `null` value writes JSON `null`. Readers are tolerant and +backward-compatible: a JSON **number** is still read as **legacy epoch +milliseconds**; a JSON **string** is tried in order as an ISO instant (the `Z` +form) → a local date-time (no `Z`) → a date-only form, failing with a message +naming all three accepted forms. + +**Known bounded caveat:** a hand-constructed `field.date` value carrying a +sub-day time component writes as the calendar date only (truncated on first +write, stable thereafter) — this matches the shipped OMDB DATE codec, which +anchors DATE columns at midnight UTC. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md index f312b2bc7..f86c24d6b 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md @@ -53,6 +53,14 @@ rather than a throw. The payload record itself comes from `SpringPayloadGenerato — the parser is a companion to it, so the parser and payload VO can't silently drift. +Both `parse()` and `extractLenient(...)` here return **plain Java 21 records** — +safe with any mapper, nothing special needed. That's specific to this +`codegen-spring` extract tier: the codegen-base flavored `Extractor` and the +raw `MetaObjectExtractor` (the alternative extraction path, see the codegen +reference) return `MetaObjectAware` instances instead, and those need +`JsonObjectWriter`/`MetaObjectSerializer` — not a bare mapper — to serialize +correctly (see the codegen reference's "Serializing generated objects" section). + ## The output-format prompt fragment (FR-010) For every json/xml-format `template.output`, `codegen-spring`'s diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md index 393d7969a..5018454a8 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/java.md @@ -65,6 +65,22 @@ try { taking a `QueryOptions` (built from an `Expression`). `ValueObject` is the map-backed runtime carrier. +## Serializing a row + +A `ValueObject` **is** a `Map`, so a default Jackson +`ObjectMapper` map-serializes it without special configuration — you may not +hit a hard failure at all. The hard failure other shapes hit is the +**`pojoAware`** codegen flavor's bean shape (a public `getMetaData()` +back-reference a bean-style mapper walks into) and any direct Gson field walk +over a `MetaObjectAware` instance — an OMDB `ValueObject` row sidesteps both. + +Even so, the MetaObjects JSON layer (`JsonObjectWriter`/`JsonObjectReader`, +`com.metaobjects.io.object.json`) is the sanctioned path for an OMDB row +regardless of mapper friendliness — it's what applies the temporal wire form +(`field.date`/`field.timestamp` render per the cross-port contract; a default +mapper has no idea what shape those should take). See the codegen reference's +"Serializing generated objects" section for the write+read snippet. + ## Spring wiring `metaobjects-core-spring` (or the Spring Boot starter) declares an diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index b2a0b902d..88d99ea6f 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index b2a0b902d..88d99ea6f 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md index 1b105224d..7021005f5 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md @@ -89,9 +89,10 @@ concrete imports and signatures so you don't have to guess them. ## `codegen-spring` generators -All live in `metaobjects-codegen-spring` under +Most live in `metaobjects-codegen-spring` under `com.metaobjects.generator.spring.*`; wire any subset, typically all three of the -first group together: +first group together. (`JavaObjectCodeGenerator`, last row below, lives in the +separate `metaobjects-codegen-base` module instead.) | Generator | Output | |---|---| @@ -105,6 +106,7 @@ first group together: | `SpringRenderHelperGenerator` | the typed render helper for a `template.prompt` payload | | `LlmTraceHelperGenerator` | `TraceHelper.java` per concrete entity — the LLM-trace helper | | `SpringFilterAllowlistGenerator` | per-entity filter allowlist | +| `JavaObjectCodeGenerator` | module `metaobjects-codegen-base` (`com.metaobjects.generator.direct.object.javacode`), a separate module from the Spring generators above. Flavor-selected via the `flavor` generator arg. `flavor=pojoAware` → `class extends PojoObject` (a concrete `MetaObjectAware` class with a `(MetaObject)` constructor) — its inherited `getMetaData()` back-reference is what breaks a default Jackson/Gson mapper, see "Serializing generated objects" below. `flavor=valueObject` → `class extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. | **Projections (read-only views).** An `object.projection` (read-only `source.rdb` `@kind: view` child) is served read-only through OMDB at the ObjectManager layer @@ -153,3 +155,69 @@ polymorphic + per-subtype-scoped repository seam the consumer implements against Spring Data JPA / JDBC. Conformance-gated by `fixtures/api-contract-conformance/tph` (HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table runtime semantics). + +## Serializing generated objects + +Two paths hand you a `MetaObjectAware` instance: (a) `JavaObjectCodeGenerator`'s +flavored codegen above (a `pojoAware` or `valueObject` class), and (b) the om/omdb +runtime (`ObjectManager.getObjects(...)` / `MetaObject.newInstance()` — see the +runtime-ui reference). **A default Jackson/Gson mapper over a `PojoObject` subtype +fails on the `MetaObject` back-reference** — the inherited `getMetaData()` getter +leads a bean-style mapper into the metadata graph, and on a modular JVM into +`InaccessibleObjectException`. This is expected, not a bug to work around. If you +want a type that serializes cleanly with a bare default mapper, use the +`codegen-spring` record surface (`SpringDtoGenerator` / `SpringPayloadGenerator` / +`SpringValueObjectGenerator`) instead — never `pojoAware`. + +Serialize any `MetaObjectAware` instance through the MetaObjects JSON layer's +`JsonObjectWriter`/`JsonObjectReader`, not a bare mapper — it applies the temporal +wire form below, and read/write round-trip through the same pair of calls: + +```java +import com.metaobjects.io.object.json.JsonObjectWriter; +import com.metaobjects.io.object.json.JsonObjectReader; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; + +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Path; + +MetaDataLoader loader = MetaDataLoader.fromDirectory("app", Path.of("src/main/metaobjects")); +MetaObject mo = loader.getMetaObjectByName("acme::blog::Author"); + +// pojoAware-flavor generated class: public Author(MetaObject mo) { super(mo); } +Author author = new Author(mo); +author.setName("Ada"); +author.setBirthDate(new java.util.Date()); // field.date + +// Write +StringWriter out = new StringWriter(); +JsonObjectWriter.writeObject(author, out); +String json = out.toString(); +// {"@type":"acme::blog::Author","name":"Ada","birthDate":"2026-06-03"} + +// Read +Author roundTripped = JsonObjectReader.readObject(Author.class, mo, new StringReader(json)); +``` + +**Wire form** (`field.date` / `field.timestamp`): + +| Field | Wire form | Example | +|---|---|---| +| `field.date` | calendar date of the instant at UTC — `YYYY-MM-DD` | `"2026-06-03"` | +| `field.timestamp` + `@localTime: true` | wall clock of the instant at UTC, no `Z` | `"2026-06-03T14:30:00.123"` | +| `field.timestamp` (default, tz-aware) | UTC instant, with `Z` | `"2026-06-03T14:30:00.123Z"` | + +Fraction is millisecond resolution, trailing zeros stripped, and the `.` plus +fraction omitted entirely when zero (`.123`→`.123`, `.120`→`.12`, `.100`→`.1`, +`.000`→omitted). A `null` value writes JSON `null`. Readers are tolerant and +backward-compatible: a JSON **number** is still read as **legacy epoch +milliseconds**; a JSON **string** is tried in order as an ISO instant (the `Z` +form) → a local date-time (no `Z`) → a date-only form, failing with a message +naming all three accepted forms. + +**Known bounded caveat:** a hand-constructed `field.date` value carrying a +sub-day time component writes as the calendar date only (truncated on first +write, stable thereafter) — this matches the shipped OMDB DATE codec, which +anchors DATE columns at midnight UTC. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md index f312b2bc7..f86c24d6b 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md @@ -53,6 +53,14 @@ rather than a throw. The payload record itself comes from `SpringPayloadGenerato — the parser is a companion to it, so the parser and payload VO can't silently drift. +Both `parse()` and `extractLenient(...)` here return **plain Java 21 records** — +safe with any mapper, nothing special needed. That's specific to this +`codegen-spring` extract tier: the codegen-base flavored `Extractor` and the +raw `MetaObjectExtractor` (the alternative extraction path, see the codegen +reference) return `MetaObjectAware` instances instead, and those need +`JsonObjectWriter`/`MetaObjectSerializer` — not a bare mapper — to serialize +correctly (see the codegen reference's "Serializing generated objects" section). + ## The output-format prompt fragment (FR-010) For every json/xml-format `template.output`, `codegen-spring`'s diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md index 393d7969a..5018454a8 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-runtime-ui/references/java.md @@ -65,6 +65,22 @@ try { taking a `QueryOptions` (built from an `Expression`). `ValueObject` is the map-backed runtime carrier. +## Serializing a row + +A `ValueObject` **is** a `Map`, so a default Jackson +`ObjectMapper` map-serializes it without special configuration — you may not +hit a hard failure at all. The hard failure other shapes hit is the +**`pojoAware`** codegen flavor's bean shape (a public `getMetaData()` +back-reference a bean-style mapper walks into) and any direct Gson field walk +over a `MetaObjectAware` instance — an OMDB `ValueObject` row sidesteps both. + +Even so, the MetaObjects JSON layer (`JsonObjectWriter`/`JsonObjectReader`, +`com.metaobjects.io.object.json`) is the sanctioned path for an OMDB row +regardless of mapper friendliness — it's what applies the temporal wire form +(`field.date`/`field.timestamp` render per the cross-port contract; a default +mapper has no idea what shape those should take). See the codegen reference's +"Serializing generated objects" section for the write+read snippet. + ## Spring wiring `metaobjects-core-spring` (or the Spring Boot starter) declares an diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index 11bd2b2ed..b7c2afb7d 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index 11bd2b2ed..b7c2afb7d 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index f3d70d619..2fcd9bd77 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `metaobjects g - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index f3d70d619..2fcd9bd77 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `metaobjects g - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index 4ad172056..9503c3dba 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `npx meta gen` - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index 4ad172056..9503c3dba 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `npx meta gen` - Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions). - Use the generated constants for any string that names metadata. - The loaded metadata model is READ-ONLY — never inject nodes or mutate the tree at load time (no "enrich the model on load" hooks). Need an extra field/column? Author it in the metadata, or derive it during codegen (read the metadata, emit output). Mutating the loaded model makes it diverge from what's declared — a bad practice reserved for very rare cases. +- **JVM:** serialize a MetaObject-backed instance (a `pojoAware`-flavor generated class, a runtime `ValueObject`, or any `MetaObjectAware` type) through the MetaObjects JSON layer — never hand-configure a Jackson/Gson mapper around the framework fields to make a default mapper cope. ## Authoring rules you must not violate - Nodes are fused-key maps: `{".": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys. diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java index b0bc9c5f7..e72796ca5 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java @@ -148,7 +148,7 @@ private static String canonicalDecimal(BigDecimal d) { private static String fractionalSuffix(int nanos) { long millis = nanos / 1_000_000L; if (millis == 0) return ""; - String s = String.format("%03d", millis).replaceAll("0+$", ""); + String s = String.format(java.util.Locale.ROOT, "%03d", millis).replaceAll("0+$", ""); return "." + s; } diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/json/TemporalWireFormat.java b/server/java/metadata/src/main/java/com/metaobjects/io/json/TemporalWireFormat.java new file mode 100644 index 000000000..ab9732187 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/io/json/TemporalWireFormat.java @@ -0,0 +1,121 @@ +package com.metaobjects.io.json; + +import com.metaobjects.field.DateField; +import com.metaobjects.field.MetaField; +import com.metaobjects.field.TimestampField; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Date; + +/** + * Shared wire-form contract for {@code DataTypes.DATE} fields ({@code field.date} and + * {@code field.timestamp}) — used by the Gson serializer/deserializer + * ({@link com.metaobjects.io.object.gson.MetaObjectSerializer} / + * {@link com.metaobjects.io.object.gson.MetaObjectDeserializer}) and the streaming + * {@link com.metaobjects.io.object.json.JsonObjectReader}. One implementation, not three (#275). + * + *

Per {@code fixtures/persistence-conformance/normalization.md} (the cross-port wire-form + * source of truth): + *

    + *
  • {@code field.date} — calendar date of the instant at UTC: {@code "YYYY-MM-DD"} + * (e.g. {@code "2026-06-03"})
  • + *
  • {@code field.timestamp} with {@code @localTime: true} — naive wall clock of the + * instant at UTC, no {@code Z}: {@code "YYYY-MM-DDTHH:MM:SS[.fff]"} + * (e.g. {@code "2026-06-03T14:30:00.123"})
  • + *
  • {@code field.timestamp} (default, tz-aware) — UTC instant: + * {@code "YYYY-MM-DDTHH:MM:SS[.fff]Z"} (e.g. {@code "2026-06-03T14:30:00.123Z"})
  • + *
+ * The fraction is millisecond resolution, trailing zeros stripped, and omitted (with its + * leading {@code .}) entirely when zero: {@code .123}→{@code .123}, {@code .120}→{@code .12}, + * {@code .100}→{@code .1}, {@code .000}→omitted. + * + *

Known bounded caveat: a hand-constructed {@code DateField} value carrying a sub-day + * time component writes as the calendar date only (truncation on first write, stable + * thereafter). This matches the shipped OMDB DATE codec, which anchors DATE columns at + * midnight UTC — not something this class should "fix". + */ +public final class TemporalWireFormat { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter TIMESTAMP_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + private TemporalWireFormat() {} + + /** + * Write-side: format {@code d} per {@code mf}'s subtype + * ({@link DateField#SUBTYPE_DATE} / {@link TimestampField#SUBTYPE_TIMESTAMP}) and, for a + * timestamp, whether {@link TimestampField#ATTR_LOCAL_TIME} is set. All conversion is at + * {@link ZoneOffset#UTC} from {@code Instant.ofEpochMilli(d.getTime())}. + * + * @param mf the field carrying {@code d} (its subtype/attrs pick the wire shape) + * @param d the value to format, or {@code null} + * @return the wire string, or {@code null} if {@code d} is {@code null} + */ + public static String format(MetaField mf, Date d) { + if (d == null) return null; + + Instant instant = Instant.ofEpochMilli(d.getTime()); + + if (DateField.SUBTYPE_DATE.equals(mf.getSubType())) { + return instant.atZone(ZoneOffset.UTC).toLocalDate().format(DATE_FMT); + } + + // The only other DataTypes.DATE-backed subtype is field.timestamp. + LocalDateTime wallClock = instant.atZone(ZoneOffset.UTC).toLocalDateTime(); + String base = wallClock.format(TIMESTAMP_FMT) + fractionalSuffix(wallClock.getNano()); + + boolean localTime = mf.hasMetaAttr(TimestampField.ATTR_LOCAL_TIME) + && Boolean.parseBoolean(mf.getMetaAttr(TimestampField.ATTR_LOCAL_TIME).getValueAsString()); + + return localTime ? base : base + "Z"; + } + + /** + * Read-side: tolerant parse, tried in order: {@link Instant#parse} (the {@code Z} form), + * {@link LocalDateTime#parse} anchored at UTC (the no-{@code Z} form), then + * {@link LocalDate#parse} at midnight UTC (the date-only form). + * + * @param s the wire string + * @return the parsed {@link Date} + * @throws IllegalArgumentException if none of the three forms parse {@code s}; the message + * names the accepted forms. Callers add field context on top (matching each call + * site's existing error-wrapping convention), since this helper has no MetaField. + */ + public static Date parse(String s) { + try { + return Date.from(Instant.parse(s)); + } catch (DateTimeParseException eInstant) { + try { + return Date.from(LocalDateTime.parse(s).toInstant(ZoneOffset.UTC)); + } catch (DateTimeParseException eLocalDateTime) { + try { + return Date.from(LocalDate.parse(s).atStartOfDay(ZoneOffset.UTC).toInstant()); + } catch (DateTimeParseException eLocalDate) { + throw new IllegalArgumentException( + "Cannot parse temporal value [" + s + "]; accepted forms are an ISO instant " + + "(\"YYYY-MM-DDTHH:MM:SS[.fff]Z\"), a local date-time " + + "(\"YYYY-MM-DDTHH:MM:SS[.fff]\"), or a date (\"YYYY-MM-DD\")", eLocalDate); + } + } + } + } + + /** + * Millisecond-resolution fraction, trailing zeros stripped, omitted (with its leading + * {@code .}) when zero. Mirrors {@code Normalization.fractionalSuffix} in + * {@code integration-tests} ({@code metadata} cannot depend on that test-only module, so + * this is a fresh implementation of the same rule, not a shared import). + */ + private static String fractionalSuffix(int nanos) { + long millis = nanos / 1_000_000L; + if (millis == 0) return ""; + String s = String.format(java.util.Locale.ROOT, "%03d", millis).replaceAll("0+$", ""); + return "." + s; + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectDeserializer.java b/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectDeserializer.java index 0c3492269..f97e4b67e 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectDeserializer.java +++ b/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectDeserializer.java @@ -5,6 +5,7 @@ import com.metaobjects.io.MetaDataIOException; import com.metaobjects.io.json.JsonIOConstants; import com.metaobjects.io.json.JsonSerializationHandler; +import com.metaobjects.io.json.TemporalWireFormat; import com.metaobjects.io.json.raw.GsonSerializationHandler; import com.metaobjects.io.string.StringSerializationHandler; import com.metaobjects.loader.MetaDataLoader; @@ -16,6 +17,7 @@ import java.io.IOException; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Date; import java.util.Iterator; import java.util.List; @@ -107,7 +109,6 @@ protected void readFieldValue(MetaObject mo, MetaField mf, Object vo, } break; - case DATE: case LONG: // Check if this is an array field using universal @isArray support if (mf.isArrayType()) { @@ -118,6 +119,34 @@ protected void readFieldValue(MetaObject mo, MetaField mf, Object vo, } break; + case DATE: + // #275: DATE used to share LONG's branch unconditionally, which only ever + // worked for the legacy epoch-millis form. Number -> epoch millis (kept, the + // existing LONG coercion path -- this is what makes the release a PATCH: + // nothing that parsed before stops parsing). String -> tolerant ISO parse + // (TemporalWireFormat). Array: element-wise into a List via + // setObjectArray, skipping context.deserialize(el, List.class), which yields a + // type-losing List. setObjectArray only bypasses MetaField's OWN + // DataConverter.toType call; setObjectAttribute still routes through + // AbstractObjectRepresentation.setValue, which unconditionally applies + // DataConverter.toType(effectiveDataType, value) -- and DataConverter's + // DATE_ARRAY case is unimplemented (unsupported()). So today this branch + // throws UnsupportedOperationException for a non-empty date array on the + // default (non-proxy) representation path; it becomes correct once + // DataConverter grows a DATE_ARRAY conversion. + if (mf.isArrayType() && el.isJsonArray()) { + List dates = new ArrayList<>(); + for (JsonElement item : el.getAsJsonArray()) { + dates.add(readDateElement(mf, item)); + } + mf.setObjectArray(vo, dates); + } else if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isNumber()) { + mf.setLong(vo, el.getAsLong()); + } else { + mf.setDate(vo, parseDate(mf, el.getAsString())); + } + break; + case FLOAT: case DOUBLE: // Check if this is an array field using universal @isArray support @@ -174,6 +203,24 @@ protected void readFieldValue(MetaObject mo, MetaField mf, Object vo, } } + /** Single JSON array element of a DATE-array field: number -> epoch millis, string -> tolerant ISO parse. */ + private Date readDateElement(MetaField mf, JsonElement el) { + if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isNumber()) { + return new Date(el.getAsLong()); + } + return parseDate(mf, el.getAsString()); + } + + /** TemporalWireFormat.parse has no MetaField context; add it here, matching this class's + * existing MetaDataException-with-field-name convention (see getObjectRefClass/readFieldObject). */ + private Date parseDate(MetaField mf, String s) { + try { + return TemporalWireFormat.parse(s); + } catch (IllegalArgumentException e) { + throw new MetaDataException("Error reading MetaField [" + mf + "]: " + e.getMessage(), e); + } + } + private Class getObjectRefClass(MetaField mf) { MetaObject refmo = null; diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectGsonInitializer.java b/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectGsonInitializer.java index f2c73d80c..f12d06b3a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectGsonInitializer.java +++ b/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectGsonInitializer.java @@ -59,9 +59,9 @@ private final static GsonBuilder addAdaptersToBuilder(MetaDataLoader loader, Gso if (hasMultipleClasses( nameClassMap, entry.getKey(), clazz)) { if (!classList.contains( clazz)) { - //if (addSerializer) + if (addSerializer) builder.registerTypeAdapter(clazz, new MetaObjectSerializer(loader, true)); - //if (addDeserializer) + if (addDeserializer) builder.registerTypeAdapter(clazz, new MetaObjectDeserializer(loader, true)); classList.add(clazz); } @@ -69,9 +69,9 @@ private final static GsonBuilder addAdaptersToBuilder(MetaDataLoader loader, Gso // Otherwise, add the specific class implementation else { - //if (addSerializer) + if (addSerializer) builder.registerTypeAdapter( clazz, new MetaObjectSerializer( mo)); - //if (addDeserializer) + if (addDeserializer) builder.registerTypeAdapter( clazz, new MetaObjectDeserializer( mo)); } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectSerializer.java b/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectSerializer.java index 67cd91582..11263b3e0 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectSerializer.java +++ b/server/java/metadata/src/main/java/com/metaobjects/io/object/gson/MetaObjectSerializer.java @@ -2,6 +2,7 @@ import com.metaobjects.field.MetaField; import com.metaobjects.io.json.JsonIOConstants; +import com.metaobjects.io.json.TemporalWireFormat; import com.metaobjects.io.json.raw.GsonSerializationHandler; import com.metaobjects.io.string.StringSerializationHandler; import com.metaobjects.loader.MetaDataLoader; @@ -64,26 +65,58 @@ protected void writeField(MetaObject mo, MetaField mf, Object vo, switch (mf.getDataType()) { case BOOLEAN: - jsonObject.addProperty(name, mf.getBoolean(vo)); + // #275 write-side isArray gap: mf.getBoolean(vo) is DataConverter.toBoolean(), + // which has no List case and falls through to a bracketed-toString-derived + // guess for an array-valued attribute. Mirror the deserializer's isArrayType() + // branch: Gson natively serializes a List as a JSON array. + if (mf.isArrayType()) jsonObject.add(name, context.serialize(mf.getObject(vo))); + else jsonObject.addProperty(name, mf.getBoolean(vo)); break; case BYTE: case SHORT: case INT: - jsonObject.addProperty(name, mf.getInt(vo)); + if (mf.isArrayType()) jsonObject.add(name, context.serialize(mf.getObject(vo))); + else jsonObject.addProperty(name, mf.getInt(vo)); break; - case DATE: // TODO: consider custom DATE serialization - jsonObject.add(name, context.serialize(vo)); + case DATE: { + // #275: this branch used to hand back `vo` (the CONTAINING object, not the + // field value) to context.serialize(vo) -- MetaObjectGsonInitializer registers + // this serializer against the VO's own class, so that re-dispatched to the + // SAME serializer for the SAME instance: unbounded recursion, StackOverflowError, + // on every field.date/field.timestamp write, even when the value was null (the + // branch never read the field at all). Wire form: TemporalWireFormat. + if (mf.isArrayType()) { + java.util.List dates = mf.getObjectArray(vo); + if (dates == null) { + jsonObject.add(name, JsonNull.INSTANCE); + } else { + JsonArray arr = new JsonArray(); + for (Object o : dates) { + java.util.Date d = (java.util.Date) o; + if (d == null) arr.add(JsonNull.INSTANCE); + else arr.add(TemporalWireFormat.format(mf, d)); + } + jsonObject.add(name, arr); + } + } else { + java.util.Date d = mf.getDate(vo); + if (d == null) jsonObject.add(name, JsonNull.INSTANCE); + else jsonObject.addProperty(name, TemporalWireFormat.format(mf, d)); + } break; + } case LONG: - jsonObject.addProperty(name, mf.getLong(vo)); + if (mf.isArrayType()) jsonObject.add(name, context.serialize(mf.getObject(vo))); + else jsonObject.addProperty(name, mf.getLong(vo)); break; case FLOAT: case DOUBLE: - jsonObject.addProperty(name, mf.getDouble(vo)); + if (mf.isArrayType()) jsonObject.add(name, context.serialize(mf.getObject(vo))); + else jsonObject.addProperty(name, mf.getDouble(vo)); break; case DECIMAL: @@ -97,7 +130,8 @@ protected void writeField(MetaObject mo, MetaField mf, Object vo, break; case STRING: - jsonObject.addProperty(name, mf.getString(vo)); + if (mf.isArrayType()) jsonObject.add(name, context.serialize(mf.getObject(vo))); + else jsonObject.addProperty(name, mf.getString(vo)); break; case OBJECT: diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectReader.java b/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectReader.java index 1cb8dbd0a..302ec6229 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectReader.java +++ b/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectReader.java @@ -5,11 +5,13 @@ import com.metaobjects.io.json.JsonMetaDataReader; import com.metaobjects.MetaDataNotFoundException; import com.metaobjects.io.json.JsonSerializationHandler; +import com.metaobjects.io.json.TemporalWireFormat; import com.metaobjects.io.object.gson.MetaObjectGsonInitializer; import com.metaobjects.io.string.StringSerializationHandler; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.object.MetaObject; import com.google.gson.JsonIOException; +import com.google.gson.stream.JsonToken; import java.io.IOException; import java.io.Reader; @@ -42,7 +44,7 @@ public static T readObject( Class clazz, MetaObject mo, Reader reader ) t public Object read(MetaObject mo ) throws IOException { - MetaObjectGsonInitializer.addSerializersToBuilder( getLoader(), builder()); + MetaObjectGsonInitializer.addDeserializersToBuilder( getLoader(), builder()); try { Class c = mo.getObjectClass(); @@ -156,7 +158,17 @@ protected void readFieldValue(MetaObject mo, MetaField mf, Object vo) throws IOE case BYTE: case SHORT: case INT: mf.setInt( vo, in().nextInt() ); break; - case DATE: + case DATE: { + // #275: split from LONG -- a number is still the legacy epoch-millis form, + // but a string is now the tolerant ISO wire form (TemporalWireFormat), not a + // long parsed straight out of nextLong(). + if ( in().peek() == JsonToken.NUMBER ) { + mf.setLong( vo, in().nextLong() ); + } else { + mf.setDate( vo, TemporalWireFormat.parse( in().nextString() ) ); + } + break; + } case LONG: mf.setLong( vo, in().nextLong() ); break; case FLOAT: case DOUBLE: mf.setDouble( vo, in().nextDouble() ); break; @@ -175,7 +187,10 @@ protected void readFieldValue(MetaObject mo, MetaField mf, Object vo) throws IOE throw new MetaDataIOException( this, "DataType ["+mf.getDataType()+"] not supported ["+mf+"]"); } } - catch (IOException e) { + catch (IOException | IllegalArgumentException e) { + // IllegalArgumentException: TemporalWireFormat.parse (#275) has no MetaField context + // of its own, so wrap it here the same way an IOException already is -- naming the + // field, consistent with this method's existing convention. throw new MetaDataIOException( this, "Error reading MetaField ["+mf+"]: "+e, e ); } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectWriter.java b/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectWriter.java index 5ffdaf356..8f1d152a7 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectWriter.java +++ b/server/java/metadata/src/main/java/com/metaobjects/io/object/json/JsonObjectWriter.java @@ -22,8 +22,11 @@ public JsonObjectWriter(MetaDataLoader loader, Writer writer ) throws IOExceptio public static void writeObject(MetaDataAware o, Writer out) throws IOException { + // #275: setDefaultDateFormat() set Gson's locale-dependent DateFormat.FULL, evidence + // this call site was never finished -- superseded by the explicit TemporalWireFormat + // handling in MetaObjectSerializer. The setDefaultDateFormat/setDateFormat methods stay + // on JsonMetaDataWriter (public-ish surface, no other caller); only this call is removed. JsonObjectWriter writer = new JsonObjectWriter(o.getMetaData().getLoader(), out); - writer.setDefaultDateFormat(); writer.write(o); writer.close(); } diff --git a/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonArrayWriteRoundTripTest.java b/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonArrayWriteRoundTripTest.java new file mode 100644 index 000000000..99125011e --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonArrayWriteRoundTripTest.java @@ -0,0 +1,317 @@ +package com.metaobjects.io.object.gson; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.metaobjects.InvalidValueException; +import com.metaobjects.field.MetaField; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.object.value.ValueObject; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Sibling defect to #275, found while fixing it: {@code MetaObjectSerializer.writeField} has no + * {@code isArrayType()} check on any primitive branch, unlike {@code MetaObjectDeserializer} + * (which does, symmetrically, on the read side). Every primitive branch unconditionally called a + * scalar accessor ({@code mf.getBoolean(vo)}, {@code mf.getInt(vo)}, ...), each of which is + * {@code DataConverter.toX(getObjectAttribute(obj))} — for an array-valued field the raw stored + * attribute is a {@code List}, and {@code DataConverter.toX} has no {@code List} case for most + * primitive types, so it falls through to the bracketed native {@code List.toString()} (or, for + * STRING, a comma-join) — silent round-trip corruption. + * + *

Test-setup note: {@code MetaField.setObject(Object,Object)} (the method backing + * {@code setBoolean}/{@code setInt}/{@code setLong}/{@code setDouble}/{@code setStringArray}) is + * itself broken for an {@code isArray} field — it converts via {@code DataConverter.toType( + * getDataType(), value)}, the field's SCALAR (not effective/array) type, so a {@code List} is + * corrupted before {@code setObjectAttribute}'s own instanceof check rejects it. This is a + * separate, pre-existing, out-of-scope defect (see the "discovered but out of scope" tests below) + * — NOT fixed here. It means the standard {@code mf.setX(vo, list)} entry points cannot be used to + * build array-valued fixtures. Instead, array fields here are populated via {@link ValueObject}'s + * own {@code Map} interface ({@code vo.put(name, list)}), which routes through + * {@code DataObjectBase._setObjectAttribute} — a DIFFERENT storage path that correctly converts + * via the field's EFFECTIVE (array) data type — for every type this task's write-side fix covers + * EXCEPT {@code field.date}/{@code field.timestamp}, whose {@code DATE_ARRAY} conversion is wholly + * unimplemented in {@code DataConverter} (see the DATE tests, which instead call {@code + * MetaObjectSerializer.writeField} directly against a plain {@code Map}-backed value object, + * sidestepping the storage layer entirely to isolate the WRITE-side logic under test). + */ +public class GsonArrayWriteRoundTripTest { + + private static final String ARRAY_TYPE = "test::arrays::ArrayThing"; + + protected MetaDataLoader arrayLoader; + + @Before + public void initLoader() throws ClassNotFoundException { + arrayLoader = MetaDataLoader.fromResources("gson-array-write-values", Arrays.asList( + "com/metaobjects/io/object/gson/array-primitive-metadata.json" + )); + } + + private static Date utc(int y, int mo, int d, int h, int mi, int s, int ms) { + return Date.from(LocalDateTime.of(y, mo, d, h, mi, s, ms * 1_000_000) + .toInstant(ZoneOffset.UTC)); + } + + private MetaObject arrayMetaObject() { + return arrayLoader.getMetaObjectByName(ARRAY_TYPE); + } + + private ValueObject newArrayThing() { + return (ValueObject) arrayMetaObject().newInstance(); + } + + private MetaField arrayField(String name) { + return arrayMetaObject().getMetaField(name); + } + + // ----------------------------------------------------------------------- + // Step 1 — the comma-join pin (STRING). + // ----------------------------------------------------------------------- + + @Test + public void stringArray_writesProperJsonArray_notCommaJoinedString() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("tags", Arrays.asList("a", "b")); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + JsonElement el = obj.get("tags"); + + Assert.assertTrue("expected a JSON array, was: " + el, el.isJsonArray()); + JsonArray arr = el.getAsJsonArray(); + Assert.assertEquals(2, arr.size()); + Assert.assertEquals("a", arr.get(0).getAsString()); + Assert.assertEquals("b", arr.get(1).getAsString()); + } + + // ----------------------------------------------------------------------- + // Step 2 — round trip for the other touched primitive branches. + // ----------------------------------------------------------------------- + + @Test + public void intArray_writesProperJsonArray() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("counts", Arrays.asList(1, 2, 3)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + JsonElement el = obj.get("counts"); + + Assert.assertTrue("expected a JSON array, was: " + el, el.isJsonArray()); + JsonArray arr = el.getAsJsonArray(); + Assert.assertEquals(3, arr.size()); + Assert.assertEquals(1, arr.get(0).getAsInt()); + Assert.assertEquals(2, arr.get(1).getAsInt()); + Assert.assertEquals(3, arr.get(2).getAsInt()); + } + + @Test + public void longArray_writesProperJsonArray() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("bigCounts", Arrays.asList(10L, 20L)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + JsonElement el = obj.get("bigCounts"); + + Assert.assertTrue("expected a JSON array, was: " + el, el.isJsonArray()); + JsonArray arr = el.getAsJsonArray(); + Assert.assertEquals(2, arr.size()); + Assert.assertEquals(10L, arr.get(0).getAsLong()); + Assert.assertEquals(20L, arr.get(1).getAsLong()); + } + + @Test + public void booleanArray_writesProperJsonArray() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("flags", Arrays.asList(true, false, true)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + JsonElement el = obj.get("flags"); + + Assert.assertTrue("expected a JSON array, was: " + el, el.isJsonArray()); + JsonArray arr = el.getAsJsonArray(); + Assert.assertEquals(3, arr.size()); + Assert.assertTrue(arr.get(0).getAsBoolean()); + Assert.assertFalse(arr.get(1).getAsBoolean()); + Assert.assertTrue(arr.get(2).getAsBoolean()); + } + + @Test + public void doubleArray_writesProperJsonArray() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("amounts", Arrays.asList(1.5, 2.25)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + JsonElement el = obj.get("amounts"); + + Assert.assertTrue("expected a JSON array, was: " + el, el.isJsonArray()); + JsonArray arr = el.getAsJsonArray(); + Assert.assertEquals(2, arr.size()); + Assert.assertEquals(1.5, arr.get(0).getAsDouble(), 0.0001); + Assert.assertEquals(2.25, arr.get(1).getAsDouble(), 0.0001); + } + + // ----------------------------------------------------------------------- + // Step 3 — DATE array. field.date/field.timestamp isArray has NO working storage path + // ANYWHERE in this codebase today (DataConverter.toType has no DATE_ARRAY case, hit + // unconditionally by AbstractObjectRepresentation.setValue regardless of entry point -- + // see the class Javadoc). So these tests call MetaObjectSerializer.writeField DIRECTLY + // (same package; protected access) against a plain Map-backed "value object", which is + // read via AbstractObjectRepresentation.getValue's Map branch (a raw, unconverted get) -- + // isolating the WRITE-side logic under test from that separate, out-of-scope defect. + // ----------------------------------------------------------------------- + + private JsonObject writeDatesField(List dates) { + MetaObject mo = arrayMetaObject(); + MetaField mf = arrayField("dates"); + Map vo = new HashMap<>(); + vo.put("dates", dates); + + JsonObject jsonObject = new JsonObject(); + new MetaObjectSerializer(mo).writeField(mo, mf, vo, jsonObject, null); + return jsonObject; + } + + @Test + public void dateArray_writesJsonArrayOfWireFormStrings() { + JsonObject obj = writeDatesField(Arrays.asList( + utc(2026, 6, 3, 0, 0, 0, 0), + utc(2026, 7, 4, 0, 0, 0, 0))); + JsonElement el = obj.get("dates"); + + Assert.assertTrue("expected a JSON array, was: " + el, el.isJsonArray()); + JsonArray arr = el.getAsJsonArray(); + Assert.assertEquals(2, arr.size()); + Assert.assertEquals("2026-06-03", arr.get(0).getAsString()); + Assert.assertEquals("2026-07-04", arr.get(1).getAsString()); + } + + @Test + public void dateArray_withNullElement_writesJsonNullAtThatPosition() { + JsonObject obj = writeDatesField(Arrays.asList(utc(2026, 6, 3, 0, 0, 0, 0), null)); + JsonArray arr = obj.get("dates").getAsJsonArray(); + + Assert.assertEquals(2, arr.size()); + Assert.assertEquals("2026-06-03", arr.get(0).getAsString()); + Assert.assertTrue("expected JSON null at index 1, was: " + arr.get(1), arr.get(1).isJsonNull()); + } + + @Test + public void dateArray_nullArrayItself_writesJsonNullForWholeField() { + JsonObject obj = writeDatesField(null); + JsonElement el = obj.get("dates"); + + Assert.assertTrue("expected JSON null for the whole field, was: " + el, el.isJsonNull()); + } + + // ----------------------------------------------------------------------- + // Step 5 — round trip through the existing (unmodified) MetaObjectDeserializer. + // + // BLOCKED by a separate, pre-existing, out-of-scope defect: MetaObjectDeserializer's own + // array-read branches populate the field via MetaField.setStringArray/setObject/setObjectArray + // -- and MetaField.setObject(Object,Object) converts via DataConverter.toType(getDataType(), + // value), the field's SCALAR type, not its EFFECTIVE (array) type -- so a List is corrupted + // (STRING: comma-joined to "a,b"; numeric types: bracketed toString() fed to a parser) BEFORE + // setObjectAttribute's own instanceof-against-List check rejects it. This affects every + // BOOLEAN/BYTE/SHORT/INT/LONG/FLOAT/DOUBLE/STRING isArray field via MetaField.setObject, and + // separately affects DATE via DataConverter's wholly-unimplemented DATE_ARRAY case. It is + // NOT specific to Gson or to this task's write-side fix -- it would break ANY caller trying to + // populate an isArray primitive field through MetaField's typed setters, deserializer included. + // Out of scope per the brief's Stop-and-escalate clause (fixing it requires touching MetaField + // and/or DataConverter, not MetaObjectSerializer.writeField). Pinned here, not fixed, as + // evidence + a regression guard for whenever that separate defect is addressed. + // ----------------------------------------------------------------------- + + @Test + public void stringArray_roundTripThroughDeserializer_blockedByPreexistingSetterBug() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("tags", Arrays.asList("a", "b")); + String json = gson.toJson(vo); + + try { + gson.fromJson(json, ValueObject.class); + Assert.fail("Expected the pre-existing MetaField.setObject scalar-dataType bug to " + + "throw; if this now succeeds, that separate defect has been fixed and this " + + "test should be replaced with a real round-trip assertion."); + } catch (InvalidValueException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("expected class")); + } + } + + @Test + public void longArray_roundTripThroughDeserializer_blockedByPreexistingSetterBug() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + vo.put("bigCounts", Arrays.asList(10L, 20L)); + String json = gson.toJson(vo); + + try { + gson.fromJson(json, ValueObject.class); + Assert.fail("Expected the pre-existing MetaField.setObject scalar-dataType bug to " + + "throw; if this now succeeds, that separate defect has been fixed and this " + + "test should be replaced with a real round-trip assertion."); + } catch (NumberFormatException e) { + // DataConverter.toLong(list.toString()) -- see class Javadoc. The elements read + // ".0"-suffixed (Gson's context.deserialize(el, List.class) widens JSON numbers to + // Double by default, absent generic type info -- the pre-existing, separately + // out-of-scope numeric-widening wart the brief itself calls out) rather than the + // "[10, 20]" the ORIGINAL List above would stringify to; this asserts what the + // round trip actually produces, not what the write-side input looked like. + Assert.assertTrue(e.getMessage(), e.getMessage().contains("[10.0, 20.0]")); + } + } + + // ----------------------------------------------------------------------- + // Step 6 — no-churn pins: every SCALAR (non-array) field of every touched type + // still serializes byte-identically to before this change. DECIMAL (untouched + // branch) also stays byte-identical. + // ----------------------------------------------------------------------- + + @Test + public void scalarFields_serializeUnaffectedByArraySupport() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(arrayLoader).create(); + + ValueObject vo = newArrayThing(); + arrayField("label").setString(vo, "solo"); + arrayField("count").setInt(vo, 7); + arrayField("bigCount").setLong(vo, 70L); + arrayField("flag").setBoolean(vo, true); + arrayField("amount").setDouble(vo, 3.5); + arrayField("day").setDate(vo, utc(2026, 6, 3, 0, 0, 0, 0)); + arrayField("price").setDecimal(vo, new java.math.BigDecimal("19.99")); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + + Assert.assertEquals("solo", obj.get("label").getAsString()); + Assert.assertEquals(7, obj.get("count").getAsInt()); + Assert.assertEquals(70L, obj.get("bigCount").getAsLong()); + Assert.assertTrue(obj.get("flag").getAsBoolean()); + Assert.assertEquals(3.5, obj.get("amount").getAsDouble(), 0.0001); + Assert.assertEquals("2026-06-03", obj.get("day").getAsString()); + Assert.assertEquals(new java.math.BigDecimal("19.99"), obj.get("price").getAsBigDecimal()); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonTemporalRoundTripTest.java b/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonTemporalRoundTripTest.java new file mode 100644 index 000000000..dabec21ce --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonTemporalRoundTripTest.java @@ -0,0 +1,293 @@ +package com.metaobjects.io.object.gson; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.metaobjects.field.MetaField; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.object.value.ValueObject; +import com.metaobjects.test.proxy.fruitbasket.Apple; +import com.metaobjects.test.proxy.fruitbasket.Orange; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; + +/** + * #275 — {@code MetaObjectSerializer.writeField}'s {@code case DATE:} handed back the + * containing object to {@code context.serialize(vo)}. Since + * {@code MetaObjectGsonInitializer} registers the serializer against the VO's own class, + * that re-dispatches to the same serializer for the same instance: unbounded recursion, + * {@link StackOverflowError}, on every {@code field.date}/{@code field.timestamp} write — + * even when the value is {@code null}, since the branch never read the field. + * + *

Pins the fix described by {@code fixtures/persistence-conformance/normalization.md} + * (the cross-port wire-form contract) as implemented by + * {@link com.metaobjects.io.json.TemporalWireFormat}. + */ +public class GsonTemporalRoundTripTest { + + private static final String TEMPORAL_TYPE = "test::temporal::TemporalThing"; + + protected MetaDataLoader fruitLoader; + protected MetaDataLoader temporalLoader; + + @Before + public void initLoaders() throws ClassNotFoundException { + fruitLoader = MetaDataLoader.fromResources("gson-temporal-fruit", Arrays.asList( + "com/metaobjects/loader/simple/fruitbasket-proxy-metadata.json" + )); + temporalLoader = MetaDataLoader.fromResources("gson-temporal-values", Arrays.asList( + "com/metaobjects/io/object/gson/temporal-metadata.json" + )); + } + + private static Date utc(int y, int mo, int d, int h, int mi, int s, int ms) { + return Date.from(LocalDateTime.of(y, mo, d, h, mi, s, ms * 1_000_000) + .toInstant(ZoneOffset.UTC)); + } + + private ValueObject newTemporal() { + MetaObject mo = temporalLoader.getMetaObjectByName(TEMPORAL_TYPE); + return (ValueObject) mo.newInstance(); + } + + private MetaField temporalField(String name) { + return temporalLoader.getMetaObjectByName(TEMPORAL_TYPE).getMetaField(name); + } + + // ----------------------------------------------------------------------- + // Step 1 — RED: the crash pin. + // ----------------------------------------------------------------------- + + @Test + public void orangeWithPickedDateSet_serializesWithoutRecursion() throws ClassNotFoundException { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(fruitLoader).create(); + + Orange o = fruitLoader.newObjectInstance(Orange.class); + o.setId(1L); + o.setName("orange"); + o.setPickedDate(utc(2026, 6, 3, 14, 30, 0, 123)); + + JsonObject obj = gson.toJsonTree(o).getAsJsonObject(); + Assert.assertEquals("2026-06-03", obj.get("pickedDate").getAsString()); + } + + @Test + public void orangeWithNullPickedDate_serializesJsonNullWithoutRecursion() throws ClassNotFoundException { + // serializeNulls(): both toJson (String) AND toJsonTree route their custom-serializer + // output through a JsonWriter (a real one, or the JsonTreeWriter toJsonTree uses + // internally), and JsonWriter#nullValue() silently drops a deferred member name when + // serializeNulls is false (the GsonBuilder default) -- true for EVERY nullable field in + // this serializer, not something specific to DATE (see + // appleWithNoTemporalFields_serializesToExactPreChangeString below, which pins that + // default-omission behavior unchanged). Opt in here to observe our own + // jsonObject.add(name, JsonNull.INSTANCE) directly. + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(fruitLoader).serializeNulls().create(); + + Orange o = fruitLoader.newObjectInstance(Orange.class); + o.setId(2L); + o.setName("orange-no-date"); + // pickedDate deliberately left null: pre-fix this ALSO recurses, since the buggy + // branch never read the field value before calling context.serialize(vo). + + JsonObject obj = gson.toJsonTree(o).getAsJsonObject(); + Assert.assertTrue(obj.get("pickedDate").isJsonNull()); + } + + // ----------------------------------------------------------------------- + // Step 2 — RED: timestamp + @localTime coverage, every fraction vector. + // ----------------------------------------------------------------------- + + @Test + public void dateField_writesCalendarDateOnly_ignoringTimeOfDay() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + ValueObject vo = newTemporal(); + temporalField("eventDate").setDate(vo, utc(2026, 6, 3, 14, 30, 0, 123)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + Assert.assertEquals("2026-06-03", obj.get("eventDate").getAsString()); + } + + @Test + public void timestampField_writesUtcInstant_withFractionRules() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + assertCreatedAt(gson, 123, "2026-06-03T14:30:00.123Z"); + assertCreatedAt(gson, 120, "2026-06-03T14:30:00.12Z"); + assertCreatedAt(gson, 100, "2026-06-03T14:30:00.1Z"); + assertCreatedAt(gson, 0, "2026-06-03T14:30:00Z"); + } + + private void assertCreatedAt(Gson gson, int ms, String expected) { + ValueObject vo = newTemporal(); + temporalField("createdAt").setDate(vo, utc(2026, 6, 3, 14, 30, 0, ms)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + Assert.assertEquals("ms=" + ms, expected, obj.get("createdAt").getAsString()); + } + + // Regression pin: TemporalWireFormat.fractionalSuffix formatted the millisecond fraction + // with a locale-dependent String.format("%03d", ...), which java.util.Formatter renders + // through Locale.getDefault(Locale.Category.FORMAT)'s DecimalFormatSymbols.getZeroDigit() -- + // under e.g. ar-EG that emits Eastern Arabic-Indic digits ("١٢٣" instead of + // "123"), producing a wire string TemporalWireFormat.parse itself cannot read back. This is + // the exact locale-hazard class JsonObjectWriter's deleted setDefaultDateFormat()/ + // DateFormat.FULL call caused, re-entering through the new shared helper. Fixed by pinning + // Locale.ROOT in the String.format call. + @Test + public void timestampField_fractionIsAsciiDigits_regardlessOfDefaultFormatLocale() { + Locale previousFormatLocale = Locale.getDefault(Locale.Category.FORMAT); + Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("ar-EG")); + try { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + // Reuses the existing ms=123 fraction vector from + // timestampField_writesUtcInstant_withFractionRules above. + assertCreatedAt(gson, 123, "2026-06-03T14:30:00.123Z"); + } finally { + Locale.setDefault(Locale.Category.FORMAT, previousFormatLocale); + } + } + + @Test + public void localTimeTimestampField_writesNaiveWallClock_noZ_withFractionRules() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + assertLocalCreatedAt(gson, 123, "2026-06-03T14:30:00.123"); + assertLocalCreatedAt(gson, 120, "2026-06-03T14:30:00.12"); + assertLocalCreatedAt(gson, 100, "2026-06-03T14:30:00.1"); + assertLocalCreatedAt(gson, 0, "2026-06-03T14:30:00"); + } + + private void assertLocalCreatedAt(Gson gson, int ms, String expected) { + ValueObject vo = newTemporal(); + temporalField("localCreatedAt").setDate(vo, utc(2026, 6, 3, 14, 30, 0, ms)); + + JsonObject obj = gson.toJsonTree(vo).getAsJsonObject(); + Assert.assertEquals("ms=" + ms, expected, obj.get("localCreatedAt").getAsString()); + } + + // ----------------------------------------------------------------------- + // Step 3 — RED: legacy epoch-millis read (the LONG coercion path DATE already shared). + // ----------------------------------------------------------------------- + + @Test + public void pickedDate_readsLegacyEpochMillisNumber() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(fruitLoader).create(); + + String json = "{\"@type\":\"simple::fruitbasket::Orange\",\"id\":1,\"name\":\"orange\"," + + "\"pickedDate\":1750000000000}"; + + Orange o = (Orange) gson.fromJson(json, Orange.class); + + Assert.assertEquals(1750000000000L, o.getPickedDate().getTime()); + } + + // ----------------------------------------------------------------------- + // Step 4 — RED: tolerant ISO string read, all three wire forms + a clear error on garbage. + // ----------------------------------------------------------------------- + + @Test + public void createdAt_readsInstantForm_withZ() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + String json = "{\"@type\":\"" + TEMPORAL_TYPE + "\",\"createdAt\":\"2026-06-03T14:30:00.123Z\"}"; + ValueObject vo = (ValueObject) gson.fromJson(json, ValueObject.class); + + Date expected = utc(2026, 6, 3, 14, 30, 0, 123); + Assert.assertEquals(expected, temporalField("createdAt").getDate(vo)); + } + + @Test + public void createdAt_readsLocalDateTimeForm_noZ() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + String json = "{\"@type\":\"" + TEMPORAL_TYPE + "\",\"createdAt\":\"2026-06-03T14:30:00.123\"}"; + ValueObject vo = (ValueObject) gson.fromJson(json, ValueObject.class); + + Date expected = utc(2026, 6, 3, 14, 30, 0, 123); + Assert.assertEquals(expected, temporalField("createdAt").getDate(vo)); + } + + @Test + public void eventDate_readsDateOnlyForm_atMidnightUtc() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + String json = "{\"@type\":\"" + TEMPORAL_TYPE + "\",\"eventDate\":\"2026-06-03\"}"; + ValueObject vo = (ValueObject) gson.fromJson(json, ValueObject.class); + + Date expected = utc(2026, 6, 3, 0, 0, 0, 0); + Assert.assertEquals(expected, temporalField("eventDate").getDate(vo)); + } + + @Test + public void createdAt_garbageString_throwsClearErrorNamingFieldAndForms() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + String json = "{\"@type\":\"" + TEMPORAL_TYPE + "\",\"createdAt\":\"not-a-date\"}"; + + try { + gson.fromJson(json, ValueObject.class); + Assert.fail("Expected an exception for an unparseable temporal value"); + } catch (Throwable t) { + String all = collectMessages(t); + Assert.assertTrue("message should name the field, was: " + all, all.contains("createdAt")); + Assert.assertTrue("message should name accepted forms, was: " + all, all.contains("YYYY-MM-DD")); + } + } + + private static String collectMessages(Throwable t) { + StringBuilder sb = new StringBuilder(); + while (t != null) { + if (t.getMessage() != null) sb.append(t.getMessage()).append(" | "); + t = t.getCause(); + } + return sb.toString(); + } + + // ----------------------------------------------------------------------- + // Step 6 — round-trip + no-churn pins. + // ----------------------------------------------------------------------- + + @Test + public void writeReadWrite_isByteIdenticalOnSecondWrite() { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(temporalLoader).create(); + + ValueObject vo = newTemporal(); + temporalField("eventDate").setDate(vo, utc(2026, 6, 3, 0, 0, 0, 0)); + temporalField("createdAt").setDate(vo, utc(2026, 6, 3, 14, 30, 0, 123)); + temporalField("localCreatedAt").setDate(vo, utc(2026, 6, 3, 14, 30, 0, 120)); + + String firstWrite = gson.toJson(vo); + ValueObject roundTripped = (ValueObject) gson.fromJson(firstWrite, ValueObject.class); + String secondWrite = gson.toJson(roundTripped); + + Assert.assertEquals(firstWrite, secondWrite); + } + + @Test + public void appleWithNoTemporalFields_serializesToExactPreChangeString() throws ClassNotFoundException { + Gson gson = MetaObjectGsonInitializer.getBuilderWithAdapters(fruitLoader).create(); + + Apple f = fruitLoader.newObjectInstance(Apple.class); + f.setId(1L); + f.setName("apple"); + + String s = gson.toJson(f); + + // Apple has no temporal field, so this class's change must not move this string by + // one byte. Captured from the UNFIXED (pre-#275) production code (see task report): + // null-valued fields (basketId/length/weight/inBasket/orchard/worms are all unset) + // are omitted entirely -- default GsonBuilder#serializeNulls is false, a pre-existing, + // universal convention for every field type in this serializer, not something DATE-specific. + Assert.assertEquals( + "{\"@type\":\"simple::fruitbasket::Apple\",\"id\":1,\"name\":\"apple\"}", + s); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonWiringBugsTest.java b/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonWiringBugsTest.java new file mode 100644 index 000000000..cd99dca3e --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/io/object/gson/GsonWiringBugsTest.java @@ -0,0 +1,203 @@ +package com.metaobjects.io.object.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.metaobjects.field.MetaField; +import com.metaobjects.io.object.json.JsonObjectReader; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.object.value.ValueObject; +import com.metaobjects.test.proxy.fruitbasket.Apple; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.StringReader; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Date; + +/** + * #275 follow-on -- two Gson adapter-wiring bugs found tracing the registration path while + * fixing the DATE-recursion crash (see {@link GsonTemporalRoundTripTest}). Neither is the + * DATE bug; both are pre-existing wiring defects in the same package that happen to mask each + * other, so they land together in one commit (see the plan/task brief for the full ordering + * argument -- fixing either alone, in the wrong order, either changes nothing observable or + * strictly regresses). + * + *

Bug 1 -- {@link com.metaobjects.io.object.json.JsonObjectReader#read(MetaObject)} + * reads JSON via {@code gson().fromJson(...)} but registered its builder with + * {@link MetaObjectGsonInitializer#addSerializersToBuilder} (the write-side registration) + * instead of {@link MetaObjectGsonInitializer#addDeserializersToBuilder}. + * + *

Bug 2 -- {@link MetaObjectGsonInitializer}'s {@code addSerializer}/ + * {@code addDeserializer} capability flags are dead code at the "multiple classes" and + * "otherwise" (specific-class) registration sites -- both {@code if} checks are present but + * commented out, so both sites always register BOTH a serializer and a deserializer no matter + * what the caller asked for. Only the interface-registration site (the first {@code if} block) + * honors the flags. + * + *

Empirically, at commit 94a9f400 (pre-fix), Bug 2's masking hides Bug 1's effect ONLY for + * MetaObjects that resolve to a concrete class and route through one of the two masked sites + * (e.g. {@code test::temporal::TemporalThing}, a plain {@code object.value} with no + * {@code @object} attr, defaulting to {@link ValueObject}). It does NOT mask Bug 1 for a + * proxy/interface-backed MetaObject like {@code Apple} -- {@code Apple.class} is an interface, + * so its registration goes through the ALREADY-correctly-gated interface site, which is + * unaffected by Bug 2. Confirmed directly: calling + * {@code new JsonObjectReader(fruitLoader, reader).read(appleMetaObject)} on unfixed 94a9f400 + * throws {@code com.google.gson.JsonIOException: Interfaces can't be instantiated! Register an + * InstanceCreator or a TypeAdapter for this type. Interface name: + * com.metaobjects.test.proxy.fruitbasket.Apple} -- Bug 1 breaks this TODAY, unmasked. + */ +public class GsonWiringBugsTest { + + private static final String TEMPORAL_TYPE = "test::temporal::TemporalThing"; + + protected MetaDataLoader fruitLoader; + protected MetaDataLoader temporalLoader; + + @Before + public void initLoaders() throws ClassNotFoundException { + fruitLoader = MetaDataLoader.fromResources("wiring-bugs-fruit", Arrays.asList( + "com/metaobjects/loader/simple/fruitbasket-proxy-metadata.json" + )); + temporalLoader = MetaDataLoader.fromResources("wiring-bugs-temporal", Arrays.asList( + "com/metaobjects/io/object/gson/temporal-metadata.json" + )); + } + + private static Date utc(int y, int mo, int d, int h, int mi, int s, int ms) { + return Date.from(LocalDateTime.of(y, mo, d, h, mi, s, ms * 1_000_000) + .toInstant(ZoneOffset.UTC)); + } + + private ValueObject newTemporal() { + MetaObject mo = temporalLoader.getMetaObjectByName(TEMPORAL_TYPE); + return (ValueObject) mo.newInstance(); + } + + private MetaField temporalField(String name) { + return temporalLoader.getMetaObjectByName(TEMPORAL_TYPE).getMetaField(name); + } + + // ----------------------------------------------------------------------- + // Bug 2 -- the "otherwise" (specific-class) site. TemporalThing has no @object attr, so it + // defaults to ValueObject.class, a concrete class that no other MetaObject in this loader + // shares -- hasMultipleClasses() is false, routing registration through the "otherwise" + // branch (MetaObjectGsonInitializer.java, the second commented-out block). + // + // Confirmed pre-fix: MetaObjectGsonInitializer.addDeserializersToBuilder(temporalLoader, + // new GsonBuilder()).create().toJson(vo) produced {"@type":"test::temporal::TemporalThing"} + // -- our custom MetaObjectSerializer's signature output -- proving a serializer was wired + // in even though only a deserializer was requested. This is the load-bearing pin: it fails + // if the addSerializer/addDeserializer flags go dead again. + // ----------------------------------------------------------------------- + + @Test + public void addDeserializersToBuilder_specificClassSite_deserializerIsPresent() { + Gson gson = MetaObjectGsonInitializer.addDeserializersToBuilder(temporalLoader, new GsonBuilder()).create(); + + String json = "{\"@type\":\"" + TEMPORAL_TYPE + "\",\"eventDate\":\"2026-06-03\"}"; + ValueObject vo = (ValueObject) gson.fromJson(json, ValueObject.class); + + Assert.assertEquals(utc(2026, 6, 3, 0, 0, 0, 0), temporalField("eventDate").getDate(vo)); + } + + @Test + public void addDeserializersToBuilder_specificClassSite_doesNotAlsoRegisterASerializer() { + Gson gson = MetaObjectGsonInitializer.addDeserializersToBuilder(temporalLoader, new GsonBuilder()).create(); + + ValueObject vo = newTemporal(); + temporalField("eventDate").setDate(vo, utc(2026, 6, 3, 0, 0, 0, 0)); + + // MetaObjectSerializer's signature output is "@type"-prefixed (see GsonAdapterTest, + // GsonTemporalRoundTripTest). With no serializer wired, Gson falls back to its own + // built-in handling for ValueObject (a Map implementation) or reflection -- neither + // ever emits a literal "@type" key. + String out = gson.toJson(vo); + Assert.assertFalse("expected no MetaObjectSerializer wired for a deserializers-only " + + "builder, got: " + out, out.contains("@type")); + } + + // ----------------------------------------------------------------------- + // Bug 2 -- the "multiple classes" site (MetaObjectGsonInitializer.java, the first + // commented-out block). Two DIFFERENT MetaObjects (TemporalThing, and Money from the + // existing meta.entityvalue.json fixture) both lack an @object attr and both default to + // ValueObject.class, so hasMultipleClasses() is true for each -- distinct code path from + // the "otherwise" site above (a shared, loader-scoped adapter keyed by the common class, + // not an mo-specific one). + // ----------------------------------------------------------------------- + + @Test + public void addDeserializersToBuilder_multipleClassesSite_deserializerIsPresentSerializerIsNot() + throws ClassNotFoundException { + MetaDataLoader combo = MetaDataLoader.fromResources("wiring-bugs-multi", Arrays.asList( + "com/metaobjects/io/object/gson/temporal-metadata.json", + "com/metaobjects/object/meta.entityvalue.json" + )); + MetaObject temporalThing = combo.getMetaObjectByName(TEMPORAL_TYPE); + MetaObject money = combo.getMetaObjectByName("myapp::commerce::Money"); + // Sanity: confirms this loader combination really does exercise the "multiple classes" + // branch (both share ValueObject.class) rather than silently degrading to "otherwise". + Assert.assertEquals(temporalThing.getObjectClass(), money.getObjectClass()); + + Gson gson = MetaObjectGsonInitializer.addDeserializersToBuilder(combo, new GsonBuilder()).create(); + + String json = "{\"@type\":\"myapp::commerce::Money\",\"cents\":500}"; + ValueObject vo = (ValueObject) gson.fromJson(json, ValueObject.class); + Assert.assertEquals(Long.valueOf(500L), money.getMetaField("cents").getLong(vo)); + + ValueObject vo2 = (ValueObject) money.newInstance(); + money.getMetaField("cents").setLong(vo2, 250L); + String out = gson.toJson(vo2); + Assert.assertFalse("expected no MetaObjectSerializer wired for a deserializers-only " + + "builder, got: " + out, out.contains("@type")); + } + + // ----------------------------------------------------------------------- + // Bug 1 -- JsonObjectReader.read(MetaObject) directly, on the existing Apple proxy + // fixture. Apple.class is an interface (mo.getObjectClass() == Apple.class, per + // ProxyObjectAdapter), so its registration goes through the interface site -- which + // already correctly honors the addSerializer/addDeserializer flags and is NOT affected by + // Bug 2. That makes this pin unmasked: pre-fix it throws + // "com.google.gson.JsonIOException: Interfaces can't be instantiated!" (confirmed above); + // post-fix (JsonObjectReader asks addDeserializersToBuilder for a real deserializer) it + // must succeed regardless of Bug 2's state. + // ----------------------------------------------------------------------- + + @Test + public void jsonObjectReader_read_onApple_directCall_roundTrips() throws Exception { + MetaObject mo = fruitLoader.getMetaObjectByName("simple::fruitbasket::Apple"); + // "worms" deliberately omitted: the Apple fixture declares it as field.int in metadata + // but the Apple interface's setWorms(Short) takes a Short -- a pre-existing type + // mismatch in this fixture, unrelated to the two bugs this test targets. + String json = "{\"@type\":\"simple::fruitbasket::Apple\",\"id\":1,\"name\":\"apple\"," + + "\"orchard\":\"north forty\"}"; + + JsonObjectReader reader = new JsonObjectReader(fruitLoader, new StringReader(json)); + Apple a = (Apple) reader.read(mo); + reader.close(); + + Assert.assertEquals(Long.valueOf(1L), a.getId()); + Assert.assertEquals("apple", a.getName()); + Assert.assertEquals("north forty", a.getOrchard()); + } + + // ----------------------------------------------------------------------- + // Regression: JsonObjectReader.read(MetaObject) on a concrete-class ("otherwise" site) + // MetaObject must keep working once it asks for a real (not masked-in) deserializer. + // ----------------------------------------------------------------------- + + @Test + public void jsonObjectReader_read_onTemporalThing_directCall_roundTrips() throws Exception { + MetaObject mo = temporalLoader.getMetaObjectByName(TEMPORAL_TYPE); + String json = "{\"@type\":\"" + TEMPORAL_TYPE + "\",\"eventDate\":\"2026-06-03\"}"; + + JsonObjectReader reader = new JsonObjectReader(temporalLoader, new StringReader(json)); + ValueObject vo = (ValueObject) reader.read(mo); + reader.close(); + + Assert.assertEquals(utc(2026, 6, 3, 0, 0, 0, 0), temporalField("eventDate").getDate(vo)); + } +} diff --git a/server/java/metadata/src/test/resources/com/metaobjects/io/object/gson/array-primitive-metadata.json b/server/java/metadata/src/test/resources/com/metaobjects/io/object/gson/array-primitive-metadata.json new file mode 100644 index 000000000..174105f02 --- /dev/null +++ b/server/java/metadata/src/test/resources/com/metaobjects/io/object/gson/array-primitive-metadata.json @@ -0,0 +1,20 @@ +{ "metadata.root": { + "package": "test::arrays", + "children": [ + { "object.value": { "name": "ArrayThing", "children": [ + { "field.string": { "name": "tags", "isArray": true } }, + { "field.string": { "name": "label" } }, + { "field.int": { "name": "counts", "isArray": true } }, + { "field.int": { "name": "count" } }, + { "field.long": { "name": "bigCounts", "isArray": true } }, + { "field.long": { "name": "bigCount" } }, + { "field.boolean": { "name": "flags", "isArray": true } }, + { "field.boolean": { "name": "flag" } }, + { "field.double": { "name": "amounts", "isArray": true } }, + { "field.double": { "name": "amount" } }, + { "field.date": { "name": "dates", "isArray": true } }, + { "field.date": { "name": "day" } }, + { "field.decimal": { "name": "price" } } + ]}} + ] +}} diff --git a/server/java/metadata/src/test/resources/com/metaobjects/io/object/gson/temporal-metadata.json b/server/java/metadata/src/test/resources/com/metaobjects/io/object/gson/temporal-metadata.json new file mode 100644 index 000000000..36227e79c --- /dev/null +++ b/server/java/metadata/src/test/resources/com/metaobjects/io/object/gson/temporal-metadata.json @@ -0,0 +1,10 @@ +{ "metadata.root": { + "package": "test::temporal", + "children": [ + { "object.value": { "name": "TemporalThing", "children": [ + { "field.date": { "name": "eventDate" } }, + { "field.timestamp": { "name": "createdAt" } }, + { "field.timestamp": { "name": "localCreatedAt", "@localTime": true } } + ]}} + ] +}} diff --git a/server/typescript/packages/sdk/test/agent-context/drift.test.ts b/server/typescript/packages/sdk/test/agent-context/drift.test.ts index 18d5bf862..81e5000fc 100644 --- a/server/typescript/packages/sdk/test/agent-context/drift.test.ts +++ b/server/typescript/packages/sdk/test/agent-context/drift.test.ts @@ -45,7 +45,7 @@ describe("agent-context vocabulary drift", () => { const known = new Set(FIELD_SUBTYPES as readonly string[]); const bad: string[] = []; for (const { f, text } of corpus) { - for (const m of text.matchAll(/\bfield\.([a-z][a-zA-Z0-9]*)\b/g)) { + for (const m of text.matchAll(/(? { const tmpls = new Set(TEMPLATE_SUBTYPES as readonly string[]); const bad: string[] = []; for (const { f, text } of corpus) { - for (const m of text.matchAll(/\bobject\.([a-z][a-zA-Z0-9]*)\b/g)) if (!objs.has(m[1]!)) bad.push(`${f} :: object.${m[1]}`); - for (const m of text.matchAll(/\bsource\.([a-z][a-zA-Z0-9]*)\b/g)) if (!srcs.has(m[1]!)) bad.push(`${f} :: source.${m[1]}`); - for (const m of text.matchAll(/\btemplate\.([a-z][a-zA-Z0-9]*)\b/g)) if (!tmpls.has(m[1]!)) bad.push(`${f} :: template.${m[1]}`); + for (const m of text.matchAll(/(?