diff --git a/fixtures/api-contract-conformance/jsonb/README.md b/fixtures/api-contract-conformance/jsonb/README.md
index a108f1586..07cb22672 100644
--- a/fixtures/api-contract-conformance/jsonb/README.md
+++ b/fixtures/api-contract-conformance/jsonb/README.md
@@ -15,7 +15,7 @@ Java `Object`, Kotlin `JsonElement`, C# `JsonDocument`); this corpus locks it
in cross-port.
The single `Document` entity (`acme::store`, `@table="documents"`) carries an
-open-bag field plus three value-object columns:
+open-bag field plus four value-object columns:
| Field | Type | Notes |
|------------------|----------------------------------------------------------|------------------------------------------|
@@ -25,6 +25,7 @@ open-bag field plus three value-object columns:
| `primaryMarker` | `field.object @objectRef=Marker @storage=jsonb @required`| required single VO — present-null → 400 |
| `optionalMarker` | `field.object @objectRef=Marker @storage=jsonb` | nullable single VO — FR-035 tristate |
| `markers` | `field.object @objectRef=Marker @storage=jsonb isArray` | nullable array of VO — `[]` ≠ null |
+| `telemetry` | `field.object @objectRef=Telemetry @storage=jsonb` | nullable VO with required non-nullable value types — see `jsonb-value-object-required-value-types` |
**Why `id` is `@required`.** It is the shape every real model has — a shared
`BaseEntity` declares `id` required — and the one no other api-contract entity
@@ -53,13 +54,14 @@ The `Marker` value object (`object.value`) is `{ label: field.string @required
```
jsonb/
├── README.md # this file
-├── meta.json # the Document entity + Marker value object
+├── meta.json # the Document entity + Marker and Telemetry value objects
├── seed.json # 2 seed Documents (ids 1, 2), jsonb object + VO columns
└── scenarios/
├── jsonb-open-bag-roundtrip.yaml # open-bag parsed-value contract
├── jsonb-value-object-patch.yaml # Program D — cross-port VO-column PATCH/POST
├── jsonb-value-object-map-decimals.yaml # decimals in a field.map of value objects, per map value
- └── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object
+ ├── jsonb-value-object-numbers.yaml # doubles and decimals inside a value object
+ └── jsonb-value-object-required-value-types.yaml # required double + timestamp in a value object
```
`seed.json` is applied fresh before the scenario; the runner advances the id
@@ -162,3 +164,18 @@ Amendment 1; see `../decimal/README.md`), per element for an array of value obje
| `r10`/`r11` PATCH + GET | PATCH single `measure` (decimal as a number) is canonical, `measures` untouched |
| `r12`-`r15` | PATCH array element canonicalized (`"3.10"` gives `"3.1"`); excess scale in an array element or in `measure` is `400` and the stored value is unchanged |
| `r16`-`r18` PUT | PUT canonicalizes and persists; excess scale on PUT is `400` and nothing changes |
+
+## Scenario — `jsonb-value-object-required-value-types.yaml`
+
+A value object whose REQUIRED members are non-nullable value types in the native language
+(`Telemetry { capturedAt: field.timestamp @required; latitude: field.double @required; tempC:
+field.int }`, held by the nullable `Document.telemetry`). A port whose generated model cannot
+serialize or deserialize such a member answers every request that touches the owning entity
+with a 500 (C# emitted a null-omission condition on a non-nullable `double` /
+`DateTimeOffset`, which System.Text.Json rejects with `InvalidOperationException`;
+the condition is emitted on nullable members only).
+
+| Requests | What it pins |
+|---|---|
+| `r1`/`r2` POST + GET | create is `201`, the double stays a JSON number, the timestamp keeps its UTC `Z` spelling, `tempC` round-trips |
+| `r3`/`r4` PATCH + GET | PATCH replaces the value object (`tempC` omitted when absent) and it persists |
diff --git a/fixtures/api-contract-conformance/jsonb/meta.json b/fixtures/api-contract-conformance/jsonb/meta.json
index 7dc9b1352..944d3a1ff 100644
--- a/fixtures/api-contract-conformance/jsonb/meta.json
+++ b/fixtures/api-contract-conformance/jsonb/meta.json
@@ -17,6 +17,14 @@
{ "field.decimal": { "name": "amount", "@precision": 12, "@scale": 4 } }
]
}},
+ { "object.value": {
+ "name": "Telemetry",
+ "children": [
+ { "field.timestamp": { "name": "capturedAt", "@required": true } },
+ { "field.double": { "name": "latitude", "@required": true } },
+ { "field.int": { "name": "tempC" } }
+ ]
+ }},
{ "object.value": {
"name": "Tier",
"children": [
@@ -41,6 +49,7 @@
{ "field.object": { "name": "markers", "@objectRef": "Marker", "@storage": "jsonb", "isArray": true } },
{ "field.object": { "name": "measure", "@objectRef": "Measure", "@storage": "jsonb" } },
{ "field.object": { "name": "measures", "@objectRef": "Measure", "@storage": "jsonb", "isArray": true } },
+ { "field.object": { "name": "telemetry", "@objectRef": "Telemetry", "@storage": "jsonb" } },
{ "field.map": { "name": "tiers", "@objectRef": "Tier" } },
{ "field.object": { "name": "pricing", "@objectRef": "Pricing", "@storage": "jsonb" } },
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
diff --git a/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml b/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml
new file mode 100644
index 000000000..687025250
--- /dev/null
+++ b/fixtures/api-contract-conformance/jsonb/scenarios/jsonb-value-object-required-value-types.yaml
@@ -0,0 +1,50 @@
+name: jsonb-value-object-required-value-types
+description: >
+ A jsonb value object whose REQUIRED members are non-nullable value types (Telemetry:
+ `capturedAt` field.timestamp @required, `latitude` field.double @required, `tempC` field.int
+ optional), held by Document.telemetry. A port whose generated model refuses to serialize or
+ deserialize such a type answers every request that touches the owning entity with a 500
+ (System.Text.Json threw InvalidOperationException when a null-omission condition sat on a
+ non-nullable `double` / `DateTimeOffset`, so POST /api/documents answered 500). The contract: a
+ POST, the read-back and a PATCH all succeed, the double stays a JSON number, the timestamp keeps
+ its UTC `Z` spelling, and the optional `tempC` is omitted when absent. Implicit-id POSTs land at 3.
+requests:
+ - id: r1
+ method: POST
+ path: /api/documents
+ body:
+ title: "telemetry-a"
+ primaryMarker: { label: "a", score: 1 }
+ telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 }
+ expect:
+ status: 201
+ body:
+ hasId: true
+ row:
+ telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 }
+ - id: r2
+ method: GET
+ path: /api/documents/3
+ expect:
+ status: 200
+ body:
+ row:
+ telemetry: { capturedAt: "2026-03-01T12:00:00Z", latitude: 51.5, tempC: 18 }
+ - id: r3
+ method: PATCH
+ path: /api/documents/3
+ body:
+ telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 }
+ expect:
+ status: 200
+ body:
+ row:
+ telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 }
+ - id: r4
+ method: GET
+ path: /api/documents/3
+ expect:
+ status: 200
+ body:
+ row:
+ telemetry: { capturedAt: "2026-03-02T08:30:00Z", latitude: -33.25 }
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs
new file mode 100644
index 000000000..f72690240
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/ValueObjectWireIgnoreTests.cs
@@ -0,0 +1,76 @@
+using System.Reflection;
+using System.Text.Json;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using MetaObjects.Codegen;
+using MetaObjects.Codegen.Generators;
+using MetaObjects.Loader;
+using Xunit;
+
+namespace MetaObjects.Codegen.Tests;
+
+///
+/// A value-object member that is null is omitted on the wire via
+/// [JsonIgnore(WhenWritingNull)], but System.Text.Json throws InvalidOperationException
+/// the first time it builds type info for a type carrying that condition on a NON-nullable value
+/// type (double, DateTimeOffset), so every request that touched the owning entity
+/// answered 500. The condition belongs on the nullable members only.
+///
+public class ValueObjectWireIgnoreTests
+{
+ private const string Model = """
+ { "metadata.root": { "package": "acme", "children": [
+ { "object.entity": { "name": "Leg", "children": [
+ { "source.rdb": { "@table": "legs" } },
+ { "field.long": { "name": "id" } },
+ { "field.object": { "name": "telemetry", "@objectRef": "acme::Telemetry", "@storage": "jsonb" } },
+ { "identity.primary": { "@fields": "id" } }
+ ]}},
+ { "object.value": { "name": "Telemetry", "children": [
+ { "field.timestamp": { "name": "capturedAt", "@required": true } },
+ { "field.double": { "name": "latitude", "@required": true } },
+ { "field.int": { "name": "tempC" } },
+ { "field.boolean": { "name": "live", "@required": true } },
+ { "field.decimal": { "name": "fee", "@required": true, "@precision": 10, "@scale": 2 } },
+ { "field.uuid": { "name": "token", "@required": true } },
+ { "field.string": { "name": "tags", "isArray": true } },
+ { "field.string": { "name": "note" } }
+ ]}}
+ ]}}
+ """;
+
+ [Fact]
+ public void A_value_object_with_required_value_type_members_round_trips_and_omits_a_null_optional_member()
+ {
+ var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "model.json")]);
+ Assert.Empty(r.Errors);
+ var files = new EntityGenerator().Generate(new GenContext
+ {
+ Entities = r.Root.Objects(),
+ Root = r.Root,
+ Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
+ });
+ var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path));
+ var comp = CSharpCompilation.Create("vo_wire_ignore_" + Guid.NewGuid().ToString("N"), trees,
+ DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ using var ms = new MemoryStream();
+ var emit = comp.Emit(ms);
+ Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)));
+ var telemetry = Assembly.Load(ms.ToArray()).GetTypes().Single(t => t.Name == "Telemetry");
+
+ const string body = """
+ {"capturedAt":"2026-03-04T05:06:07Z","latitude":51.5,"live":true,"fee":"12.50","token":"3f2504e0-4f89-11d3-9a0c-0305e82c3301"}
+ """;
+ var value = JsonSerializer.Deserialize(body, telemetry)!;
+ Assert.Equal(51.5, telemetry.GetProperty("Latitude")!.GetValue(value));
+ Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), telemetry.GetProperty("CapturedAt")!.GetValue(value));
+ Assert.Null(telemetry.GetProperty("TempC")!.GetValue(value));
+
+ var wire = JsonDocument.Parse(JsonSerializer.Serialize(value, telemetry)).RootElement;
+ Assert.Equal(51.5, wire.GetProperty("latitude").GetDouble());
+ Assert.Equal(DateTimeOffset.Parse("2026-03-04T05:06:07Z"), wire.GetProperty("capturedAt").GetDateTimeOffset());
+ Assert.True(wire.GetProperty("live").GetBoolean());
+ Assert.False(wire.TryGetProperty("tempC", out _));
+ Assert.False(wire.TryGetProperty("note", out _));
+ }
+}
diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
index 9a5172370..846c73818 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
@@ -852,8 +852,14 @@ protected virtual EmittedFile EmitValueObjectPoco(MetaObject vo, GenContext ctx)
if (member is null) continue;
// Pin the jsonb element name (+ wire name) to the metadata field name so the
// stored owned-JSON keys match the shared seed + the cross-port wire contract.
- // A null member is omitted on the wire (the cross-port value-object contract).
- member = $" [JsonPropertyName(\"{field.Name}\")]\n [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" + member;
+ // A null member is omitted on the wire (the cross-port value-object contract). Only a
+ // NULLABLE member can be null, and System.Text.Json throws InvalidOperationException
+ // when WhenWritingNull sits on a non-nullable value type (double, DateTimeOffset, ...),
+ // so the condition is emitted for the nullable members alone.
+ var nullableMember = System.Text.RegularExpressions.Regex.IsMatch(member, @"^\s*public\s+[^=;{(]*\?\s+\w+", System.Text.RegularExpressions.RegexOptions.Multiline);
+ member = $" [JsonPropertyName(\"{field.Name}\")]\n"
+ + (nullableMember ? " [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]\n" : "")
+ + member;
member = ApplyPropertyAttributes(member, vo, field, ctx);
members.Add(XmlDocBuilder.Prepend(member, field, " "));
}
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs
index ab0c827ac..23cb0682e 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbFixture.cs
@@ -32,6 +32,7 @@ internal static class JsonbFixture
"markers" jsonb,
"measure" jsonb,
"measures" jsonb,
+ "telemetry" jsonb,
"tiers" jsonb,
"pricing" jsonb,
CONSTRAINT "documents_pkey" PRIMARY KEY ("id")
@@ -74,8 +75,8 @@ public static async Task ApplySeedAsync(string connString, string seedPath)
if (rowNode is not JsonObject row) continue;
await using var ins = c.CreateCommand();
ins.CommandText =
- "INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"tiers\", \"pricing\") " +
- "VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @tiers, @pricing)";
+ "INSERT INTO \"documents\" (\"id\", \"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"telemetry\", \"tiers\", \"pricing\") " +
+ "VALUES (@id, @title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @telemetry, @tiers, @pricing)";
ins.Parameters.AddWithValue("@id", row["id"]!.GetValue());
ins.Parameters.AddWithValue("@title", row["title"]!.GetValue());
// payload + the VO columns are JSON — serialize each and bind as jsonb (an
@@ -87,6 +88,7 @@ public static async Task ApplySeedAsync(string connString, string seedPath)
AddJsonbParam(ins, "@markers", row["markers"]);
AddJsonbParam(ins, "@measure", row["measure"]);
AddJsonbParam(ins, "@measures", row["measures"]);
+ AddJsonbParam(ins, "@telemetry", row["telemetry"]);
AddJsonbParam(ins, "@tiers", row["tiers"]);
AddJsonbParam(ins, "@pricing", row["pricing"]);
await ins.ExecuteNonQueryAsync();
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs
index 8e1dfac80..9ced81211 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs
@@ -62,6 +62,10 @@ public static async Task StartAsync(PostgresContain
builder.WebHost.UseUrls(baseUrl);
builder.Logging.ClearProviders();
RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString);
+ // The documented host registration: a field.timestamp crosses the wire as UTC with the Z
+ // designator (a Telemetry value object holds one).
+ builder.Services.ConfigureHttpJsonOptions(o =>
+ o.SerializerOptions.Converters.Add(new MetaObjects.Codegen.Runtime.Iso8601TimestampConverter()));
var app = builder.Build();
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs
index 69bd8cac5..2d79f3ef1 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/JsonbReferenceServer.cs
@@ -41,7 +41,7 @@ internal sealed class JsonbReferenceServer : IAsyncDisposable
private const int MarkerLabelMaxLength = 40;
// The jsonb columns carried in a document row's wire projection (id + title are scalar).
- private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing" };
+ private static readonly string[] JsonbColumns = { "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing" };
private JsonbReferenceServer(PostgresContainer pg, HttpListener listener, string baseUrl)
{
@@ -160,8 +160,8 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx)
await using (var ins = c.CreateCommand())
{
ins.CommandText =
- "INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"tiers\", \"pricing\") " +
- "VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @tiers, @pricing) RETURNING id";
+ "INSERT INTO \"documents\" (\"title\", \"payload\", \"primaryMarker\", \"optionalMarker\", \"markers\", \"measure\", \"measures\", \"telemetry\", \"tiers\", \"pricing\") " +
+ "VALUES (@title, @payload, @primaryMarker, @optionalMarker, @markers, @measure, @measures, @telemetry, @tiers, @pricing) RETURNING id";
ins.Parameters.AddWithValue("@title", body["title"]!.GetValue());
AddJsonbParam(ins, "@payload", body["payload"]);
AddJsonbParam(ins, "@primaryMarker", body["primaryMarker"]);
@@ -169,6 +169,7 @@ private async Task CreateDocumentAsync(HttpListenerContext ctx)
AddJsonbParam(ins, "@markers", body["markers"]);
AddJsonbParam(ins, "@measure", body["measure"]);
AddJsonbParam(ins, "@measures", body["measures"]);
+ AddJsonbParam(ins, "@telemetry", body["telemetry"]);
AddJsonbParam(ins, "@tiers", body["tiers"]);
AddJsonbParam(ins, "@pricing", body["pricing"]);
newId = Convert.ToInt64(await ins.ExecuteScalarAsync());
diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs
index 2a291fd74..ab933938d 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramBrief.g.cs
@@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated;
public class ProgramBrief
{
[JsonPropertyName("title")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Required(AllowEmptyStrings = true)]
[MaxLength(200)]
[MinLength(1)]
diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs
index 2c4829c00..6e52e6d5b 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramVerdict.g.cs
@@ -13,10 +13,8 @@ public class ProgramVerdict
{
public enum ProgramVerdictVerdict { GOOD, OK, POOR }
[JsonPropertyName("verdict")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ProgramVerdictVerdict Verdict { get; set; }
[JsonPropertyName("rationale")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Required(AllowEmptyStrings = true)]
[MaxLength(480)]
[MinLength(1)]
diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs
index ede7456b1..13626fb5e 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Generated/WeekLabel.g.cs
@@ -12,7 +12,6 @@ namespace MetaObjects.IntegrationTests.Generated;
public class WeekLabel
{
[JsonPropertyName("label")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Required(AllowEmptyStrings = true)]
[MaxLength(80)]
[MinLength(1)]
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt
index bb97528a4..6bde5f560 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/DocumentApiServer.kt
@@ -82,6 +82,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable {
it[markers] = toJsonElement(r["markers"])
it[measure] = toJsonElement(r["measure"])
it[measures] = toJsonElement(r["measures"])
+ it[telemetry] = toJsonElement(r["telemetry"])
it[tiers] = toJsonElement(r["tiers"])
it[pricing] = toJsonElement(r["pricing"])
}
@@ -155,6 +156,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable {
it[markers] = toJsonElement(markersVal)
it[measure] = toJsonElement(canon["measure"])
it[measures] = toJsonElement(canon["measures"])
+ it[telemetry] = toJsonElement(body["telemetry"])
it[tiers] = toJsonElement(canon["tiers"])
it[pricing] = toJsonElement(canon["pricing"])
}[DocumentTable.id]
@@ -198,7 +200,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable {
}
// Apply only the present keys (absent = untouched). Guard the empty-SET case (Exposed throws).
val canon = canonicalizeMeasures(body) ?: return sendJson(exchange, 400, mapOf("error" to "validation"))
- val settableKeys = listOf("title", "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing")
+ val settableKeys = listOf("title", "payload", "primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing")
if (settableKeys.any { body.containsKey(it) }) {
transaction(db) {
DocumentTable.update({ DocumentTable.id eq id }) {
@@ -209,6 +211,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable {
if (body.containsKey("markers")) it[markers] = toJsonElement(body["markers"])
if (body.containsKey("measure")) it[measure] = toJsonElement(canon["measure"])
if (body.containsKey("measures")) it[measures] = toJsonElement(canon["measures"])
+ if (body.containsKey("telemetry")) it[telemetry] = toJsonElement(body["telemetry"])
if (body.containsKey("tiers")) it[tiers] = toJsonElement(canon["tiers"])
if (body.containsKey("pricing")) it[pricing] = toJsonElement(canon["pricing"])
}
@@ -310,6 +313,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable {
"markers" to fromJsonElement(row[DocumentTable.markers]),
"measure" to fromJsonElement(row[DocumentTable.measure]),
"measures" to fromJsonElement(row[DocumentTable.measures]),
+ "telemetry" to fromJsonElement(row[DocumentTable.telemetry]),
"tiers" to fromJsonElement(row[DocumentTable.tiers]),
"pricing" to fromJsonElement(row[DocumentTable.pricing]),
)
@@ -356,6 +360,7 @@ class DocumentApiServer(private val pg: PostgresContainer) : AutoCloseable {
val markers = jsonb("markers", { it.toString() }, { Json.parseToJsonElement(it) }).nullable()
val measure = jsonb("measure", { it.toString() }, { Json.parseToJsonElement(it) }).nullable()
val measures = jsonb("measures", { it.toString() }, { Json.parseToJsonElement(it) }).nullable()
+ val telemetry = jsonb("telemetry", { it.toString() }, { Json.parseToJsonElement(it) }).nullable()
val tiers = jsonb("tiers", { it.toString() }, { Json.parseToJsonElement(it) }).nullable()
val pricing = jsonb("pricing", { it.toString() }, { Json.parseToJsonElement(it) }).nullable()
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt
index 2050ce96d..c74065f5b 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/jsonb/generated/GeneratedDocumentControllerHarness.kt
@@ -78,6 +78,8 @@ class GeneratedDocumentControllerHarness(
* serialization seam). */
private val mapper: ObjectMapper = ObjectMapper()
.registerKotlinModule()
+ .registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule())
+ .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.registerModule(jsonElementModule())
private val classLoader: ClassLoader
@@ -172,7 +174,7 @@ class GeneratedDocumentControllerHarness(
// Program D: the seed rows carry the required primaryMarker (+ optional single/array
// VO on row 1). Pass every present VO column through the generated controller's OWN
// create path — so the create MUST handle value objects for seeding to succeed.
- for (k in listOf("primaryMarker", "optionalMarker", "markers", "measure", "measures", "tiers", "pricing")) {
+ for (k in listOf("primaryMarker", "optionalMarker", "markers", "measure", "measures", "telemetry", "tiers", "pricing")) {
if (r.containsKey(k)) body[k] = r[k]
}
val res = exchange("POST", "/api/documents", body)
diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java
index e959236ee..d476a4c0b 100644
--- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java
+++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/JsonbReferenceServer.java
@@ -82,6 +82,7 @@ private void createSchema() throws SQLException {
+ "markers JSONB, " // nullable array-of-VO
+ "measure JSONB, " // nullable single Measure VO (decimal amount)
+ "measures JSONB, " // nullable array-of-Measure VO
+ + "telemetry JSONB, " // nullable single Telemetry VO (required value-type members)
+ "tiers JSONB, " // nullable map of Tier VO (decimal amount per value)
+ "pricing JSONB)"); // nullable Pricing VO holding a map of Tier
}
@@ -90,8 +91,8 @@ private void createSchema() throws SQLException {
private void applySeed(List