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
3 changes: 2 additions & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ jobs:
cd ../integration-tests && bun test test/validation-conformance.test.ts
# migrate-ts — the sole cross-port schema-migration engine (ADR-0015).
# Its unit + integrity suites need no DB; the PG integration tests
# (gated on MIGRATE_TS_PG_URL) self-skip here and run in integration-tests.yml.
# (gated on MIGRATE_TS_PG_URL) self-skip here and run in local-ci's ts-slow
# lane (every push to main) + integration-tests.yml (the v* tag backstop).
cd ../migrate-ts && bun test
# Doc-template + CLI suites: byte-identity template gate, embedded-template
# gate, neutrality / collision guards, and the docs golden corpus +
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,14 @@ jobs:
# migrate-ts PG integration tests — the apply / lifecycle / rollback +
# introspection suites that exercise REAL Postgres behavior (advisory locks,
# multi-tenant ledger, down-migrations) that pg-mem cannot fake. They
# `describe.skip` unless MIGRATE_TS_PG_URL is set, so no workflow ran them
# until now. A `services: postgres` container supplies the URL.
# `describe.skip` unless MIGRATE_TS_PG_URL is set; a `services: postgres`
# container supplies the URL.
#
# The PRIMARY gate for these suites is local-ci.yml's ts-slow lane, on every push
# to main. This job is the cold-environment RELEASE BACKSTOP on the v* tag. Tags
# are pushed AFTER publish (docs/RELEASING.md), so red HERE means a broken release
# is already live on four immutable registries — treat it as an incident, never as
# noise. That inversion is exactly how this lane sat red for eight releases.
migrate-ts-pg:
runs-on: ubuntu-latest
services:
Expand Down Expand Up @@ -150,4 +156,7 @@ jobs:
- name: Run migrate-ts suite against real Postgres
env:
MIGRATE_TS_PG_URL: postgres://migrate:migrate@localhost:5432/migrate_test
# Arms the in-suite sentinel: if the URL above ever stops being set, the
# suite FAILS instead of silently skipping and reporting a green release gate.
MIGRATE_TS_PG_EXPECT: '1'
run: cd server/typescript/packages/migrate-ts && bun test
10 changes: 10 additions & 0 deletions .github/workflows/local-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ jobs:
persist-credentials: false
- env:
METAOBJECTS_TEST_PG_URL: postgres://metaobjects:metaobjects@localhost:${{ job.services.postgres.ports['5432'] }}/metaobjects_test
# Arms migrate-ts's real-Postgres suites (apply / lifecycle / rollback /
# introspection) on every push to main. They previously ran ONLY on the v* tag
# push — strictly AFTER the immutable four-registry publish — and so sat red for
# eight straight releases (v0.20.11 … v0.21.1) with nobody looking. Reuses this
# job's existing sidecar; the suite is proven to coexist in one database in a
# serial run (the hosted tag job runs it against a single migrate_test DB).
MIGRATE_TS_PG_URL: postgres://metaobjects:metaobjects@localhost:${{ job.services.postgres.ports['5432'] }}/metaobjects_test
# Makes the in-suite sentinel FAIL if the URL above ever rots away (renamed
# variable, dropped sidecar) rather than describe.skip-ing in silence.
MIGRATE_TS_PG_EXPECT: '1'
run: scripts/ci-local.sh --only ts-slow --strict-toolchains

# Java FAST lane — java + kotlin conformance only (the quick correctness signal).
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Fixed — a POJO-bound temporal value in a jsonb column was written locale- and timezone-dependently (Java/Maven only)

Closes the last carry-forward item from the #275 batch: the OMDB jsonb temporal path — the
motivating blast radius for that whole fix — had **no test at any level**, and adding one surfaced
a live defect on the adjacent branch.

`MetaObjectGsonInitializer` registers the metadata-driven serializers against each `MetaObject`'s
declared `@object` class. A value object bound to a hand-written POJO through `ObjectClassRegistry`
is therefore serialized by Gson's **default reflection**, so its `java.util.Date` properties never
reached `TemporalWireFormat` and took Gson's built-in adapter instead. A `@storage: jsonb` column
holding such an object stored e.g. `"Jun 3, 2026, 10:30:00 AM"` — rendered in the JVM's **local
zone rather than UTC**, varying with the default **locale**, silently dropping **milliseconds** (so
even a Java-only round-trip did not return the original instant), and **unreadable by the other
four ports**, which expect the ISO form in `fixtures/persistence-conformance/normalization.md`.
Same defect class as #275, on the one path #275 did not reach.

A new `TemporalGsonAdapter` is registered for `java.util.Date` on that builder: it writes
`TemporalWireFormat.formatInstant` (the canonical `…Z` instant) and reads tolerantly —
`TemporalWireFormat.parse` first, falling back to Gson's former localized default so rows already
written in the legacy format still load. The metadata-driven path is provably unaffected:
`MetaObjectSerializer`'s `DATE` branch formats and calls `addProperty` itself rather than
delegating to `context.serialize`, so that output is byte-identical.

