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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ number. Both patterns are the convergent-publishing rule working as intended. **
`dist-tag rm` 403s for every token we hold (`docs/RELEASING.md` §4).
`metamodelVersion` read **`1.0`** through 1.0.13 — C4 landed and G4 shipped it, and no 1.0.x patch
moved it (1.0.4 relaxed `@sourceRefField` onto `@cardinality: one` with a prose-only
manifest footprint, and was ruled a hold). **`metamodelVersion` now reads `1.1`** for the FR-044 reporting vocabulary and the requirements slice 1 (the first slice of two, covering existence gates and per-entity coverage). Per-release detail lives in **`CHANGELOG.md`** — it is the log, and this file does
manifest footprint, and was ruled a hold). `metamodelVersion` read **`1.1`** from 1.1.0 for the FR-044 reporting vocabulary and the requirements slice 1 (the first slice of two, covering existence gates and per-entity coverage). **On `main` it now reads `1.2`** for calendar-aligned relative report windows (`startOf` on a relative date). Per-release detail lives in **`CHANGELOG.md`** — it is the log, and this file does
not duplicate it.

The npm surface is **14 `@metaobjectsdev/*` packages in full lockstep**; the two `angular`
Expand All @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.

**Cross-port conformance corpora** (every port runs the shared corpus):
- Metamodel: `fixtures/conformance/` (410 fixtures; 31 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Metamodel: `fixtures/conformance/` (413 fixtures; 31 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived.
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ here.**

## [Unreleased]

### Added

- **Calendar-aligned relative report windows.** A relative date in a report, segment or measure
filter can now carry `startOf`: `{ "now": "-P90D", "startOf": "day" }` is midnight UTC 90 days
back, where `{ "now": "-P90D" }` is 90 days before the moment of the query. `startOf` is `day`,
`week` (ISO, Monday) or `month`; the duration is applied first, then the result is truncated.
The truncation is written into the report view SQL for Postgres, SQLite/D1 and MySQL, so every
port that reads the view gets it. Any other `startOf`, or an extra key, is refused with
`ERR_BAD_ATTR_FILTER` in every port. Pinned by the `reporting-relative-startof`,
`error-relative-date-bad-startof` and `error-relative-date-extra-key` conformance fixtures and
the persistence `report-relative-date` scenario, which seeds a row a minute either side of the
window start. UTC only; there is no time-zone form. **`metamodelVersion` moves `1.1` to `1.2`**
(a new accepted filter value form, no registered attribute): a model that uses `startOf` needs
every port at this version. A model that does not use it is unchanged. See
[docs/features/reporting.md](docs/features/reporting.md#calendar-aligned-windows-startof).

### Fixed

- **A relative date with months or years now lands on the same day on SQLite and D1 as on
Postgres and MySQL.** SQLite rolls an overflowing month shift into the next month, so
`{ "now": "-P1M" }` on March 31 was March 3 in a SQLite view and February 28 on Postgres, and
with `startOf: "month"` the window started a whole month late. The SQLite view SQL now clamps
the shift to the last day of the target month, as Postgres and MySQL do, and applies years and
months as one month count. A SQLite report view whose relative date has a month or year
component gets new SQL, so `meta migrate` proposes recreating it; nothing else changes.

## [1.1.3] — 2026-10-26

_npm `1.1.3` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates), PyPI `1.1.3`, NuGet `1.1.3` and Maven Central `8.1.3`._
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Only those last two rows make a measure other than `count`, or a dimension reach

A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock.

Add `"startOf": "day"`, `"week"` or `"month"` to cut the window to a calendar boundary: `{ "now": "-P90D", "startOf": "day" }` is midnight UTC, 90 days back, not 90 days before the moment of the query. The duration is applied first, then the result is truncated, and a week starts on Monday. No other unit is accepted (`ERR_BAD_ATTR_FILTER`), and there is no time-zone form.

## Nulls and zeros

- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted.
Expand Down
6 changes: 3 additions & 3 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor

| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
|---|---|---|---|---|---|---|
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 410 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 413 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
Expand Down Expand Up @@ -278,7 +278,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.

## Fixture-to-doc mapping

### `fixtures/conformance/` — metamodel loader + canonical serializer (410)
### `fixtures/conformance/` — metamodel loader + canonical serializer (413)

| Fixture prefix | Feature doc |
|---|---|
Expand Down Expand Up @@ -554,7 +554,7 @@ files into a real Cube.

## Orphaned fixtures (tested but not yet documented)

The fixtures in the nine corpora mapped above (metamodel 410 + yaml 16 + verify 37
The fixtures in the nine corpora mapped above (metamodel 413 + yaml 16 + verify 37
+ render 16 + persistence 59 + api-contract 106 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc, and so do the two requirement corpora and the
`cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining
Expand Down
5 changes: 2 additions & 3 deletions docs/features/cube-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ joins, dimensions, measures, segments, and a rollup for each served report._
listed by `meta gen --list`, imported from its package to run as shipped (or copied into your
repo with `meta eject cube-model` to change it), and checked by `meta verify --codegen`. It is not core: what MetaObjects guarantees is the vocabulary and
the loader's checks on it, and a generator that writes files into your repo is a helper you can
copy and own. It adds no vocabulary (`metamodelVersion` stays 1.1) and has no runtime: the
copy and own. It adds no vocabulary and has no runtime: the
files it writes import nothing.

**What it does.** For each concrete entity that has a table and declares a `dimension`,
Expand Down Expand Up @@ -680,8 +680,7 @@ jobs API) before a query can use them.

## Compatibility

Nothing here changes a model or a file you already have. No vocabulary was registered, and
`metamodelVersion` stays 1.1. The report view lowering and the exporter now share one SQL
Nothing here changes a model or a file you already have. No vocabulary was registered. The report view lowering and the exporter now share one SQL
module, a move that left every view's bytes as they were. No other generator's output changed.

The design, the contract tables and the decisions made while building it are in
Expand Down
47 changes: 41 additions & 6 deletions docs/features/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ Closed sets:
`@spine` is a path written like a dimension's `@via`. A measure's `@default` is an integer.

Plus one new value form inside the existing portable filter grammar: the **relative date**
`{ "now": "<ISO-8601 duration>" }` (see [Relative dates](#relative-dates-in-filters)).
`{ "now": "<ISO-8601 duration>" }`, optionally with `"startOf"` (see
[Relative dates](#relative-dates-in-filters)).

## Declaring one

Expand Down Expand Up @@ -877,10 +878,35 @@ signed duration (`-P7D` is seven days ago, `-PT12H` twelve hours ago):
It is evaluated when the view is **queried**, not when it is created. A view that uses it
is therefore not cacheable by query text.

#### Calendar-aligned windows: `startOf`

A bare `-P90D` is 90 days before the moment of the query, so the window start moves through
the day and a daily report counts a partial day at its oldest edge. Add `startOf` to cut the
start to a calendar boundary:

```jsonc
"@filter": { "occurredAt": { "gte": { "now": "-P90D", "startOf": "day" } } }
```

| `startOf` | The window starts at |
|---|---|
| `day` | 00:00 UTC of the day the shifted instant falls on |
| `week` | 00:00 UTC on the Monday of that ISO week |
| `month` | 00:00 UTC on the first of that month |

The duration is applied first and the result is then truncated: `{ "now": "-P1M", "startOf":
"month" }` is the first of last month, and `{ "now": "P0D", "startOf": "month" }` is the first
of this month. The cut is in UTC, the same clock as the time grains. There is no time-zone
form; a window aligned to another zone is a separate piece of work. The view SQL carries the
truncation on Postgres, SQLite (and D1) and MySQL, so every port that reads the view gets it.

Only `day`, `week` and `month` are accepted. The key is legal exactly where `now` is, and
only beside `now`.

| Rule | Code | Refused |
|---|---|---|
| **F1** | `ERR_BAD_ATTR_FILTER` | A relative value anywhere except the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. In a `layout.dataGrid` preset, a projection `@filter` or an `origin.aggregate` `@filter` it fails the load, because no lowering exists for those hosts. |
| **F2** | `ERR_BAD_ATTR_FILTER` | A relative value on any field except a `field.date` or `field.timestamp`; under any operator except `gt, gte, lt, lte`; as an object that carries keys besides `now`; or with a duration that does not match the ISO-8601 pattern `^[+-]?P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$`. |
| **F2** | `ERR_BAD_ATTR_FILTER` | A relative value on any field except a `field.date` or `field.timestamp`; under any operator except `gt, gte, lt, lte`; as an object that carries keys besides `now` and `startOf`; with a duration that does not match the ISO-8601 pattern `^[+-]?P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$`; or with a `startOf` other than `day`, `week` or `month`. |

An operator-less `{ "occurredAt": { "now": "-P7D" } }` is read as `eq`, and `eq` is not one
of the four operators a relative date may sit under, so it is refused.
Expand All @@ -903,17 +929,26 @@ A model that declares no reporting vocabulary gets nothing new from it. Outside
loads changes in two places: a bare `extends` naming an object in another package is refused in
TypeScript, Python and C#, and fixes to Java and Python parsing bugs move those ports toward what
TypeScript already did. The [CHANGELOG](../../CHANGELOG.md) lists them and the models they affect,
and [upgrading-1.0-to-1.1.md](migrations/upgrading-1.0-to-1.1.md) the upgrade steps. Until 1.1
ships, `main` carries `metamodelVersion` 1.1, so no 1.0.x PATCH is cut from it.
and [upgrading-1.0-to-1.1.md](migrations/upgrading-1.0-to-1.1.md) the upgrade steps. 1.1
shipped with `metamodelVersion` 1.1; `main` now carries 1.2 (below), so no 1.1.x PATCH is cut
from it.

`startOf` on a relative date is a second MINOR: `metamodelVersion` moves `1.1` to `1.2`. It
is a new accepted value form inside `attr.filter`, so no registered attribute changed, and a
model that does not use it loads and generates exactly as before. A model that uses it needs
every port at this version, because an older loader refuses the extra key as a malformed
relative date. The view SQL changes only for a report that declares it, and for a SQLite view
whose relative date shifts by months or years, which now clamps to the end of a short month
as Postgres and MySQL do.

`@spine` and a measure's `@default` joined the same 1.1 change before it shipped, so
`metamodelVersion` stays 1.1. A model that uses neither generates exactly as before. No
`metamodelVersion` did not move for them. A model that uses neither generates exactly as before. No
generator you can own changed for them (the view SQL and the model pages' wording come from the
packages), so an owned generator needs no resync. A report that declares
`@spine`, or lists a measure with `@default`, lowers to different SQL and a tighter row type
than the same report without it.

Serving reports added no vocabulary (`metamodelVersion` stays 1.1). The same change corrected
Serving reports added no vocabulary and did not move `metamodelVersion`. The same change corrected
generated output for models that declare no report. Each is in the
[CHANGELOG](../../CHANGELOG.md) with the shape it affects:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,14 @@ An `attr.filter` value form meaning "the current time plus an ISO-8601 duration"
against `field.timestamp` and `field.date`. Evaluated when the view is **queried**, not when
it is created.

*Amendment (calendar windows):* the value may also carry `startOf: day | week | month`
(`{ now: "-P90D", startOf: "day" }`). The duration is applied, then the instant is truncated to
the start of that UTC calendar unit (ISO week, Monday). It lowers on Postgres, SQLite/D1 and
MySQL in the report view SQL, is validated by rule F2 in every port, and is pinned by the
`reporting-relative-startof` and `error-relative-date-*` conformance fixtures and by the
persistence `report-relative-date` scenario. Time zones are not part of it. `metamodelVersion`
`1.2`.

**ADR-0037:** a new value form inside an existing registered grammar, not a new attribute.
**Admission:** RDB `now() - interval` (PG) / `strftime('%Y-%m-%dT%H:%M:%fZ','now','-7 days')`
(SQLite, matching the ISO text storage adapters use); MongoDB `$$NOW` arithmetic; Search date
Expand Down
4 changes: 2 additions & 2 deletions examples/showcase/site-payload.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
"pypi": "1.1.3",
"nuget": "1.1.3",
"maven": "8.1.3",
"metamodel": "1.1"
"metamodel": "1.2"
},
"counts": {
"fixtures": 410,
"fixtures": 413,
"corpora": 31,
"baseTypes": 17
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Only those last two rows make a measure other than `count`, or a dimension reach

A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock.

Add `"startOf": "day"`, `"week"` or `"month"` to cut the window to a calendar boundary: `{ "now": "-P90D", "startOf": "day" }` is midnight UTC, 90 days back, not 90 days before the moment of the query. The duration is applied first, then the result is truncated, and a week starts on Monday. No other unit is accepted (`ERR_BAD_ATTR_FILTER`), and there is no time-zone form.

## Nulls and zeros

- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Only those last two rows make a measure other than `count`, or a dimension reach

A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock.

Add `"startOf": "day"`, `"week"` or `"month"` to cut the window to a calendar boundary: `{ "now": "-P90D", "startOf": "day" }` is midnight UTC, 90 days back, not 90 days before the moment of the query. The duration is applied first, then the result is truncated, and a week starts on Monday. No other unit is accepted (`ERR_BAD_ATTR_FILTER`), and there is no time-zone form.

## Nulls and zeros

- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted.
Expand Down
Loading
Loading