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
19 changes: 19 additions & 0 deletions .changeset/30095-object-list-view-sort-door.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed, and nothing an author may write changes: `os validate` / `os build` / `os lint` already refused this sort. The object publish door now gives an object's own list view sort the verdict the CLI, the view door and the page door already give it. A refused name never sorted anything (the list door answered every load 400 INVALID_SORT), so there is no rewrite that keeps what the author meant; the author sorts by a sortable field, and the refusal says which field and why. -->

**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.
34 changes: 33 additions & 1 deletion packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 51 additions & 3 deletions packages/lint/src/runtime-gate.object-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ const cleanObject = (over: Record<string, unknown> = {}) => ({
...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 });

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Row>) =>
Array.from(rows.values()).filter((r) => r.type === 'object' && r.state === 'active');

let warn: ReturnType<typeof vi.spyOn>;
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]);
}
});
});
Loading