Skip to content

Commit ce90dab

Browse files
authored
Merge pull request #277 from metaobjectsdev/fix/serializer-date-recursion-and-json-docs
fix(metadata): repair temporal recursion, gson wiring, and array writes in Java serializer
2 parents 25f3a0f + 431b51d commit ce90dab

34 files changed

Lines changed: 2115 additions & 40 deletions

File tree

agent-context/skills/metaobjects-codegen/references/java.md

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,10 @@ concrete imports and signatures so you don't have to guess them.
8989

9090
## `codegen-spring` generators
9191

92-
All live in `metaobjects-codegen-spring` under
92+
Most live in `metaobjects-codegen-spring` under
9393
`com.metaobjects.generator.spring.*`; wire any subset, typically all three of the
94-
first group together:
94+
first group together. (`JavaObjectCodeGenerator`, last row below, lives in the
95+
separate `metaobjects-codegen-base` module instead.)
9596

9697
| Generator | Output |
9798
|---|---|
@@ -105,6 +106,7 @@ first group together:
105106
| `SpringRenderHelperGenerator` | the typed render helper for a `template.prompt` payload |
106107
| `LlmTraceHelperGenerator` | `<Entity>TraceHelper.java` per concrete entity — the LLM-trace helper |
107108
| `SpringFilterAllowlistGenerator` | per-entity filter allowlist |
109+
| `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 <Name> 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 <Name> extends ValueObject` (map-backed; less hostile to a default mapper, but still not the sanctioned serialization path). Either concrete flavor also emits a `<Name>Extractor` plus a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
108110

109111
**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
110112
`@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
153155
Spring Data JPA / JDBC. Conformance-gated by `fixtures/api-contract-conformance/tph`
154156
(HTTP wire shape) and `fixtures/persistence-conformance/tph-*` (single-table
155157
runtime semantics).
158+
159+
## Serializing generated objects
160+
161+
Two paths hand you a `MetaObjectAware` instance: (a) `JavaObjectCodeGenerator`'s
162+
flavored codegen above (a `pojoAware` or `valueObject` class), and (b) the om/omdb
163+
runtime (`ObjectManager.getObjects(...)` / `MetaObject.newInstance()` — see the
164+
runtime-ui reference). **A default Jackson/Gson mapper over a `PojoObject` subtype
165+
fails on the `MetaObject` back-reference** — the inherited `getMetaData()` getter
166+
leads a bean-style mapper into the metadata graph, and on a modular JVM into
167+
`InaccessibleObjectException`. This is expected, not a bug to work around. If you
168+
want a type that serializes cleanly with a bare default mapper, use the
169+
`codegen-spring` record surface (`SpringDtoGenerator` / `SpringPayloadGenerator` /
170+
`SpringValueObjectGenerator`) instead — never `pojoAware`.
171+
172+
Serialize any `MetaObjectAware` instance through the MetaObjects JSON layer's
173+
`JsonObjectWriter`/`JsonObjectReader`, not a bare mapper — it applies the temporal
174+
wire form below, and read/write round-trip through the same pair of calls:
175+
176+
```java
177+
import com.metaobjects.io.object.json.JsonObjectWriter;
178+
import com.metaobjects.io.object.json.JsonObjectReader;
179+
import com.metaobjects.loader.MetaDataLoader;
180+
import com.metaobjects.object.MetaObject;
181+
182+
import java.io.StringReader;
183+
import java.io.StringWriter;
184+
import java.nio.file.Path;
185+
186+
MetaDataLoader loader = MetaDataLoader.fromDirectory("app", Path.of("src/main/metaobjects"));
187+
MetaObject mo = loader.getMetaObjectByName("acme::blog::Author");
188+
189+
// pojoAware-flavor generated class: public Author(MetaObject mo) { super(mo); }
190+
Author author = new Author(mo);
191+
author.setName("Ada");
192+
author.setBirthDate(new java.util.Date()); // field.date
193+
194+
// Write
195+
StringWriter out = new StringWriter();
196+
JsonObjectWriter.writeObject(author, out);
197+
String json = out.toString();
198+
// {"@type":"acme::blog::Author","name":"Ada","birthDate":"2026-06-03"}
199+
200+
// Read
201+
Author roundTripped = JsonObjectReader.readObject(Author.class, mo, new StringReader(json));
202+
```
203+
204+
**Wire form** (`field.date` / `field.timestamp`):
205+
206+
| Field | Wire form | Example |
207+
|---|---|---|
208+
| `field.date` | calendar date of the instant at UTC — `YYYY-MM-DD` | `"2026-06-03"` |
209+
| `field.timestamp` + `@localTime: true` | wall clock of the instant at UTC, no `Z` | `"2026-06-03T14:30:00.123"` |
210+
| `field.timestamp` (default, tz-aware) | UTC instant, with `Z` | `"2026-06-03T14:30:00.123Z"` |
211+
212+
Fraction is millisecond resolution, trailing zeros stripped, and the `.` plus
213+
fraction omitted entirely when zero (`.123``.123`, `.120``.12`, `.100``.1`,
214+
`.000`→omitted). A `null` value writes JSON `null`. Readers are tolerant and
215+
backward-compatible: a JSON **number** is still read as **legacy epoch
216+
milliseconds**; a JSON **string** is tried in order as an ISO instant (the `Z`
217+
form) → a local date-time (no `Z`) → a date-only form, failing with a message
218+
naming all three accepted forms.
219+
220+
**Known bounded caveat:** a hand-constructed `field.date` value carrying a
221+
sub-day time component writes as the calendar date only (truncated on first
222+
write, stable thereafter) — this matches the shipped OMDB DATE codec, which
223+
anchors DATE columns at midnight UTC.

agent-context/skills/metaobjects-prompts/references/java.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ rather than a throw. The payload record itself comes from `SpringPayloadGenerato
5353
— the parser is a companion to it, so the parser and payload VO can't silently
5454
drift.
5555

56+
Both `parse()` and `extractLenient(...)` here return **plain Java 21 records**
57+
safe with any mapper, nothing special needed. That's specific to this
58+
`codegen-spring` extract tier: the codegen-base flavored `<Name>Extractor` and the
59+
raw `MetaObjectExtractor` (the alternative extraction path, see the codegen
60+
reference) return `MetaObjectAware` instances instead, and those need
61+
`JsonObjectWriter`/`MetaObjectSerializer` — not a bare mapper — to serialize
62+
correctly (see the codegen reference's "Serializing generated objects" section).
63+
5664
## The output-format prompt fragment (FR-010)
5765

5866
For every json/xml-format `template.output`, `codegen-spring`'s

agent-context/skills/metaobjects-runtime-ui/references/java.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ try {
6565
taking a `QueryOptions` (built from an `Expression`). `ValueObject` is the
6666
map-backed runtime carrier.
6767

68+
## Serializing a row
69+
70+
A `ValueObject` **is** a `Map<String, Object>`, so a default Jackson
71+
`ObjectMapper` map-serializes it without special configuration — you may not
72+
hit a hard failure at all. The hard failure other shapes hit is the
73+
**`pojoAware`** codegen flavor's bean shape (a public `getMetaData()`
74+
back-reference a bean-style mapper walks into) and any direct Gson field walk
75+
over a `MetaObjectAware` instance — an OMDB `ValueObject` row sidesteps both.
76+
77+
Even so, the MetaObjects JSON layer (`JsonObjectWriter`/`JsonObjectReader`,
78+
`com.metaobjects.io.object.json`) is the sanctioned path for an OMDB row
79+
regardless of mapper friendliness — it's what applies the temporal wire form
80+
(`field.date`/`field.timestamp` render per the cross-port contract; a default
81+
mapper has no idea what shape those should take). See the codegen reference's
82+
"Serializing generated objects" section for the write+read snippet.
83+
6884
## Spring wiring
6985

7086
`metaobjects-core-spring` (or the Spring Boot starter) declares an

agent-context/templates/always-on.md.mustache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ spine; generated code is the disposable artifact. Regenerate with `{{codegenComm
1515
- Never hand-edit generated files — change the metadata and regenerate (three-way merge preserves hand-written regions).
1616
- Use the generated constants for any string that names metadata.
1717
- 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.
18+
- **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.
1819

1920
## Authoring rules you must not violate
2021
- Nodes are fused-key maps: `{"<type>.<subType>": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys.

docs/ports/java.md

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,13 @@ auto-create path was removed per ADR-0015.
172172
OMDB reads the same metadata at runtime and drives CRUD; no per-entity ORM
173173
boilerplate.
174174

175-
The Java port generates **no typed entity POJO** — the only entity-shaped Java
176-
output is the immutable `<Entity>Dto` record (from `codegen-spring`). OMDB drives
177-
CRUD against the loaded metadata plus generic `ValueObject` instances, and its API
178-
is connection-first (you pass an `ObjectConnection` to each call):
175+
`codegen-spring`'s only entity-shaped output is the immutable `<Entity>Dto`
176+
record — it generates no typed entity POJO. (A typed `MetaObjectAware` class
177+
is available separately, from `JavaObjectCodeGenerator`'s flavored codegen —
178+
see [Serializing generated objects](#serializing-generated-objects) below.)
179+
OMDB drives CRUD against the loaded metadata plus generic `ValueObject`
180+
instances, and its API is connection-first (you pass an `ObjectConnection` to
181+
each call):
179182

180183
```java
181184
import com.metaobjects.loader.MetaDataLoader;
@@ -264,13 +267,78 @@ into a Maven test (e.g. a JUnit assertion in the `test` phase).
264267
| `SpringControllerGenerator` | `metaobjects-codegen-spring` | One `<Entity>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 `<Entity>FilterAllowlist` (`SpringFilterAllowlistGenerator`) + the runtime `FilterParser`, wired directly into the list handler. |
265268
| `SpringDtoGenerator` | `metaobjects-codegen-spring` | One `<Entity>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. |
266269
| `SpringRepositoryGenerator` | `metaobjects-codegen-spring` | One `<Entity>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. |
270+
| `JavaObjectCodeGenerator` | `metaobjects-codegen-base` | Flavor-selected via the `flavor` generator arg (`com.metaobjects.generator.direct.object.javacode`). `flavor=pojoAware` emits `class <Name> 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 <Name> extends ValueObject` instead. Either flavor also emits a `<Name>Extractor` and a self-registering `ObjectClassBindingProvider`. For a plain default-Jackson-friendly type, use the `codegen-spring` record surface instead — never `pojoAware`. |
267271

268-
Wire any of them via the Maven plugin's `<generator>` entry pointing at
269-
`com.metaobjects.generator.spring.SpringControllerGenerator` /
272+
Wire any of the three Spring generators via the Maven plugin's `<generator>`
273+
entry pointing at `com.metaobjects.generator.spring.SpringControllerGenerator` /
270274
`SpringDtoGenerator` / `SpringRepositoryGenerator`. The three are
271275
independently configurable; typical use is all three together (controller +
272276
DTO + repository).
273277

278+
## Serializing generated objects
279+
280+
Two paths hand you a `MetaObjectAware` instance: the `JavaObjectCodeGenerator`
281+
flavored codegen above (a `pojoAware` or `valueObject` class), and the OMDB
282+
runtime (`ObjectManagerDB.getObjects(...)` / `MetaObject.newInstance()`, see
283+
[Use](#use) above). Serialize either through the MetaObjects JSON layer
284+
(`com.metaobjects.io.object.json`) — `JsonObjectWriter` for the write side,
285+
`JsonObjectReader` for the read side — rather than a bare Jackson/Gson mapper:
286+
287+
```java
288+
import com.metaobjects.io.object.json.JsonObjectWriter;
289+
import com.metaobjects.io.object.json.JsonObjectReader;
290+
import com.metaobjects.loader.MetaDataLoader;
291+
import com.metaobjects.object.MetaObject;
292+
293+
import java.io.StringReader;
294+
import java.io.StringWriter;
295+
import java.nio.file.Path;
296+
297+
MetaDataLoader loader = MetaDataLoader.fromDirectory("app", Path.of("src/main/metaobjects"));
298+
MetaObject mo = loader.getMetaObjectByName("acme::blog::Author");
299+
300+
// pojoAware-flavor generated class: public Author(MetaObject mo) { super(mo); }
301+
Author author = new Author(mo);
302+
author.setName("Ada");
303+
304+
StringWriter out = new StringWriter();
305+
JsonObjectWriter.writeObject(author, out);
306+
String json = out.toString();
307+
// {"@type":"acme::blog::Author","name":"Ada"}
308+
309+
Author roundTripped = JsonObjectReader.readObject(Author.class, mo, new StringReader(json));
310+
```
311+
312+
A default Jackson/Gson mapper pointed directly at a `pojoAware`-flavor class
313+
fails on the `MetaObject` back-reference every generated `PojoObject` subtype
314+
carries (the inherited `getMetaData()` getter leads a bean-style mapper into
315+
the metadata graph, and on a modular JVM into `InaccessibleObjectException`)
316+
**this is expected, not a bug to work around.** If you want a type that
317+
serializes cleanly with a bare default mapper, generate the `codegen-spring`
318+
record surface instead (`SpringDtoGenerator` / `SpringPayloadGenerator` /
319+
`SpringValueObjectGenerator`) — never `pojoAware`.
320+
321+
**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):
322+
323+
| Field | Wire form | Example |
324+
|---|---|---|
325+
| `field.date` | calendar date of the instant at UTC — `YYYY-MM-DD` | `"2026-06-03"` |
326+
| `field.timestamp` + `@localTime: true` | wall clock of the instant at UTC, no `Z` | `"2026-06-03T14:30:00.123"` |
327+
| `field.timestamp` (default, tz-aware) | UTC instant, with `Z` | `"2026-06-03T14:30:00.123Z"` |
328+
329+
The fraction is millisecond resolution, trailing zeros stripped, and the `.`
330+
plus fraction omitted entirely when zero (`.123``.123`, `.120``.12`,
331+
`.100``.1`, `.000`→omitted). A `null` value writes JSON `null`. Readers stay
332+
tolerant and backward-compatible: a JSON **number** is still read as **legacy
333+
epoch milliseconds**; a JSON **string** is tried in order as an ISO instant
334+
(the `Z` form) → a local date-time (no `Z`) → a date-only form, and the error
335+
message names all three accepted forms if none match.
336+
337+
A hand-constructed `field.date` value carrying a sub-day time component
338+
writes as the calendar date only (truncated on first write, stable
339+
thereafter) — this matches the shipped OMDB DATE codec, which anchors DATE
340+
columns at midnight UTC.
341+
274342
## Universal Angular 18 client
275343

276344
The browser-side Angular 18 client (`@metaobjectsdev/angular` +

docs/superpowers/plans/2026-08-08-serializer-date-recursion-and-java-json-docs.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,31 @@ verified at the baseline SHA but must be **re-derived from code** before acting
1313

1414
## STATUS — update as you go (edit this file, commit the checkbox flips with the work)
1515

16-
- [ ] Phase 0 — setup, premise recon
17-
- [ ] Unit A — wire-form implementation: serializer DATE branch + deserializer DATE split + streaming-reader split + `TemporalWireFormat` + gate tests (TDD)
18-
- [ ] Unit B — Gson wiring siblings: `JsonObjectReader` registers serializers-only; initializer's add-flags are dead code (fix TOGETHER — they mask each other)
19-
- [ ] Unit C — serializer write-side `@isArray` asymmetry (bounded; **maintainer checkpoint before widening**)
20-
- [ ] Unit D — #273 docs (5 files; gated on Unit A being merged-or-on-the-same-branch)
21-
- [ ] Free-text sweep (hazard discipline — member VALUES, spelling-agnostic)
22-
- [ ] Independent review (branch + `no-mistakes` gate) → merge to `main` → local-ci green
16+
- [x] Phase 0 — setup, premise recon
17+
- [x] Unit A — wire-form implementation: serializer DATE branch + deserializer DATE split + streaming-reader split + `TemporalWireFormat` + gate tests (TDD) — `94a9f400`
18+
- [x] Unit B — Gson wiring siblings: `JsonObjectReader` registers serializers-only; initializer's add-flags are dead code (fix TOGETHER — they mask each other) — `daa8d677`
19+
- [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.
20+
- [x] Unit D — #273 docs (5 files; gated on Unit A being merged-or-on-the-same-branch) — `30e8946c`, `7138e580`
21+
- [x] Free-text sweep (hazard discipline — member VALUES, spelling-agnostic) — two passes, code side + doc side, clean
22+
- [x] Independent review (branch) — final whole-branch review clean after one fix wave (`f8e10c39`); 25 deferred findings triaged, 1 parked
23+
- [ ] `no-mistakes` gate → merge to `main` → local-ci green
2324
- [ ] Release — coordinated PATCH: npm `0.21.1` · PyPI `0.21.1` · NuGet `0.21.1` · Maven `7.21.1` (**checkpoint with the maintainer first**)
2425
- [ ] Close #275 + #273 with receipts
2526

27+
**Carry-forward out of this batch** (deliberately NOT fixed; Unit C's bounded-scope clause names both
28+
almost verbatim as scope-creep triggers). Recommended as ONE future unit, which would also close
29+
deferred findings A6/C1/C3/C4/C5 and the `Apple.worms` fixture question:
30+
1. `MetaField.setObject(Object,Object)` converts via the field's **scalar** `getDataType()` instead of
31+
the array-aware `getEffectiveDataType()`, corrupting any `isArray` primitive before storage.
32+
2. `DataConverter` has **no `DATE_ARRAY` implementation** (`case DATE_ARRAY:``unsupported()`), so
33+
no entry point can store a `List<Date>` on an `isArray` DATE field.
34+
Net: Unit C fixed the array **write** side while array **storage** stays broken. Verified coherent
35+
to ship — the read half already threw at baseline, so Unit C introduces no regression; it converts
36+
silent write corruption into correct output, and leaves two unreachable-but-correct code paths.
37+
3. The OMDB jsonb-temporal gap — the motivating blast-radius claim for this whole fix still has no
38+
test at any level. A metadata-local or omdb-local regression test is in-repo scope and does not
39+
require touching the shared five-port `labels` fixture.
40+
2641
---
2742

2843
## Meta-lesson (read before every unit)

0 commit comments

Comments
 (0)