From db8462ca2fc5e3655ef34f9b7777a67cb6ec251e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 23:17:29 -0400 Subject: [PATCH 1/7] feat(reporting): calendar-aligned relative windows via startOf (day, week, month) A relative date filter value can carry startOf, truncating the shifted instant to the start of a UTC calendar unit. Lowered in the report view SQL for Postgres, SQLite/D1 and MySQL; validated in all four loaders; pinned by shared conformance fixtures and the persistence report-relative-date scenario. metamodelVersion 1.1 -> 1.2. --- AGENTS.md | 2 +- CHANGELOG.md | 16 + .../references/reporting.md | 2 + docs/CONFORMANCE.md | 6 +- docs/features/reporting.md | 36 +- ...2026-10-02-fr-044-core-reporting-design.md | 8 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + .../references/reporting.md | 2 + fixtures/conformance/ERROR-CODES.json | 2 +- .../expected-errors.json | 15 + .../input/meta.shop.json | 357 +++++++++++++++++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.shop.json | 358 +++++++++++++++++ .../providers.json | 1 + .../reporting-relative-startof/expected.json | 375 ++++++++++++++++++ .../input/meta.shop.json | 357 +++++++++++++++++ .../reporting-relative-startof/providers.json | 1 + .../expected/model/cubes/Program.yml | 2 +- fixtures/persistence-conformance/README.md | 2 +- .../canonical/meta.fitness.json | 2 +- .../canonical/schema.mysql.sql | 2 +- .../canonical/schema.postgres.sql | 4 +- .../canonical/schema.sqlite.sql | 2 +- .../queries/report-relative-date.yaml | 22 +- .../expected-registry.json | 2 +- .../ReportingValidationTests.cs | 2 +- .../Core/Reporting/ReportingConstants.cs | 10 + .../Loader/ValidationPasses.Reporting.cs | 11 +- server/csharp/MetaObjects/RegistryManifest.cs | 2 +- .../loader/ReportingValidation.java | 14 +- .../registry/RegistryManifest.java | 2 +- .../reporting/ReportingConstants.java | 15 + .../loader/ReportingValidationTest.java | 4 +- .../metaobjects/loader/validate_reporting.py | 14 +- .../core/reporting/reporting_constants.py | 7 + .../src/metaobjects/registry_manifest.py | 2 +- .../tests/unit/test_reporting_accessors.py | 2 +- .../codegen-ts/src/projection/report-spec.ts | 4 +- .../codegen-ts/src/projection/report-sql.ts | 5 +- .../codegen-ts/src/projection/time-sql.ts | 36 +- .../test/cube/build-cube-model.test.ts | 2 +- .../codegen-ts/test/cube/cube-yaml.test.ts | 2 +- .../test/projection/time-sql.test.ts | 29 ++ .../codegen-ts/test/shared-model-file.test.ts | 2 +- .../src/core/reporting/reporting-constants.ts | 8 + .../src/loader/reporting-validation.ts | 18 +- .../metadata/src/registry-manifest.ts | 2 +- .../test/reporting-validation.test.ts | 20 +- 52 files changed, 1763 insertions(+), 50 deletions(-) create mode 100644 fixtures/conformance/error-relative-date-bad-startof/expected-errors.json create mode 100644 fixtures/conformance/error-relative-date-bad-startof/input/meta.shop.json create mode 100644 fixtures/conformance/error-relative-date-bad-startof/providers.json create mode 100644 fixtures/conformance/error-relative-date-extra-key/expected-errors.json create mode 100644 fixtures/conformance/error-relative-date-extra-key/input/meta.shop.json create mode 100644 fixtures/conformance/error-relative-date-extra-key/providers.json create mode 100644 fixtures/conformance/reporting-relative-startof/expected.json create mode 100644 fixtures/conformance/reporting-relative-startof/input/meta.shop.json create mode 100644 fixtures/conformance/reporting-relative-startof/providers.json diff --git a/AGENTS.md b/AGENTS.md index 82ba30a61..03f464fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d424c306..96197ea13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ 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). + ## [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`._ diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 8136ec433..33c60ec19 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -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. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 5b584f716..4fbac3ddc 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | 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 | ✓ | — | — | — | ✓ | @@ -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 | |---|---| @@ -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 diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 18e578145..b41cc842c 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -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": "" }` (see [Relative dates](#relative-dates-in-filters)). +`{ "now": "" }`, optionally with `"startOf"` (see +[Relative dates](#relative-dates-in-filters)). ## Declaring one @@ -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. @@ -906,6 +932,12 @@ TypeScript already did. The [CHANGELOG](../../CHANGELOG.md) lists them and the m 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. +`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. + `@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 generator you can own changed for them (the view SQL and the model pages' wording come from the diff --git a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md index 6b7a08925..e85d10916 100644 --- a/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md +++ b/docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md @@ -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 diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 8136ec433..33c60ec19 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -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. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 8136ec433..33c60ec19 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -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. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 8136ec433..33c60ec19 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -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. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 8136ec433..33c60ec19 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -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. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 8136ec433..33c60ec19 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -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. diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index a679de1ef..b27f9b91b 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -47,7 +47,7 @@ "ERR_PARTIAL_UNRESOLVED": "Build-time verify: a template partial ({{> group/source}}) does not resolve in the configured provider.", "ERR_REQUIRED_SLOT_UNUSED": "Build-time verify (warning): a template's declared @requiredSlots slot is never referenced by the template text.", "ERR_OUTPUT_TAG_MISSING": "Build-time verify: a template declares @requiredTags but its text (including provider-resolved partials) omits a required output tag's opening () form. path = the tag name.", - "ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207). FR-044: a segment / measure.aggregate / object.report @filter names a field its entity (or @from) lacks or an op illegal for the field (S1); a relative-date value { \"now\": \"\" } appears outside the @filter of a segment, measure.aggregate or object.report (F1); or a relative-date value sits on a field that is not field.date/field.timestamp, under an op other than gt/gte/lt/lte, or carries a value that is not an ISO-8601 duration (F2).", + "ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207). FR-044: a segment / measure.aggregate / object.report @filter names a field its entity (or @from) lacks or an op illegal for the field (S1); a relative-date value { \"now\": \"\" } appears outside the @filter of a segment, measure.aggregate or object.report (F1); or a relative-date value sits on a field that is not field.date/field.timestamp, under an op other than gt/gte/lt/lte, carries a value that is not an ISO-8601 duration, or has a startOf other than day, week or month, or a key besides now and startOf (F2).", "ERR_STORAGE_FLATTENED_ARRAY": "@storage \"flattened\" cannot be combined with isArray=true.", "ERR_STORAGE_WITHOUT_OBJECT_REF": "@storage was set on a field that has no @objectRef.", "ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF": "ADR-0013: a field.object declares no @objectRef. A field.object models a typed nested value and REQUIRES @objectRef. The message states the whole ladder rather than only the escape hatch: a known key set is an object.value the field @objectRefs; dynamic keys over a known value type are a field.map (@objectRef for a value object, @valueType for a scalar); only a bag no reader pins a key in is @dbColumnType: jsonb on a field.string. Naming the escape hatch alone is what taught adopters to reach for the bag by default.", diff --git a/fixtures/conformance/error-relative-date-bad-startof/expected-errors.json b/fixtures/conformance/error-relative-date-bad-startof/expected-errors.json new file mode 100644 index 000000000..e727f27a4 --- /dev/null +++ b/fixtures/conformance/error-relative-date-bad-startof/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-bad-startof/input/meta.shop.json b/fixtures/conformance/error-relative-date-bad-startof/input/meta.shop.json new file mode 100644 index 000000000..b0422be73 --- /dev/null +++ b/fixtures/conformance/error-relative-date-bad-startof/input/meta.shop.json @@ -0,0 +1,357 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D", + "startOf": "year" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-bad-startof/providers.json b/fixtures/conformance/error-relative-date-bad-startof/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-relative-date-bad-startof/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-relative-date-extra-key/expected-errors.json b/fixtures/conformance/error-relative-date-extra-key/expected-errors.json new file mode 100644 index 000000000..e727f27a4 --- /dev/null +++ b/fixtures/conformance/error-relative-date-extra-key/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_FILTER", + "source": { + "format": "json", + "files": [ + "meta.shop.json" + ], + "jsonPath": "$['metadata.root'].children[4]['object.report']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relative-date-extra-key/input/meta.shop.json b/fixtures/conformance/error-relative-date-extra-key/input/meta.shop.json new file mode 100644 index 000000000..e360a70fe --- /dev/null +++ b/fixtures/conformance/error-relative-date-extra-key/input/meta.shop.json @@ -0,0 +1,358 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D", + "startOf": "day", + "zone": "UTC" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relative-date-extra-key/providers.json b/fixtures/conformance/error-relative-date-extra-key/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-relative-date-extra-key/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/reporting-relative-startof/expected.json b/fixtures/conformance/reporting-relative-startof/expected.json new file mode 100644 index 000000000..e5264f380 --- /dev/null +++ b/fixtures/conformance/reporting-relative-startof/expected.json @@ -0,0 +1,375 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@cardinality": "many", + "@objectRef": "acme::shop::Purchase" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@fields": [ + "programId" + ], + "@references": "acme::shop::Program" + } + }, + { + "relationship.association": { + "name": "program", + "@cardinality": "one", + "@objectRef": "acme::shop::Program" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "acme::shop::Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "acme::shop::Program.title", + "@via": "acme::shop::Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "@of": "acme::shop::Purchase.purchasedAt" + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@grains": [ + "month", + "year" + ], + "@of": "acme::shop::Program.createdAt", + "@via": "acme::shop::Purchase.program" + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@filter": { + "refunded": { + "eq": true + } + }, + "@of": [ + "acme::shop::Purchase.id" + ] + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": [ + "acme::shop::Purchase.id" + ], + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": [ + "acme::shop::Purchase.customerEmail" + ], + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": [ + "acme::shop::Purchase.amountCents" + ], + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": { + "eq": "active" + } + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "acme::shop::WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": [ + "acme::shop::WorkoutEvent.customerEmail" + ] + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "acme::shop::WorkoutEvent.programId", + "acme::shop::WorkoutEvent.customerEmail", + "acme::shop::WorkoutEvent.weekNumber", + "acme::shop::WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": [ + "acme::shop::WorkoutEvent.occurredAt" + ] + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@denominator": "starters", + "@numerator": "daysEngaged" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": { + "eq": "exercise_complete" + } + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@dimensions": [ + "program" + ], + "@from": "acme::shop::WorkoutEvent", + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@dimensions": [ + "purchasedAt:day" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D", + "startOf": "day" + } + } + }, + "@from": "acme::shop::Purchase", + "@measures": [ + "purchases", + "revenue" + ] + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "acme::shop::Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-relative-startof/input/meta.shop.json b/fixtures/conformance/reporting-relative-startof/input/meta.shop.json new file mode 100644 index 000000000..501565fed --- /dev/null +++ b/fixtures/conformance/reporting-relative-startof/input/meta.shop.json @@ -0,0 +1,357 @@ +{ + "metadata.root": { + "package": "acme::shop", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title" + } + }, + { + "field.timestamp": { + "name": "createdAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "purchases", + "@objectRef": "Purchase", + "@cardinality": "many" + } + } + ] + } + }, + { + "object.entity": { + "name": "Purchase", + "children": [ + { + "source.rdb": { + "@table": "purchases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.currency": { + "name": "amountCents" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.boolean": { + "name": "refunded" + } + }, + { + "field.timestamp": { + "name": "purchasedAt" + } + }, + { + "field.date": { + "name": "purchasedOn" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "programRef", + "@references": "Program", + "@fields": [ + "programId" + ] + } + }, + { + "relationship.association": { + "name": "program", + "@objectRef": "Program", + "@cardinality": "one" + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "Purchase.programId" + } + }, + { + "dimension.attribute": { + "name": "programTitle", + "@of": "Program.title", + "@via": "Purchase.program" + } + }, + { + "dimension.time": { + "name": "purchasedAt", + "@of": "Purchase.purchasedAt", + "@grains": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + { + "dimension.time": { + "name": "programCreatedAt", + "@of": "Program.createdAt", + "@via": "Purchase.program", + "@grains": [ + "month", + "year" + ] + } + }, + { + "measure.aggregate": { + "name": "refundedPurchases", + "@agg": "count", + "@of": "Purchase.id", + "@filter": { + "refunded": true + } + } + }, + { + "measure.aggregate": { + "name": "purchases", + "@agg": "count", + "@of": "Purchase.id", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "buyers", + "@agg": "count", + "@distinct": true, + "@of": "Purchase.customerEmail", + "@segment": "active" + } + }, + { + "measure.aggregate": { + "name": "revenue", + "@agg": "sum", + "@of": "Purchase.amountCents", + "@segment": "active" + } + }, + { + "segment.filter": { + "name": "active", + "@filter": { + "status": "active" + } + } + } + ] + } + }, + { + "object.entity": { + "name": "WorkoutEvent", + "children": [ + { + "source.rdb": { + "@table": "workout_events" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "programId" + } + }, + { + "field.string": { + "name": "customerEmail" + } + }, + { + "field.int": { + "name": "weekNumber" + } + }, + { + "field.int": { + "name": "dayNumber" + } + }, + { + "field.string": { + "name": "eventType" + } + }, + { + "field.timestamp": { + "name": "occurredAt" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "program", + "@of": "WorkoutEvent.programId" + } + }, + { + "measure.aggregate": { + "name": "starters", + "@agg": "count", + "@distinct": true, + "@of": "WorkoutEvent.customerEmail" + } + }, + { + "measure.aggregate": { + "name": "daysEngaged", + "@agg": "count", + "@distinct": true, + "@of": [ + "WorkoutEvent.programId", + "WorkoutEvent.customerEmail", + "WorkoutEvent.weekNumber", + "WorkoutEvent.dayNumber" + ] + } + }, + { + "measure.aggregate": { + "name": "lastActivityAt", + "@agg": "max", + "@of": "WorkoutEvent.occurredAt" + } + }, + { + "measure.ratio": { + "name": "avgDaysPerStarter", + "@numerator": "daysEngaged", + "@denominator": "starters" + } + }, + { + "segment.filter": { + "name": "completions", + "@filter": { + "eventType": "exercise_complete" + } + } + } + ] + } + }, + { + "object.report": { + "name": "ProgramEngagement", + "@from": "WorkoutEvent", + "@dimensions": [ + "program" + ], + "@measures": [ + "starters", + "daysEngaged", + "avgDaysPerStarter", + "lastActivityAt" + ], + "@segment": "completions" + } + }, + { + "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": [ + "purchasedAt:day" + ], + "@measures": [ + "purchases", + "revenue" + ], + "@filter": { + "purchasedAt": { + "gte": { + "now": "-P90D", + "startOf": "day" + } + } + } + } + }, + { + "object.report": { + "name": "StoreTotals", + "@from": "Purchase", + "@measures": [ + "purchases", + "buyers", + "revenue" + ] + } + } + ] + } +} diff --git a/fixtures/conformance/reporting-relative-startof/providers.json b/fixtures/conformance/reporting-relative-startof/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/reporting-relative-startof/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/cube-model/canonical/expected/model/cubes/Program.yml b/fixtures/cube-model/canonical/expected/model/cubes/Program.yml index a427a9c7f..aa7cfbaa0 100644 --- a/fixtures/cube-model/canonical/expected/model/cubes/Program.yml +++ b/fixtures/cube-model/canonical/expected/model/cubes/Program.yml @@ -70,7 +70,7 @@ cubes: - name: introOrFree sql: '({CUBE}."title" LIKE ''Intro%'' OR {CUBE}."priceCents" = 0)' - name: recentProgramsScope - sql: '{CUBE}."created_ts" >= ((now() AT TIME ZONE ''UTC'') - INTERVAL ''P30D'')' + sql: '{CUBE}."created_ts" >= date_trunc(''day'', ((now() AT TIME ZONE ''UTC'') - INTERVAL ''P30D''))' - name: liveIntroProgramsScope sql: '({CUBE}."status" IN (''DRAFT'', ''PUBLISHED'') AND {CUBE}."priceCents" < 5000)' - name: programsEmptyScopeScope diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index 0da8f0c18..1066924b1 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -385,7 +385,7 @@ then measures in `@measures` order; a time dimension is named ``, f | `report-totals-empty` | one row over an empty table: `count` is `0`, `sum` is `null`, a zero-denominator ratio is `null` | | `report-time-grains` | `month` grain beside an enum dimension; a segment-filtered `sum` that is `null`; the ISO Monday week boundary; a report-level `@segment` | | `report-time-hour-and-date` | `hour` on an instant, `week` on a `field.date` | -| `report-relative-date` | a `{ now: "-P30D" }` filter, with a seed relative to the database clock | +| `report-relative-date` | a `{ now: "-P30D", startOf: "day" }` filter, with a seed relative to the database clock | | `report-spine-zero-rows` | `@spine`: a program with no weeks has a row (`weeks` `0`, `totalMinutes` and `longShare` null, the `@default: 0` measures `0`); `count` is the number of programs; a measure filter drops the empty rows; filter and sort on a defaulted measure | | `report-spine-scoped` | `@spine` with a report `@segment`: a program whose weeks are all out of scope keeps its row, with `weeks` `0` | | `report-boolean-measures` | `@agg: any` / `all` over a nullable `field.boolean`: a boolean on the wire (never `0` / `1`), nulls ignored, `null` for a group with nothing to judge, a `segment`-scoped `any`; `filter` by value and `isNull` | diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 38de722c7..12dbeb55a 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -404,7 +404,7 @@ "@dimensions": ["createdAt:week"], "@measures": ["programs"], "@segment": "published", "children": [ { "source.rdb": { "@kind": "view", "@view": "v_programs_by_week" } } ] } }, { "object.report": { "name": "RecentPrograms", "@from": "Program", - "@measures": ["programs"], "@filter": { "createdAt": { "gte": { "now": "-P30D" } } }, + "@measures": ["programs"], "@filter": { "createdAt": { "gte": { "now": "-P30D", "startOf": "day" } } }, "children": [ { "source.rdb": { "@kind": "view", "@view": "v_recent_programs" } } ] } }, { "object.report": { "name": "AssetActivity", "@from": "Asset", "@dimensions": ["recordedAt:hour", "asOfDate:week"], "@measures": ["assets"], diff --git a/fixtures/persistence-conformance/canonical/schema.mysql.sql b/fixtures/persistence-conformance/canonical/schema.mysql.sql index 1099c8e87..8d2a31430 100644 --- a/fixtures/persistence-conformance/canonical/schema.mysql.sql +++ b/fixtures/persistence-conformance/canonical/schema.mysql.sql @@ -151,7 +151,7 @@ CREATE VIEW `v_recent_programs` AS SELECT COUNT(p.`id`) AS `programs` FROM `programs` p - WHERE p.`created_ts` >= (UTC_TIMESTAMP(3) - INTERVAL 30 DAY); + WHERE p.`created_ts` >= DATE(UTC_TIMESTAMP(3) - INTERVAL 30 DAY); CREATE VIEW `v_asset_activity` AS SELECT diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index edb7bcbba..3631a660a 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -257,8 +257,8 @@ CREATE VIEW "v_recent_programs" AS SELECT COUNT(p."id") AS "programs" FROM "programs" p - WHERE p."created_ts" >= ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D'); -COMMENT ON VIEW "v_recent_programs" IS 'metaobjects:v1:sha256:9e675bb71fe4a3c74f06f5db65d7908e246a59c2a8fc062fc2c899948841eccc'; + WHERE p."created_ts" >= date_trunc('day', ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D')); +COMMENT ON VIEW "v_recent_programs" IS 'metaobjects:v1:sha256:c003a47e4cfe2bde4be608547553408adbd261fb1b2ccadccaf523073d30aeb7'; CREATE VIEW "v_asset_activity" AS SELECT diff --git a/fixtures/persistence-conformance/canonical/schema.sqlite.sql b/fixtures/persistence-conformance/canonical/schema.sqlite.sql index b2c086a80..c7cc93430 100644 --- a/fixtures/persistence-conformance/canonical/schema.sqlite.sql +++ b/fixtures/persistence-conformance/canonical/schema.sqlite.sql @@ -225,7 +225,7 @@ CREATE VIEW "v_recent_programs" AS SELECT COUNT(p."id") AS "programs" FROM "programs" p - WHERE p."created_ts" >= strftime('%Y-%m-%dT%H:%M:%f', 'now', '-30 days'); + WHERE p."created_ts" >= strftime('%Y-%m-%dT%H:%M:%f', 'now', '-30 days', 'start of day'); CREATE VIEW "v_asset_activity" AS SELECT diff --git a/fixtures/persistence-conformance/queries/report-relative-date.yaml b/fixtures/persistence-conformance/queries/report-relative-date.yaml index 779074638..47867f1b6 100644 --- a/fixtures/persistence-conformance/queries/report-relative-date.yaml +++ b/fixtures/persistence-conformance/queries/report-relative-date.yaml @@ -1,27 +1,35 @@ name: report-relative-date description: | - RecentPrograms carries the report-level filter createdAt >= now - P30D. A relative-date + RecentPrograms carries the report-level filter createdAt >= start of day (now - P30D). A relative-date value is evaluated by the database when the view is QUERIED (the view calls now()), not when it is created, so a fixed seed date would rot as the clock advances. The seed is therefore clock-relative: seed SQL is executed raw, so an expression is legal. One - program is three days old (inside the window), the other sixty (outside). + program is three days old (inside the window), the other sixty (outside). Two more sit + one minute either side of the window start, which `startOf: day` puts at 00:00 UTC thirty + days ago: without the truncation the first would be cut off and the second let in. created_ts is a naive timestamp holding the UTC wall clock. seed-data: | INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES (1, 'Fresh', 1000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '3 days'), - (2, 'Stale', 2000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '60 days'); + (2, 'Stale', 2000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '60 days'), + (3, 'EdgeIn', 3000, 'PUBLISHED', date_trunc('day', (now() AT TIME ZONE 'UTC') - INTERVAL '30 days') + INTERVAL '1 minute'), + (4, 'EdgeOut', 4000, 'PUBLISHED', date_trunc('day', (now() AT TIME ZONE 'UTC') - INTERVAL '30 days') - INTERVAL '1 minute'); seed-data-engine: sqlite: | INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES (1, 'Fresh', 1000, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%fZ','now','-3 days')), - (2, 'Stale', 2000, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%fZ','now','-60 days')); + (2, 'Stale', 2000, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%fZ','now','-60 days')), + (3, 'EdgeIn', 3000, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%fZ','now','-30 days','start of day','+1 minutes')), + (4, 'EdgeOut', 4000, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%fZ','now','-30 days','start of day','-1 minutes')); mysql: | INSERT INTO `programs` (`id`,`title`,`priceCents`,`status`,`created_ts`) VALUES (1, 'Fresh', 1000, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 3 DAY), - (2, 'Stale', 2000, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 60 DAY); + (2, 'Stale', 2000, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 60 DAY), + (3, 'EdgeIn', 3000, 'PUBLISHED', DATE(UTC_TIMESTAMP(3) - INTERVAL 30 DAY) + INTERVAL 1 MINUTE), + (4, 'EdgeOut', 4000, 'PUBLISHED', DATE(UTC_TIMESTAMP(3) - INTERVAL 30 DAY) - INTERVAL 1 MINUTE); queries: - - name: only-the-recent-program-counts + - name: only-programs-inside-the-calendar-window-count op: list entity: RecentPrograms expect: - - { programs: "1" } + - { programs: "2" } diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 8c3c8fec6..3d8130ccd 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -1,5 +1,5 @@ { - "metamodelVersion": "1.1", + "metamodelVersion": "1.2", "types": [ { "type": "attr", diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs index 2563d30d8..e30c3b087 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingValidationTests.cs @@ -583,7 +583,7 @@ public void F2_a_now_key_plus_other_keys_is_refused_on_a_reporting_host() string msg = Single(Load(m), ErrorCode.ERR_BAD_ATTR_FILTER); Assert.Equal( "report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has a malformed relative date " + - "{\"now\":\"-P7D\",\"x\":1}; a relative date is exactly { now: \"\" } with no other keys.", + "{\"now\":\"-P7D\",\"x\":1}; a relative date is { now: \"\" } with an optional startOf, and no other keys.", msg); } diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs b/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs index 8aa3f4ac8..c469f117b 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportingConstants.cs @@ -124,6 +124,16 @@ public static class ReportingConstants /// public const string FILTER_RELATIVE_NOW = "now"; + /// + /// The optional second key of a relative-date value: + /// { "now": "-P90D", "startOf": "day" } truncates the instant (after the duration is + /// applied) to the start of that UTC calendar unit. + /// + public const string FILTER_RELATIVE_START_OF = "startOf"; + + /// The calendar units startOf accepts. A week is the ISO week, Monday start. + public static readonly IReadOnlyList RELATIVE_START_OF_UNITS = new[] { "day", "week", "month" }; + /// /// A signed ISO-8601 duration (-P7D, P1Y2M, -PT12H). The lookaheads /// refuse the degenerate P and PT forms (a designator with no component). diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index 6fc0be69a..9a74354a8 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -184,7 +184,7 @@ private static bool IsRelativeValue(object? v) => v is IReadOnlyDictionary o && o.ContainsKey(FILTER_RELATIVE_NOW); /// True for a well-formed relative value: exactly the one key now. - private static bool IsExactRelativeValue(IReadOnlyDictionary v) => v.Count == 1; + private static bool IsExactRelativeValue(IReadOnlyDictionary v) => v.Keys.All(k => k == FILTER_RELATIVE_NOW || k == FILTER_RELATIVE_START_OF); /// The relative value an op's operand carries: the operand itself, or one inside an array operand. private static IReadOnlyDictionary? RelativeOperand(object? v) @@ -766,7 +766,7 @@ void Err(string message) => if (!IsExactRelativeValue(relative)) { Err($"@filter on '{key}' has a malformed relative date {JsonStringify(relative)}; a relative date is " + - "exactly { now: \"\" } with no other keys."); + "{ now: \"\" } with an optional startOf, and no other keys."); continue; } if (!ReportingTemporalFieldSubtypes.Contains(field.SubType)) @@ -786,6 +786,13 @@ void Err(string message) => { Err($"@filter on '{key}' has relative date '{QuoteValue(duration)}', which is not an ISO-8601 " + "duration (e.g. '-P7D', '-PT12H')."); + continue; + } + if (relative.TryGetValue(FILTER_RELATIVE_START_OF, out var startOf) + && (startOf is not string unit || !RELATIVE_START_OF_UNITS.Contains(unit))) + { + Err($"@filter on '{key}' has startOf '{QuoteValue(startOf)}'; startOf is one of " + + $"{string.Join(", ", RELATIVE_START_OF_UNITS)}."); } } } diff --git a/server/csharp/MetaObjects/RegistryManifest.cs b/server/csharp/MetaObjects/RegistryManifest.cs index 5d013810f..b59670f15 100644 --- a/server/csharp/MetaObjects/RegistryManifest.cs +++ b/server/csharp/MetaObjects/RegistryManifest.cs @@ -375,7 +375,7 @@ private static ManifestType ToManifestType(TypeDefinition def) => /// unstable (semver major-0). The 1.0 cut flips this to "1.0". /// Emitted as the FIRST top-level key in the registry manifest (C4). /// - public const string MetamodelVersion = "1.1"; + public const string MetamodelVersion = "1.2"; /// /// Emit the canonical registry manifest as a byte-stable JSON string. diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java index b111e12e9..726a4dda8 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java @@ -863,9 +863,9 @@ private static void checkFilter(Map filter, MetaData entity, String entity } Map relative = relativeOperand(opEntry.getValue()); if (relative == null) continue; - if (relative.size() != 1) { + if (!ReportingConstants.isWellFormedRelativeKeys(relative.keySet())) { err.accept("@filter on '" + key + "' has a malformed relative date " + GSON.toJson(relative) - + "; a relative date is exactly { now: \"\" } with no other keys."); + + "; a relative date is { now: \"\" } with an optional startOf, and no other keys."); continue; } if (!TEMPORAL_FIELD_SUBTYPES.contains(field.getSubType())) { @@ -884,6 +884,16 @@ private static void checkFilter(Map filter, MetaData entity, String entity || !ReportingConstants.ISO_DURATION_RE.matcher((String) duration).matches()) { err.accept("@filter on '" + key + "' has relative date '" + quoteValue(duration) + "', which is not an ISO-8601 duration (e.g. '-P7D', '-PT12H')."); + continue; + } + if (relative.containsKey(ReportingConstants.FILTER_RELATIVE_START_OF)) { + Object startOf = relative.get(ReportingConstants.FILTER_RELATIVE_START_OF); + if (!(startOf instanceof String) + || !ReportingConstants.RELATIVE_START_OF_UNITS.contains(startOf)) { + err.accept("@filter on '" + key + "' has startOf '" + quoteValue(startOf) + + "'; startOf is one of " + + String.join(", ", ReportingConstants.RELATIVE_START_OF_UNITS) + "."); + } } } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java index d73f9b54b..596e86fd5 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java +++ b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java @@ -277,7 +277,7 @@ public static List metamodelProviders() { * the 1.0 cut will flip it to {@code "1.0"}. Mirrors the TS reference's * {@code METAMODEL_VERSION} constant. */ - public static final String METAMODEL_VERSION = "1.1"; + public static final String METAMODEL_VERSION = "1.2"; // ------------------------------------------------------------------ // SP-G Phase1 Units2-3 — manifest emitter exclusions (documented, uniform diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java index b564e02ab..69f77f965 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportingConstants.java @@ -127,6 +127,21 @@ private ReportingConstants() { */ public static final String FILTER_RELATIVE_NOW = "now"; + /** + * The optional second key of a relative-date value: + * {@code { "now": "-P90D", "startOf": "day" }} truncates the instant (after the duration is + * applied) to the start of that UTC calendar unit. + */ + public static final String FILTER_RELATIVE_START_OF = "startOf"; + + /** The calendar units {@code startOf} accepts. A week is the ISO week, Monday start. */ + public static final java.util.List RELATIVE_START_OF_UNITS = java.util.List.of("day", "week", "month"); + + /** True when a relative value's keys are {@code now} plus optionally {@code startOf}, and nothing else. */ + public static boolean isWellFormedRelativeKeys(java.util.Set keys) { + return keys.stream().allMatch(k -> FILTER_RELATIVE_NOW.equals(k) || FILTER_RELATIVE_START_OF.equals(k)); + } + /** * A signed ISO-8601 duration ({@code -P7D}, {@code P1Y2M}, {@code -PT12H}). The lookaheads * refuse the degenerate {@code P} and {@code PT} forms. Use with {@code matches()} so the diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java index 047490fa9..248282d4f 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/ReportingValidationTest.java @@ -417,8 +417,8 @@ public void malformedRelativeValueIsRefused() throws IOException { List got = ValidationPhase.validateReporting(loadJson(doc)); assertEquals(List.of("ERR_BAD_ATTR_FILTER"), codes(got)); assertEquals("report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has a malformed relative date " - + "{\"now\":\"-P7D\",\"x\":1}; a relative date is exactly { now: \"\" } with no " - + "other keys.", got.get(0).getMessage()); + + "{\"now\":\"-P7D\",\"x\":1}; a relative date is { now: \"\" } with an " + + "optional startOf, and no other keys.", got.get(0).getMessage()); } @Test diff --git a/server/python/src/metaobjects/loader/validate_reporting.py b/server/python/src/metaobjects/loader/validate_reporting.py index d8e0f5270..1a1359a09 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -96,6 +96,8 @@ AGG_MIN, AGG_SUM, FILTER_RELATIVE_NOW, + FILTER_RELATIVE_START_OF, + RELATIVE_START_OF_UNITS, GRAIN_HOUR, ISO_DURATION_RE, MEASURE_SUBTYPE_AGGREGATE, @@ -861,10 +863,10 @@ def err(message: str) -> None: relative = _relative_operand(operand) if relative is None: continue - if len(relative) != 1: + if not set(relative) <= {FILTER_RELATIVE_NOW, FILTER_RELATIVE_START_OF}: err( f"@filter on '{key}' has a malformed relative date {_js_json(relative)}; a relative date is " - f'exactly {{ now: "" }} with no other keys.' + f'{{ now: "" }} with an optional startOf, and no other keys.' ) continue if field.sub_type not in _TEMPORAL_FIELD_SUBTYPES: @@ -885,6 +887,14 @@ def err(message: str) -> None: f"@filter on '{key}' has relative date '{_quote_value(duration)}', which is not an ISO-8601 " f"duration (e.g. '-P7D', '-PT12H')." ) + continue + if FILTER_RELATIVE_START_OF in relative: + start_of = relative[FILTER_RELATIVE_START_OF] + if not isinstance(start_of, str) or start_of not in RELATIVE_START_OF_UNITS: + err( + f"@filter on '{key}' has startOf '{_quote_value(start_of)}'; startOf is one of " + f"{', '.join(RELATIVE_START_OF_UNITS)}." + ) # --------------------------------------------------------------------------- diff --git a/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py b/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py index 7504ba598..cd31ac5f9 100644 --- a/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py +++ b/server/python/src/metaobjects/meta/core/reporting/reporting_constants.py @@ -107,6 +107,13 @@ #: Legal only in the ``@filter`` of a ``segment``, ``measure.aggregate`` or ``object.report``. FILTER_RELATIVE_NOW = "now" +#: The optional second key of a relative-date value: ``{ "now": "-P90D", "startOf": "day" }`` +#: truncates the instant (after the duration is applied) to the start of that UTC calendar unit. +FILTER_RELATIVE_START_OF = "startOf" + +#: The calendar units ``startOf`` accepts. A week is the ISO week, Monday start. +RELATIVE_START_OF_UNITS: tuple[str, ...] = ("day", "week", "month") + #: A signed ISO-8601 duration (``-P7D``, ``P1Y2M``, ``-PT12H``). The lookaheads refuse #: the degenerate ``P`` and ``PT`` forms (a designator with no component). The pattern #: is the rule table's verbatim; ``\Z`` stands in for JS ``$`` (Python ``$`` would also diff --git a/server/python/src/metaobjects/registry_manifest.py b/server/python/src/metaobjects/registry_manifest.py index 4027a38e7..0340fb466 100644 --- a/server/python/src/metaobjects/registry_manifest.py +++ b/server/python/src/metaobjects/registry_manifest.py @@ -255,7 +255,7 @@ def _sorted_children(definition: TypeDefinition) -> list[dict[str, object]]: return children -METAMODEL_VERSION = "1.1" +METAMODEL_VERSION = "1.2" """Rolled-up spec-version for the cross-port registry manifest. ``"0"`` = pre-1.0 / unstable (semver major-0). Flips to ``"1.0"`` at the diff --git a/server/python/tests/unit/test_reporting_accessors.py b/server/python/tests/unit/test_reporting_accessors.py index 16aadf822..bdc7862c5 100644 --- a/server/python/tests/unit/test_reporting_accessors.py +++ b/server/python/tests/unit/test_reporting_accessors.py @@ -333,7 +333,7 @@ def test_malformed_relative_value_is_refused() -> None: ( ErrorCode.ERR_BAD_ATTR_FILTER, "report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has a malformed relative date " - '{"now":"-P7D","x":1}; a relative date is exactly { now: "" } with no other keys.', + '{"now":"-P7D","x":1}; a relative date is { now: "" } with an optional startOf, and no other keys.', ) ] diff --git a/server/typescript/packages/codegen-ts/src/projection/report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/report-spec.ts index fe6bafa01..eebd5fe50 100644 --- a/server/typescript/packages/codegen-ts/src/projection/report-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/report-spec.ts @@ -2,7 +2,7 @@ // `extractReportSpec` produces it; the report DDL emitter renders it per dialect. Column // references are already resolved to unquoted `alias.column`, as in `ViewSpec`. -import type { MeasureAgg, TimeGrain } from "@metaobjectsdev/metadata"; +import type { MeasureAgg, RelativeStartOf, TimeGrain } from "@metaobjectsdev/metadata"; import type { JoinTree, ViewFilterClause } from "./view-spec.js"; import type { ReportTemporal } from "./time-sql.js"; @@ -12,6 +12,8 @@ export interface RelativeNow { /** The signed ISO-8601 duration as authored, e.g. "-P90D". */ readonly duration: string; readonly temporal: ReportTemporal; + /** Truncate the shifted instant to the start of this UTC calendar unit, when authored. */ + readonly startOf?: RelativeStartOf; } export function isRelativeNow(v: unknown): v is RelativeNow { diff --git a/server/typescript/packages/codegen-ts/src/projection/report-sql.ts b/server/typescript/packages/codegen-ts/src/projection/report-sql.ts index 24cbb7676..8a230e867 100644 --- a/server/typescript/packages/codegen-ts/src/projection/report-sql.ts +++ b/server/typescript/packages/codegen-ts/src/projection/report-sql.ts @@ -17,6 +17,7 @@ import { FILTER_COMPOSE_OR, FILTER_OP_IN, FILTER_RELATIVE_NOW, + FILTER_RELATIVE_START_OF, OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT, TYPE_SEGMENT, @@ -24,6 +25,7 @@ import { type MetaMeasure, type MetaObject, type MetaSegment, + type RelativeStartOf, } from "@metaobjectsdev/metadata"; import { intValueMapOf } from "../enum-meta.js"; import { @@ -48,7 +50,7 @@ export function ref(r: string, d: ReportDialect, renderer: SqlRenderer = VIEW_SQ } export function literal(v: unknown, d: ReportDialect): string { - if (isRelativeNow(v)) return relativeNowSql(v.duration, v.temporal, d); + if (isRelativeNow(v)) return relativeNowSql(v.duration, v.temporal, d, v.startOf); if (v === null || v === undefined) return "NULL"; if (typeof v === "number") return String(v); if (typeof v === "boolean") return d === "sqlite" ? (v ? "1" : "0") : v ? "TRUE" : "FALSE"; @@ -171,6 +173,7 @@ function lowerFilterValue(raw: unknown, op: string, field: MetaField, key: strin kind: "relativeNow" as const, duration: String(v[FILTER_RELATIVE_NOW]), temporal: temporalOf(field), + ...(FILTER_RELATIVE_START_OF in v ? { startOf: v[FILTER_RELATIVE_START_OF] as RelativeStartOf } : {}), }; }; if (isRelativeValue(raw)) return relative(raw); diff --git a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts index 02dd3633d..75589a911 100644 --- a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts +++ b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts @@ -4,6 +4,8 @@ import { GRAIN_DAY, GRAIN_HOUR, GRAIN_MONTH, GRAIN_QUARTER, GRAIN_WEEK, GRAIN_YEAR, ISO_DURATION_RE, + RELATIVE_START_OF_UNITS, + type RelativeStartOf, TIME_GRAINS, type TimeGrain, } from "@metaobjectsdev/metadata"; @@ -131,19 +133,33 @@ function truncateMysql(x: string, grain: TimeGrain): string { } } -/** Table E. `{ now: "" }` as SQL, evaluated when the view is queried. */ +/** Table E. `{ now: "", startOf? }` as SQL, evaluated when the view is queried. + * `startOf` truncates the shifted instant to the start of that UTC calendar unit (the week + * starts on Monday); the result keeps the column's kind, so a day start is midnight UTC. */ export function relativeNowSql( duration: string, temporal: ReportTemporal, dialect: ReportDialect, + startOf?: RelativeStartOf, ): string { + if (startOf !== undefined && !(RELATIVE_START_OF_UNITS as readonly string[]).includes(startOf)) { + // The loader validates it; a programmatic caller skips the loader and the unit is written into SQL. + throw new Error(`time-sql: "${String(startOf)}" is not a startOf unit (${RELATIVE_START_OF_UNITS.join(", ")}).`); + } const d = parseIsoDuration(duration); switch (dialect) { case "postgres": { // ISO-8601 interval input is accepted as written. const interval = `INTERVAL '${d.magnitude}'`; - if (temporal === "instant") return `(now() ${d.sign} ${interval})`; - const utcWall = `((now() AT TIME ZONE 'UTC') ${d.sign} ${interval})`; + if (temporal === "instant") { + const shifted = `(now() ${d.sign} ${interval})`; + // date_trunc on a timestamptz truncates in the session zone, so cut the UTC wall clock. + return startOf === undefined + ? shifted + : `(date_trunc('${startOf}', ${shifted} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')`; + } + const shiftedWall = `((now() AT TIME ZONE 'UTC') ${d.sign} ${interval})`; + const utcWall = startOf === undefined ? shiftedWall : `date_trunc('${startOf}', ${shiftedWall})`; return temporal === "naive" ? utcWall : `CAST(${utcWall} AS DATE)`; } case "sqlite": { @@ -154,7 +170,13 @@ export function relativeNowSql( ] .filter(([n]) => (n as number) !== 0) .map(([n, unit]) => `'${d.sign}${n} ${unit}'`); - const args = ["'now'", ...mods].join(", "); + // Applied after the duration. SQLite's `weekday 0` moves forward to Sunday, so -6 days is Monday. + const truncation = + startOf === GRAIN_DAY ? ["'start of day'"] + : startOf === GRAIN_WEEK ? ["'weekday 0'", "'-6 days'", "'start of day'"] + : startOf === GRAIN_MONTH ? ["'start of month'"] + : []; + const args = ["'now'", ...mods, ...truncation].join(", "); if (temporal === "date") return `date(${args})`; const fmt = temporal === "instant" ? "%Y-%m-%dT%H:%M:%fZ" : "%Y-%m-%dT%H:%M:%f"; return `strftime('${fmt}', ${args})`; @@ -168,7 +190,11 @@ export function relativeNowSql( .map(([n, unit]) => ` ${d.sign} INTERVAL ${n} ${unit}`) .join(""); const now = `UTC_TIMESTAMP(3)${intervals}`; - return temporal === "date" ? `DATE(${now})` : `(${now})`; + if (startOf === undefined) return temporal === "date" ? `DATE(${now})` : `(${now})`; + // The same cuts as Table D's MySQL arm; a DATE compares as midnight against a DATETIME. + return startOf === GRAIN_DAY ? `DATE(${now})` + : startOf === GRAIN_WEEK ? `DATE(DATE_SUB(${now}, INTERVAL WEEKDAY(${now}) DAY))` + : `DATE(DATE_FORMAT(${now}, '%Y-%m-01'))`; } } } diff --git a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts index 1bea40ae4..e431c038d 100644 --- a/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/build-cube-model.test.ts @@ -1535,7 +1535,7 @@ describe("Table H — the canonical model", () => { { name: "live", sql: `{CUBE}."status" IN ('DRAFT', 'PUBLISHED')` }, { name: "introOrFree", sql: `({CUBE}."title" LIKE 'Intro%' OR {CUBE}."priceCents" = 0)` }, // RecentPrograms' relative @filter: a scope segment, and no rollup. - { name: "recentProgramsScope", sql: `{CUBE}."created_ts" >= ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D')` }, + { name: "recentProgramsScope", sql: `{CUBE}."created_ts" >= date_trunc('day', ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D'))` }, { name: "liveIntroProgramsScope", sql: `({CUBE}."status" IN ('DRAFT', 'PUBLISHED') AND {CUBE}."priceCents" < 5000)` }, { name: "programsEmptyScopeScope", sql: `{CUBE}."priceCents" > 1000000` }, ], diff --git a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts index c385a78eb..6e793ea83 100644 --- a/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/cube-yaml.test.ts @@ -151,7 +151,7 @@ cubes: - name: introOrFree sql: '({CUBE}."title" LIKE ''Intro%'' OR {CUBE}."priceCents" = 0)' - name: recentProgramsScope - sql: '{CUBE}."created_ts" >= ((now() AT TIME ZONE ''UTC'') - INTERVAL ''P30D'')' + sql: '{CUBE}."created_ts" >= date_trunc(''day'', ((now() AT TIME ZONE ''UTC'') - INTERVAL ''P30D''))' - name: liveIntroProgramsScope sql: '({CUBE}."status" IN (''DRAFT'', ''PUBLISHED'') AND {CUBE}."priceCents" < 5000)' - name: programsEmptyScopeScope diff --git a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts index 37301f76a..6abef4998 100644 --- a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts @@ -117,6 +117,35 @@ describe("relativeNowSql (Table E)", () => { expect(relativeNowSql(duration, temporal, "mysql")).toBe(sql); }); + test.each([ + ["-P90D", "instant", "day", `(date_trunc('day', (now() - INTERVAL 'P90D') AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')`], + ["-P1W", "naive", "week", `date_trunc('week', ((now() AT TIME ZONE 'UTC') - INTERVAL 'P1W'))`], + ["P0D", "date", "month", `CAST(date_trunc('month', ((now() AT TIME ZONE 'UTC') + INTERVAL 'P0D')) AS DATE)`], + ] as const)("postgres %s on %s startOf %s", (duration, temporal, startOf, sql) => { + expect(relativeNowSql(duration, temporal, "postgres", startOf)).toBe(sql); + }); + + test.each([ + ["-P90D", "instant", "day", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days', 'start of day')`], + ["P0D", "naive", "week", `strftime('%Y-%m-%dT%H:%M:%f', 'now', 'weekday 0', '-6 days', 'start of day')`], + ["-P1M", "date", "month", `date('now', '-1 months', 'start of month')`], + ] as const)("sqlite %s on %s startOf %s", (duration, temporal, startOf, sql) => { + expect(relativeNowSql(duration, temporal, "sqlite", startOf)).toBe(sql); + }); + + test.each([ + ["-P90D", "instant", "day", `DATE(UTC_TIMESTAMP(3) - INTERVAL 90 DAY)`], + ["-P1W", "naive", "week", + `DATE(DATE_SUB(UTC_TIMESTAMP(3) - INTERVAL 1 WEEK, INTERVAL WEEKDAY(UTC_TIMESTAMP(3) - INTERVAL 1 WEEK) DAY))`], + ["P0D", "date", "month", `DATE(DATE_FORMAT(UTC_TIMESTAMP(3), '%Y-%m-01'))`], + ] as const)("mysql %s on %s startOf %s", (duration, temporal, startOf, sql) => { + expect(relativeNowSql(duration, temporal, "mysql", startOf)).toBe(sql); + }); + + test("an unknown startOf unit throws instead of reaching the SQL", () => { + expect(() => relativeNowSql("-P1D", "instant", "postgres", "year" as never)).toThrow(/year/); + }); + test("a malformed duration throws, naming it", () => { expect(() => parseIsoDuration("P")).toThrow(/P/); expect(() => relativeNowSql("7 days", "instant", "postgres")).toThrow(/7 days/); diff --git a/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts b/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts index ac9d4d7cb..bd5b4e1d2 100644 --- a/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/shared-model-file.test.ts @@ -208,7 +208,7 @@ describe("sharedModelFile()", () => { schema_version: 1, name: "acme-common", version: "1.0.0", - metamodelVersion: "1.1", + metamodelVersion: "1.2", artifact: "acme-common.metaobjects.json", integrity: PINNED_HASH, packages: ["acme::common"], diff --git a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts index 1dd7d3839..24af7e390 100644 --- a/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts +++ b/server/typescript/packages/metadata/src/core/reporting/reporting-constants.ts @@ -98,6 +98,14 @@ export const REPORT_DIMENSION_GRAIN_SEPARATOR = ":"; * Legal only in the `@filter` of a `segment`, `measure.aggregate` or `object.report`. */ export const FILTER_RELATIVE_NOW = "now"; +/** The optional second key of a relative-date value: `{ now: "-P90D", startOf: "day" }` + * truncates the instant (after the duration is applied) to the start of that UTC calendar unit. */ +export const FILTER_RELATIVE_START_OF = "startOf"; + +/** The calendar units `startOf` accepts. A week is the ISO week, Monday start. */ +export const RELATIVE_START_OF_UNITS = ["day", "week", "month"] as const; +export type RelativeStartOf = (typeof RELATIVE_START_OF_UNITS)[number]; + /** A signed ISO-8601 duration (`-P7D`, `P1Y2M`, `-PT12H`). The lookaheads refuse the * degenerate `P` and `PT` forms (a designator with no component). */ export const ISO_DURATION_RE = diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index f202e5127..52498aa1c 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -82,6 +82,8 @@ import { SOURCE_KIND_VIEW } from "../persistence/source/source-constants.js"; import { AGG_ALL, AGG_ANY } from "../persistence/origin/origin-constants.js"; import { FILTER_RELATIVE_NOW, + FILTER_RELATIVE_START_OF, + RELATIVE_START_OF_UNITS, ISO_DURATION_RE, AGG_COUNT, AGG_SUM, @@ -251,9 +253,9 @@ function isRelativeValue(v: unknown): v is Record { return isPlainObject(v) && Object.keys(v).includes(FILTER_RELATIVE_NOW); } -/** True for a well-formed relative value: exactly the one key `now`. */ +/** True for a well-formed relative value: the key `now`, plus optionally `startOf`, and nothing else. */ function isExactRelativeValue(v: Record): boolean { - return Object.keys(v).length === 1; + return Object.keys(v).every((k) => k === FILTER_RELATIVE_NOW || k === FILTER_RELATIVE_START_OF); } /** The relative value an op's operand carries: the operand itself, or one inside an array operand. */ @@ -812,7 +814,7 @@ function checkFilter( if (!isExactRelativeValue(relative)) { err( `@filter on '${key}' has a malformed relative date ${JSON.stringify(relative)}; a relative date is ` + - `exactly { now: "" } with no other keys.`, + `{ now: "" } with an optional startOf, and no other keys.`, ); continue; } @@ -836,6 +838,16 @@ function checkFilter( `@filter on '${key}' has relative date '${quoteValue(duration)}', which is not an ISO-8601 ` + `duration (e.g. '-P7D', '-PT12H').`, ); + continue; + } + if (FILTER_RELATIVE_START_OF in relative) { + const startOf = relative[FILTER_RELATIVE_START_OF]; + if (typeof startOf !== "string" || !(RELATIVE_START_OF_UNITS as readonly string[]).includes(startOf)) { + err( + `@filter on '${key}' has startOf '${quoteValue(startOf)}'; startOf is one of ` + + `${RELATIVE_START_OF_UNITS.join(", ")}.`, + ); + } } } } diff --git a/server/typescript/packages/metadata/src/registry-manifest.ts b/server/typescript/packages/metadata/src/registry-manifest.ts index ac3d6db9d..3f1acdfe9 100644 --- a/server/typescript/packages/metadata/src/registry-manifest.ts +++ b/server/typescript/packages/metadata/src/registry-manifest.ts @@ -112,7 +112,7 @@ export interface ManifestType { * constant read `"0.10"`). Bump with that script — never by hand — so the manifest and * all four port constants move together. */ -export const METAMODEL_VERSION = "1.1"; +export const METAMODEL_VERSION = "1.2"; /** The full canonical manifest. All collections are sorted for byte-stability. */ interface RegistryManifest { diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index eddd4dcea..579247c42 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -975,10 +975,28 @@ describe("validateReporting — relative dates", () => { const msg = await single(m, "ERR_BAD_ATTR_FILTER"); expect(msg).toBe( "report 'acme::shop::DailyRevenue': @filter on 'purchasedAt' has a malformed relative date " + - '{"now":"-P7D","x":1}; a relative date is exactly { now: "" } with no other keys.', + '{"now":"-P7D","x":1}; a relative date is { now: "" } with an optional startOf, and no other keys.', ); }); + test("F2: startOf day, week and month are accepted beside now", async () => { + for (const startOf of ["day", "week", "month"]) { + const m = edit((x) => + patchObject(x, "DailyRevenue", { "@filter": { purchasedAt: { gte: { now: "-P90D", startOf } } } }), + ); + expect((await loadInline(m)).errors).toEqual([]); + } + }); + + test("F2: a startOf outside day, week and month is refused, naming the allowed units", async () => { + const m = edit((x) => + patchObject(x, "DailyRevenue", { "@filter": { purchasedAt: { gte: { now: "-P90D", startOf: "year" } } } }), + ); + const msg = await single(m, "ERR_BAD_ATTR_FILTER"); + expect(msg).toContain("startOf 'year'"); + expect(msg).toContain("day, week, month"); + }); + test("F1: an operand with a `now` key plus other keys is refused on a non-reporting host", async () => { const m = edit((x) => x["metadata.root"].children.push({ From 43bf6e81257f9d5aacfeae10f80a2ed2524ea559 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 23:21:07 -0400 Subject: [PATCH 2/7] no-mistakes(review): apply reviewer inline fixes --- .../persistence-conformance/queries/report-relative-date.yaml | 3 ++- server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs | 2 +- .../main/java/com/metaobjects/loader/ReportingValidation.java | 2 +- server/python/src/metaobjects/loader/validate_reporting.py | 2 +- .../packages/metadata/src/loader/reporting-validation.ts | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/fixtures/persistence-conformance/queries/report-relative-date.yaml b/fixtures/persistence-conformance/queries/report-relative-date.yaml index 47867f1b6..16bc256be 100644 --- a/fixtures/persistence-conformance/queries/report-relative-date.yaml +++ b/fixtures/persistence-conformance/queries/report-relative-date.yaml @@ -6,7 +6,8 @@ description: | therefore clock-relative: seed SQL is executed raw, so an expression is legal. One program is three days old (inside the window), the other sixty (outside). Two more sit one minute either side of the window start, which `startOf: day` puts at 00:00 UTC thirty - days ago: without the truncation the first would be cut off and the second let in. + days ago: without the truncation the first would be cut off; the second pins that the + window reaches no further back than that midnight. created_ts is a naive timestamp holding the UTC wall clock. seed-data: | INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index 9a74354a8..326fedb04 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -183,7 +183,7 @@ internal static bool IsSelfOrAncestor(MetaData? candidate, MetaData entity) private static bool IsRelativeValue(object? v) => v is IReadOnlyDictionary o && o.ContainsKey(FILTER_RELATIVE_NOW); - /// True for a well-formed relative value: exactly the one key now. + /// True for a well-formed relative value: the key now, plus optionally startOf, and nothing else. private static bool IsExactRelativeValue(IReadOnlyDictionary v) => v.Keys.All(k => k == FILTER_RELATIVE_NOW || k == FILTER_RELATIVE_START_OF); /// The relative value an op's operand carries: the operand itself, or one inside an array operand. diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java index 726a4dda8..77c4439b4 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java @@ -254,7 +254,7 @@ private static MetaData fieldOf(MetaData obj, String name) { } /** An object operand carrying a {@code now} key — a relative-date value, well-formed - * (exactly {@code { now }}) or not. A malformed one is refused, never read as data. */ + * ({@code { now }} plus an optional {@code startOf}) or not. A malformed one is refused, never read as data. */ private static boolean isRelativeValue(Object v) { return v instanceof Map && ((Map) v).containsKey(ReportingConstants.FILTER_RELATIVE_NOW); } diff --git a/server/python/src/metaobjects/loader/validate_reporting.py b/server/python/src/metaobjects/loader/validate_reporting.py index 1a1359a09..065257a75 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -312,7 +312,7 @@ def _is_document_field(field: MetaData) -> bool: def _is_relative_value(v: object) -> TypeGuard[dict[str, object]]: """An object operand carrying a ``now`` key — a relative-date value, well-formed - (exactly ``{ now }``) or not. A malformed one is refused, never read as data.""" + (``{ now }`` plus an optional ``startOf``) or not. A malformed one is refused, never read as data.""" return isinstance(v, dict) and FILTER_RELATIVE_NOW in v diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 52498aa1c..af9c12c16 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -248,7 +248,7 @@ function isPlainObject(v: unknown): v is Record { } /** An object operand carrying a `now` key — a relative-date value, well-formed - * (exactly `{ now }`) or not. A malformed one is refused, never read as data. */ + * (`{ now }` plus an optional `startOf`) or not. A malformed one is refused, never read as data. */ function isRelativeValue(v: unknown): v is Record { return isPlainObject(v) && Object.keys(v).includes(FILTER_RELATIVE_NOW); } From 750db5c2e433994c55b20afe2eb0def3a6d3abac Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 23:25:32 -0400 Subject: [PATCH 3/7] no-mistakes(review): Clamp SQLite month shifts; engine-test windows; fix version docs --- AGENTS.md | 2 +- CHANGELOG.md | 8 +++++ docs/features/reporting.md | 9 +++-- .../codegen-ts/src/projection/time-sql.ts | 32 +++++++++++++---- .../test/projection/time-sql.test.ts | 34 +++++++++++++++++-- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 03f464fb1..0ec3e94ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 96197ea13..a2e737d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,14 @@ _npm `1.1.3` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates ### 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. + - **`meta verify --db` no longer reports false drift on a regex CHECK over a varchar/text column.** Postgres stores `"key" ~ '^[a-z]+$'` as `((key)::text ~ '^[a-z]+$'::text)`; the literal's `::text` was already folded away but the column's was not, so a database `migrate` diff --git a/docs/features/reporting.md b/docs/features/reporting.md index b41cc842c..efb4ffa5c 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -929,14 +929,17 @@ 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. +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 diff --git a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts index 75589a911..8dadb0c5c 100644 --- a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts +++ b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts @@ -163,13 +163,18 @@ export function relativeNowSql( return temporal === "naive" ? utcWall : `CAST(${utcWall} AS DATE)`; } case "sqlite": { - // One modifier per non-zero component, Y M W D H M S order; a week is 7 days. + // Years and months shift as one month count, then one modifier per non-zero component, + // W D H M S order; a week is 7 days. + const months = d.years * 12 + d.months; const mods = [ - [d.years, "years"], [d.months, "months"], [d.weeks * 7, "days"], [d.days, "days"], - [d.hours, "hours"], [d.minutes, "minutes"], [d.seconds, "seconds"], - ] - .filter(([n]) => (n as number) !== 0) - .map(([n, unit]) => `'${d.sign}${n} ${unit}'`); + ...(months === 0 ? [] : sqliteClampedMonthShift(`${d.sign}${months} months`)), + ...[ + [d.weeks * 7, "days"], [d.days, "days"], + [d.hours, "hours"], [d.minutes, "minutes"], [d.seconds, "seconds"], + ] + .filter(([n]) => (n as number) !== 0) + .map(([n, unit]) => `'${d.sign}${n} ${unit}'`), + ]; // Applied after the duration. SQLite's `weekday 0` moves forward to Sunday, so -6 days is Monday. const truncation = startOf === GRAIN_DAY ? ["'start of day'"] @@ -198,3 +203,18 @@ export function relativeNowSql( } } } + +/** SQLite rolls an overflowing month shift into the next month (March 31 minus a month is + * March 3), where Postgres and MySQL clamp to the last day of the target month. So shift from + * the first of the month, add back the day of the month capped at the target month's length, + * then the time of day. */ +function sqliteClampedMonthShift(shift: string): string[] { + const day = `CAST(strftime('%d', 'now') AS INTEGER)`; + const lastDay = `CAST(strftime('%d', 'now', 'start of month', '${shift}', '+1 months', '-1 days') AS INTEGER)`; + return [ + "'start of month'", + `'${shift}'`, + `'+' || (min(${day}, ${lastDay}) - 1) || ' days'`, + `'+' || strftime('%H:%M:%f', 'now')`, + ]; +} diff --git a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts index 6abef4998..8f1033523 100644 --- a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite"; import { describe, expect, test } from "bun:test"; import { parseIsoDuration, relativeNowSql, truncateToGrain } from "../../src/projection/time-sql.js"; @@ -95,11 +96,10 @@ describe("relativeNowSql (Table E)", () => { test.each([ ["-P7D", "instant", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')`], ["P1D", "instant", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+1 days')`], - ["-P1Y2M3WT4H", "naive", `strftime('%Y-%m-%dT%H:%M:%f', 'now', '-1 years', '-2 months', '-21 days', '-4 hours')`], ["PT5M6S", "naive", `strftime('%Y-%m-%dT%H:%M:%f', 'now', '+5 minutes', '+6 seconds')`], ["-P1W2D", "instant", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days', '-2 days')`], + ["-P3DT4H5M6S", "date", `date('now', '-3 days', '-4 hours', '-5 minutes', '-6 seconds')`], ["+P1D", "date", `date('now', '+1 days')`], - ["-P1Y2M3DT4H5M6S", "date", `date('now', '-1 years', '-2 months', '-3 days', '-4 hours', '-5 minutes', '-6 seconds')`], ["P0D", "date", `date('now')`], ] as const)("sqlite %s on %s", (duration, temporal, sql) => { expect(relativeNowSql(duration, temporal, "sqlite")).toBe(sql); @@ -128,11 +128,39 @@ describe("relativeNowSql (Table E)", () => { test.each([ ["-P90D", "instant", "day", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-90 days', 'start of day')`], ["P0D", "naive", "week", `strftime('%Y-%m-%dT%H:%M:%f', 'now', 'weekday 0', '-6 days', 'start of day')`], - ["-P1M", "date", "month", `date('now', '-1 months', 'start of month')`], ] as const)("sqlite %s on %s startOf %s", (duration, temporal, startOf, sql) => { expect(relativeNowSql(duration, temporal, "sqlite", startOf)).toBe(sql); }); + // Evaluated on a real SQLite with 'now' pinned. The expected values are calendar facts and + // Postgres interval semantics (a month shift clamps to the target month's last day), so a + // Sunday-start week or an overflowing month (March 31 minus a month read as March 3) fails. + // 2026-03-29 is a Sunday, 2026-03-30 a Monday, 2026-03-31 a Tuesday; 2028 is a leap year. + const atSqlite = (now: string, sql: string): unknown => { + const db = new Database(":memory:"); + try { + return Object.values(db.query(`SELECT ${sql.replaceAll("'now'", `'${now}'`)}`).get() as object)[0]; + } finally { + db.close(); + } + }; + test.each([ + ["2026-03-31 10:20:30.456", "-P1M", "date", "month", "2026-02-01"], + ["2026-03-31 10:20:30.456", "-P1M1D", "date", "month", "2026-02-01"], + ["2026-05-31 10:20:30.456", "-P3M", "instant", "month", "2026-02-01T00:00:00.000Z"], + ["2026-03-31 10:20:30.456", "P0D", "naive", "month", "2026-03-01T00:00:00.000"], + ["2026-03-31 10:20:30.456", "-P1M", "instant", "day", "2026-02-28T00:00:00.000Z"], + ["2028-02-29 10:20:30.456", "-P1Y", "date", "day", "2027-02-28"], + ["2026-03-29 10:20:30.456", "P0D", "date", "week", "2026-03-23"], + ["2026-03-30 10:20:30.456", "P0D", "instant", "week", "2026-03-30T00:00:00.000Z"], + ["2026-03-31 10:20:30.456", "-P1W", "naive", "week", "2026-03-23T00:00:00.000"], + ["2026-03-31 10:20:30.456", "-P1M", "instant", undefined, "2026-02-28T10:20:30.456Z"], + ["2026-03-31 01:20:30.456", "-P1Y2M3DT4H", "naive", undefined, "2025-01-27T21:20:30.456"], + ["2026-01-31 10:20:30.456", "+P1M", "date", undefined, "2026-02-28"], + ] as const)("sqlite at %s: %s on %s startOf %s is %s", (now, duration, temporal, startOf, expected) => { + expect(atSqlite(now, relativeNowSql(duration, temporal, "sqlite", startOf))).toBe(expected); + }); + test.each([ ["-P90D", "instant", "day", `DATE(UTC_TIMESTAMP(3) - INTERVAL 90 DAY)`], ["-P1W", "naive", "week", From 3ab3c6573164051e3993a4064247daa4d6714a82 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 25 Oct 2026 23:26:57 -0400 Subject: [PATCH 4/7] no-mistakes(review): Shift Postgres instant startOf on UTC wall clock --- .../packages/codegen-ts/src/projection/time-sql.ts | 10 +++------- .../codegen-ts/test/projection/time-sql.test.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts index 8dadb0c5c..50808b344 100644 --- a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts +++ b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts @@ -151,15 +151,11 @@ export function relativeNowSql( case "postgres": { // ISO-8601 interval input is accepted as written. const interval = `INTERVAL '${d.magnitude}'`; - if (temporal === "instant") { - const shifted = `(now() ${d.sign} ${interval})`; - // date_trunc on a timestamptz truncates in the session zone, so cut the UTC wall clock. - return startOf === undefined - ? shifted - : `(date_trunc('${startOf}', ${shifted} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')`; - } + if (temporal === "instant" && startOf === undefined) return `(now() ${d.sign} ${interval})`; + // A timestamptz shifts and truncates in the session zone, so do both on the UTC wall clock. const shiftedWall = `((now() AT TIME ZONE 'UTC') ${d.sign} ${interval})`; const utcWall = startOf === undefined ? shiftedWall : `date_trunc('${startOf}', ${shiftedWall})`; + if (temporal === "instant") return `(${utcWall} AT TIME ZONE 'UTC')`; return temporal === "naive" ? utcWall : `CAST(${utcWall} AS DATE)`; } case "sqlite": { diff --git a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts index 8f1033523..95e34fe1e 100644 --- a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts @@ -118,7 +118,7 @@ describe("relativeNowSql (Table E)", () => { }); test.each([ - ["-P90D", "instant", "day", `(date_trunc('day', (now() - INTERVAL 'P90D') AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')`], + ["-P90D", "instant", "day", `(date_trunc('day', ((now() AT TIME ZONE 'UTC') - INTERVAL 'P90D')) AT TIME ZONE 'UTC')`], ["-P1W", "naive", "week", `date_trunc('week', ((now() AT TIME ZONE 'UTC') - INTERVAL 'P1W'))`], ["P0D", "date", "month", `CAST(date_trunc('month', ((now() AT TIME ZONE 'UTC') + INTERVAL 'P0D')) AS DATE)`], ] as const)("postgres %s on %s startOf %s", (duration, temporal, startOf, sql) => { From 80a7245f06d7a742ee12ae8c96a34075cecc2a30 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 26 Oct 2026 02:20:35 -0400 Subject: [PATCH 5/7] no-mistakes(document): Fix stale metamodelVersion 1.1 claims after startOf change --- docs/features/cube-export.md | 5 ++--- docs/features/reporting.md | 4 ++-- spec/roadmap.md | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/features/cube-export.md b/docs/features/cube-export.md index c828f5375..8c4d9cbfd 100644 --- a/docs/features/cube-export.md +++ b/docs/features/cube-export.md @@ -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`, @@ -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 diff --git a/docs/features/reporting.md b/docs/features/reporting.md index efb4ffa5c..3f95d0d8c 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -942,13 +942,13 @@ whose relative date shifts by months or years, which now clamps to the end of a 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: diff --git a/spec/roadmap.md b/spec/roadmap.md index 3d73965df..52b2b7fd3 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -161,7 +161,7 @@ under **Shipped**; planned FRs under **Planned** + the **Release plan**. ✅ shi | FR-041 | Public A/B drift benchmark — coding agents with vs without MetaObjects, pre-registered, friction-first | 📋 **design settled 2026-09-12, unbuilt** — the "proving the value" work; it is what licenses the claims FR-042 §4 withholds. Revised after an adversarial two-reviewer design review (spec §14): the scored task set is now held out from the friction pass (the draft tuned Arm B on the tasks it would later be scored on, with no symmetric loop for the control), Arm A is derived from Arm B's generated output so the seeds differ only in the model and the gate, `n` is calibrated from a pilot instead of asserted (one identical config measured 25/51/28 turns at temperature 0 — 41% CV), H2 is time-to-**correct** rather than time-to-done, the primary is analysed intention-to-treat so an arm cannot win by not finishing, and escaped defects are reported split by whether `meta verify` already covers the invariant class (it covers four of five, so H1 is partly definitional for those). First deliverable is the Phase 0b friction log. Design: `docs/superpowers/specs/2026-09-11-fr-041-drift-ab-benchmark-design.md` | 1.x | — | | FR-042 | First-touch positioning — one typed model, two verbs (README, llms, sites) | 🟢 shipped on the four first-touch surfaces — pitch **locked** 2026-09-12 (two verbs: Generate + Verify; requirements fold into Verify; H1 model-first), Verify clause amended 2026-09-14 to "fails or warns", six pillars carry maturity labels, do-not-say list gated in the `gates` lane. **Remaining:** the drift terminal recording for the .dev hero, and the drift-demo page its CTA should point at (spec §8). Design: `docs/superpowers/specs/2026-09-11-fr-042-first-touch-positioning-design.md` | — | — | | FR-043 | **Libraries** — reusable declared design: model metadata + the requirements that make it checkable (+ an implied generator selection), opted into by name | 🟢 **design approved 2026-09-13, un-deferred** — proposed as a SIXTH pillar. Not greenfield: `library/ai/llm-call.yaml` already ships one (opt-in via the loader's `libraries: ["ai"]`, embedded per port under an `embedded-library drift` gate, with `trace-helper` codegen beside it). Generalises it — requirements as a component, discovery through the codegen catalog's `kind: "library"`, declared package→generator coupling replacing `trace-helper`'s hard-coded `LlmCallBase`, `overlay: true` as the adaptation door and `meta eject` for repackaging. **No new metamodel vocabulary; `metamodelVersion` does not move.** Second library `iam` (users, typed nestable groups, roles as permission bundles, global and group-scoped grants) ships `stability: preview`; its model is verified to load clean under strict. Phase 2: third-party authoring over FR-023's deferred transports. Design: `docs/superpowers/specs/2026-09-13-fr-043-feature-and-nfr-packages-design.md` | 1.1 | — | -| FR-044 | **Core reporting** — declared measures, dimensions, segments and reports (compiled to SQL views in every port), plus Cube and dbt MetricFlow exporters | 🟢 **Plans 1–4 of 5 shipped on `main` (Plan 1 2026-10-03, Plan 2 2026-10-04, Plan 3 2026-10-05; Plan 4 with the Cube exporter)** — the vocabulary (`dimension.attribute`, `dimension.time`, `measure.aggregate`, `measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value) is registered and loader-validated in all five ports, gated by 27 new conformance fixtures and the new `codegen-noop` corpus; decisions D1–D6 are settled. `metamodelVersion` reads **`1.1`** on `main` (1.0.x PATCH releases held until 1.1 ships). **Plan 2 lowered the view-backed report**: a report that declares a read-only `source.rdb @kind: view` becomes that view in `meta migrate` (Postgres / SQLite / D1; MySQL through `buildReportViews` + the recipe), every port's runtime reads it (six shared persistence scenarios, columns pinned by `report-shapes.json`), C# generates a keyless EF Core row and Kotlin an Exposed table object. **Plan 3 served it**: every port's generators write a read-only list route (filter, sort and paging on the derived fields, `POST` answers 405, no `/{id}`), gated by the api-contract `report/` sub-corpus, with a docs page for each served report and, in TypeScript, the list hook. **Plan 4 added the Cube exporter**: the TypeScript reference generator `cube-model` writes the vocabulary as Cube data-model files (a cube per entity, joins, dimensions, measures, segments and a rollup per served report), gated by the 44-case `fixtures/cube-model/` corpus and a `cube` lane that loads the output into a real Cube and compares each report's Cube query with its view. **The zero-rows / measure-defaults build** (`2026-10-09-fr-044-zero-rows-and-measure-defaults.md`, #415) added `@spine` (rows from a dimension's entity) and an integer `@default` on a measure, in all five ports, and the exporter maps both: a `@spine` report is a Cube view over a scoped facts cube, a defaulted measure a `COALESCE`. **Still to come:** Plan 5, the `reporting` library; the dbt MetricFlow exporter, built on first adopter demand (D5); `measure.derived` stays unregistered until FR-037 R5. A query-time engine remains parked with its re-entry trigger. Feature docs: `docs/features/reporting.md`, `docs/features/cube-export.md`. Design: `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` | 1.1 | [#391](https://github.com/metaobjectsdev/metaobjects/issues/391) | +| FR-044 | **Core reporting** — declared measures, dimensions, segments and reports (compiled to SQL views in every port), plus Cube and dbt MetricFlow exporters | 🟢 **Plans 1–4 of 5 shipped on `main` (Plan 1 2026-10-03, Plan 2 2026-10-04, Plan 3 2026-10-05; Plan 4 with the Cube exporter)** — the vocabulary (`dimension.attribute`, `dimension.time`, `measure.aggregate`, `measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value) is registered and loader-validated in all five ports, gated by 27 new conformance fixtures and the new `codegen-noop` corpus; decisions D1–D6 are settled. `metamodelVersion` reads **`1.2`** on `main` (`startOf` on relative dates; [reporting.md](../docs/features/reporting.md#compatibility) has the history). **Plan 2 lowered the view-backed report**: a report that declares a read-only `source.rdb @kind: view` becomes that view in `meta migrate` (Postgres / SQLite / D1; MySQL through `buildReportViews` + the recipe), every port's runtime reads it (six shared persistence scenarios, columns pinned by `report-shapes.json`), C# generates a keyless EF Core row and Kotlin an Exposed table object. **Plan 3 served it**: every port's generators write a read-only list route (filter, sort and paging on the derived fields, `POST` answers 405, no `/{id}`), gated by the api-contract `report/` sub-corpus, with a docs page for each served report and, in TypeScript, the list hook. **Plan 4 added the Cube exporter**: the TypeScript reference generator `cube-model` writes the vocabulary as Cube data-model files (a cube per entity, joins, dimensions, measures, segments and a rollup per served report), gated by the 44-case `fixtures/cube-model/` corpus and a `cube` lane that loads the output into a real Cube and compares each report's Cube query with its view. **The zero-rows / measure-defaults build** (`2026-10-09-fr-044-zero-rows-and-measure-defaults.md`, #415) added `@spine` (rows from a dimension's entity) and an integer `@default` on a measure, in all five ports, and the exporter maps both: a `@spine` report is a Cube view over a scoped facts cube, a defaulted measure a `COALESCE`. **Still to come:** Plan 5, the `reporting` library; the dbt MetricFlow exporter, built on first adopter demand (D5); `measure.derived` stays unregistered until FR-037 R5. A query-time engine remains parked with its re-entry trigger. Feature docs: `docs/features/reporting.md`, `docs/features/cube-export.md`. Design: `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` | 1.1 | [#391](https://github.com/metaobjectsdev/metaobjects/issues/391) | _(FR-001 was the original metamodel foundation — pre-dates the FR-numbered tracking.)_ _(FR-032 was developed under the working number "FR-026" — see commit history; renumbered to avoid the FR-026=Forms collision. Design: `docs/superpowers/specs/2026-06-13-fr-032-canonical-fqn-refs-design.md`, ADR-0032.)_ From a722ef931aceb12e7ed14b15a14e5444ec941a34 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 26 Oct 2026 02:23:15 -0400 Subject: [PATCH 6/7] no-mistakes(lint): Regenerate stale site-payload.json --- examples/showcase/site-payload.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 5facce02c..51f4f138b 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -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 }, From 49fba60a581877722f3382e3cfc4822bb468ee12 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 26 Oct 2026 06:52:39 -0400 Subject: [PATCH 7/7] no-mistakes(document): Moved SQLite month-clamp fix entry from released 1.1.3 into [Unreleased]; other stale metamodelVersion and startOf docs already current. --- CHANGELOG.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e737d9f..f95ae1727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ here.** 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`._ @@ -55,14 +65,6 @@ _npm `1.1.3` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates ### 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. - - **`meta verify --db` no longer reports false drift on a regex CHECK over a varchar/text column.** Postgres stores `"key" ~ '^[a-z]+$'` as `((key)::text ~ '^[a-z]+$'::text)`; the literal's `::text` was already folded away but the column's was not, so a database `migrate`