diff --git a/.changeset/24129-blueprint-view-filters-sort.md b/.changeset/24129-blueprint-view-filters-sort.md new file mode 100644 index 00000000000..a9338c153d8 --- /dev/null +++ b/.changeset/24129-blueprint-view-filters-sort.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a solution blueprint view declares `filters` and `sort`, so a view named by a condition can carry that condition + +Clause-②: yes (widening) + +`BlueprintViewSchema` and the strict structured-output mirror `SolutionBlueprintStrictSchema.views[]` gain two keys. Before this, a blueprint could say "overdue only" in a view's name and nowhere else, and the built view showed every record. + +- **`filters`** — which records the view shows, as list-view filter rules `[{ field, operator, value }]`, ANDed. + - Lenient (`BlueprintViewSchema`): optional, and the array element IS `ViewFilterRuleSchema` (the rule `ListView.filter` declares), so it takes the same operators, folds the same legacy spellings, and refuses the same operator/value-shape mismatches (`in` without an array, `between` without `[min, max]`, `icontains` without a non-empty string). + - Strict (`SolutionBlueprintStrictSchema`): required and nullable (null = a view over every record). Each rule carries the same three keys, every one present: `operator` is the canonical vocabulary only (`VIEW_FILTER_OPERATORS`, no legacy shorthand such as `eq`), and `value` is nullable (null for the unary operators). The operator/value-shape coupling is not in the strict mirror's JSON Schema; the lenient parse refuses it. +- **`sort`** — the default record order, `[{ field, order }]` with `order` `asc` or `desc`, the first entry sorting first. Lenient: optional, the element is the shared `SortItemSchema` (the `{ field, order }` pair `ListView.sort` declares; array only, a `"field desc"` string is refused). Strict: required and nullable, `{ field, order }` with both keys present. +- Both describes tell the model that a view whose name or label states a condition ("overdue", "my open") must carry the matching filter, with worked rules using the platform tokens `{today}` and `{current_user_id}`. +- Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `filters` and `sort` on each view (null when it has none), as with every other key in that mirror. diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index f3c2a34bba9..4c6c520cbcc 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -271,6 +271,27 @@ const result = BlueprintAppSchema.parse(data); | **type** | `Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'>` | optional (default: `"list"`) | View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list". | | **columns** | `string[]` | optional | Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. | | **groupBy** | `string` | optional | REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows). | +| **filters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Which records this view shows: the list-view filter rules [`{ field, operator, value }`] the built view carries, ANDed. REQUIRED whenever the view's name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [`{ field: "due_date", operator: "less_than", value: "{today}" }`, `{ field: "status", operator: "not_equals", value: "completed" }`]; my open → [`{ field: "owner", operator: "equals", value: "{current_user_id}" }`, `{ field: "status", operator: "equals", value: "open" }`]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record. | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default record order: [`{ field, order }`] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [`{ field: "due_date", order: "asc" }`]). Omit for the platform default order. | + +### Nested Shape: `BlueprintView.filters[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +### Nested Shape: `BlueprintView.sort[number]` + +Sort field and direction pair + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to sort by | +| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | --- @@ -324,6 +345,8 @@ const result = BlueprintAppSchema.parse(data); | **type** | `Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'>` | optional (default: `"list"`) | View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list". | | **columns** | `string[]` | optional | Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. | | **groupBy** | `string` | optional | REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows). | +| **filters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Which records this view shows: the list-view filter rules [`{ field, operator, value }`] the built view carries, ANDed. REQUIRED whenever the view's name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [`{ field: "due_date", operator: "less_than", value: "{today}" }`, `{ field: "status", operator: "not_equals", value: "completed" }`]; my open → [`{ field: "owner", operator: "equals", value: "{current_user_id}" }`, `{ field: "status", operator: "equals", value: "open" }`]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record. | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default record order: [`{ field, order }`] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [`{ field: "due_date", order: "asc" }`]). Omit for the platform default order. | ### Nested Shape: `SolutionBlueprint.dashboards[number]` @@ -387,6 +410,8 @@ const result = BlueprintAppSchema.parse(data); | **type** | `Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'> \| null` | ✅ | View kind, or null for list. "gallery" = visual card/cover browse (画廊/相册/卡片墙/封面/海报, or an object with an image/avatar/file field); "gantt" = timeline/schedule (甘特图/时间线/排期, object with BOTH a start and an end date field); "kanban" = board grouped by a status/select field; "calendar" = single-date schedule; "form" = record editor. | | **columns** | `string[] \| null` | ✅ | Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. | | **groupBy** | `string \| null` | ✅ | REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery. | +| **filters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value: string \| number \| boolean \| (string \| number)[] \| null }[] \| null` | ✅ | Which records this view shows: list-view filter rules [`{field, operator, value}`], ANDed, or null for a view over EVERY record. REQUIRED whenever the view's name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [`{field:"due_date",operator:"less_than",value:"{today}"}`,`{field:"status",operator:"not_equals",value:"completed"}`]; my open → [`{field:"owner",operator:"equals",value:"{current_user_id}"}`,`{field:"status",operator:"equals",value:"open"}`]. Null under such a name ships a view that shows every record while its name promises a subset. | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[] \| null` | ✅ | Default record order: [`{field, order}`] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [`{field:"due_date",order:"asc"}`]), or null for the platform default order. | ### Nested Shape: `SolutionBlueprintStrict.dashboards[number]` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md index a9bbc5c8067..7bd28612ec5 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `ai/` | 78 | +| `ai/` | 80 | diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index aaf40e662e8..a6eb58384e0 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -76,10 +76,12 @@ "ai/BlueprintSummaryOperations:object", "ai/BlueprintSummaryOperations:relationshipField", "ai/BlueprintView:columns", + "ai/BlueprintView:filters", "ai/BlueprintView:groupBy", "ai/BlueprintView:label", "ai/BlueprintView:name", "ai/BlueprintView:object", + "ai/BlueprintView:sort", "ai/BlueprintView:type", "ai/BlueprintWidgetCondition:field", "ai/BlueprintWidgetCondition:op", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 7ff317ac1cb..8c68fe528a2 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 234, - "droppedRefinementSites": 764, + "publishedSchemasWithDroppedRefinements": 235, + "droppedRefinementSites": 766, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -29,6 +29,11 @@ "filter.lazy" ] }, + "ai/BlueprintView": { + "sites": [ + "filters.element" + ] + }, "ai/Skill": { "sites": [ "triggerConditions.element" @@ -41,7 +46,8 @@ }, "ai/SolutionBlueprint": { "sites": [ - "objects.element.fields.element.summaryOperations.filter.lazy" + "objects.element.fields.element.summaryOperations.filter.lazy", + "views.element.filters.element" ] }, "ai/StructuredOutputConfig": { diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 1d93ff694e1..d800146804b 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -2,11 +2,14 @@ import { describe, it, expect } from 'vitest'; import { + BlueprintViewSchema, SolutionBlueprintSchema, SolutionBlueprintStrictSchema, defineSolutionBlueprint, type SolutionBlueprint, } from './solution-blueprint.zod'; +import { ViewFilterRuleSchema } from '../ui/view.zod'; +import { SortItemSchema } from '../shared/enums.zod'; const validBlueprint: SolutionBlueprint = { summary: 'A simple project tracker', @@ -282,6 +285,94 @@ describe('SolutionBlueprintSchema', () => { }); expect(result.success).toBe(false); }); + + describe('view filters + sort (a view named by a condition carries the condition)', () => { + const taskObject = { + name: 'task', + fields: [ + { name: 'title', type: 'text' as const }, + { name: 'status', type: 'select' as const }, + { name: 'due_date', type: 'date' as const }, + ], + }; + const overdueRules = [ + { field: 'due_date', operator: 'less_than' as const, value: '{today}' }, + { field: 'status', operator: 'not_equals' as const, value: 'completed' }, + ]; + /** The first issue of a failed lenient parse, as `{ path, code }`. */ + const firstIssue = (input: unknown) => { + const result = SolutionBlueprintSchema.safeParse(input); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + return { path: issue?.path, code: issue?.code }; + }; + + it('carries a view\'s filters and sort through the parse — z.object would strip an undeclared key', () => { + const parsed = SolutionBlueprintSchema.parse({ + summary: 's', + objects: [taskObject], + views: [{ + object: 'task', name: 'overdue_tasks', label: '逾期任务', columns: ['title', 'due_date'], + filters: overdueRules, + sort: [{ field: 'due_date', order: 'asc' }], + }], + }); + expect(parsed.views?.[0].filters).toEqual(overdueRules); + expect(parsed.views?.[0].sort).toEqual([{ field: 'due_date', order: 'asc' }]); + }); + + it('keeps both keys optional — a view over every record omits them', () => { + const parsed = SolutionBlueprintSchema.parse({ + summary: 's', + objects: [taskObject], + views: [{ object: 'task', name: 'all_tasks' }], + }); + expect(parsed.views?.[0].filters).toBeUndefined(); + expect(parsed.views?.[0].sort).toBeUndefined(); + }); + + it('declares no new format: `filters` IS ViewFilterRuleSchema and `sort` IS the shared SortItemSchema', () => { + // Identity, not shape: a copy would agree today and drift on the next + // edit of the ListView rule, which is the dialect this key must not fork. + const shape = (BlueprintViewSchema as any).shape; + expect(shape.filters.unwrap().element).toBe(ViewFilterRuleSchema); + expect(shape.sort.unwrap().element).toBe(SortItemSchema); + }); + + it('refuses an operator outside the view-filter vocabulary, at the rule\'s own path', () => { + expect(firstIssue({ + summary: 's', + objects: [taskObject], + views: [{ object: 'task', name: 'overdue_tasks', filters: [{ field: 'due_date', operator: 'older_than', value: '{today}' }] }], + })).toEqual({ path: ['views', 0, 'filters', 0, 'operator'], code: 'invalid_value' }); + }); + + it('refuses the list-view rule\'s value-shape violations too (the rule is reused, refinement included)', () => { + // The strict mirror cannot express this coupling in JSON Schema; the + // lenient parse `apply_blueprint` runs is where it is refused. + expect(firstIssue({ + summary: 's', + objects: [taskObject], + views: [{ object: 'task', name: 'open_or_new', filters: [{ field: 'status', operator: 'in', value: 'open' }] }], + })).toEqual({ path: ['views', 0, 'filters', 0, 'value'], code: 'custom' }); + }); + + it('refuses a sort order outside asc / desc, at the entry\'s own path', () => { + expect(firstIssue({ + summary: 's', + objects: [taskObject], + views: [{ object: 'task', name: 'by_due', sort: [{ field: 'due_date', order: 'descending' }] }], + })).toEqual({ path: ['views', 0, 'sort', 0, 'order'], code: 'invalid_value' }); + }); + + it('refuses a bare-string sort clause — the array is the only spelling, as on ListView', () => { + expect(firstIssue({ + summary: 's', + objects: [taskObject], + views: [{ object: 'task', name: 'by_due', sort: 'due_date desc' }], + })).toEqual({ path: ['views', 0, 'sort'], code: 'invalid_type' }); + }); + }); }); // The strict mirror is what `generateObject` sends to OpenAI: every property @@ -324,8 +415,8 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { const parsed = SolutionBlueprintStrictSchema.parse({ ...strictBp, views: [ - { object: 'event', name: 'wall', label: null, type: 'gallery', columns: null, groupBy: null }, - { object: 'event', name: 'plan', label: null, type: 'gantt', columns: null, groupBy: null }, + { object: 'event', name: 'wall', label: null, type: 'gallery', columns: null, groupBy: null, filters: null, sort: null }, + { object: 'event', name: 'plan', label: null, type: 'gantt', columns: null, groupBy: null, filters: null, sort: null }, ], }); expect(parsed.views?.map((v) => v.type)).toEqual(['gallery', 'gantt']); @@ -516,6 +607,72 @@ describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => { }; expect(() => SolutionBlueprintStrictSchema.parse(missingKeys)).toThrow(); }); + + describe('view filters + sort (the model states a view\'s filter or says null)', () => { + const strictView = { + object: 'task', name: 'overdue_tasks', label: '逾期任务', type: 'list', columns: ['title', 'due_date'], groupBy: null, + }; + /** The first issue of a failed strict parse, as `{ path, code }`. */ + const firstIssue = (input: unknown) => { + const result = SolutionBlueprintStrictSchema.safeParse(input); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + return { path: issue?.path, code: issue?.code }; + }; + + it('carries a view\'s filters and sort — the filter its name promises is expressible in the structured output', () => { + const filters = [ + { field: 'due_date', operator: 'less_than', value: '{today}' }, + { field: 'status', operator: 'not_equals', value: 'completed' }, + { field: 'status', operator: 'in', value: ['open', 'blocked'] }, + { field: 'archived_at', operator: 'is_null', value: null }, + ]; + const parsed = SolutionBlueprintStrictSchema.parse({ + ...strictBp, + views: [{ ...strictView, filters, sort: [{ field: 'due_date', order: 'asc' }] }], + }); + expect(parsed.views?.[0].filters).toEqual(filters); + expect(parsed.views?.[0].sort).toEqual([{ field: 'due_date', order: 'asc' }]); + }); + + it('accepts null for both keys — a view over every record, in the platform default order', () => { + const parsed = SolutionBlueprintStrictSchema.parse({ + ...strictBp, + views: [{ ...strictView, name: 'all_tasks', filters: null, sort: null }], + }); + expect(parsed.views?.[0].filters).toBeNull(); + expect(parsed.views?.[0].sort).toBeNull(); + }); + + it('requires both keys to be present (OpenAI strict: every key in `required`)', () => { + expect(firstIssue({ ...strictBp, views: [{ ...strictView, sort: null }] })) + .toEqual({ path: ['views', 0, 'filters'], code: 'invalid_type' }); + expect(firstIssue({ ...strictBp, views: [{ ...strictView, filters: null }] })) + .toEqual({ path: ['views', 0, 'sort'], code: 'invalid_type' }); + }); + + it('requires a rule\'s `value` key to be present — null is the unary operators\' spelling', () => { + expect(firstIssue({ + ...strictBp, + views: [{ ...strictView, filters: [{ field: 'archived_at', operator: 'is_null' }], sort: null }], + // `invalid_union`: an absent key fails every arm of the nullable value union. + })).toEqual({ path: ['views', 0, 'filters', 0, 'value'], code: 'invalid_union' }); + }); + + it('takes the canonical operator vocabulary only — a legacy shorthand is not a word the model may emit', () => { + expect(firstIssue({ + ...strictBp, + views: [{ ...strictView, filters: [{ field: 'status', operator: 'eq', value: 'open' }], sort: null }], + })).toEqual({ path: ['views', 0, 'filters', 0, 'operator'], code: 'invalid_value' }); + }); + + it('refuses a sort order outside asc / desc', () => { + expect(firstIssue({ + ...strictBp, + views: [{ ...strictView, filters: null, sort: [{ field: 'due_date', order: 'descending' }] }], + })).toEqual({ path: ['views', 0, 'sort', 0, 'order'], code: 'invalid_value' }); + }); + }); }); // --------------------------------------------------------------------------- @@ -578,6 +735,47 @@ describe('strict mirror ↔ lenient schema — key parity', () => { expect(strictNavKeys).toEqual(lenientNavKeys); }); + const lenientViewShape = () => (SolutionBlueprintSchema as any).shape.views.unwrap().element.shape; + const strictViewShape = () => (SolutionBlueprintStrictSchema as any).shape.views.unwrap().element.shape; + + it('the VIEW schemas carry exactly the same keys', () => { + // Widened with `filters` / `sort`, the fourth level of the same gap + // (fields → objects → nav items → views). The view is where "WHICH records + // does this view show" lives; with the key on the lenient side only, the + // design step could never state the filter its view's name promises, and the + // applier would accept a filter no proposal can contain. There are NO + // deliberate view-level exclusions (measured: both sides carry + // object/name/label/type/columns/groupBy/filters/sort); if one ever becomes + // deliberate, list it explicitly here with its reason. + expect(Object.keys(strictViewShape()).sort()).toEqual(Object.keys(lenientViewShape()).sort()); + expect(Object.keys(lenientViewShape())).toEqual(expect.arrayContaining(['filters', 'sort'])); + }); + + it('the view FILTER RULE and SORT ENTRY shapes carry exactly the same keys', () => { + // One level down, for the same reason the field pin sits under the object + // pin: the strict mirror re-declares these two shapes inline (strict mode + // cannot take the lenient rule's preprocess / refinement), so a key added to + // `ViewFilterRuleSchema` or `SortItemSchema` would otherwise be one the + // model can never emit. No deliberate exclusions at either level. + const keysOf = (arraySchema: any) => Object.keys(arraySchema.unwrap().element.shape).sort(); + expect(keysOf(strictViewShape().filters)).toEqual(keysOf(lenientViewShape().filters)); + expect(keysOf(strictViewShape().sort)).toEqual(keysOf(lenientViewShape().sort)); + }); + + it('the strict filter rule offers exactly the view-filter operator vocabulary the lenient rule accepts', () => { + // Value parity for the one enum the VALUE-parity walker below cannot see + // (it indexes string patterns): the lenient operator is an enum behind a + // preprocess, the strict one the same enum without it. A canonical operator + // missing here is one the model cannot choose; an extra one, a rule the + // applier refuses. + const strictOperators = strictViewShape().filters.unwrap().element.shape.operator.options; + const lenientOperators = lenientViewShape().filters.unwrap().element.shape.operator.def.out.options; + expect([...strictOperators].sort()).toEqual([...lenientOperators].sort()); + const strictOrders = strictViewShape().sort.unwrap().element.shape.order.options; + const lenientOrders = lenientViewShape().sort.unwrap().element.shape.order.options; + expect([...strictOrders].sort()).toEqual([...lenientOrders].sort()); + }); + it('carries `viewName`, so a menu entry can say WHICH view it opens', () => { // Guards the specific hole (cloud#2150): this surface can always author a // kanban view AND a nav entry, so without a way to bind the two the model's diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 6ec5a0076a6..29f9c53754b 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -4,6 +4,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { FieldType } from '../data/field.zod'; import { FilterConditionSchema } from '../data/filter.zod'; +import { SortDirectionEnum, SortItemSchema } from '../shared/enums.zod'; +import { VIEW_FILTER_OPERATORS, ViewFilterRuleSchema } from '../ui/view.zod'; /** * Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) @@ -109,7 +111,21 @@ export const BlueprintObjectSchema = lazySchema(() => z.object({ })); export type BlueprintObject = z.input; -/** A proposed list/form/kanban/calendar/gallery/gantt view over an object. */ +/** + * A proposed list/form/kanban/calendar/gallery/gantt view over an object. + * + * `filters` and `sort` are what let a view keep the promise its name makes + * (ruling A on cloud#2932). Without them a plan could say 「overdue only」 in a + * view's name and nowhere else: the applier shipped an unfiltered grid under + * that name, and build verification passed it, because nothing on this shape + * could carry the condition (cloud#2922 — 12 rows shown where the plan + * promised 3). Neither key invents a format: `filters` IS + * {@link ViewFilterRuleSchema}, the rule `ListView.filter` declares, and `sort` + * is the shared {@link SortItemSchema} whose `{ field, order }` keys and + * `asc` / `desc` vocabulary the `ListView.sort` entry declares (that entry is an + * inline shape, not an export). The strict mirror below carries both keys in + * the model-facing shape; the key-parity tests fail if either side drops one. + */ export const BlueprintViewSchema = lazySchema(() => z.object({ object: z.string().regex(SNAKE_CASE).describe('Object this view displays (snake_case)'), name: z.string().regex(SNAKE_CASE).describe('View machine name (snake_case)'), @@ -120,6 +136,10 @@ export const BlueprintViewSchema = lazySchema(() => z.object({ .describe('Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'), groupBy: z.string().regex(SNAKE_CASE).optional() .describe('REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows).'), + filters: z.array(ViewFilterRuleSchema).optional() + .describe('Which records this view shows: the list-view filter rules [{ field, operator, value }] the built view carries, ANDed. REQUIRED whenever the view\'s name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [{ field: "due_date", operator: "less_than", value: "{today}" }, { field: "status", operator: "not_equals", value: "completed" }]; my open → [{ field: "owner", operator: "equals", value: "{current_user_id}" }, { field: "status", operator: "equals", value: "open" }]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record.'), + sort: z.array(SortItemSchema).optional() + .describe('Default record order: [{ field, order }] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [{ field: "due_date", order: "asc" }]). Omit for the platform default order.'), })); export type BlueprintView = z.input; /** Post-parse shape of {@link BlueprintView} — defaults applied, transforms run (ADR-0122). */ @@ -338,6 +358,35 @@ const StrictObject = z.object({ nameField: strictIdentOrNull('The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079), or null to let the platform auto-pick a text field. Set it to the object\'s text label field (e.g. "product_name") — snake_case. For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "{order_no} · {customer}"). Declaring it is strongly preferred over null.'), }); +// One list-view filter rule, strict-shaped. The lenient side reuses +// `ViewFilterRuleSchema` itself; this mirror carries its SAME three keys and +// differs only where strict structured output cannot express the original: +// - `value` is nullable and present, not optional — strict mode needs every +// key in `required`, and null is the unary operators' spelling. The blueprint +// tools strip it to an absent `value`, which is what the lenient rule reads. +// - `operator` is the plain canonical enum. The lenient `z.preprocess` that +// folds legacy spellings (`eq`, `gt`, …) is a parse-time transform with no +// JSON-Schema form, and a new producer emits the canonical vocabulary anyway. +// - the operator ↔ value-shape refinement (`in` takes an array, `between` a +// pair, `icontains` a non-empty string) has no JSON-Schema form either; the +// lenient rule `apply_blueprint` parses against refuses those, by name. +// The key-parity test pins this key set to `ViewFilterRuleSchema`'s. +const StrictViewFilterRule = z.object({ + field: z.string().describe('Field on the view\'s object to filter by (snake_case, e.g. "due_date", "status", "owner")'), + operator: z.enum(VIEW_FILTER_OPERATORS).describe('Filter operator — the canonical vocabulary only'), + value: z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))]).nullable() + .describe('Comparison value, or null for the unary operators (is_empty / is_not_empty / is_null / is_not_null), which take their direction from the operator name. "in" / "not_in" take an ARRAY (any length); "between" takes exactly [min, max]; every other operator takes a scalar. For a select field use its option VALUE, never its label. Today\'s date is the platform token "{today}" and the signed-in user is "{current_user_id}".'), +}); + +// One sort entry, strict-shaped: the lenient side reuses the shared +// `SortItemSchema`; both of its keys are already required, so the mirror needs +// no nullable — it is inlined only because this mirror declares every shape it +// sends, as the other nested shapes here do. +const StrictSortItem = z.object({ + field: z.string().describe('Field on the view\'s object to sort by (snake_case)'), + order: SortDirectionEnum.describe('Sort direction: "asc" or "desc"'), +}); + const StrictView = z.object({ object: strictIdent('Object this view displays (snake_case)'), name: strictIdent('View machine name (snake_case)'), @@ -345,6 +394,14 @@ const StrictView = z.object({ type: z.enum(['list', 'form', 'kanban', 'calendar', 'gallery', 'gantt']).nullable().describe('View kind, or null for list. "gallery" = visual card/cover browse (画廊/相册/卡片墙/封面/海报, or an object with an image/avatar/file field); "gantt" = timeline/schedule (甘特图/时间线/排期, object with BOTH a start and an end date field); "kanban" = board grouped by a status/select field; "calendar" = single-date schedule; "form" = record editor.'), columns: z.array(z.string().regex(SNAKE_CASE)).nullable().describe('Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column.'), groupBy: strictIdentOrNull('REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery.'), + // ⛔ Must stay in lockstep with the lenient `BlueprintViewSchema.filters`. + // THIS describe is the one the design model reads, and strict mode makes the + // key required, so the model decides on every view: state the filter its + // name promises, or say null. The view key-parity pin fails if it goes. + filters: z.array(StrictViewFilterRule).nullable() + .describe('Which records this view shows: list-view filter rules [{field, operator, value}], ANDed, or null for a view over EVERY record. REQUIRED whenever the view\'s name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [{field:"due_date",operator:"less_than",value:"{today}"},{field:"status",operator:"not_equals",value:"completed"}]; my open → [{field:"owner",operator:"equals",value:"{current_user_id}"},{field:"status",operator:"equals",value:"open"}]. Null under such a name ships a view that shows every record while its name promises a subset.'), + sort: z.array(StrictSortItem).nullable() + .describe('Default record order: [{field, order}] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [{field:"due_date",order:"asc"}]), or null for the platform default order.'), }); const StrictDashboard = z.object({ diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 0b0123455bc..c29de5f76a2 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -24,11 +24,18 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/shared/conditional-formatting.zod.ts` — The **single declaration** of a conditional formatting rule — +- `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol +- `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — Exports: SystemIdentifierSchema, SnakeCaseIdentifierSchema, MetadataItemNameSchema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities - `node_modules/@objectstack/spec/src/shared/value-domain.zod.ts` — Standard value domains: one closed vocabulary and one membership predicate for settings and fields. +- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas +- `node_modules/@objectstack/spec/src/ui/chart.zod.ts` — Unified Chart Type Taxonomy +- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — Display-label and ARIA-label primitives shared by every `ui/` shape. +- `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol +- `node_modules/@objectstack/spec/src/ui/view.zod.ts` — View protocol schemas — the `view` metadata type and its three persisted body spellings. ## How to read these