Skip to content

Commit bea0465

Browse files
committed
feat: ADR-0036 Wave 2 (Python) — field.timestamp instant-by-default + @localTime
Port the TS reference (fa40542) to Python. field.timestamp is now an absolute instant / timezone-aware BY DEFAULT; the rare naive / wall-clock case opts out with the boolean @localTime attr. The @dbColumnType: timestamp_with_tz escape hatch is retired — the legal @dbColumnType set shrinks to { uuid, jsonb }. The canonical golden + shared persistence fixtures are TS-owned and unchanged; this makes the Python port byte-match them. Metamodel: - db_constants.py: add FIELD_ATTR_LOCAL_TIME; drop DB_COLUMN_TYPE_TIMESTAMP_TZ from VALID_DB_COLUMN_TYPES (now [uuid, jsonb]); tombstone the retired constant. - db_provider.py: register @localTime (boolean, NO allowed_values) on field.timestamp only; description sourced from the embedded spec_metamodel/db.json by apply_spec_descriptions (single-source). @dbColumnType allowed_values shrinks via VALID_DB_COLUMN_TYPES. - spec_metamodel/db.json: re-copied from the TS-updated repo-root spec/metamodel/db.json (byte-identity gate) — new boolean @localTime on field.timestamp + slimmed @dbColumnType allowedValues/description. Loader: - validation_passes.py: drop timestamp_with_tz from the @dbColumnType legal pairing map (now uuid/jsonb→field.string only). A timestamp_with_tz value now fails Rule 1 (unrecognized value) → still ERR_BAD_ATTR_VALUE, so the shared error-dbcolumntype-illegal-pairing fixture stays green (trigger shifts from illegal-pairing to unknown-value). Runtime (the core native-type flip): - object_manager.py write codec: default field.timestamp → tz-aware datetime (UTC tzinfo → driver binds timestamptz); @localTime:true → naive datetime (no tzinfo → driver binds plain timestamp). Inverts the prior default. The normalization boundary (tests/integration/normalization.py) already keys tz-aware↔Z / naive↔no-Z off tzinfo, so it is unchanged. Codegen/schema: Python is pure data-access (ADR-0015) — no DDL/schema emitter and the Pydantic type map is datetime.datetime for both modes, so no codegen column-type branch existed to flip. Tests: registry-conformance byte-match green; spec_metamodel embed byte-match green; full conformance (301) green incl. the shared illegal-pairing fixture; test_field_uuid_dbcolumntype updated for the retirement + a new @localTime-ok case. Native-type rule unit-verified (default aware / @localTime naive). The persistence op:roundtrip (tsVal naive / tsTzVal tz-aware) needs pg8000 + Testcontainers Postgres — CI-gated, not run locally. The 2 failing test_cli_staleness_nudge tests are pre-existing (unrelated agent-docs message wording), failing identically on the base branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n
1 parent a50f1d4 commit bea0465

6 files changed

Lines changed: 81 additions & 29 deletions

File tree

