Skip to content

Commit 1de24da

Browse files
committed
Merge origin/main into claude/issue-21419-cube-measure-aggregate-leg
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
2 parents 5141487 + 68c5ab7 commit 1de24da

15 files changed

Lines changed: 121 additions & 65 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
The list-view field-reference rule no longer walks a list view's own `tabs[].filter`
6+
7+
Clause-②: no
8+
9+
The list view's own `tabs` is a `retiredKey` tombstone on every list-view shape, and this rule judges the parsed stack, so the key could never reach the walk: the parse refuses it first, with its prescription. The dead branch is deleted. The rule still judges `filter` and `userFilters.tabs[].filter` exactly as before.
10+
11+
No finding changes for any stack that `os validate`, `os lint` or `os build` accepts.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
`computeViewReferenceDiagnostics` no longer walks a list view's own `tabs[].filter`
6+
7+
Clause-②: no
8+
9+
The list view's own `tabs` is a `retiredKey` tombstone on every list-view shape. The write door refuses it, and a stored or artifact-shipped body has it stripped by the conversion replay before it is served, so the read could never see it. A served body that still carries it is already badged by the spec diagnostics (`computeMetadataDiagnostics`), with the tombstone's prescription. The `userFilters.tabs[].filter`, `filterableFields` and `kanban` checks are unchanged.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Liveness ledger: the view container's body `name` row stays `dead`, and its note now states what the platform actually does with the key
6+
7+
Clause-②: no
8+
9+
- The old note said the body copy was "a copy nobody reads". Measured, the metadata door stamps the save name into every saved view body that has none, containers included (`normalizeViewMetadata` in `@objectstack/metadata-protocol`). Its overlay paths key on that stamped copy: `hydrateOverlayIntoRegistry` registers no body without a `name`, and `mergePackageAwareOverlay` slots an overlay row by it.
10+
- The verdict is unchanged, because the ledger's `live` means that authoring the key changes runtime behaviour. An authored container `name` only restates the key the container already registers under, or contradicts it. `os validate` and `os lint` keep warning `liveness-dead-property` ("drop it").
11+
- The note records why the key is kept rather than tombstoned: the door's own saves stamp it, so a tombstone would refuse the platform's own writes. A maintainer ruling also refused a spec-level forbid of a container's `name`.
12+
- It corrects the old attribution too. Artifact-shipped containers and the metadata-validation sweep author no `name`; what was read as theirs is the door's stamp.
13+
- The ledger README's `view` cell says the same. The `view.list.tabs` row's note now records that the two author-time walks that still read a list view's own `tabs` are deleted.
14+
- A comment in `system/i18n-resolver.ts` that still called the list view's own `tabs` a live carrier now says the key is a tombstone and `UserFiltersSchema.tabs` is the one carrier.
15+
- ⛔ No schema, parse, export, status or accept-set change.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the null ordering-comparand refusals name only evaluation faces that exist, and say only what was measured
6+
7+
Clause-②: no
8+
9+
`FieldOperatorsSchema` and `ComparisonOperatorSchema` refuse a `null` comparand of `$gt` / `$gte` /
10+
`$lt` / `$lte` with a pointed message. Its example of the evaluation faces disagreeing named
11+
driver-memory's reference matcher, which has been deleted, so an author or agent reading the
12+
refusal went looking for a face that no longer exists. The example now names two faces that exist
13+
and were measured to disagree: driver-memory's query path reads a stored `null` as equal to the
14+
comparand, so `{"$gte": null}` admits that row, while driver-sql compares against SQL `NULL` and
15+
admits no row.
16+
17+
That refusal and its runtime twin, the `parseFilterAST` refusal for the same comparand
18+
(`Operator "$gt" on field "…" does not accept a null comparand …`), both said "no two evaluation
19+
faces agree" on what an ordering against `null` matches. Measured, two faces do agree (driver-sql
20+
and formula both admit no row), so both now say "the evaluation faces do not agree".
21+
22+
Text only: each message's first sentence, its prescription (`{"$eq": null}` / `{"$ne": null}`), the
23+
schema door's ruling sentence and the runtime door's "NOT applied" sentence are unchanged, and both
24+
doors accept and refuse exactly the same filters. A client or log filter that matches the old
25+
wording needs the new spelling.

