Skip to content

Commit aacb62d

Browse files
committed
fix(spec): os migrate meta --stored lists a stored dashboard whose global filter reuses the reserved dateRange key
REFUSED_STORED_SHAPES gains dashboardReservedDateRangeGlobalFilter, built on reservedDateRangeGlobalFilters (the dashboard refinement's own predicate), and both dashboard globalFilters D3 entries say the stored pass lists such a row. Claude-Session: https://claude.ai/code/session_01JQwxbXBJMBnWyspsxBN7j8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff827bc commit aacb62d

5 files changed

Lines changed: 137 additions & 7 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `os migrate meta --stored` lists a stored dashboard whose global filter is keyed `dateRange` beside a declared `dateRange`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** `DashboardSchema` refuses a dashboard that declares the built-in `dateRange` and also keys a `globalFilters` entry `dateRange` (its `name`, or its `field` when it has no `name`), and the dashboard save door answers 422 `INVALID_METADATA`. A dashboard already stored in that shape still loads with `_diagnostics` reading `valid: false`, but the v18 stored-row preview had no detector for it, so `os migrate meta --stored` named no such row before the upgrade.
11+
- **Now.** The preview lists each such stored dashboard under the TODO id `ui-dashboard-global-filter-reserved-date-range-refused` (the refusal's upgrade guide entry), at the entry's `name` (or `field`), the site the parse refuses, with the refusal's own message. The detector calls the refinement's own predicate, so a row is listed exactly when the parse refuses it. Nothing rewrites it: whether the entry was meant to be the date range or a filter of its own, only the author knows. The upgrade guide entries of this refusal and of the duplicate-key refusal now say that `os migrate meta --stored` lists such a row.
12+
- **Not changed.** What any door accepts or refuses. On a dashboard that does not declare `dateRange`, an entry keyed `dateRange` is not listed; nor is a named entry whose `field` is `dateRange`.

‎packages/spec/src/conversions/refused-shapes.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ describe('the refused-shape list — registered against the migration chain', ()
7373
'visibility-predicate-has-argument-refused',
7474
'object-duplicate-action-name-refused',
7575
'ui-dashboard-global-filter-duplicate-name-refused',
76+
'ui-dashboard-global-filter-reserved-date-range-refused',
7677
]);
7778
});
7879

