Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions agent-context/skills/metaobjects-codegen/references/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -105,6 +106,7 @@ first group together:
| `SpringRenderHelperGenerator` | the typed render helper for a `template.prompt` payload |
| `LlmTraceHelperGenerator` | `<Entity>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 <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`. |

**Projections (read-only views).** An `object.projection` (read-only `source.rdb`
`@kind: view` child) is served read-only through OMDB at the ObjectManager layer
Expand Down Expand Up @@ -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.
8 changes: 8 additions & 0 deletions agent-context/skills/metaobjects-prompts/references/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name>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
Expand Down
16 changes: 16 additions & 0 deletions agent-context/skills/metaobjects-runtime-ui/references/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object>`, 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
Expand Down
1 change: 1 addition & 0 deletions agent-context/templates/always-on.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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: `{"<type>.<subType>": { ... }}` (e.g. `{"field.string": {"name": "email"}}`) — never split the type and subtype into separate keys.
Expand Down
80 changes: 74 additions & 6 deletions docs/ports/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Entity>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 `<Entity>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;
Expand Down Expand Up @@ -264,13 +267,78 @@ into a Maven test (e.g. a JUnit assertion in the `test` phase).
| `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. |
| `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. |
| `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. |
| `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`. |

Wire any of them via the Maven plugin's `<generator>` entry pointing at
`com.metaobjects.generator.spring.SpringControllerGenerator` /
Wire any of the three Spring generators via the Maven plugin's `<generator>`
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` +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date>` 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)
Expand Down
Loading
Loading