Skip to content

feat(reporting): add @spine and measure @default for FR-044 - #415

Merged
dmealing merged 19 commits into
mainfrom
fm/fr044-zero-rows-and-defaults
Oct 10, 2026
Merged

dmealing merged 19 commits into
mainfrom
fm/fr044-zero-rows-and-defaults

Conversation

@dmealing

Copy link
Copy Markdown
Member

Intent

Add two things to the FR-044 reporting vocabulary for the 1.1.0 release, in all five ports, because a first real adopter's report pages need them and vocabulary added after 1.1 ships would force another metamodel move: "I don't want a half baked 1.1 and then have to do a 1.2 right away".

  1. Zero rows from a dimension's entity. Today a report has one row per dimension tuple that has fact rows. Real pages need one row per row of the dimension's entity, with measures zero or null where there are no facts: a published program with no purchases must still show in a per-program table, and a scheduled day nobody completed must show as an empty bar. The spec covers only the time case (the R7 calendar spine); the entity case is not expressible.
  2. A default for an empty measure. A ratio over zero and a sum of nothing are null by contract, so every caller defaults in the client where a hand-written view would have written COALESCE(..., 0). A measure should be able to declare its value when there is nothing to aggregate.
    Both are additions: a model that uses neither generates byte-for-byte what it does now, and metamodelVersion stays 1.1.
    Standing decisions still hold: compiled SQL views only, no query-time engine; a report lowers only with source.rdb @kind: view; all measures from the report's own @from entity and @via to-one only, every hop a declared identity.reference; no cross-fact reports, no anti-join, no join on a natural key; measure.derived excluded.