‎packages/lint/src/validate-list-view-field-refs.test.ts‎

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ const FULL_LIST_VIEW: AnyRec = {
8383
fields: [{ field: 'status' }],
8484
tabs: [{ name: 'open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }],
8585
},
86-
tabs: [{ name: 'mine', filter: [{ field: 'business_unit', operator: 'equals', value: 'x' }] }],
8786
kanban: { groupByField: 'status', summarizeField: 'estimate', columns: ['title'], titleField: 'title' },
8887
calendar: {
8988
startDateField: 'due_at',
@@ -241,23 +240,22 @@ function positionAsserted(path: string, walked: ReadonlySet<string>): string | u
241240
}
242241

243242
/**
244-
* [#18836] The rule's three hard-coded filter walks, spelled as
243+
* [#18836] The rule's two hard-coded filter walks, spelled as
245244
* {@link casePath} normalises the paths they report at.
246245
*
247246
* They are open code, not table rows — `checkListView` calls `checkFilter` on
248-
* `listView.filter`, on `tabs[]` and on `userFilters.tabs[]` — so the position
247+
* `listView.filter` and on `userFilters.tabs[]` — so the position
249248
* set derived from the rule cannot reach them, and the completeness assertion
250249
* below would let their rows be deleted in silence. That is precisely the hole
251250
* the floor this card replaced DID cover, by counting rows.
252251
*
253252
* So they are declared here and asserted EXACTLY: delete one of their rows and
254-
* the list comes up short; give a fourth hard-coded walk a row without adding
253+
* the list comes up short; give a third hard-coded walk a row without adding
255254
* it here and the list comes up long. Together with the derived assertion, every
256255
* row in both tables is then accounted for by one criterion or the other.
257256
*/
258257
const HARD_CODED_FILTER_WALKS = [
259258
'filter.field',
260-
'tabs.filter.field',
261259
'userFilters.tabs.filter.field',
262260
];
263261

@@ -292,11 +290,6 @@ describe('#14107 — every other walked position', () => {
292290
'views[0].list.userFilters.tabs[0].filter[0].field',
293291
'error',
294292
],
295-
[
296-
{ tabs: [{ name: 'a', filter: [{ field: BAD, operator: 'equals', value: 1 }] }] },
297-
'views[0].list.tabs[0].filter[0].field',
298-
'error',
299-
],
300293
[{ kanban: { summarizeField: BAD } }, 'views[0].list.kanban.summarizeField', 'warning'],
301294
[{ kanban: { columns: [BAD] } }, 'views[0].list.kanban.columns[0]', 'warning'],
302295
// [#18565] Calendar's level, not the required siblings' — see the row's
@@ -426,15 +419,15 @@ describe('#14107 — every other walked position', () => {
426419
// filter walks — nothing else. A set, compared in both directions, which
427420
// holds three things the completeness assertion does not:
428421
//
429-
// - SHORT ⇒ RED. Delete a filter-walk row and the set loses a member. All
430-
// three rows measured, one by one. That is exactly the coverage the
422+
// - SHORT ⇒ RED. Delete a filter-walk row and the set loses a member. Every
423+
// row measured, one by one. That is exactly the coverage the
431424
// row-counting floor had and the derived assertion cannot reach.
432-
// - LONG ⇒ RED, measured two ways. Remove one of the three declarations
425+
// - LONG ⇒ RED, measured two ways. Remove one of the declarations
433426
// below and the rows outnumber them. Leave a row behind for a position the
434427
// rule has DROPPED and it matches neither side, so it arrives here as an
435428
// extra — red here as well as in that row's own per-case assertion, which
436429
// is how this assertion ends up carrying the direction its sibling above
437-
// cannot. A fourth hard-coded walk given a row without being declared
430+
// cannot. A third hard-coded walk given a row without being declared
438431
// below lands in the same place by the same comparison.
439432
it('accounts for every asserted path, as a walked position or a declared filter walk', () => {
440433
const walkedSet = new Set(listViewWalkedPositions());
@@ -707,11 +700,10 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
707700
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('id.x'))))).toEqual([]);
708701
});
709702

710-
it('the tab and user-filter tab presets are judged on the same axis', () => {
703+
it('the user-filter tab presets are judged on the same axis', () => {
711704
const findings = validateListViewFieldRefs(
712705
stackWith(
713706
mutate({
714-
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
715707
userFilters: {
716708
fields: [{ field: 'status' }],
717709
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
@@ -720,7 +712,6 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
720712
),
721713
);
722714
expect(idsOf(findings).sort()).toEqual([
723-
'views[0].list.tabs[0].filter[0].field',
724715
'views[0].list.userFilters.tabs[0].filter[0].field',
725716
]);
726717
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);

‎packages/lint/src/validate-list-view-field-refs.ts‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@
134134
* `INVALID_FIELD` / 400 (`packages/objectql/src/engine.ts`, #7589), and
135135
* `assertProjectionFieldsExist` answers the same at the REST ingress
136136
* (#7532). So every dotted column is reported, whatever its head's type.
137-
* - **Filter** — the view's own `filter`, its `tabs[].filter`, its
138-
* `userFilters.tabs[].filter`, and the two positions that DECLARE which
139-
* names the end user may filter on (`filterableFields`, spelled by the
137+
* - **Filter** — the view's own `filter`, its `userFilters.tabs[].filter`,
138+
* and the two positions that DECLARE which names the end user may
139+
* filter on (`filterableFields`, spelled by the
140140
* spec as "bare field names enabled for end-user filtering", and
141141
* `userFilters.fields`; objectui folds the resulting conditions into the
142142
* fetched query through `buildEffectiveFilter`). Here the door does NOT
@@ -201,9 +201,10 @@
201201
* owned by `validateActionNameRefs`.
202202
* - **`conditionalFormatting[].condition`** — a CEL predicate, owned by the
203203
* expression rules.
204-
* - **`tabs[].view` / `addRecord.formView`** — view names, owned by
205-
* `lintViewRefs`. (`pageName` was here too until #17063 retired the
206-
* `type: 'page'` view mount; a list view carries no page reference now.)
204+
* - **`addRecord.formView`** — a view name, owned by `lintViewRefs`.
205+
* (`pageName` was here too until #17063 retired the `type: 'page'` view
206+
* mount, and `tabs[].view` until the list view's own `tabs` was retired; a
207+
* list view carries neither reference now.)
207208
* - **The `data.object` binding itself** — `validateObjectReferences` owns
208209
* object-name reference sites, with the curated cross-package severity
209210
* ladder a local "not in this stack ⇒ error" would not have. When the bound
@@ -520,11 +521,11 @@ const COLUMN_ENTRY_POSITIONS: Array<{ block: string; key: string; severity: Sev
520521
* It names positions only — no severity, no shape — so reading it can never
521522
* stand in for reading the tables.
522523
*
523-
* The three filter walks further down (`listView.filter`, `tabs[].filter`,
524+
* The two filter walks further down (`listView.filter`,
524525
* `userFilters.tabs[].filter`) are deliberately absent: they are open code, not
525526
* table rows, so there is nothing HERE to derive them from. ⛔ Absent from this
526527
* list is not absent from the test's account of itself — the test declares those
527-
* three as an explicit list and asserts it exactly, because a row of theirs
528+
* two as an explicit list and asserts it exactly, because a row of theirs
528529
* deleted in silence is the one thing the row-counting floor this replaced did
529530
* cover.
530531
*/
@@ -768,12 +769,16 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin
768769
}
769770
}
770771

771-
// ── Filter KEYS: the view's own filter, its tabs' filters, and the tab
772-
// presets inside `userFilters`. `walkFilterFieldKeys` handles all three
773-
// authored filter shapes (Mongo condition object, `{ field, operator,
774-
// value }` rules, `[field, op, value]` triples) so a filter authored one
775-
// way is not judged while another is silently skipped (#3574's own
776-
// failure mode).
772+
// ── Filter KEYS: the view's own filter and the tab presets inside
773+
// `userFilters`. `walkFilterFieldKeys` handles all three authored filter
774+
// shapes (Mongo condition object, `{ field, operator, value }` rules,
775+
// `[field, op, value]` triples) so a filter authored one way is not judged
776+
// while another is silently skipped (#3574's own failure mode).
777+
//
778+
// The list view's OWN `tabs` is not walked: it is a `retiredKey` tombstone
779+
// on every list-view shape, and this rule judges the PARSED stack
780+
// (`input: 'parsed'` in the authoring-rule registry), where the key can
781+
// never arrive — the parse refuses it with its prescription first.
777782
const checkFilter = (filter: unknown, filterWhere: string, filterPath: string): void => {
778783
if (filter === undefined || filter === null) return;
779784
walkFilterFieldKeys(filter, filterPath, ({ field, path: at }) => {
@@ -792,7 +797,6 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin
792797
});
793798
};
794799

795-
checkTabs(listView.tabs, `${where} › tabs`, `${path}.tabs`);
796800
if (isRec(listView.userFilters)) {
797801
checkTabs(
798802
listView.userFilters.tabs,

‎packages/metadata-protocol/src/metadata-diagnostics.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,11 @@ export function computeViewReferenceDiagnostics(
228228
userFilters?.tabs?.forEach((t, i) =>
229229
t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`)));
230230

231-
(view?.tabs as Array<{ filter?: Array<{ field?: string }> }> | undefined)?.forEach((t, i) =>
232-
t?.filter?.forEach((r, j) => requireField(r?.field, `tabs.${i}.filter.${j}.field`)));
231+
// A list view's OWN `tabs` is not walked: it is a `retiredKey` tombstone on
232+
// every list-view shape. The write door refuses it, and a stored or
233+
// artifact-shipped body has it stripped by the conversion replay before it
234+
// is served. A body that still carries it is already badged by the spec
235+
// diagnostics, with the tombstone's prescription.
233236

234237
(view?.filterableFields as string[] | undefined)?.forEach((f, i) =>
235238
requireField(f, `filterableFields.${i}`));

‎packages/objectql/src/metadata-diagnostics.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
3030
fields: [{ field: 'industry' }, { field: 'is_active' }],
3131
tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }],
3232
},
33-
tabs: [{ name: 'a', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }],
3433
filterableFields: ['status'],
3534
kanban: { groupByField: 'status', columns: ['name'] },
3635
}, objectDef);
@@ -48,12 +47,12 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
4847
});
4948
});
5049

51-
it('flags tab filter rules pointing at unknown fields', () => {
50+
it('flags user-filter tab preset rules pointing at unknown fields', () => {
5251
const result = computeViewReferenceDiagnostics({
53-
tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }],
52+
userFilters: { element: 'tabs', tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }] },
5453
}, objectDef);
5554
expect(result.valid).toBe(false);
56-
expect(result.errors?.[0].path).toBe('tabs.0.filter.0.field');
55+
expect(result.errors?.[0].path).toBe('userFilters.tabs.0.filter.0.field');
5756
});
5857

5958
it('flags kanban groupBy on a non-select-like field', () => {

0 commit comments

Comments
 (0)