@@ -853,3 +854,86 @@ describe('#29212 — a stored dashboard that declares one global filter key twic
853854
}
854855
});
855856
});
857+
858+
// ── #29541 (the #29213 refusal) ───────────────────────────────────────────
859+
860+
/** A dashboard that declares the built-in `dateRange`, unless `declared` is false. */
861+
const dateRangeBoard = (globalFilters: unknown[], declared = true) => ({
862+
...dupBoard(globalFilters),
863+
...(declared ? { dateRange: { field: 'close_date' } } : {}),
864+
});
865+
/** The dashboard parse's reserved-`dateRange` issues, as paths. */
866+
const reservedFilterIssuePaths = (row: unknown) => {
867+
const parsed = DashboardSchema.safeParse(row);
868+
return parsed.success
869+
? []
870+
: parsed.error.issues
871+
.filter((i) => i.path[0] === 'globalFilters' && /the reserved name of the built-in date range/.test(i.message))
872+
.map((i) => i.path);
873+
};
874+
875+
describe('#29541 — a stored dashboard whose globalFilters entry is keyed dateRange beside a declared dateRange is listed', () => {
876+
it('one TODO at the entry\'s name, naming the reserved word and the built-in date range; the row is not rewritten', () => {
877+
const row = dateRangeBoard([{ name: 'dateRange', field: 'region', type: 'text' }]);
878+
const { item, todos } = storedTodos('dashboard', row);
879+
expect(item).toEqual(row);
880+
expect(todos).toHaveLength(1);
881+
const todo = todos[0]!;
882+
expect(todo.conversionId).toBe('ui-dashboard-global-filter-reserved-date-range-refused');
883+
expect(todo.path).toBe('dashboards[0].globalFilters[0].name');
884+
expect(todo.reason).toMatch(/^No lossless rewrite: whether the entry was meant to be the date range/);
885+
expect(todo.reason).toContain(
886+
"globalFilters[0] is keyed 'dateRange', the reserved name of the built-in date range this dashboard "
887+
+ 'declares in `dateRange`.',
888+
);
889+
expect(todo.reason).not.toMatch(/#\d{3,}/);
890+
expect(JSON.parse(todo.from)).toEqual({ name: 'dateRange', index: 0 });
891+
// The listing and the parse refuse ONE set: the stored row still fails the parse, at the same site.
892+
expect(reservedFilterIssuePaths(item)).toEqual([['globalFilters', 0, 'name']]);
893+
});
894+
895+
it('an entry with no `name` keyed by its `field` is listed at that field, as the parse locates it', () => {
896+
const row = dateRangeBoard([
897+
{ name: 'region', field: 'region', type: 'text' },
898+
{ field: 'dateRange', type: 'date' },
899+
]);
900+
const { todos } = storedTodos('dashboard', row);
901+
expect(todos).toHaveLength(1);
902+
expect(todos[0]!.path).toBe('dashboards[0].globalFilters[1].field');
903+
expect(JSON.parse(todos[0]!.from)).toEqual({ name: 'dateRange', index: 1 });
904+
expect(todos[0]!.reason).toContain('(a filter with no `name` is keyed by its `field`)');
905+
expect(reservedFilterIssuePaths(row)).toEqual([['globalFilters', 1, 'field']]);
906+
});
907+
908+
it('a key both repeated and reserved is listed under each refusal, as the parse refuses it under each', () => {
909+
const row = dateRangeBoard([
910+
{ name: 'dateRange', field: 'region', type: 'text' },
911+
{ name: 'dateRange', field: 'status', type: 'text' },
912+
]);
913+
const { todos } = storedTodos('dashboard', row);
914+
expect(todos.map((t) => `${t.conversionId} @ ${t.path}`)).toEqual([
915+
'ui-dashboard-global-filter-duplicate-name-refused @ dashboards[0].globalFilters[1].name',
916+
'ui-dashboard-global-filter-reserved-date-range-refused @ dashboards[0].globalFilters[0].name',
917+
'ui-dashboard-global-filter-reserved-date-range-refused @ dashboards[0].globalFilters[1].name',
918+
]);
919+
expect(reservedFilterIssuePaths(row)).toEqual([['globalFilters', 0, 'name'], ['globalFilters', 1, 'name']]);
920+
expect(dupFilterIssue(row)?.path).toEqual(['globalFilters', 1, 'name']);
921+
});
922+
923+
it('CONTROL: with no dateRange declared, an entry keyed dateRange is not listed — and the parse accepts it', () => {
924+
for (const filters of [
925+
[{ name: 'dateRange', field: 'close_date', type: 'date' }],
926+
[{ field: 'dateRange', type: 'date' }],
927+
]) {
928+
const row = dateRangeBoard(filters, false);
929+
expect(storedTodos('dashboard', row).todos).toEqual([]);
930+
expect(DashboardSchema.safeParse(row).success).toBe(true);
931+
}
932+
});
933+
934+
it('CONTROL: beside a declared dateRange, a named entry over the field dateRange is not listed — and parses', () => {
935+
const row = dateRangeBoard([{ name: 'when', field: 'dateRange', type: 'date' }]);
936+
expect(storedTodos('dashboard', row).todos).toEqual([]);
937+
expect(DashboardSchema.safeParse(row).success).toBe(true);
938+
});
939+
});

‎packages/spec/src/conversions/refused-shapes.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
* ({@link externalBindingDatasourceRefusal}), and the one-name-per-action
4646
* judge of the object refinement ({@link duplicateActionNames}), and the
4747
* one-key-per-global-filter judge of the dashboard refinement
48-
* ({@link duplicateGlobalFilterNames}). A site is listed here exactly
48+
* ({@link duplicateGlobalFilterNames}), and its reserved-`dateRange` judge
49+
* ({@link reservedDateRangeGlobalFilters}). A site is listed here exactly
4950
* when that door refuses it. One door lives outside this package: the
5051
* page/view `visibleWhen` rule in `@objectstack/lint` judges a `has()`
5152
* argument on the engine's AST, which this package cannot parse, so its
@@ -70,7 +71,11 @@ import {
7071
import { duplicateActionNames } from '../data/duplicate-action-names.js';
7172
import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js';
7273
import { ComponentPropsMap } from '../ui/component.zod.js';
73-
import { duplicateGlobalFilterNames } from '../ui/duplicate-global-filter-names.js';
74+
import {
75+
duplicateGlobalFilterNames,
76+
RESERVED_DATE_RANGE_FILTER_NAME,
77+
reservedDateRangeGlobalFilters,
78+
} from '../ui/duplicate-global-filter-names.js';
7479
import { ViewDataSchema } from '../ui/view.zod.js';
7580
import { refusedHasArguments } from './cel-has-argument.js';
7681
import type { ConversionTodoDetail } from './types.js';
@@ -584,6 +589,32 @@ const dashboardDuplicateGlobalFilterName: RefusedStoredShape = {
584589
},
585590
};
586591

592+
// ── #29541: a dashboard whose globalFilters entry reuses the reserved key `dateRange` (the #29213 refusal) ──
593+
594+
const dashboardReservedDateRangeGlobalFilter: RefusedStoredShape = {
595+
id: 'ui-dashboard-global-filter-reserved-date-range-refused',
596+
toMajor: 18,
597+
surface: 'dashboard.globalFilters — an entry keyed `dateRange` (its `name`, or its `field` when it has no '
598+
+ '`name`) on a dashboard that also declares the built-in `dateRange`',
599+
detect(stack, report) {
600+
mapCollection(stack, 'dashboards', (dashboard, path) => {
601+
for (const entry of reservedDateRangeGlobalFilters(dashboard.dateRange, dashboard.globalFilters)) {
602+
report({
603+
// The site the dashboard parse refuses: the entry's `name`, or its `field`.
604+
path: `${path}.globalFilters[${entry.index}].${entry.key}`,
605+
from: JSON.stringify({ name: RESERVED_DATE_RANGE_FILTER_NAME, index: entry.index }),
606+
reason: 'No lossless rewrite: whether the entry was meant to be the date range or a filter of its own, '
607+
+ 'and which widget `filterBindings.dateRange` keys mean it, only the author knows. Left as stored, '
608+
+ 'the dashboard still loads with `_diagnostics` reading `valid: false`, and its next save, draft or '
609+
+ 'publish, is refused with 422 INVALID_METADATA. '
610+
+ entry.message,
611+
});
612+
}
613+
return dashboard;
614+
});
615+
},
616+
};
617+
587618
/**
588619
* Every refused stored shape, in report order. A new entry is a reviewed edit:
589620
* `refused-shapes.test.ts` pins each one.
@@ -597,6 +628,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
597628
visibilityHasArgument,
598629
objectDuplicateActionName,
599630
dashboardDuplicateGlobalFilterName,
631+
dashboardReservedDateRangeGlobalFilter,
600632
];
601633

602634
/**

‎packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const entry: SemanticMigration = {
3434
+ 'filter it meant, and that `os validate` reports no `dashboard-filter-binding-unknown` finding. A '
3535
+ 'stored `sys_metadata` dashboard that still repeats a key still loads, with `_diagnostics` reading '
3636
+ '`valid: false` and carrying this refusal, and its next save (draft or publish) is refused; nothing '
37-
+ 'rewrites it. Measured before the change: no authored dashboard in this repository\'s examples, '
38-
+ 'platform objects, docs or skills repeats a filter key.',
37+
+ 'rewrites it. `os migrate meta --stored` lists each such stored dashboard under this entry\'s id, at '
38+
+ 'the second declaration\'s `name` (or `field`). Measured before the change: no authored dashboard in '
39+
+ 'this repository\'s examples, platform objects, docs or skills repeats a filter key.',
3940
};

‎packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-reserved-date-range-refused.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const entry: SemanticMigration = {
3535
+ '`filterBindings` key names the filter it meant, and that `os validate` reports no '
3636
+ '`dashboard-filter-binding-unknown` finding. A stored `sys_metadata` dashboard that still carries '
3737
+ 'the pair still loads, with `_diagnostics` reading `valid: false` and carrying this refusal, and its '
38-
+ 'next save (draft or publish) is refused; nothing rewrites it. Measured before the change: no '
39-
+ 'authored dashboard in this repository\'s examples, apps, packages or ui tree keys a global filter '
40-
+ '`dateRange`.',
38+
+ 'next save (draft or publish) is refused; nothing rewrites it. `os migrate meta --stored` lists each '
39+
+ 'such stored dashboard under this entry\'s id, at the entry\'s `name` (or `field`). Measured before the '
40+
+ 'change: no authored dashboard in this repository\'s examples, apps, packages or ui tree keys a '
41+
+ 'global filter `dateRange`.',
4142
};

0 commit comments

Comments
 (0)