What Changed

  • Added @spine attribute to object.report — a to-one path from the fact entity to a dimension entity that ensures every row of the dimension appears in the report, even with zero matching facts. Enables "show all programs even with no purchases" tables and zero-row group scenarios.

  • Added @default attribute to measure.aggregate and measure.ratio — an integer value a measure reads when it would otherwise be null (no rows, zero denominator, every value null). Eliminates client-side COALESCE boilerplate by letting measures declare their own empty-set behavior.

  • Implemented both features across all five language ports (TypeScript, C#, Java, Kotlin, Python) with metamodel registration, loader validation, SQL view lowering, runtime read support, and complete test coverage (conformance fixtures, persistence scenarios, API contract integration tests).

  • Added 10 new conformance error fixtures for @spine and @default validation rules (dimension not reachable through spine, measure defaults on non-numeric types, count/ratio misuse, etc.).

  • Updated all documentation — feature guide, port-specific docs, authoring skill references, AGENTS.md, and cross-language porting rules for the new vocabulary contracts.

  • metamodelVersion remains 1.1 — both are additive attributes; a model using neither generates byte-for-byte unchanged output.

Risk Assessment

✅ Low: Large but well-bounded additive feature (zero-rows spine + measure @default) implemented consistently across all five ports with matching validation rules, SQL emission logic (LEFT JOIN semantics correctly preserve spine rows), and extensive new conformance/unit tests; no contradiction of stated intent (metamodelVersion stays 1.1, measure.derived excluded, view-only lowering, additive-only) found in the diff.

Testing

Drove 12 scenarios live against product: spine dimension includes zero-fact entity rows, measure defaults provide fallback values, filter/sort work on defaulted measures, REST API serves spine reports correctly, validation catches all spine errors (R8/R9) and default errors (M7/M8), backward-compat confirmed (inert reports unchanged), cross-port metamodel vocabulary matches, codegen DDL and shape tests pass, baseline regression suite passes. 8 conformance gates green (TS / C# / Java / Kotlin / Python), 35 Python validation tests green, 38 TS inert tests green, no failures or regressions.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Spine dimension reports include all spine entity rows, including zero-fact rows ✅ pass live SQLite integration test report-views-sqlite.test.ts passed (21/21 tests, 9.53s), persistence-conformance report-spine-zero-rows.yaml shows Program 3 (Mobility, zero weeks) in results with weeks=0, def…
Measure defaults provide fallback values when aggregation is empty ✅ pass live SQLite test report-default-empty.yaml passes: FitnessTotalsFilled over empty weeks table returns one row with count=0, defaulted sum=0, defaulted ratio=0 instead of null
Filters and sorts work on defaulted measures ✅ pass live API contract test api-contract-report.test.ts passed (17/17 tests, 109s), scenarios filter-on-defaulted-measure and sort-on-defaulted-measure both pass: filter eq 0 matches empty rows, ascending sort…
Spine reports serve correctly via REST GET /api/<report> ✅ pass live API contract scenario list-spine.yaml passes: ProductRevenue with @spine Sale.fkProduct returns 3 products including Cinder (no sales), with row present and revenueOrZero=0
Validation catches spine cardinality error (R8: to-many hop) ✅ pass live Python test_report_spine_to_many passes: @spine with to-many relationship hop refused with ERR_INVALID_SPINE_RELATIONSHIP
Validation catches spine entity owner error (R8: wrong entity) ✅ pass live Python test_report_spine_not_from passes: @spine path from wrong entity refused with ERR_INVALID_SPINE_RELATIONSHIP
Validation catches default on count error (M7) ✅ pass live Python test_m7_default_on_a_count_is_refused_on_the_measure_node passes: @default on count measure refused with ERR_INVALID_MEASURE_DEFAULT
Validation catches default on min/max of non-numeric error (M8) ✅ pass live Python test_m8_default_on_a_max_of_a_timestamp_is_refused passes: @default on max(timestamp) refused
Models without @spine and @default generate unchanged output (backward-compat) ✅ pass live TS reporting-inert.test.ts passed (38/38 tests, 1247ms): 18 sourceless reports + 20 report validation fixtures without spine/default produce byte-identical codegen vs baseline
Metamodel vocabulary registered identically across all five ports ✅ pass live Registry conformance: TS 8 tests (2053 expects), C# 30 tests, Java 1954 tests, Kotlin integrated with Java, Python 3 tests all green; expected-registry.json byte-matched by all ports
Generated SQL views correctly structure spine and default measures ✅ pass live DDL emit tests (42 tests) pass: view definitions correctly use LEFT JOIN for spine, COALESCE for defaults; Report shape tests (30 tests) pass: row types include defaulted measure fields with correct n…
Baseline regression suite passes (TS build, typecheck, conformance, unit) ✅ pass live scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains exited 0: TS build green, typecheck green, conformance green, unit suites green, mutation score 52.76 >= threshold 35

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Spine dimension reports include all spine entity rows, including zero-fact rows ✅ pass live SQLite integration test report-views-sqlite.test.ts passed (21/21 tests, 9.53s), persistence-conformance report-spine-zero-rows.yaml shows Program 3 (Mobility, zero weeks) in results with weeks=0, def…
Measure defaults provide fallback values when aggregation is empty ✅ pass live SQLite test report-default-empty.yaml passes: FitnessTotalsFilled over empty weeks table returns one row with count=0, defaulted sum=0, defaulted ratio=0 instead of null
Filters and sorts work on defaulted measures ✅ pass live API contract test api-contract-report.test.ts passed (17/17 tests, 109s), scenarios filter-on-defaulted-measure and sort-on-defaulted-measure both pass: filter eq 0 matches empty rows, ascending sort…
Spine reports serve correctly via REST GET /api/<report> ✅ pass live API contract scenario list-spine.yaml passes: ProductRevenue with @spine Sale.fkProduct returns 3 products including Cinder (no sales), with row present and revenueOrZero=0
Validation catches spine cardinality error (R8: to-many hop) ✅ pass live Python test_report_spine_to_many passes: @spine with to-many relationship hop refused with ERR_INVALID_SPINE_RELATIONSHIP
Validation catches spine entity owner error (R8: wrong entity) ✅ pass live Python test_report_spine_not_from passes: @spine path from wrong entity refused with ERR_INVALID_SPINE_RELATIONSHIP
Validation catches default on count error (M7) ✅ pass live Python test_m7_default_on_a_count_is_refused_on_the_measure_node passes: @default on count measure refused with ERR_INVALID_MEASURE_DEFAULT
Validation catches default on min/max of non-numeric error (M8) ✅ pass live Python test_m8_default_on_a_max_of_a_timestamp_is_refused passes: @default on max(timestamp) refused
Models without @spine and @default generate unchanged output (backward-compat) ✅ pass live TS reporting-inert.test.ts passed (38/38 tests, 1247ms): 18 sourceless reports + 20 report validation fixtures without spine/default produce byte-identical codegen vs baseline
Metamodel vocabulary registered identically across all five ports ✅ pass live Registry conformance: TS 8 tests (2053 expects), C# 30 tests, Java 1954 tests, Kotlin integrated with Java, Python 3 tests all green; expected-registry.json byte-matched by all ports
Generated SQL views correctly structure spine and default measures ✅ pass live DDL emit tests (42 tests) pass: view definitions correctly use LEFT JOIN for spine, COALESCE for defaults; Report shape tests (30 tests) pass: row types include defaulted measure fields with correct n…
Baseline regression suite passes (TS build, typecheck, conformance, unit) ✅ pass live scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains exited 0: TS build green, typecheck green, conformance green, unit suites green, mutation score 52.76 >= threshold 35
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • TS reporting conformance (report.test.ts — 2 tests)
  • TS reporting validation (reporting-validation.test.ts — 115 tests)
  • TS report shape tests (report-shape.test.ts — 30 tests)
  • TS DDL emit tests (report-ddl-emit.test.ts — 42 tests)
  • TS SQLite integration (report-views-sqlite.test.ts — 21 tests)
  • TS API contract (api-contract-report.test.ts — 17 tests, 109s)
  • TS reporting inert (reporting-inert.test.ts — 38 tests)
  • TS registry conformance (registry-conformance.test.ts — 8 tests, 2053 expects)
  • Python spine/default validation (test_reporting_validation_spine_default.py — 35 tests)
  • Python registry conformance (test_registry_conformance.py — 3 tests)
  • Java metadata tests (ReportShapeTest — 30 tests, full suite 1954 tests)
  • C# ReportShapeTests — 30 tests
  • Baseline scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains (TS build+typecheck+conformance+unit)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…for an empty measure

Design for two additions to the 1.1 reporting vocabulary, in all five ports:
@spine on object.report (the report's rows come from a dimension's entity, so
a tuple with no fact rows still has a row) and @default on measure.aggregate
and measure.ratio (the integer a measure reads when it would be null).

The plan settles where each attribute lives and what was rejected, the loader
rules (R8, R9, M7, M8) and their error codes, the view SQL per dialect, what a
measure reads in a row with no facts, the derived shape's nullability and the
row type in every port, and filter and sort on the derived fields. The SQL
shapes were executed on Postgres 16, SQLite and MySQL 8.4.

The spec gains the ADR-0023 register amendment (3.2), requirements R8 and R9,
two mapping rows, one acceptance bullet and the parked list. metamodelVersion
stays 1.1. No code changes in this commit.
…8 error cases)

Two positive fixtures (reporting-spine-and-default, reporting-spine-inherited)
and the eight error fixtures of Table B, each the positive input with one
change and one expected error: R8 (to-many hop, owner not @from), R9 (no
dimensions, a time dimension over a fact column, a dimension through the
reference's name for the spine's join), M7 (@default on a count), M8
(@default on a max of a timestamp) and the type check (@default: 0.5).

attr.int and attr.long now refuse a fractional number in TypeScript: a
fractional number in any int/long attribute fails to load with
ERR_BAD_ATTR_VALUE (input with no valid meaning; it used to pass the
attribute type check unchanged). attr.double is unchanged. No fixture,
example or test model carried such a value.

Metamodel corpus count 364 -> 374. The registry coverage snapshot is
regenerated: spine and default are now exercised, and the stale `sortable`
entries on field.double and field.float are dropped (already exercised by the
api-contract projection fixtures). CAPABILITIES.json regenerates unchanged.

The C#, Java, Kotlin and Python conformance lanes are red on the new fixtures
until their own tasks.
…attr.int (TypeScript)

Reverts the attr.int/attr.long tightening from the previous commit. It also
refused a fraction in validator @min/@max, which are registered attr.int but
documented as numeric values, so `validator.numeric @min: 0.5` over a double
field stopped loading. That changed released vocabulary, which this plan must
not do. A fractional number in an int/long attribute loads again as before.

A measure's @default is now checked in validateReporting instead: a number
that is not an integer on a measure.aggregate or measure.ratio is
ERR_BAD_ATTR_VALUE on the measure node, with the text "@default '<value>' is
not an integer. A measure's @default is a whole number (for example 0)." The
check runs first; when it fires, M7/M8 are skipped. A non-number stays the
attribute type check's error alone. error-measure-default-not-integer is
unchanged and still yields exactly one error.

The registry coverage snapshot gets back the two `sortable` entries
(field.decimal, field.float) that the regeneration had dropped, so the file
no longer differs from main.
…w SQL

FR-044 Table D in TypeScript (Postgres, SQLite/D1, MySQL). A @spine report
reads FROM the spine entity and walks the spine's hops back to @from, every
join LEFT OUTER, with the report scope ANDed onto the ON of the join that
introduces @from (no WHERE). Aliases are those of the same report without
@spine. A measure or ratio @default wraps its full expression in COALESCE
(a REAL literal on SQLite for a real measure). The extractor refuses a
spine that reaches an entity with no table or a TPH subtype, reports a hop
with no identity.reference as @spine's hop error, and refuses a dimension
not reached through the spine. A model declaring neither attribute emits
byte-identical SQL.
FR-044 zero rows and measure defaults, the C# port of the TypeScript
reference (Tables A, B and C).

- Register @spine (string) on object.report and @default (int) on
  measure.aggregate and measure.ratio; byte-copy the two spec files.
  MetaMeasure.DefaultValue() and ReportAccessors.ReportSpine().
- Loader rules R8 (the spine is D2's to-one walk, started at @from), R9
  (every listed dimension is reached through the spine, hop names compared
  as written), M7 (@default on a count) and M8 (@default on min/max of a
  non-numeric field), with the TypeScript message texts. WalkToOneVia takes
  a ToOneWalk (D2Walk / SpineWalk); every D2 message is unchanged. A
  fractional @default is already refused by this port's generic attr.int
  type check (ERR_BAD_ATTR_VALUE on the measure), so no second error is
  added; it only skips M7/M8.
- ReportShape Table C: under @spine a dimension on the spine is required
  when its @Of is @required or a primary-key column; a defaulted measure is
  required. New ReportingViaHops / ReportSpineHops.
- No generator change: the keyless row types a defaulted measure as long /
  decimal and the spine key as non-nullable. Regenerated the integration
  fixtures (three new rows, AppDbContext additions).
- The report REST lane seeds every base table of seed.json in file order
  and expects ProductRevenue (and the Product / Sale entities) routed.
  Corpus counts: nine canonical reports, four inert reports.
FR-044 zero rows and measure defaults, the Java port of the TypeScript
reference (Tables A, B and C). Kotlin rides on the loader and ReportShape.

- Register @spine (string) on object.report and @default (int) on
  measure.aggregate and measure.ratio. MetaMeasure.getDefaultValue() and
  ReportAccessors.reportSpine(). The registry manifest's null value type
  for an attr named default is narrowed to field registrations, so a
  measure's default prints int.
- Loader rules R8 (the spine is D2's to-one walk, started at @from), R9
  (every listed dimension is reached through the spine, hop names compared
  as written), M7 (@default on a count) and M8 (@default on min/max of a
  non-numeric field), with the TypeScript message texts. walkToOneVia takes
  a ToOneWalk (d2Walk / spineWalk); every D2 message is unchanged. A
  fractional @default is already refused by this port's generic attr.int
  parse (ERR_BAD_ATTR_VALUE on the measure, and the load stops there), so
  no second error is added; a non-integer value only skips M7/M8.
- ReportShape Table C: under @spine a dimension on the spine is required
  when its @Of is @required or a primary-key column; a defaulted measure is
  required. New reportingViaHops / reportSpineHops.
- No generator change: the report DTO marks the spine key and a defaulted
  measure @NotNull, as it does a count. OMDB reads a @spine view's empty
  row.
- The report REST seam lane serves ProductRevenue; corpus counts: nine
  canonical reports, four inert reports, sixteen REST scenarios.
FR-044 zero rows and measure defaults, the Kotlin side. Kotlin reads the
loader and ReportShape from Java, so no Kotlin generator changes: a spine
key and a defaulted measure are required in the shape, and a required
field is already a non-null Exposed column and data class property.

- KotlinReportTableGeneratorTest pins Table C/F: ProgramRoster's table
  text; a defaulted sum, min and ratio are non-null in the table and the
  row while the same measure without @default stays nullable; under
  @spine the spine entity's key is non-null and without @spine the same
  dimension is nullable.
- Three hand-written reference tables (ProgramRoster, ProgramLongWeeks,
  FitnessTotalsFilled) for the persistence lane, which now runs the three
  new report scenarios. KotlinCodegenMatchesReferenceTest also checks every
  report reference table against the same expectations (view, columns in
  order, nullability), so a reference cannot drift from the generator.
- The report/ REST seam lane serves ProductRevenue (sixteen scenarios);
  the inert test counts four reports, ProgramCatalogue sourceless.
FR-044 zero rows and measure defaults, the Python port of the TypeScript
reference (Tables A, B and C).

- Register @spine (string) on object.report and @default (int) on
  measure.aggregate and measure.ratio; byte-copy the two spec files.
  MetaMeasure.default_value() (refuses bool) and report_spine().
- Loader rules R8 (the spine is D2's to-one walk, started at @from), R9
  (every listed dimension is reached through the spine, hop names compared
  as written), M7 (@default on a count) and M8 (@default on min/max of a
  non-numeric field), with the TypeScript message texts. _walk_to_one_via
  takes a _ToOneWalk (_d2_walk / _spine_walk); every D2 message is
  unchanged. A fractional @default is already refused by this port's
  generic attr.int type check (ERR_BAD_ATTR_VALUE on the measure), so no
  second error is added; it only skips M7/M8.
- report_shape Table C: under @spine a dimension on the spine is required
  when its @Of is @required or a primary-key column; a defaulted measure is
  required. New reporting_via_hops / report_spine_hops.
- No generator or runtime change: the Pydantic row model types the spine
  key and a defaulted measure without "| None", as it does a count, and the
  ObjectManager reads a @spine view's empty row.
- The report REST seam lane serves ProductRevenue; corpus counts: nine
  canonical reports, four inert reports, sixteen REST scenarios. A new
  unit file ports the TypeScript exact-text assertions for R8/R9/M7/M8.
…ail-closed spine check

Without @spine a fact row with a null or unmatched reference still counts: it
falls in a null group through a nullable (LEFT OUTER) reference and is dropped
through a required (INNER) one. Corrected in the CHANGELOG, reporting.md and
the spec's R8. reporting.md also notes that an @SQL or @Unmanaged report keeps
the derived read shape, so a defaulted measure and a @spine key are non-null in
every port. extractReportSpec now throws on an unresolvable spine-chain entity
instead of skipping the no-table and TPH refusals. Comment-only tidy-ups in the
Kotlin harness, the C# reporting passes and the Python validator.
The phase-1 plan PR was squash-merged, so its original commit is not an
ancestor of this branch. This merge records it as one without changing
any file, so the branch pushes as a fast-forward.
@dmealing
dmealing merged commit c0c43e1 into main Oct 10, 2026
1 check passed
@dmealing
dmealing deleted the fm/fr044-zero-rows-and-defaults branch October 10, 2026 04:27
dmealing added a commit that referenced this pull request Oct 10, 2026
* docs(fr-044): plan 4, the Cube exporter (cube-model reference generator)

The plan for the cube-model reference helper: the mapping contract per
spec section 5 with a golden fixture per row, the rollup rules, name and
escaping rules, the canonical golden, and a live lane against a pinned
Cube instance. The Cube shapes were executed against Cube v1.7.43 and
Postgres 16 before writing; @spine and @default rows are provisional on
the zero-rows-and-defaults plan.

* refactor(reporting): one module for the report SQL fragments (FR-044)

Move q, ref, literal, FILTER_OP_SQL and cond (report-ddl-emit) and resolveReportFilter, segmentClause, declared, andOf and temporalOf (extract-report-spec) into projection/report-sql.ts so a second consumer can reuse the one filter-to-SQL translator. No behaviour change: the existing goldens pass unedited. extract-report-spec still exports temporalOf.

* feat(cube-model): build the Cube model of the reporting vocabulary (FR-044)

buildCubeModel turns a loaded model's dimension.*, measure.* and
segment.filter members into plain Cube data: cubes, joins, dimensions,
measures and segments (plan 4 Tables A to E and G). Rollups, report
scope segments and Cube views are typed but left empty for later tasks.

- cube/cube-model-spec.ts: the CubeModel data types.
- cube/build-cube-model.ts: which cubes exist (vocabulary, join-target
  and alias cubes), joins, @via members, and the ordering Table H fixes.
- cube/cube-members.ts: Table C dimension types and Table D measures,
  split out so the builder stays near its size budget.
- cube/cube-names.ts: Table G name rules and member/cube collisions.
- cube/cube-sql.ts: the Cube renderer (brace escaping, Jinja raw wrap).
- cube/cube-errors.ts: CubeModelError and the seven ERR_CUBE_* codes.

@Of, @via, segments and filters resolve through the same functions the
report view lowering uses, and a join's ON columns come from the view's
own hop walk. report-sql.ts gains an optional SqlRenderer on ref() and
cond(); the default writes exactly what it wrote before, and every
projection golden passes unedited. The Task 1 review notes are fixed
there too: the local ref no longer shadows ref(), cond's errors name
report-sql, and the header describes the module rather than the move.
extract-report-spec.ts exports viaHopError for reuse.

The canonical fitness model builds to Table H's Program, Week and Asset
cubes (without rollups and the scope segment).

* fix(cube-model): composite joins, shared @via resolver, review fixes (FR-044)

Task 2 review, fix round 1.

- A composite identity.reference joins on every column pair, ANDed in
  position order. A reference whose @fields count differs from the key
  it references is ERR_CUBE_UNMAPPABLE_JOIN (new code), naming the cube,
  the reference and both field lists. walkViaPath and view SQL are
  unchanged; extract-view-spec only exports joinColumnFor and a new
  hopReferenceIdentity for the full @fields list.
- A hop the view walk refuses while joins are emitted (an ambiguous
  to-one relationship) is ERR_CUBE_UNMAPPABLE_JOIN keeping the reason.
- One shared resolver per concern in projection/report-resolve.ts:
  dimensionOfField, resolveDimensionViaPath (with viaHopError, moved
  from extract-report-spec.ts), resolveAggregate and ratioOperand. The
  view lowering and the Cube build both call them; view messages and SQL
  are byte-identical and the projection goldens pass unedited.
- Identifiers take the literal rule: braces escaped, raw-wrapped when
  holding {% or {#, endraw refused.
- A self-referencing to-one hop joins an alias cube <Cube>_<hop>. A @via
  that returns to an entity on its path any other way, or crosses one
  cube twice, is refused naming the path.
- A @via reuses any declared dimension (attribute or time) without @via
  over the field. field.uri and field.inet dimensions are strings.
- CubeRollupSpec is a union of its three time forms; a type test pins
  that two forms at once do not compile.
- An alias cube colliding with an entity cube is ERR_CUBE_NAME_COLLISION
  (tested). MemberNamespace.has removed. The spec header says SQL
  arrives escaped and free text raw.

* fix(cube-model): match a hop by its reference, not its first column (FR-044)

crosses() picked the join a @via step crosses by comparing the step's
first foreign-key column. Two composite references onto one entity that
share a leading column (fkProgram [tenantId, programId] and
fkFormerProgram [tenantId, formerProgramId]) both have tenantId first, so
a dimension with @via Week.fkFormerProgram was written reading
{Week_fkProgram.title}: the wrong alias cube, with nothing failing.

A belongs-to step now matches the hop whose identity.reference is the
one the step crosses (hopReferenceIdentity, the view walk's own
resolution). The one_to_one branch keeps matching by relationship name.
Test: two composite references sharing a leading column, each @via
reading its own alias cube.

* feat(cube-model): a rollup per served report (FR-044)

Table F of plan 4. A served object.report (servedReport: concrete, read
source @kind view) writes into its @from cube:

- a rollup named after the report: its attribute dimensions and measures
  in listed order (a qualified item such as Week.weeks is the measure,
  a @via dimension is a member of the @from cube like any other); one
  time dimension in the documented time_dimension + granularity form,
  two or more as the time_dimensions list; its @segment, then its scope
  segment, in segments;
- for its @filter, a public segment <report>Scope (lower-camel report
  name) whose SQL is the filter, rendered by the view lowering's own
  cond with the Cube renderer, listed after the declared segments.

No rollup, only the scope segment, when the report's @filter, its
@segment or any listed measure's condition (a ratio's operands included)
holds a relative date: a rollup is built at refresh time and would
answer with the "now" of its build. The relative date is found on the
lowered clauses (RelativeNow operands), never on the authored JSON.

A report that is not served (sourceless, abstract, materializedView)
contributes nothing. matches selects entities, not reports: a report's
rollup and scope segment are members of its @from cube and are written
whenever that cube is emitted as the entity's own, so selecting the
entity alone keeps them. A selected @from that cannot be a cube
(abstract, or no table) is refused, as the view lowering refuses it.

Rollup names join the cube's member namespace: Cube 1.7.43's compiler
reports a name shared by measures, dimensions, segments and
pre-aggregations as "defined more than once", so a rollup named like a
member, or two same-named reports on one cube, is
ERR_CUBE_MEMBER_COLLISION naming both, and a report named with a Python
keyword is ERR_CUBE_INVALID_NAME. The collision message now says so.

The canonical model test now pins all of Table H, rollups and the
recentProgramsScope segment included.

* fix(cube-model): standalone alias cubes, report refusals (FR-044)

Alias cubes no longer extend their target. Cube's extends copies every
member and every pre-aggregation of the parent, so each alias exposed
the target's measures again and rebuilt its rollups under scheduled
refresh. An alias cube is now a standalone cube like a join-target cube:
the target's sql_table (or a TPH subtype's sql), public: false, the
target's primary-key dimensions, the members the dimensions reaching it
read, and only the joins of multi-hop @via paths that continue through
it, rendered from the alias. The ambiguity check walks those joins
only, so a second alias of the same entity no longer counts as a route
it does not have. CubeSpec drops the unused extends field.

A cube is now written when a selected entity's cube reaches it through
joins: an entity reached only through alias cubes gets no plain
join-target cube, since nothing joins it.

A served report whose @from has no cube (abstract, or no writable
table) is ERR_CUBE_UNMAPPABLE_REPORT, naming the report, the entity and
the reason. A TPH-subtype @from gets its rollup on the subtype's cube,
whose sql applies the discriminator.

Tests: alias cubes carry none of the target's measures, segments or
rollups; a multi-hop @via through an alias; ambiguity through an alias;
two self-references; the alias tests in their new shape; the abstract
@from refusal; the TPH-subtype rollup; dimensions in report order.

* feat(cube-model): deterministic Cube YAML (FR-044)

renderCubeYaml writes one CubeSpec as Table H's bytes: block lists, flow lists for rollup members and meta.grains, single-quoted sql with the quote doubled, free text as a JSON string wrapped in a raw block when it holds a Jinja opener. A name a YAML reader takes for a boolean or null is quoted. Free text holding endraw and a sql value holding a line break are refused with ERR_CUBE_UNESCAPABLE_LITERAL.

* fix(cube-model): lossless SQL scalars, source XOR, YAML parse oracle (FR-044)

A sql value holding a control character, line break, line or paragraph separator or byte-order mark is legal model data (a string filter value), so sqlScalar now writes it as a JSON double-quoted scalar instead of refusing it; plain values stay single-quoted, so Table H is unchanged. CubeSpec carries exactly one of sqlTable or sql in its type. Tests parse the rendered YAML with Bun's built-in parser and compare it with the spec.

* feat(cube-model): the cube-model generator and its mapping corpus (FR-044)

cubeModel({ dialect?, filter?, target? }) wires buildCubeModel and
renderCubeYaml into meta gen: one model/cubes/<Cube>.yml per cube under
the target's outDir. Model scope; dialect defaults to the config's;
column naming from the config.

Selection (Ruling 17, amended with the controller): the build covers the
whole loaded model narrowed by the generator's own filter, which is fixed
config, so a cube's bytes never depend on the run. The run's selection
(meta gen <Entity>, scope) only picks which files are written: each
selected entity's cube and every cube it reaches through joins. A reached
entity cube is written too, because a @via adds the member it reads to the
cube it reaches. The filter is the build's universe, so it settles
ERR_CUBE_NAME_COLLISION and steps around a refusal on an excluded entity,
as the error messages advise.

ERR_CUBE_UNSUPPORTED_DIALECT refuses sqlite only when the run would write
a cube; a model or a selection with no reporting vocabulary writes nothing
and raises nothing under any dialect.

fixtures/cube-model/: 38 cases, one per Table B row (measure-default and
report-spine wait for #411), Table K's extras, the controller's extras
(dimension-time-two-grains, join-composite, join-self-reference,
dimension-uri-inet, measure-mysql, free-text-jinja) and one case per
CubeModelError code. Every expected file was written by hand from its
contract row. canonical/expected/ is Table H byte for byte, written by
bun run gen:cube-canonical and drift-checked.

Exports cubeModel, buildCubeModel, hasReportingVocabulary,
renderCubeYaml, CubeModelError, CUBE_ERROR_CODES, the ERR_CUBE_* codes
and the spec types from the package root (Ruling 3). No registry entry,
reference template or eject wiring (Task 6).

* test(cube-model): tuple distinct count with a condition, MySQL time dimensions (FR-044)

Two mapping-corpus cases, each expected tree written by hand from the plan's
Tables C and D before the generator ran:

- measure-count-distinct-tuple-condition (postgres): a tuple distinct count
  with a segment, a filter, both, and an or-group segment keeps ONE filters
  entry, the not-null terms first and the condition ANDed after them; an or
  group and a segment-plus-filter arrive parenthesised.
- dimension-time-mysql: a timestamp is the backtick-quoted column, and a
  date, as a time dimension or an attribute, is CAST(... AS DATETIME), since
  MySQL's CAST has no TIMESTAMP target.

* feat(cube-model): catalog entry, ejectable reference copy (FR-044)

cube-model becomes a first-class catalog citizen.

- Registry: layer capability, native, ejectable, no runtime packages or peers,
  config keys dialect and columnNamingStrategy. meta gen --list shows the row
  and its use-when/emits facets, read from the template header.
- Reference copy src/reference/cube-model.ts (meta eject cube-model). The
  adopter owns which entities get a cube, the file layout and the YAML call;
  buildCubeModel and renderCubeYaml stay in the package.
- Cross-port manifest: cube-model for typescript only. The other four ports'
  registry tests select their own slice, so a TS-only entry cannot break them.
- Orphan cleanup, built-in and copy together: the generator opts in to the
  runner's orphanPolicy for model/cubes/*.yml. A stale cube file breaks Cube's
  whole-model compile. The runner removes only an untouched file that a prior
  run wrote and this run did not re-emit, refuses a hand-edited one, and skips
  reconciliation entirely when the run names entities (meta gen <Entity>).
- Tests: the copy is byte-identical to the built-in over the canonical model
  and every corpus case, errors included, and is the same generator (name,
  target, filter, orphan namespace); orphan cleanup through the real runner for
  both; the inert test pins the three cubes the with/ model writes and nothing
  for without/; catalog gates treat cube-model like trace-helper (a model
  trigger the probe fixture lacks).

* test(cube-model): live check against a real Cube instance (FR-044)

A pinned Cube (cubejs/cube:v1.7.43, development mode) over a throwaway
postgres:16-alpine on a private docker network loads the cube-model
generator's output for the canonical persistence model, and:

- the generated tree must equal fixtures/cube-model/canonical/expected/;
- /v1/meta must compile it and list every cube and member the files
  declare, with Cube's type, aggregate and visibility;
- each served report's Table F query (UTC) must be answered from the
  report's rollup when Table F emits one, and its rows must equal
  SELECT * FROM <view> under Table I's normalization;
- every Postgres case of the mapping corpus is swapped into the same
  Cube (through an empty model, so a shared cube name cannot mask a
  stale compile) and must compile with the members its files declare.

Postgres is reached only through docker exec; Cube binds one ephemeral
127.0.0.1 port. Both containers and the network are force-removed from
afterAll, from every failure path in setup, and on exit or SIGINT/SIGTERM.
Without docker every test is skipped behind a banner. The file is
*.live.ts, run only by its path (bun run test:cube).

fixtures/cube-model/canonical/seed.sql is the planning spike's seed.

Two checks fail on this commit and need rulings, not edits to the corpus:
FitnessTotals is answered from the ProgramMinutes rollup (Cube takes the
first matching rollup in declaration order), and Cube rejects the
free-text-jinja case (it parses title/description as reference templates,
so braces need the SQL escape and a backslash is consumed).

* ci(cube-model): run the live check as its own `cube` lane (FR-044)

scripts/ci-local.sh gains a `cube` section: accepted by --only and
listed in --help, run in the full no-flag run after the 5-port
integration suite, kept by --integration-only, dropped by --quick (a
SKIP entry) and --no-integration, and never part of `ts`/`ts-slow`.
`--only cube` installs and builds the workspace first when no ts-fast
or ts-slow step in the selection did. Docker down records a SKIP behind
a banner, a FAIL under --strict-toolchains.

scripts/integration-test.sh gains a `cube` target (`bun run test:cube`),
left out of `all`. integration-tests.yml gains a `cube` matrix entry
with the Bun setup and cache steps; it brings its own containers and
ignores the job's Postgres sidecar. No local-ci.yml job is added, so the
lane map in ci-ports-to-run.sh is unchanged (its test reads only
local-ci.yml's jobs).

* fix(cube-model): Cube-safe free text, rollups coarsest first (FR-044)

The live lane found two defects; both are fixed in the generator and the
lane keeps its assertions.

Free text (Ruling 28). Cube 1.7.43 reads `title` and `description` as
templates: `{x}` is a member reference, `${x}` an interpolation, a
backslash an escape. So `a {b} c` failed the whole model and `C:\path`
came back `C:path`. textScalar now doubles every backslash, escapes `{`
and `}`, then raw-wraps when the ORIGINAL text holds `{{`, `{%` or `{#`
(the `endraw` refusal stays), then writes the JSON double-quoted scalar.
free-text-jinja gains a measure description with lone braces, `${` and
a backslash. Its golden is updated by hand. The lane now checks that
every declared title and description comes back verbatim from /v1/meta
(cube title/description, member shortTitle/description).

Rollup order (Ruling 29). Cube serves a query from the first matching
rollup in definition order, and a finer rollup serves a coarser additive
query: FitnessTotals was answered from ProgramMinutes. Each cube's
rollups are now written by dimension count (attribute + time)
ascending, ties in report order. In the canonical golden, Week lists
FitnessTotals before ProgramMinutes and Program lists ProgramsByWeek
before ProgramsByMonth. report-rollup lists ProgramTotals first.
Nothing else moved.

scripts/ci-local.sh --only cube is green: 10 of 10, each report served
from its own rollup (dev_pre_aggregations.<cube>__<rollup>), 28 of 28
corpus cases compiled.

* fix(cube-model): lane robustness, rollup tie order (FR-044)

Review minors, ruled in by the controller.

- The generated canonical model must equal the golden in beforeAll,
  before Cube starts. On drift, setup fails, naming each drifted file
  and the regeneration command.
- startCubeStack takes `initSql`: the schema and seed are applied after
  Postgres is ready and before Cube starts. No rollup can be built over
  empty tables.
- Rollup tie order: dimension count ascending, then the coarser time
  grain first (listed order), then fewer measures, then report order.
  The comment now claims only Cube's first-match rule: an exact-match
  rollup comes before a strictly finer one, and rows are correct either
  way. Day-vs-month and superset-of-measures ties are unit-tested. No
  golden moved.
- The corpus pass gives each case a deadline drawn from one budget, and
  its test timeout scales with the case count. A slow Cube names the
  case it was on and lists the cases already rejected.
- load() retries transport errors (refused, reset, aborted, a non-JSON
  body) with bounded backoff, inside the test timeout.
- Rows are compared first. A rollup-key mismatch reports the query,
  Cube's SQL and whether the rows matched.
- stop() warns on stderr, naming the container or network, when a
  removal fails for any reason but absence. It still never throws.
- free-text-jinja carries a backtick in a raw-wrapped and a plain
  description. Cube 1.7.43 hands both back unchanged, so no encoding
  change.
- Nits: U+2028 written as an escape in a test; the Ruling 29 tests sit
  under their own banner; a non-numeric MO_CUBE_READY_TIMEOUT_S is
  refused by name; with docker down, `--only cube` records its SKIP
  without installing or building.

* docs(cube-model): the Cube exporter (FR-044)

Document the cube-model reference generator as built, not as planned.

- New docs/features/cube-export.md: what it writes, wiring (target, eject,
  options, dialects), the mapping as prose with a worked example, the
  refusals, which files a run writes and the orphan cleanup, the Cube query
  that reproduces a report, where a Cube query and the view differ, how the
  corpus and the `cube` lane check it, rollups needing Cube Store in
  production, and known limits (@spine and @default are not mapped yet).
- reporting.md: an "Exporting to Cube" section; the "does not exist yet"
  paragraph no longer calls the list hook missing and names the MetricFlow
  exporter as on demand.
- own-your-codegen.md, ports/typescript.md, docs/README.md: cube-model where
  reference generators are listed. cli.md lists no generators, so unchanged.
- Skills: one paragraph each in metaobjects-codegen (typescript) and
  metaobjects-authoring (reporting). Agent-context goldens regenerated; the
  regen also picked up SKILL.md text that already differed on main.
- CONFORMANCE.md and AGENTS.md: fixtures/cube-model/ is the 29th corpus
  (TypeScript only), with its table row and section; the stated total moves
  from 28 to 29 and counts.test.ts is green again.
- CHANGELOG [Unreleased]: the cube-model generator and the `cube` lane; no
  vocabulary change, metamodelVersion stays 1.1.
- spec/roadmap.md FR-044 row brought current.
- Code comments from the build ledger: the reference header now carries the
  built-in's orphan-cleanup caveats (a join no longer reached; a changed
  scope reconciles on a full run); the corpus README row for
  error-unmappable-report says it needs an unrelated cube; the alias test
  comment no longer says Team keeps its own join.
- The reference template's dialect message was four concatenated template
  literals, which failed check-reference-templates-lint. It is one literal
  now, with the same text (reference-byte-identical stays green).

metaobjects/meta.requirements.yaml: the reporting branch (and objectReport)
describes declaring the vocabulary and leaves lowering and serving out. No
entry is about export, so the ledger is unchanged.

* docs(cube-model): plan answers and as built, spec section 5 (FR-044)

Plan 4 gets "Answers to the open questions" (all six accepted as
recommended) and an "As built" section, in Plan 3's style. The contract
tables are left as first written; each As built item names the table it
changes. Tasks 9 and 10 (@default, @spine) are recorded as not built: the
#411 build is not on main.

Spec section 5: the relative-filter row is the view's SQL in a segment or
measure filter (no rollup), the object.report row is a rollup on the @from
cube (coarsest first), and the @spine row is a Cube view with no rollup.
Three cells the build contradicted are corrected too: the tuple distinct
count is ROW(...) with a not-null filter, @Grains is carried as meta.grains,
and a to-one join is one_to_one from the side that does not hold the key.
One sentence under the table points at Plan 4.

* docs(cube-model): drop build-ledger ruling numbers from the plan's As built (FR-044)

The ledger is not in the repo, so a ruling number told a reader nothing.

* docs(cube-model): review fixes (FR-044)

- A report with a relative date in its @filter, its @segment's filter or a
  listed measure's condition gets no rollup; its <report>Scope segment is
  written only when it has a @filter. Said exactly that in the authoring and
  codegen skills (goldens regenerated), cube-export.md, reporting.md, the
  CHANGELOG and the plan's answer 2.
- Spec section 5: the note now also excludes measure.derived; the ratio cell
  gives the Postgres CAST and the MySQL form; the tuple cell names MySQL's
  JSON_ARRAY.
- cube-export.md: an alias cube clash is ERR_CUBE_NAME_COLLISION, not a
  member collision; --forbid-hand-edits makes a hand edit fail verify.
- ports/typescript.md: the config example has outDir; both it and
  reporting.md say a cube for each concrete, table-backed entity plus the
  join-target and alias cubes its @via dimensions need.
- reporting.md: the filters entry is measure.aggregate only; measure.ratio
  is a number measure over its operands.
- fixtures/cube-model/README.md: no ledger ruling numbers; the
  error-unmappable-report row states the rule.
- Plan answer 5: <m>Raw belongs to the unbuilt @default mapping.
- Root README capability matrix, TypeScript reporting cell: the list hook and
  the cube-model generator (it said "no client hook yet").

* feat(cube-model): refuse @spine reports and @default measures until mapped (FR-044)

The zero-rows/measure-defaults vocabulary (@spine on object.report, an integer
@default on measure.aggregate and measure.ratio) is planned and not built. When
it lands, cube-model would otherwise ignore both and write wrong Cube output
silently: a rollup lacking the spine's zero rows, a measure lacking the COALESCE
the view applies.

The build now refuses a served report that declares @spine, and a measure.aggregate
or measure.ratio written on a cube that declares @default, with the new
ERR_CUBE_UNMAPPED_VOCABULARY naming the node and how to proceed. A sourceless
report, a report or measure on an entity the generator's filter leaves out, a
measure on an entity with no table and a join-target cube's measures stay inert.
The attribute names are local constants in cube-pending.ts, read with the
resolving hasAttr(); the module goes when the real mapping replaces it.

Adds the error-unmapped-vocabulary corpus case (41 cases, 30 trees + 11 errors)
and updates the counts and the known-limits entry.

* fix(cube-model): escape backslashes for Cube, key-field dimension, final review fixes (FR-044)

Cube compiles every `sql` and `sql_table` as a JS template literal after
Jinja, so a backslash in a SQL literal or identifier was an escape: 'a\b'
reached the database as a backspace, 'A\_%' as 'A_%', a trailing backslash
swallowed the closing quote and \u broke the compile. escapeToken now doubles
every backslash before it escapes the braces, the order free text already
uses. The escaping case gains a trailing backslash and a backslash before a
brace, and the cube lane now reads each of its literals (and the braced
column) back from Cube's /v1/sql and requires the view's own SQL. A negative
control with the old single-backslash golden fails that check on Cube 1.7.43
(Cube's SQL holds a backspace).

A declared dimension without @via named after a key field and reading that
field is now that key dimension: one dimension, primary_key and public: true,
with the declared title, description and grains. Over another field, or with
a @via, it stays ERR_CUBE_MEMBER_COLLISION. New corpus case
dimension-over-key (hand-written golden).

Also from the final review:
- The lane's compile pass loads the two MySQL cases too (compiling runs no
  SQL): 31 of the 42 cases.
- Counts corrected everywhere: 42 cases, 31 trees, 11 errors.
- Ledger ruling numbers replaced with the rule they stood for.
- The filter option and ERR_CUBE_NAME_COLLISION remedy say an excluded
  entity a @via reaches is still written, as a join-target cube.
- CubeViewSpec and its parts are no longer exported from the package root.
- A test for a ratio whose operand declares @default; the tautological
  constant test is gone.
- Doc statements on relative dates and the served-report row; test titles
  and comments that misdescribed the rollup order, the eject install set and
  the catalog's silent list.

* chore(site): payload counts the cube-model corpus (FR-044)

The new fixtures/cube-model corpus raises the corpus count from 28 to 29;
the published site payload carries that count.

* test(cube-model): guard tests load clean under the @spine/@default rules (FR-044)

#415 registered @spine and a measure's @default with load rules R8, R9
and M7, so four models in cube-unmapped-vocabulary.test.ts no longer
loaded and the build under test was never reached:

- the @spine report named a hop Program does not declare (R8) and listed
  no dimension (R9). It now walks Program.fkOwner, an identity.reference
  onto a new Owner entity, and lists a dimension read through it.
- the abstract-base measure put @default on a count (M7). It is a sum.

Each test still pins the same ERR_CUBE_UNMAPPED_VOCABULARY refusal or the
same inert result; the guard and its messages are unchanged. Comments
that described the attributes as unregistered are corrected.

* fix(cube-model): MySQL tuple count as the view's, one Jinja rule, review fixes (FR-044)

Pre-gate review findings CR1 to CR5:

- MySQL tuple distinct count: the view's own COUNT(DISTINCT a, b) as a
  `number` measure (a condition on the first component, as the view writes
  it). A count_distinct over JSON_ARRAY(a, b) compares JSON bytes, not the
  column collation: executed on mysql:8.4 (utf8mb4_0900_ai_ci) over rows with
  case and accent variants, nulls and duplicates, it counted 6 tuples where
  the view counts 4. Integer tuples agreed (4 and 4).
- One Jinja-opener rule for SQL and free text (cube-template.ts): text holding
  {{, {% or {# is raw-wrapped, so a {{x}} literal is now wrapped too.
- endraw is refused only when the text is raw-wrapped; unwrapped text is never
  inside a raw block. The error-unescapable-literal case now holds {%.
- Tests: a relationship-backed @via hop crosses its own reference's alias, and
  an inherited one crosses the inherited reference's join.
- ERR_CUBE_AMBIGUOUS_PATH says when more routes exist than it lists.
- The join-target remedy text says an entity reached only through alias cubes
  gets no join-target cube; a false test comment about String.raw is fixed.

* feat(cube-model): a measure's @default (FR-044)

A measure.aggregate with `@default: n` is two Cube members: `<m>Raw`, the
aggregate as before (type, sql and filters), `public: false`, then `<m>`,
`type: number`, `COALESCE({<m>Raw}, n)`, carrying the measure's title and
description. A measure.ratio with `@default: n` wraps its quotient in
COALESCE (Postgres with the cast, MySQL without). An operand that declares
its own default is referenced by member name, so its COALESCE reaches the
ratio (#411 decision 4); a unit test expands the Cube members of such a ratio
and requires the report view's own SQL for it, character for character.
`<m>Raw` is an added member, so a clash is ERR_CUBE_MEMBER_COLLISION. A
rollup lists `<m>`. #415's Tables A and E agree with the plan's Task 9 table,
so the mapping is the table's.

The @default half of the guard is gone; the @spine half stays until @spine
is mapped, and error-unmapped-vocabulary now pins that refusal. Corpus case
measure-default (hand-written golden). The codegen-noop `with/` model now
exports; its defaulted ratio is pinned in the inert test.

The canonical golden still cannot be regenerated: its @spine reports are
refused until the next commit maps them, so the canonical cube tests and the
two canonical reference-byte-identical tests stay red until then.

* feat(cube-model): a @spine report as a Cube view (FR-044)

A served report with @spine is a Cube view, model/views/<Report>.yml, with no
rollup and no scope segment (a rollup on the spine cube is built from the
fact cube and loses the zero rows). Its first join_path is the spine cube
(the cube every listed dimension reaches after the spine's hops), including
the member each dimension reads under the dimension's name, with the
dimension's title, description and grains; its last is <Report>Facts with
the listed measures.

<Report>Facts is a standalone public: false cube (no `extends`, which would
copy the fact cube's rollups): `SELECT * FROM <@from table> <alias>` with the
report's @segment and @filter, ANDed, as its WHERE, so the scope stays inside
the join and a spine row whose facts are all filtered out keeps its row. It
holds @from's key and @from's own definitions of the listed measures, ratio
operands and <m>Raw members. The spine cube gets one one_to_many join onto it;
no reverse join is added to an ordinary cube, so ad-hoc answers are
unchanged. A multi-hop spine is built with standalone chain cubes
<Report>_<hop>, one per entity between @from and the spine entity.

Executed on Cube 1.7.43 before building: a view includes a private key and a
public: false member under an alias, include-level docs and meta reach
/v1/meta, the roster view returns every program (weeks 0, sums null, defaults
0), the scoped facts cube keeps the programs whose weeks are all short, a
two-hop chain equals the view lowering's SQL, and {Week.weeks, Program.id}
stays rooted at Week.

The guard is deleted: cube-pending.ts, ERR_CUBE_UNMAPPED_VOCABULARY and the
error-unmapped-vocabulary case. renderCubeViewYaml and the view types are
exported; the generator writes a view when every cube it reads is written and
cleans up model/views/*.yml. Corpus cases report-spine and
report-spine-multi-hop (hand-written goldens); the canonical golden gains the
two facts cubes and views. The cube lane compares ProgramRoster and
ProgramLongWeeks through their views (7 rows each), FitnessTotalsFilled
through its rollup, and reads a defaulted operand's COALESCE inside a ratio
from /v1/sql. Docs, spec section 5, roadmap and skills say both attributes are
mapped.

* fix(cube-model): spine review fixes (FR-044)

- A TPH subtype as the spine entity is exported through its own
  discriminator-scoped cube, so the view's rows are that subtype's only;
  pinned by a unit test and documented beside the TPH @from note (the view
  lowering refuses both reports).
- Two listed dimensions over one field and path include one member twice
  under two aliases. Added to the report-spine corpus case (golden edited by
  hand); the cube lane's compile pass shows Cube 1.7.43 accepts it.
- One exported JOIN_PATH_SEPARATOR, used by the build, both generator copies
  and the live lane.
- One definition of the report scope and of the MySQL tuple count:
  reportScope and mysqlTupleCount in projection/report-sql.ts, called by the
  view lowering (extract-report-spec, report-ddl-emit) and the exporter
  (cube-members, cube-reports, build-cube-model). View SQL, the canonical
  schema, the report shapes and the projection goldens are unchanged.
- ERR_CUBE_NAME_COLLISION says "cube or view" when one side is a view.
- A facts or chain cube carries no title or description of its entity.

* refactor(cube-model): simplify pass over the exporter source (FR-044)

Collapse the mirrored join arms, share the YAML file framing and the grains
tail, derive the unmappable-dimension message from the dispatch sets, and make
four helpers with no outside importers module-private. No output byte changes:
the corpus, canonical golden and reference byte-identical tests are unchanged.

* no-mistakes(document): Documentation updated for FR-044 Plan 4 Cube exporter: refresh date and FR-044 feature description in AGENTS.md (authority file).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant