diff --git a/.changeset/30095-object-list-view-sort-door.md b/.changeset/30095-object-list-view-sort-door.md new file mode 100644 index 00000000000..59fab61a915 --- /dev/null +++ b/.changeset/30095-object-list-view-sort-door.md @@ -0,0 +1,19 @@ +--- +'@objectstack/lint': patch +--- + +fix(lint)!: the object publish door refuses an object whose own list view sorts by a field the list door refuses (`sortable: false`, a `formula`, or a name the object lacks) (#30095) + +Clause-②: no (narrowing) — an object write the door accepted before is refused: an object whose built-in `listViews.*.sort` names a field the list door answers `400 INVALID_SORT` now saves `422 INVALID_METADATA`. +Metadata-protocol: none +Skills-docs: none — `skills/**` names no `sortable` field key, and `content/docs/deployment/validating-metadata.mdx` lists the doors by rule family, not by reference-integrity member, so it names no object crossing to correct; the prescription ships in the refusal text. + + + +**BREAKING** accept-set narrowing at the object publish door (`PUT /api/v1/meta/object/NAME`; Studio, REST and MCP authors). + +- **Before.** `validateSortableFields` judged a list view's `sort` on the CLI, at the `view` door and at the `page` door, but not on an `object` write. An object whose built-in list view sorted by a field declaring `sortable: false`, by a virtual `formula`, or by a name the object does not have saved green, and that list view answered `400 INVALID_SORT` on its first load and every load after it. +- **Now.** The member declares `object`, so the object door refuses that save `422 INVALID_METADATA` with `sort-field-unsortable` or `sort-field-unknown` at `error`, at the sort key's path (`objects.NAME.listViews.KEY.sort[i]`). Nothing is stored. +- **Not changed.** A stored object already carrying such a sort keeps loading; only its next save is judged, and a stored sibling in violation is never charged to another object's write. A `?mode=draft` save is not judged, as before. + +**What an author does.** Sort the list view by a sortable field, or remove `sortable: false` from the field it names. A refused sort never ordered the list, so there is nothing to keep. diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index e9b59c9b36f..b7f8dd3297c 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -295,7 +295,38 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // missing-collection false-positive channel — the #9313 reason, one write // type over. It is the only member that declares `page`, so a page write // dispatches nothing else from this suite. - { name: 'validateSortableFields', runtimeTypes: ['flow', 'view', 'page'], run: validateSortableFields }, + // + // [#30095] `object` joins, on the #15254 note's terms (below, at + // `validateObjectFieldRefs`) and its own measurement. Its first rung walks + // `objects[].listViews.*.sort`, a list view that lives on the OBJECT, the + // artifact Studio's app builder mints; before this the object door read that + // sort with nothing, so an object whose list view sorts by a field it + // declares `sortable: false` (or a `formula`, or a name it does not have) + // saved green and that list view answered `400 INVALID_SORT` on every load, + // while `os validate` refused the same body. Its two list-view siblings + // crossed in #15495; this member was held back for its own adjudication (the + // note at `validateListViewFieldRefs` says so), and this is it. + // + // Safe for the #9313 reason, unchanged by the new type: it resolves only + // against `stack.objects`, the collection every per-write snapshot carries, + // so there is no missing-collection false-positive channel. Its `views[]` + // and `pages[]` rungs find no such collection on an object snapshot and + // contribute nothing — rungs that are ABSENT, not rungs that read dead. + // + // ⚠️ A REFUSAL narrowing, like #15254's: an object republished with a list + // view sort the list door refuses is now refused (422) rather than stored. + // The gate's differential keeps it honest in the one direction that matters + // (#4463 D4): a STORED sibling already in violation is never charged to this + // write. MEASURED before crossing, on the shipped object corpus, through the + // door's own shape (`runRuntimeAuthoringRules`, type `object`, the other + // objects as context): 127 objects, every `*.object.ts` under `packages/` + // and `examples/`, 45 of them carrying 102 built-in list views with 113 sort + // keys — 0 findings, precision 1.0. The zero is a fact about the corpus AND + // the rule: the same sweep's synthetic probe (a list view sorting by a + // `sortable: false` field) is refused with `sort-field-unsortable` at + // `error`, and the door controls in `runtime-gate.object-writes.test.ts` + // are that probe made permanent. + { name: 'validateSortableFields', runtimeTypes: ['flow', 'view', 'page', 'object'], run: validateSortableFields }, // [#14107] The REST of the same list view's field surface. The two members // above own `sort` and `searchableFields`; every OTHER field-naming position // on a list view — `columns`, filter keys, `grouping`, `rowColor`, @@ -336,6 +367,7 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // here: it measured equally clean (0 findings over the same corpus) but its // crossing is its own adjudication with its own non-vacuity control, and // this card was scoped to the two axes its title names. + // [#30095] That adjudication is made: see the member's own note above. // // [#24563] A member that reads `packageScope` (#27827: so does // `validateObjectReferences` above, for its fix line only). Under a package diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index 05d55b959b5..ca467a022d1 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -91,6 +91,18 @@ const cleanObject = (over: Record = {}) => ({ ...over, }); +/** + * [#30095] A field map carrying each sort verdict: `owner` has no `sortable` + * key, `priority` declares `sortable: true`, `body` declares `sortable: false` + * and `score` is a virtual `formula`. + */ +const SORT_FIELDS = { + owner: { type: 'text' }, + priority: { type: 'number', sortable: true }, + body: { type: 'textarea', sortable: false }, + score: { type: 'formula', expression: 'record.priority * 2' }, +}; + const gateObject = (item: unknown, context: object = { objects: STORED }) => runRuntimeAuthoringRules({ type: 'object', item, context }); @@ -278,16 +290,19 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // `defineStack` gives a related-list name matching no inbound FK: resolved // against `stack.objects`, so no missing-collection channel. The keys are // new (0 shipped declarations, 0 findings); its controls are below. + // [#30095] `validateSortableFields` is the sixth, the adjudication this pin + // used to hold open: an object's built-in `listViews.*.sort`, resolved + // against `stack.objects`. Measured over the shipped object corpus (127 + // objects, 102 list views, 113 sort keys, 0 findings); its controls are + // below. expect(crossed).toEqual([ 'validateSearchableFields', + 'validateSortableFields', 'validateListViewFieldRefs', 'validateObjectFieldRefs', 'validateRelatedListRefs', 'validateChartBindings', ]); - // `validateSortableFields` measured equally clean but was NOT crossed: - // that is its own adjudication, and this pin is where it has to be made. - expect(crossed).not.toContain('validateSortableFields'); // Every crossed member still judges flow snapshots — the #4463 P1 surface // is not narrowed by the member axis existing. const offFlow = REFERENCE_INTEGRITY_RULES @@ -336,6 +351,39 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () expect(f.message).toContain('gone_column'); }); + // [#30095] The sort axis of the same built-in list view. The list door + // refuses each of these `400 INVALID_SORT` on the view's first fetch and + // every fetch after it, so the object door refuses the declaration. + it.each([ + ['a field that declares `sortable: false`', 'body', 'sort-field-unsortable', 'declares `sortable: false`'], + ['a virtual `formula` field', 'score', 'sort-field-unsortable', "a virtual 'formula' field"], + ['a name the object does not have', 'gone_field', 'sort-field-unknown', 'gone_field'], + ])('REFUSES an object whose built-in list view sorts by %s', (_label, field, rule, said) => { + const result = expectSingleRefusal( + cleanObject({ + fields: SORT_FIELDS, + listViews: { inbox: { label: 'Inbox', columns: ['owner'], sort: [{ field, order: 'asc' }] } }, + }), + rule, + ); + const sortErrors = result.errors.filter((e) => e.rule.startsWith('sort-field-')); + expect(sortErrors.map((e) => e.path)).toEqual(['objects.leave_request.listViews.inbox.sort[0]']); + expect(sortErrors[0]!.message).toContain(said); + }); + + it('CONTROL — the same list view sorting by a sortable field publishes, and the member ran', () => { + for (const field of ['owner', 'priority']) { + const result = gateObject( + cleanObject({ + fields: SORT_FIELDS, + listViews: { inbox: { label: 'Inbox', columns: ['owner'], sort: [{ field, order: 'asc' }] } }, + }), + ); + expect(result.errors, `${field}: ${JSON.stringify(result.errors)}`).toEqual([]); + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + } + }); + it('a clean object carrying BOTH declarations publishes — the refusal is about the reference', () => { // The other half of each control above: the same two keys, every name // resolving, and the door adds nothing. Without this, "refuses a dangling diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index bc61ec7bd9d..d078ed1cc54 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -5531,3 +5531,76 @@ describe('runtime authoring gate — a stored page sort naming an unknown field expect(activePages(rows)).toHaveLength(1); }); }); + +// #30095 — an OBJECT whose own built-in list view sorts by a field the list +// door refuses is refused at the object publish door. `validateSortableFields` +// judged `listViews.*.sort` on `os validate` and on the view and page doors, +// but not on an object write, so the object saved green and that list view +// answered `400 INVALID_SORT` on every load (`sortable: false`: #29896). The +// member now declares `object`, on the #15254 terms its two list-view siblings +// crossed on. +describe('runtime authoring gate — an object list view sort the list door refuses (#30095)', () => { + const OBJECT = 'help_ticket'; + /** A list view on the object, sorting by `field`. */ + const ticket = (field: string) => ({ + name: OBJECT, + label: 'Ticket', + sharingModel: 'private', + fields: { + subject: { type: 'text', label: 'Subject' }, + body: { type: 'textarea', label: 'Body', sortable: false }, + priority: { type: 'number', label: 'Priority', sortable: true }, + }, + listViews: { + inbox: { label: 'Inbox', columns: ['subject'], sort: [{ field, order: 'asc' }] }, + }, + }); + const saveTicket = (protocol: any, field: string) => + protocol.saveMetaItem({ type: 'object', name: OBJECT, item: ticket(field) }); + const sortIssues = (err: any) => (err?.issues ?? []).filter((i: any) => String(i.rule).startsWith('sort-field-')); + const activeObjects = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.state === 'active'); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + }); + + it('refuses a list view sorting by a `sortable: false` field, 422 with the rule named, and stores nothing', async () => { + const { protocol, rows } = makeProtocol(); + const err = await saveTicket(protocol, 'body').catch((e: any) => e); + expect(err?.status, JSON.stringify(err?.issues ?? err?.message)).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(sortIssues(err)).toEqual([expect.objectContaining({ + rule: 'sort-field-unsortable', + severity: 'error', + path: `objects.${OBJECT}.listViews.inbox.sort[0]`, + message: expect.stringContaining('declares `sortable: false`'), + })]); + expect(err.rulesRun).toContain('validateReferenceIntegrity'); + expect(activeObjects(rows)).toEqual([]); + }); + + it('refuses a list view sorting by a name the object does not have, the same way', async () => { + const { protocol, rows } = makeProtocol(); + const err = await saveTicket(protocol, 'gone_field').catch((e: any) => e); + expect(err?.status, JSON.stringify(err?.issues ?? err?.message)).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(sortIssues(err).map((i: any) => [i.rule, i.severity, i.path])).toEqual([ + ['sort-field-unknown', 'error', `objects.${OBJECT}.listViews.inbox.sort[0]`], + ]); + expect(activeObjects(rows)).toEqual([]); + }); + + it('CONTROL — the same list view sorting by a sortable field saves', async () => { + for (const field of ['subject', 'priority']) { + const { protocol, rows } = makeProtocol(); + await expect(saveTicket(protocol, field), field).resolves.toMatchObject({ success: true }); + expect(activeObjects(rows).map((r) => r.name)).toEqual([OBJECT]); + } + }); +});