Skip to content

Commit a5fcd06

Browse files
fix(spec): os migrate meta --stored lists a stored dashboard whose global filter reuses the reserved dateRange key (#29720)
domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBY61hHdLdFdzwXbxaNtv58y)_ Fixes #29541 Supersedes #29586 (same commits, resumed on a new branch, merged with main after #29589; #29586 is closed) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`os migrate meta --stored` 现在会列出「看板声明了内置 `dateRange`,又有一个全局筛选器也叫 `dateRange`」的已存数据行;两条看板筛选器迁移条目的验收句都补上了「存量预览会列出这种行」。 - 为什么改:#29213 已让每道门拒收这种看板,但升级前的存量预览不报它,运维升级前看不到哪些看板下次保存会被拒。 - 风险与回滚:只读检测,任何入口的接受/拒收结果都不变,不改写任何行;回滚即 revert 本 PR。 - 先看:`packages/spec/src/conversions/refused-shapes.ts` 的 `dashboardReservedDateRangeGlobalFilter`(调用拒收门同一个判定函数,与 #29589 的 `objectExtensionFieldPatchKeyGrammar` 并存),再看 `refused-shapes.test.ts` 末尾 #29541 五条。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/conversions/refused-shapes.test.ts`,预期 92 个测试全过(含 #29554 的四条),其中 #29541 五条。 - 需要你做的:无,席位落地。 ## What changes - **Stored listing.** `REFUSED_STORED_SHAPES` gains `dashboardReservedDateRangeGlobalFilter` (id `ui-dashboard-global-filter-reserved-date-range-refused`, the #29213 D3 entry of step 18), registered right after `dashboardDuplicateGlobalFilterName`. It walks `stack.dashboards[]` and calls `reservedDateRangeGlobalFilters(dashboard.dateRange, dashboard.globalFilters)` from `ui/duplicate-global-filter-names.ts`, the predicate `DashboardSchema`'s refinement reads; no logic is copied. One TODO per entry, at `dashboards[i].globalFilters[j].name` (or `.field` when the entry has no `name`), which is the site the parse refuses. `from` carries the reserved key and the index only. The reason leads with the fate (no lossless rewrite, loads with `_diagnostics.valid: false`, next save refused 422) and ends with the predicate's own message. - **Upgrade guide entries.** `acceptanceCriteria` of `18.ui-dashboard-global-filter-reserved-date-range-refused.ts` and of `18.ui-dashboard-global-filter-duplicate-name-refused.ts` (#29023) each gain one sentence: `os migrate meta --stored` lists each such stored dashboard under the entry's id, at the site the parse refuses. - **Docblock.** The `refused-shapes.ts` header's list of borrowed predicates names `reservedDateRangeGlobalFilters`. - **Changeset.** `.changeset/29541-reserved-date-range-stored-detector.md`, `@objectstack/spec` patch. ## Why The refusal's D3 entry already promised that a stored row with the pair loads as invalid and is refused on its next save, but the stored preview read such a row as already on protocol. An operator previewing the upgrade saw nothing. ## Tests `refused-shapes.test.ts` gains five cases under `#29541`, and the roster pin names the new id: - the card shape (a declared `dateRange` beside an entry named `dateRange`): one TODO at `dashboards[0].globalFilters[0].name` with the reserved word, the built-in date range and the predicate's message; the row is not rewritten, and the parse still refuses the same site; - an entry with no `name` and `field: 'dateRange'`: listed once at `.field`, as the parse locates it; - two entries both named `dateRange`: listed under each refusal (one duplicate-key TODO and two reserved-key TODOs), exactly the three `custom` issues the parse raises; - CONTROL: with no `dateRange` declared, an entry keyed `dateRange` (by name or by field) is not listed and the parse accepts it; - CONTROL: beside a declared `dateRange`, a named entry whose `field` is `dateRange` is not listed and parses. Ablation (fix committed first; `scripts/ablation-replace.mjs --delete` on the registry line ` dashboardReservedDateRangeGlobalFilter,`): Tests 4 failed | 84 passed (88). The roster pin and the three listing cases went red, both controls stayed green. Restored: blob after restore equals HEAD, `git diff HEAD` empty. The spec suite imports `./refused-shapes.js` from source, so no dist was involved. ## Acceptance notes - Premise measured on `origin/main` @ `ff827bcf` before the change: `applyConversionsToStoredItem('dashboard', row, { onTodo })` for a declared `dateRange` beside an entry named `dateRange` gave 0 TODOs while the parse refused `globalFilters.0.name`. The control, two filters named `r`, gave 1 (`ui-dashboard-global-filter-duplicate-name-refused`). - `duplicate-global-filter-names.ts` and `dashboard.zod.ts` are untouched. No ledger moved. The detector adds no new export, so `api-surface` did not change. - No save-door pin is added in `packages/metadata-protocol`. The sibling's PR added one; this order's file surface names none, and #29370's spec test covers the refusal in each door's form. - Serial: #29554's PR #29589 (`57958aab`) edited the same `refused-shapes.ts` and merged first. The merge of `origin/main` @ `3d5933a9` conflicted in `refused-shapes.ts` and its test and was resolved additively: #29589's `refusedFieldPatchKeys` / `objectExtensionFieldPatchKeyGrammar` (and its `ObjectExtensionSchema` import) stay, this card's dashboard detector stays, the header docblock names both borrowed predicates, and `REFUSED_STORED_SHAPES` lists `dashboardReservedDateRangeGlobalFilter` then `objectExtensionFieldPatchKeyGrammar`. The roster pin counts both new ids. No generated path moved on either side, so `os-regen-merge.sh` had nothing to take. ## Verification Head `76d25fa9`: the commits of #29586 (through `9e7c4c35`) plus one merge of `origin/main` at `3d5933a9`. Every reading below was taken on that head. The readings of the old head are in #29586. <details><summary>Package readings</summary> - `pnpm --filter @objectstack/spec test` + `typecheck` under `scripts/pm/os-verify-lock.sh`: Test Files 802 passed | 1 skipped (803), Tests 23789 passed | 1 skipped | 1 todo (23791); typecheck exit 0 (migration registry current: 535 semantic). Lock verdict command-exit 0. - `refused-shapes.test.ts` alone: 92 passed (92), the 88 of #29586 plus #29554's four. - `pnpm build` (whole repo, under the lock): 117 of 117 tasks, exit 0. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 80 families from 5 paths (144 changed lines vs merge base `3d5933a9`). Each was run on this head. `--ran`: 80 derived, 80 run, 0 NOT-MEASURED, 0 UNRUN. - `check-changeset-no-major.mjs --base origin/main` and `check-adr-0087-registration.mjs --base origin/main` exit 0 on the merged head with the unchanged declarations (`Clause-②: no`, `Metadata-protocol: none`, `@objectstack/spec` patch). - Eleven families first answered exit 3 (no `dist/`); after the full build each answered exit 0. - CI families are read from this PR's check-runs. Dogfood is not owed: no `!` in the title and no `(narrowing)` arm. The at-tier contract review on this head goes to triage, per the claim. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzg4LNpBsJKihJRTLqD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f328cd commit a5fcd06

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
'object-extension-field-patch-key-grammar-refused',
7778
]);
7879
});
@@ -855,6 +856,89 @@ describe('#29212 — a stored dashboard that declares one global filter key twic
855856
});
856857
});
857858

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