server/python/src/metaobjects/loader/validation_passes.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
)
4343
from ..meta.persistence.db.db_constants import (
4444
DB_COLUMN_TYPE_JSONB,
45-
DB_COLUMN_TYPE_TIMESTAMP_TZ,
4645
DB_COLUMN_TYPE_UUID,
4746
FIELD_ATTR_DB_COLUMN_TYPE,
4847
VALID_DB_COLUMN_TYPES,
@@ -662,25 +661,27 @@ def _validate_field_defaults(root: MetaData, errors: list[MetaError]) -> None:
662661
# Own-only validation of the @dbColumnType physical column-type attribute,
663662
# mirroring the field.enum @values precedent. Two rules, both → ERR_BAD_ATTR_VALUE:
664663
#
665-
# 1. The value must be one of the closed set uuid|jsonb|timestamp_with_tz.
664+
# 1. The value must be one of the closed set uuid|jsonb.
666665
# (@dbColumnType is registered as a bare string attr — no allowed_values — so
667666
# this pass is the SOLE enforcer of the closed set: an unknown value fires
668667
# exactly one ERR_BAD_ATTR_VALUE, matching TS/Java/C#.)
669668
# 2. The (logical subtype × value) pairing must be legal:
670-
# uuid → field.string
671-
# jsonb → field.string
672-
# timestamp_with_tz → field.timestamp
669+
# uuid → field.string
670+
# jsonb → field.string
671+
#
672+
# ADR-0036 Wave 2: timestamp_with_tz is RETIRED — timezone-awareness moved to
673+
# field.timestamp (instant by default) + @localTime (the naive opt-out), so it is
674+
# no longer a legal @dbColumnType value or pairing.
673675
#
674676
# Own-only: only @dbColumnType declared on THIS node is validated (a physical
675677
# attr is never inherited via extends:). Cross-port: TS/C#/Java run the identical
676678
# own-only check.
677679

678-
# value → the field subtype it is legal on (Phase 1: three surviving values).
680+
# value → the field subtype it is legal on (uuid/jsonb on field.string).
679681
# uuid_array / text_array are REMOVED — derive from field.uuid/field.string + isArray.
680682
_DB_COLUMN_TYPE_REQUIRED_SUBTYPE: dict[str, str] = {
681683
DB_COLUMN_TYPE_UUID: FIELD_SUBTYPE_STRING,
682684
DB_COLUMN_TYPE_JSONB: FIELD_SUBTYPE_STRING,
683-
DB_COLUMN_TYPE_TIMESTAMP_TZ: FIELD_SUBTYPE_TIMESTAMP,
684685
}
685686

686687

server/python/src/metaobjects/meta/persistence/db/db_constants.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@
1818
# ---------------------------------------------------------------------------
1919

2020
# Physical DB column-type override on a field (@dbColumnType). Closed value set:
21-
# DB_COLUMN_TYPE_UUID / DB_COLUMN_TYPE_JSONB / DB_COLUMN_TYPE_TIMESTAMP_TZ.
21+
# DB_COLUMN_TYPE_UUID / DB_COLUMN_TYPE_JSONB.
2222
FIELD_ATTR_DB_COLUMN_TYPE = "dbColumnType"
2323

24+
# ADR-0036 Wave 2: @localTime — a boolean flag on field.timestamp marking the rare
25+
# naive / wall-clock case. When true the column is Postgres `timestamp without time
26+
# zone`; absent/false (the default) is an absolute instant → `timestamptz`. This
27+
# replaces the retired @dbColumnType: timestamp_with_tz escape hatch — timezone-
28+
# awareness now lives in the logical field type (instant-by-default) + this opt-out,
29+
# not in a physical column-type string.
30+
FIELD_ATTR_LOCAL_TIME = "localTime"
31+
2432
# @db.indexed — boolean DB-domain attr on every field subtype. Suppresses the
2533
# @filterable-without-index warning by declaring an explicit index intent.
2634
# Mirrors TS persistence/db/db-constants.ts FIELD_ATTR_DB_INDEXED.
@@ -30,8 +38,12 @@
3038
DB_COLUMN_TYPE_UUID = "uuid"
3139
# @dbColumnType: jsonb — genuinely-open JSON column (legal on field.string).
3240
DB_COLUMN_TYPE_JSONB = "jsonb"
33-
# @dbColumnType: timestamp_with_tz — timestamp with time zone (legal on field.timestamp).
34-
DB_COLUMN_TYPE_TIMESTAMP_TZ = "timestamp_with_tz"
41+
42+
# ADR-0036 Wave 2 removal: timestamp_with_tz is no longer a valid @dbColumnType
43+
# value. field.timestamp is instant / tz-aware BY DEFAULT and @localTime opts into
44+
# the naive wall-clock case — timezone-awareness moved out of @dbColumnType. The
45+
# constant is kept as a tombstone so any straggling caller gets a clear NameError.
46+
# DB_COLUMN_TYPE_TIMESTAMP_TZ = "timestamp_with_tz" # REMOVED — use @localTime opt-out
3547

3648
# Phase 1 removal: uuid_array / text_array are no longer valid @dbColumnType values.
3749
# Derive native uuid[]/text[] from field.uuid/field.string + isArray=true instead.
@@ -40,11 +52,11 @@
4052
# DB_COLUMN_TYPE_UUID_ARRAY = "uuid_array" # REMOVED — use field.uuid isArray:true
4153
# DB_COLUMN_TYPE_TEXT_ARRAY = "text_array" # REMOVED — use field.string isArray:true
4254

43-
# The closed set of legal @dbColumnType values (Phase 1: uuid | jsonb | timestamp_with_tz).
55+
# The closed set of legal @dbColumnType values. ADR-0036 Wave 2 retires
56+
# timestamp_with_tz — the legal set is now just { uuid, jsonb }, both on field.string.
4457
VALID_DB_COLUMN_TYPES = (
4558
DB_COLUMN_TYPE_UUID,
4659
DB_COLUMN_TYPE_JSONB,
47-
DB_COLUMN_TYPE_TIMESTAMP_TZ,
4860
)
4961

5062
# ---------------------------------------------------------------------------

server/python/src/metaobjects/meta/persistence/db/db_provider.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"""
1717
from __future__ import annotations
1818

19-
from ...core.attr.attr_constants import ATTR_SUBTYPE_STRING
19+
from ...core.attr.attr_constants import ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_STRING
2020
from ...core.field import field_constants as fc
2121
from ...core.identity.identity_constants import (
2222
IDENTITY_SUBTYPE_REFERENCE,
@@ -27,6 +27,7 @@
2727
from ....shared.base_types import TYPE_FIELD, TYPE_IDENTITY
2828
from .db_constants import (
2929
FIELD_ATTR_DB_COLUMN_TYPE,
30+
FIELD_ATTR_LOCAL_TIME,
3031
IDENTITY_REFERENCE_ATTR_CONSTRAINT_NAME,
3132
IDENTITY_SECONDARY_ATTR_ORDERS,
3233
IDENTITY_SECONDARY_ATTR_WHERE,
@@ -46,21 +47,30 @@
4647
_COLUMN_SCHEMA = AttrSchema(name=fc.FIELD_ATTR_COLUMN, value_type=ATTR_SUBTYPE_STRING, required=False)
4748

4849
# @dbColumnType — physical column-type override on a field. Carries the closed
49-
# value-set (uuid | jsonb | timestamp_with_tz) PURELY so it surfaces in the
50-
# registry manifest (ADR-0036 Wave 1, decision 5 — closed-value-set conformance
51-
# gate). Its REAL constraint is the (subtype × value) pairing enforced by the
52-
# loader's _validate_db_column_type pass, which emits the single ERR_BAD_ATTR_VALUE
53-
# for both an unrecognized value and an illegal pairing; that pass is the sole
54-
# enforcer, so @dbColumnType is EXEMPT from the generic flat allowed_values
55-
# membership check (Check 3 in validation_passes) to avoid double-reporting —
56-
# matching the TS reference.
50+
# value-set (uuid | jsonb) PURELY so it surfaces in the registry manifest (ADR-0036
51+
# Wave 1, decision 5 — closed-value-set conformance gate). Its REAL constraint is
52+
# the (subtype × value) pairing enforced by the loader's _validate_db_column_type
53+
# pass, which emits the single ERR_BAD_ATTR_VALUE for both an unrecognized value and
54+
# an illegal pairing; that pass is the sole enforcer, so @dbColumnType is EXEMPT from
55+
# the generic flat allowed_values membership check (Check 3 in validation_passes) to
56+
# avoid double-reporting — matching the TS reference.
5757
_DB_COLUMN_TYPE_SCHEMA = AttrSchema(
5858
name=FIELD_ATTR_DB_COLUMN_TYPE,
5959
value_type=ATTR_SUBTYPE_STRING,
6060
required=False,
6161
allowed_values=VALID_DB_COLUMN_TYPES,
6262
)
6363

64+
# @localTime — boolean opt-out into a naive / wall-clock timestamp (ADR-0036 Wave
65+
# 2). Registered on field.timestamp only; the description is sourced from the
66+
# embedded spec/metamodel/db.json by apply_spec_descriptions (single-source). NO
67+
# allowed_values — it's an open boolean, not a closed-enum attr.
68+
_LOCAL_TIME_SCHEMA = AttrSchema(
69+
name=FIELD_ATTR_LOCAL_TIME,
70+
value_type=ATTR_SUBTYPE_BOOLEAN,
71+
required=False,
72+
)
73+
6474
# DB-domain physical attrs EXTENDING identity subtypes — RDB index / FK-constraint
6575
# concerns, NOT core identity. Mirrors spec/metamodel/db.json's identity extends and
6676
# the TS/Java/C# db providers. Descriptions are byte-identical to the canonical.
@@ -129,6 +139,13 @@ def _register(registry: TypeRegistry) -> None:
129139
sub_type,
130140
attributes=[_COLUMN_SCHEMA, _DB_COLUMN_TYPE_SCHEMA],
131141
)
142+
# ADR-0036 Wave 2: @localTime is a field.timestamp-only opt-out (instant by
143+
# default → naive). Registered on the timestamp subtype only.
144+
registry.extend(
145+
TYPE_FIELD,
146+
fc.FIELD_SUBTYPE_TIMESTAMP,
147+
attributes=[_LOCAL_TIME_SCHEMA],
148+
)
132149
registry.extend(
133150
TYPE_IDENTITY,
134151
IDENTITY_SUBTYPE_SECONDARY,

server/python/src/metaobjects/runtime/object_manager.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -674,16 +674,17 @@ def _coerce_write_value(field: MetaField, value: Any) -> Any:
674674
return value if isinstance(value, _uuid.UUID) else _uuid.UUID(str(value))
675675

676676
# temporal: parse the authoring string to the native type, with tz-awareness
677-
# driven by the field (TIMESTAMP → naive, TIMESTAMPTZ → aware) so the driver
678-
# binds the correct physical type.
677+
# driven by the field so the driver binds the correct physical type. ADR-0036
678+
# Wave 2: field.timestamp is instant / tz-aware BY DEFAULT (→ timestamptz); the
679+
# rare naive wall-clock case opts out with @localTime:true (→ plain timestamp).
679680
if sub == fc.FIELD_SUBTYPE_DATE:
680681
return value if isinstance(value, _dt.date) else _dt.date.fromisoformat(str(value))
681682
if sub == fc.FIELD_SUBTYPE_TIME:
682683
return value if isinstance(value, _dt.time) else _dt.time.fromisoformat(str(value))
683684
if sub == fc.FIELD_SUBTYPE_TIMESTAMP:
684685
if isinstance(value, _dt.datetime):
685686
return value
686-
is_tz = col_type == dbc.DB_COLUMN_TYPE_TIMESTAMP_TZ
687+
is_tz = field.attr(dbc.FIELD_ATTR_LOCAL_TIME) is not True
687688
return _parse_datetime(str(value), tz_aware=is_tz)
688689

689690
# field.object (jsonb storage): a dict/list passes through — pg8000 binds it

server/python/src/metaobjects/spec_metamodel/db.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
"children": [
88
{ "type": "attr", "subType": "string", "name": "column", "min": 0, "max": 1, "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." },
99
{ "type": "attr", "subType": "boolean", "name": "db.indexed", "min": 0, "max": 1, "description": "When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means)." },
10-
{ "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb", "timestamp_with_tz"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)." }
10+
{ "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2." }
11+
]
12+
},
13+
{
14+
"type": "field",
15+
"subType": "timestamp",
16+
"children": [
17+
{ "type": "attr", "subType": "boolean", "name": "localTime", "min": 0, "max": 1, "description": "When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch." }
1118
]
1219
},
1320
{

server/python/tests/unit/test_field_uuid_dbcolumntype.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,23 @@ def test_dbcolumntype_jsonb_on_string_ok() -> None:
9090
assert codes == []
9191

9292

93-
def test_dbcolumntype_timestamptz_on_timestamp_ok() -> None:
93+
def test_dbcolumntype_timestamptz_retired_on_timestamp_error() -> None:
94+
# ADR-0036 Wave 2: @dbColumnType:timestamp_with_tz is RETIRED — it is no longer
95+
# a recognized value, so even on field.timestamp it is now ERR_BAD_ATTR_VALUE
96+
# (unknown value). Timezone-awareness moved to @localTime (instant by default).
9497
codes, _ = _load(_entity(
9598
{"field.long": {"name": "id"}},
9699
{"field.timestamp": {"name": "at", "@dbColumnType": "timestamp_with_tz"}},
97100
))
101+
assert codes == ["ERR_BAD_ATTR_VALUE"]
102+
103+
104+
def test_local_time_attr_on_timestamp_ok() -> None:
105+
# ADR-0036 Wave 2: @localTime is a valid boolean opt-out on field.timestamp.
106+
codes, _ = _load(_entity(
107+
{"field.long": {"name": "id"}},
108+
{"field.timestamp": {"name": "at", "@localTime": True}},
109+
))
98110
assert codes == []
99111

100112

@@ -104,6 +116,7 @@ def test_dbcolumntype_timestamptz_on_timestamp_ok() -> None:
104116

105117

106118
def test_dbcolumntype_timestamptz_on_string_illegal() -> None:
119+
# ADR-0036 Wave 2: timestamp_with_tz is retired → unknown value on any subtype.
107120
codes, _ = _load(_entity(
108121
{"field.long": {"name": "id"}},
109122
{"field.string": {"name": "at", "@dbColumnType": "timestamp_with_tz"}},
@@ -185,13 +198,14 @@ def test_dbcolumntype_uuid_array_error_message_names_valid_set() -> None:
185198
result = MetaDataLoader.from_directory(tmpdir, providers=[core_provider])
186199
assert len(result.errors) == 1
187200
msg = result.errors[0].message
188-
# The "allowed:" section must list only the three surviving legal values.
201+
# The "allowed:" section must list only the two surviving legal values
202+
# (ADR-0036 Wave 2 retired timestamp_with_tz → { uuid, jsonb }).
189203
allowed_idx = msg.index("allowed:")
190204
allowed_section = msg[allowed_idx:]
191205
assert "uuid" in allowed_section
192206
assert "jsonb" in allowed_section
193-
assert "timestamp_with_tz" in allowed_section
194-
# uuid_array and text_array must NOT appear in the allowed section.
207+
# timestamp_with_tz (retired), uuid_array, text_array must NOT appear.
208+
assert "timestamp_with_tz" not in allowed_section
195209
assert "uuid_array" not in allowed_section
196210
assert "text_array" not in allowed_section
197211

0 commit comments

Comments
 (0)