**Bounded narrowing, deliberate:** without an owning `MetaField` there is no way to know whether a
POJO property is a `field.date` (date-only) or a `@localTime` timestamp (no `Z`), so a POJO-bound
temporal is written as a full instant. That is lossless and portable where the previous behavior
was neither; a value object needing the exact per-field shape should stay on the metadata-driven
path, which consults its `MetaField`.

Gated by three new end-to-end tests in `JsonbFieldDBTest` (metadata-driven single, array, and
POJO-bound), each asserting the **stored column text**, not just instant equality — Gson's default
format round-trips within Java while being unportable, so an equality-only assertion would have
passed against the bug. The `jsonbtest` fixture gained a `Moment` value object carrying all three
temporal shapes; before this it held only `string` and `int`, which is why no temporal value had
ever crossed this codec. Verified non-vacuous by mutation (dropping the `Z`, and dropping the
millisecond fraction, each fail the new tests).

## [0.21.2] — npm `0.21.2` · PyPI `0.21.2` · NuGet `0.21.2` · Maven `7.21.2`

Coordinated PATCH. **Changed product code: npm only** (`codegen-ts`, plus a comment-only note in
Expand Down
11 changes: 11 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ A red run here has caught real cross-port divergence (view-DDL identifier quotin
strategy mismatches) that the unit suites missed.

### 3. Promote to `latest`

**Before `bun publish`: confirm the `local-ci` run for the release commit is green.**
Its `ts-slow` lane now carries the real-Postgres migrate gate. Publishing is irreversible on
all four registries, and the `v*` tag is pushed *after* `bun publish` — so the tag-triggered
`integration-tests` run can never be the pre-publish gate. This is the last gate that can
precede the irreversible step.

```bash
gh run list --workflow local-ci.yml --limit 1 --json headSha,conclusion
```

```bash
# bump the candidate set to the final <version>
rm bun.lock && bun install
Expand Down
11 changes: 11 additions & 0 deletions scripts/ci-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ gate_ts_build_typecheck() { bun_install && bun run --filter '*' build && bun run
# integration tests, but the typecheck already runs in ts-fast, so don't repeat it.
gate_ts_build() { bun_install && bun run --filter '*' build; }

# migrate-ts real-Postgres suites — apply / lifecycle / rollback / introspection against a
# real engine. ADR-0015 makes migrate-ts the project's ONLY migrate engine, so this is the
# ONLY real-engine gate on migration correctness. The CI ts-slow job supplies
# MIGRATE_TS_PG_URL (its Postgres sidecar) plus MIGRATE_TS_PG_EXPECT=1, which arms an
# in-suite sentinel that fails loudly if that URL ever stops being set. Without those env
# vars (a local run with no Postgres) the PG describes self-skip exactly as before.
gate_migrate_ts_pg() { bun_install && ( cd server/typescript/packages/migrate-ts && bun test ); }

# ── conformance.yml — per-port conformance corpora (exact CI commands) ────────
gate_conf_ts() {
bun_install || return 1
Expand Down Expand Up @@ -345,6 +353,9 @@ else
# runs (umbrella `ts` / local full), its build already produced it — only build
# here when ts-slow runs in isolation (the CI ts-slow job).
if want_any ts ts-slow && ! want_any ts ts-fast; then step_if bun "ts build (for integration)" gate_ts_build; fi
# Ordered BEFORE the docker integration step so a container-readiness flake there
# can never prevent the migrate verdict from being produced.
want_any ts ts-slow && step_if bun "migrate-ts real-PG suite" gate_migrate_ts_pg
want_any ts ts-slow && run_integration_for ts ts
want_any java java-slow && run_integration_for java java kotlin
want python && run_integration_for python python
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ public static String format(MetaField mf, Date d) {
return localTime ? base : base + "Z";
}

/**
* Write-side without field context: the tz-aware instant form
* ({@code "YYYY-MM-DDTHH:MM:SS[.fff]Z"}), identical to what {@link #format} produces for a
* default {@code field.timestamp}.
*
* <p>Used where a {@link Date} is serialized with no owning {@link MetaField} to consult —
* notably a {@code java.util.Date} property on a registry-bound POJO, which Gson serializes
* by plain reflection and so never reaches {@link #format}. Absent field context the
* {@code field.date} (date-only) and {@code @localTime} (no {@code Z}) shapes are
* unknowable, so this always emits the fully-qualified instant — the one form that is
* lossless, locale- and timezone-independent, and parseable by every port (and by
* {@link #parse}).
*
* @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 formatInstant(Date d) {
if (d == null) return null;
LocalDateTime wallClock = Instant.ofEpochMilli(d.getTime()).atZone(ZoneOffset.UTC).toLocalDateTime();
return wallClock.format(TIMESTAMP_FMT) + fractionalSuffix(wallClock.getNano()) + "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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public final static GsonBuilder addAdaptersToBuilder(MetaDataLoader loader, Gson
private final static GsonBuilder addAdaptersToBuilder(MetaDataLoader loader, GsonBuilder builder,
boolean addSerializer, boolean addDeserializer) {

// Canonical java.util.Date wire form for every REFLECTIVELY-serialized Date — chiefly a
// temporal property on a registry-bound POJO, which never reaches MetaObjectSerializer
// and so used to take Gson's locale-, timezone-dependent and millisecond-lossy default.
// The metadata-driven path is unaffected (MetaObjectSerializer's DATE branch formats and
// addProperty's directly rather than delegating to context.serialize).
TemporalGsonAdapter temporal = new TemporalGsonAdapter();
if (addSerializer) builder.registerTypeAdapter(java.util.Date.class, (com.google.gson.JsonSerializer<java.util.Date>) temporal);
if (addDeserializer) builder.registerTypeAdapter(java.util.Date.class, (com.google.gson.JsonDeserializer<java.util.Date>) temporal);

List<Class> classList = new ArrayList<>();

Map<MetaObject,Class> nameClassMap = getMetaObjectToClassMap(loader);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.metaobjects.io.object.gson;

import com.metaobjects.io.json.TemporalWireFormat;
import com.google.gson.*;

import java.lang.reflect.Type;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.Date;

/**
* Canonical {@code java.util.Date} wire form for every Gson path that does NOT go through
* {@link MetaObjectSerializer} — i.e. any {@code Date} Gson reaches by plain reflection.
*
* <p><b>The gap this closes.</b> {@link MetaObjectGsonInitializer} registers
* {@link MetaObjectSerializer}/{@link MetaObjectDeserializer} against each {@link
* com.metaobjects.object.MetaObject}'s declared {@code @object} class. A value object bound to a
* hand-written POJO through {@link com.metaobjects.registry.ObjectClassRegistry} is therefore
* serialized by Gson's DEFAULT reflection instead, and its {@code Date} properties took Gson's
* built-in adapter. That emitted a localized {@code DateFormat.DEFAULT} string — e.g.
* {@code "Jun 3, 2026, 10:30:00 AM"} — which is:
* <ul>
* <li><b>locale-dependent</b> — the same instant serializes differently per JVM default locale;</li>
* <li><b>timezone-dependent</b> — rendered in the JVM's local zone, not UTC, so the stored text
* depends on where the process runs;</li>
* <li><b>millisecond-lossy</b> — {@code .123} is discarded, so even a Java-only round-trip
* does not return the original instant;</li>
* <li><b>unreadable by the other ports</b> — TS/Python/C#/Kotlin all expect the ISO form
* defined in {@code fixtures/persistence-conformance/normalization.md}.</li>
* </ul>
* The visible blast radius is an OMDB {@code @storage:jsonb} column holding a POJO-bound value
* object with a temporal field (see {@code GenericSQLDriver#serializeJsonb}). This is the same
* defect class as #275, on the one path #275 did not reach.
*
* <p><b>Write:</b> always {@link TemporalWireFormat#formatInstant} (the {@code ...Z} instant).
* Field context is unavailable here, so the {@code field.date} and {@code @localTime} shapes
* cannot be reproduced — a POJO-bound temporal property is written as a full instant. That is a
* deliberate, documented narrowing: it is lossless and portable, where the previous behavior was
* neither. A value object that needs the exact per-field shape should stay on the
* metadata-driven path (no POJO binding), which consults its {@link
* com.metaobjects.field.MetaField} and calls {@link TemporalWireFormat#format}.
*
* <p><b>Read:</b> {@link TemporalWireFormat#parse} first (all three canonical shapes), then a
* fallback through Gson's own former default ({@link DateFormat#getDateTimeInstance()}) so rows
* already written in the legacy localized format still load. Write canonical, read tolerant.
*
* <p>This adapter never sees a {@code Date} on the metadata-driven path: {@link
* MetaObjectSerializer}'s {@code DATE} branch formats and calls {@code addProperty} itself rather
* than delegating to {@code context.serialize}, so that output is unaffected.
*/
public final class TemporalGsonAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {

@Override
public JsonElement serialize(Date src, Type type, JsonSerializationContext context) {
if (src == null) return JsonNull.INSTANCE;
return new JsonPrimitive(TemporalWireFormat.formatInstant(src));
}

@Override
public Date deserialize(JsonElement json, Type type, JsonDeserializationContext context) {
if (json == null || json.isJsonNull()) return null;
String s = json.getAsString();
try {
return TemporalWireFormat.parse(s);
} catch (IllegalArgumentException canonicalMiss) {
// Legacy: a value written before this adapter existed, in Gson's default
// localized form. Best-effort so old rows still read; millisecond precision
// was already lost when it was written and cannot be recovered here.
try {
return DateFormat.getDateTimeInstance().parse(s);
} catch (ParseException legacyMiss) {
throw new JsonParseException(
"Cannot parse temporal value [" + s + "] as either the canonical wire form "
+ "or the legacy localized form", canonicalMiss);
}
}
}
}
Loading
Loading