Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/29541-reserved-date-range-stored-detector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': patch
---

fix(spec): `os migrate meta --stored` lists a stored dashboard whose global filter is keyed `dateRange` beside a declared `dateRange`

Clause-②: no
Metadata-protocol: none

- **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.
- **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.
- **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`.
84 changes: 84 additions & 0 deletions packages/spec/src/conversions/refused-shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe('the refused-shape list — registered against the migration chain', ()
'visibility-predicate-has-argument-refused',
'object-duplicate-action-name-refused',
'ui-dashboard-global-filter-duplicate-name-refused',
'ui-dashboard-global-filter-reserved-date-range-refused',
'object-extension-field-patch-key-grammar-refused',
]);
});
Expand Down Expand Up @@ -855,6 +856,89 @@ describe('#29212 — a stored dashboard that declares one global filter key twic
});
});

// ── #29541 (the #29213 refusal) ───────────────────────────────────────────

/** A dashboard that declares the built-in `dateRange`, unless `declared` is false. */
const dateRangeBoard = (globalFilters: unknown[], declared = true) => ({
...dupBoard(globalFilters),
...(declared ? { dateRange: { field: 'close_date' } } : {}),
});
/** The dashboard parse's reserved-`dateRange` issues, as paths. */
const reservedFilterIssuePaths = (row: unknown) => {
const parsed = DashboardSchema.safeParse(row);
return parsed.success
? []
: parsed.error.issues
.filter((i) => i.path[0] === 'globalFilters' && /the reserved name of the built-in date range/.test(i.message))
.map((i) => i.path);
};

describe('#29541 — a stored dashboard whose globalFilters entry is keyed dateRange beside a declared dateRange is listed', () => {
it('one TODO at the entry\'s name, naming the reserved word and the built-in date range; the row is not rewritten', () => {
const row = dateRangeBoard([{ name: 'dateRange', field: 'region', type: 'text' }]);
const { item, todos } = storedTodos('dashboard', row);
expect(item).toEqual(row);
expect(todos).toHaveLength(1);
const todo = todos[0]!;
expect(todo.conversionId).toBe('ui-dashboard-global-filter-reserved-date-range-refused');
expect(todo.path).toBe('dashboards[0].globalFilters[0].name');
expect(todo.reason).toMatch(/^No lossless rewrite: whether the entry was meant to be the date range/);
expect(todo.reason).toContain(
"globalFilters[0] is keyed 'dateRange', the reserved name of the built-in date range this dashboard "
+ 'declares in `dateRange`.',
);
expect(todo.reason).not.toMatch(/#\d{3,}/);
expect(JSON.parse(todo.from)).toEqual({ name: 'dateRange', index: 0 });
// The listing and the parse refuse ONE set: the stored row still fails the parse, at the same site.
expect(reservedFilterIssuePaths(item)).toEqual([['globalFilters', 0, 'name']]);
});

it('an entry with no `name` keyed by its `field` is listed at that field, as the parse locates it', () => {
const row = dateRangeBoard([
{ name: 'region', field: 'region', type: 'text' },
{ field: 'dateRange', type: 'date' },
]);
const { todos } = storedTodos('dashboard', row);
expect(todos).toHaveLength(1);
expect(todos[0]!.path).toBe('dashboards[0].globalFilters[1].field');
expect(JSON.parse(todos[0]!.from)).toEqual({ name: 'dateRange', index: 1 });
expect(todos[0]!.reason).toContain('(a filter with no `name` is keyed by its `field`)');
expect(reservedFilterIssuePaths(row)).toEqual([['globalFilters', 1, 'field']]);
});

it('a key both repeated and reserved is listed under each refusal, as the parse refuses it under each', () => {
const row = dateRangeBoard([
{ name: 'dateRange', field: 'region', type: 'text' },
{ name: 'dateRange', field: 'status', type: 'text' },
]);
const { todos } = storedTodos('dashboard', row);
expect(todos.map((t) => `${t.conversionId} @ ${t.path}`)).toEqual([
'ui-dashboard-global-filter-duplicate-name-refused @ dashboards[0].globalFilters[1].name',
'ui-dashboard-global-filter-reserved-date-range-refused @ dashboards[0].globalFilters[0].name',
'ui-dashboard-global-filter-reserved-date-range-refused @ dashboards[0].globalFilters[1].name',
]);
expect(reservedFilterIssuePaths(row)).toEqual([['globalFilters', 0, 'name'], ['globalFilters', 1, 'name']]);
expect(dupFilterIssue(row)?.path).toEqual(['globalFilters', 1, 'name']);
});

it('CONTROL: with no dateRange declared, an entry keyed dateRange is not listed — and the parse accepts it', () => {
for (const filters of [
[{ name: 'dateRange', field: 'close_date', type: 'date' }],
[{ field: 'dateRange', type: 'date' }],
]) {
const row = dateRangeBoard(filters, false);
expect(storedTodos('dashboard', row).todos).toEqual([]);
expect(DashboardSchema.safeParse(row).success).toBe(true);
}
});

it('CONTROL: beside a declared dateRange, a named entry over the field dateRange is not listed — and parses', () => {
const row = dateRangeBoard([{ name: 'when', field: 'dateRange', type: 'date' }]);
expect(storedTodos('dashboard', row).todos).toEqual([]);
expect(DashboardSchema.safeParse(row).success).toBe(true);
});
});

// ── #29554 (the #29366 refusal) ───────────────────────────────────────────

const patchRow = (fieldPatches: Record<string, unknown>) => ({ extend: 'crm_account', fieldPatches });
Expand Down
36 changes: 34 additions & 2 deletions packages/spec/src/conversions/refused-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
* ({@link externalBindingDatasourceRefusal}), and the one-name-per-action
* judge of the object refinement ({@link duplicateActionNames}), and the
* one-key-per-global-filter judge of the dashboard refinement
* ({@link duplicateGlobalFilterNames}), and the `fieldPatches` key
* ({@link duplicateGlobalFilterNames}), and its reserved-`dateRange` judge
* ({@link reservedDateRangeGlobalFilters}), and the `fieldPatches` key
* grammar of `ObjectExtensionSchema`'s own parse
* ({@link refusedFieldPatchKeys}). A site is listed here exactly
* when that door refuses it. One door lives outside this package: the
Expand Down Expand Up @@ -73,7 +74,11 @@ import { duplicateActionNames } from '../data/duplicate-action-names.js';
import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js';
import { ObjectExtensionSchema } from '../data/object.zod.js';
import { ComponentPropsMap } from '../ui/component.zod.js';
import { duplicateGlobalFilterNames } from '../ui/duplicate-global-filter-names.js';
import {
duplicateGlobalFilterNames,
RESERVED_DATE_RANGE_FILTER_NAME,
reservedDateRangeGlobalFilters,
} from '../ui/duplicate-global-filter-names.js';
import { ViewDataSchema } from '../ui/view.zod.js';
import { refusedHasArguments } from './cel-has-argument.js';
import type { ConversionTodoDetail } from './types.js';
Expand Down Expand Up @@ -587,6 +592,32 @@ const dashboardDuplicateGlobalFilterName: RefusedStoredShape = {
},
};

// ── #29541: a dashboard whose globalFilters entry reuses the reserved key `dateRange` (the #29213 refusal) ──

const dashboardReservedDateRangeGlobalFilter: RefusedStoredShape = {
id: 'ui-dashboard-global-filter-reserved-date-range-refused',
toMajor: 18,
surface: 'dashboard.globalFilters — an entry keyed `dateRange` (its `name`, or its `field` when it has no '
+ '`name`) on a dashboard that also declares the built-in `dateRange`',
detect(stack, report) {
mapCollection(stack, 'dashboards', (dashboard, path) => {
for (const entry of reservedDateRangeGlobalFilters(dashboard.dateRange, dashboard.globalFilters)) {
report({
// The site the dashboard parse refuses: the entry's `name`, or its `field`.
path: `${path}.globalFilters[${entry.index}].${entry.key}`,
from: JSON.stringify({ name: RESERVED_DATE_RANGE_FILTER_NAME, index: entry.index }),
reason: 'No lossless rewrite: whether the entry was meant to be the date range or a filter of its own, '
+ 'and which widget `filterBindings.dateRange` keys mean it, only the author knows. Left as stored, '
+ 'the dashboard still loads with `_diagnostics` reading `valid: false`, and its next save, draft or '
+ 'publish, is refused with 422 INVALID_METADATA. '
+ entry.message,
});
}
return dashboard;
});
},
};

// ── #29554: an object extension whose `fieldPatches` key cannot be a field name (the #29366 refusal) ──

/**
Expand Down Expand Up @@ -647,6 +678,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
visibilityHasArgument,
objectDuplicateActionName,
dashboardDuplicateGlobalFilterName,
dashboardReservedDateRangeGlobalFilter,
objectExtensionFieldPatchKeyGrammar,
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const entry: SemanticMigration = {
+ 'filter it meant, and that `os validate` reports no `dashboard-filter-binding-unknown` finding. A '
+ 'stored `sys_metadata` dashboard that still repeats a key still loads, with `_diagnostics` reading '
+ '`valid: false` and carrying this refusal, and its next save (draft or publish) is refused; nothing '
+ 'rewrites it. Measured before the change: no authored dashboard in this repository\'s examples, '
+ 'platform objects, docs or skills repeats a filter key.',
+ 'rewrites it. `os migrate meta --stored` lists each such stored dashboard under this entry\'s id, at '
+ 'the second declaration\'s `name` (or `field`). Measured before the change: no authored dashboard in '
+ 'this repository\'s examples, platform objects, docs or skills repeats a filter key.',
};
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export const entry: SemanticMigration = {
+ '`filterBindings` key names the filter it meant, and that `os validate` reports no '
+ '`dashboard-filter-binding-unknown` finding. A stored `sys_metadata` dashboard that still carries '
+ 'the pair still loads, with `_diagnostics` reading `valid: false` and carrying this refusal, and its '
+ 'next save (draft or publish) is refused; nothing rewrites it. Measured before the change: no '
+ 'authored dashboard in this repository\'s examples, apps, packages or ui tree keys a global filter '
+ '`dateRange`.',
+ 'next save (draft or publish) is refused; nothing rewrites it. `os migrate meta --stored` lists each '
+ 'such stored dashboard under this entry\'s id, at the entry\'s `name` (or `field`). Measured before the '
+ 'change: no authored dashboard in this repository\'s examples, apps, packages or ui tree keys a '
+ 'global filter `dateRange`.',
};
Loading