860944
const patchRow = (fieldPatches: Record<string, unknown>) => ({ extend: 'crm_account', fieldPatches });

‎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}), and the `fieldPatches` key
48+
* ({@link duplicateGlobalFilterNames}), and its reserved-`dateRange` judge
49+
* ({@link reservedDateRangeGlobalFilters}), and the `fieldPatches` key
4950
* grammar of `ObjectExtensionSchema`'s own parse
5051
* ({@link refusedFieldPatchKeys}). A site is listed here exactly
5152
* when that door refuses it. One door lives outside this package: the
@@ -73,7 +74,11 @@ import { duplicateActionNames } from '../data/duplicate-action-names.js';
7374
import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js';
7475
import { ObjectExtensionSchema } from '../data/object.zod.js';
7576
import { ComponentPropsMap } from '../ui/component.zod.js';
76-
import { duplicateGlobalFilterNames } from '../ui/duplicate-global-filter-names.js';
77+
import {
78+
duplicateGlobalFilterNames,
79+
RESERVED_DATE_RANGE_FILTER_NAME,
80+
reservedDateRangeGlobalFilters,
81+
} from '../ui/duplicate-global-filter-names.js';
7782
import { ViewDataSchema } from '../ui/view.zod.js';
7883
import { refusedHasArguments } from './cel-has-argument.js';
7984
import type { ConversionTodoDetail } from './types.js';
@@ -587,6 +592,32 @@ const dashboardDuplicateGlobalFilterName: RefusedStoredShape = {
587592
},
588593
};
589594

595+
// ── #29541: a dashboard whose globalFilters entry reuses the reserved key `dateRange` (the #29213 refusal) ──
596+
597+
const dashboardReservedDateRangeGlobalFilter: RefusedStoredShape = {
598+
id: 'ui-dashboard-global-filter-reserved-date-range-refused',
599+
toMajor: 18,
600+
surface: 'dashboard.globalFilters — an entry keyed `dateRange` (its `name`, or its `field` when it has no '
601+
+ '`name`) on a dashboard that also declares the built-in `dateRange`',
602+
detect(stack, report) {
603+
mapCollection(stack, 'dashboards', (dashboard, path) => {
604+
for (const entry of reservedDateRangeGlobalFilters(dashboard.dateRange, dashboard.globalFilters)) {
605+
report({
606+
// The site the dashboard parse refuses: the entry's `name`, or its `field`.
607+
path: `${path}.globalFilters[${entry.index}].${entry.key}`,
608+
from: JSON.stringify({ name: RESERVED_DATE_RANGE_FILTER_NAME, index: entry.index }),
609+
reason: 'No lossless rewrite: whether the entry was meant to be the date range or a filter of its own, '
610+
+ 'and which widget `filterBindings.dateRange` keys mean it, only the author knows. Left as stored, '
611+
+ 'the dashboard still loads with `_diagnostics` reading `valid: false`, and its next save, draft or '
612+
+ 'publish, is refused with 422 INVALID_METADATA. '
613+
+ entry.message,
614+
});
615+
}
616+
return dashboard;
617+
});
618+
},
619+
};
620+
590621
// ── #29554: an object extension whose `fieldPatches` key cannot be a field name (the #29366 refusal) ──
591622

592623
/**
@@ -647,6 +678,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
647678
visibilityHasArgument,
648679
objectDuplicateActionName,
649680
dashboardDuplicateGlobalFilterName,
681+
dashboardReservedDateRangeGlobalFilter,
650682
objectExtensionFieldPatchKeyGrammar,
651683
];
652684

‎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)