Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3796d59
chore(deps): resolve @objectstack/* 17.6.0 in pnpm-lock.yaml (objectu…
claude Oct 2, 2026
b6684fd
chore(scripts): spec-symbols allows @object-ui/components' EmptyState…
claude Oct 2, 2026
c2dc334
refactor(app-shell,data-objectstack): retire the `| 'computed'` widen…
claude Oct 2, 2026
3b3cc6b
test(plugin-dashboard): read the ADR-0087 registry from @objectstack/…
claude Oct 2, 2026
1eff255
fix(components): page:header stops publishing `breadcrumb`, which @ob…
claude Oct 2, 2026
6e76e90
test(console): strike the two `endpoint` bookings @objectstack/spec 1…
claude Oct 2, 2026
7ca5a36
fix(i18n): recognise a served translation document by its `picklists`…
claude Oct 2, 2026
5ef3050
test(plugin-designer): the `constructor` field-name refusal is read a…
claude Oct 2, 2026
74fd7eb
docs(types,core,app-shell,plugins): restamp the installed-pin claims …
claude Oct 2, 2026
d73f74e
chore(changeset): declare the @objectstack/* 17.6.0 bump (objectui#11…
claude Oct 2, 2026
8879fe5
test(scripts,app-shell,console): re-pin the counts and keys @objectst…
claude Oct 3, 2026
31c6905
test(types): object-grid's bag `grouping` is judged since @objectstac…
claude Oct 3, 2026
2ba091c
test(types): the card footer is a judged child position since @object…
claude Oct 3, 2026
060cec8
chore(scripts): lower the eager-closure ceilings by the 250,096 bytes…
claude Oct 3, 2026
40d9b5a
Merge remote-tracking branch 'origin/main' into claude/issue-11438-ob…
claude Oct 3, 2026
bb6d7d3
Merge origin/main (c73cdb5) into claude/issue-11438-objectstack-17-6-0
claude Oct 3, 2026
00c13e7
test(console): book record:line_items' 17.6.0 differences to objectui…
claude Oct 3, 2026
c9121f9
test(console): the group-5 booking's prose names no installed version…
claude Oct 3, 2026
acd62a3
fix(core): ValueDataSource executes is_empty / is_not_empty and $empt…
claude Oct 3, 2026
158204d
fix(core): convertFiltersToAST lowers $empty, the canonical operator …
claude Oct 3, 2026
9786e01
test: promote the staged-$empty tripwires to @objectstack/spec 17.6.0…
claude Oct 3, 2026
4ac6be7
feat(plugin-grid,plugin-list,types): follow object-grid's 17.6.0 desc…
claude Oct 3, 2026
518c855
chore(changeset): objectui#11227's changeset, and corrections to thre…
claude Oct 3, 2026
ed622b4
test: type the two casts the slice's new pins added instead of wideni…
claude Oct 3, 2026
8a9cf1b
test(core): the refusal-subject census names the $empty flag refusal,…
claude Oct 3, 2026
6a86dd1
test(types),docs: register EmptyStateSchema at the import boundary, r…
claude Oct 3, 2026
da4c629
Merge origin/main (6f5719e1c) into claude/issue-11438-objectstack-17-6-0
claude Oct 3, 2026
9187c5c
Merge trunk claude/issue-11438-objectstack-17-6-0 (da4c629f1) into ob…
claude Oct 3, 2026
989b8b1
Merge commit '8a9cf1b5ed49b47affce85067733a228f11a4a4b' into claude/i…
claude Oct 3, 2026
0a518da
test(types): book the dimensionless measure-arity check on the 7715 c…
claude Oct 3, 2026
c4775ed
test(types,plugin-dashboard): book the dimensionless measure-arity ro…
claude Oct 3, 2026
5a631d6
fix(fields,app-shell,plugin-list): the three 「is empty」 builders writ…
claude Oct 3, 2026
6c5011d
test(console): book row 1 of the 17.6.0 bump, object-grid.keyboardNav…
claude Oct 3, 2026
bebd7ec
test(console): the row-1 booking's docblock cites ruling A″ by record…
claude Oct 3, 2026
3ac224a
test(fields): the expiry row's two new condToMongo calls take the row…
claude Oct 3, 2026
c6a0b0a
chore(changeset): dated corrections to the two pending changesets obj…
claude Oct 3, 2026
22442b8
Merge trunk claude/issue-11438-objectstack-17-6-0 (bebd7ecdf) into ob…
claude Oct 3, 2026
a36c714
fix(fields,app-shell): raise the @objectstack/spec floor to ^17.6.0, …
claude Oct 3, 2026
994d432
docs(changeset): name the ^17.6.0 spec floor, the refusing analytics …
claude Oct 3, 2026
20770e4
docs(app-shell): the dataset filter inspector wrote the $exists pair …
claude Oct 3, 2026
00a798b
Merge origin/main (6158e4c93, objectui#11531) into objectui#10813's s…
claude Oct 3, 2026
61e7e62
docs(changeset): drop the dataset changeset's stale "as it refuses $n…
claude Oct 3, 2026
1056c79
Merge origin/main (7121221fa, objectui#11547) into objectui#10813's s…
claude Oct 3, 2026
b914846
docs(app-shell): the inspector's { $empty: false } rides the drill di…
claude Oct 3, 2026
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
7 changes: 7 additions & 0 deletions .changeset/10790-empty-operators-accepted-shape.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ value OR the empty string, and its complement:
Criteria saved in the old shape still open in the builder as the same row, and
opening one rewrites nothing; the builder writes the new shape the next time any
row of that criteria is edited.

**Correction, 2026-10-03 (objectui#10813).** The two shapes above are not what this release
writes. Later in the same release the pair moved to the spec's one 「is empty」 operator:
"Is empty" stores `{ FIELD: { $empty: true } }` and "Is not empty" `{ FIELD: { $empty: false } }`,
whose meaning is the field's declared row of the spec's per-type table rather than "no value OR
the empty string" on every type. Criteria saved in either earlier shape still open as the same
row and are written as `$empty` on their next edit (`.changeset/10813-filter-condition-empty-operator.md`).
13 changes: 13 additions & 0 deletions .changeset/10813-dataset-filter-empty-operator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
---

The Studio dataset filter builder writes "Is empty" / "Is not empty" as the spec's `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` instead of `$exists` (objectui#10813).

`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The spec's own face table declares one face that refuses it: the `driver-memory` analytics (cube) face, the lowest-priority fallback strategy (`MemoryAnalyticsService`), refuses a newly authored "Is empty" / "Is not empty" filter with `INVALID_FILTER` / 400, where it answered `$exists`. The bridge keeps no copy of the table.

The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses.

The read half reads `$empty` back as the pair, with a boolean flag only.

**What moves for a stored filter.** A stored `$exists` no longer opens as "Is empty" / "Is not empty": it opens in the Source tab, with its bytes untouched, and keeps matching what it matched before. Opening it as the pair would let an edit to ANOTHER row rewrite it to `$empty` and move `''` / `[]` across the line, which the read-half invariant (objectui#10257) forbids, and this inspector offers no `exists` row. To move such a filter to the new meaning, remove the row and add "Is empty" again. Readable stores (this repository, objectstack, hotcrm and cloud) hold no `dataset.filter` with `$exists`.
20 changes: 20 additions & 0 deletions .changeset/10813-filter-condition-empty-operator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/fields': minor
---

`FilterConditionField` writes "Is empty" / "Is not empty" as the spec's one 「is empty」 operator, `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` (objectui#10813).

The widget behind sharing-rule `criteria_json`, `relatedListFilter` and `summaryOperations.filter` used to write its own meaning of 「is empty」: `{ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] }` and its complement `{ FIELD: { $nin: [''], $null: false } }`, i.e. "no value OR `''`" on every field type. The `''` member reached a number or date column as `IN ('')`, and a multi-value column (stored as JSON by the SQL driver) as an `$in` that driver refuses. `@objectstack/spec` 17.6.0 admits `$empty` to `FILTER_OPERATORS` (objectstack#20446), and its meaning is the field's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like field, null or `[]` on a multi-value field, null alone on every other type. Every evaluator expands it itself (`expandEmptyOperator`), so the widget now writes the same token on every column type and keeps no copy of the table.

The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses.

`kvToCondition` reads `$empty` back as the pair, with a boolean flag only: any other flag stays the raw criteria it is, as every evaluator refuses it.

**What moves for a stored rule.** Reading alone rewrites nothing: a criteria saved in either earlier shape (the objectui#10790 `$or` entry and `$nin` + `$null` pair, or the older `$in: [null, '']` / `$nin: [null, '']`) still opens as the same "Is empty" / "Is not empty" row, and an un-edited rule keeps its stored bytes and keeps matching as before. The next time the criteria is edited, those rows are written as `$empty`, which re-scopes the rule:

- text-like column: no change (both mean null or `''`);
- every other single-valued column (number, boolean, date, datetime, time, select, a single lookup): a row holding `''` is no longer "empty" and becomes "not empty". Since objectstack#20308 the write door stores a cleared number, boolean, date, datetime or time as null, so on those types only a value written before it can hold `''`; a select or lookup can still hold one, and the spec's ruled table does not count it as empty;
- multi-value column (multiselect, checkboxes, tags, or a `multiple: true` select, lookup or user): a row holding `[]` becomes "empty" and leaves "is not empty". On the SQL driver the earlier shape was refused outright, so a rule there starts running;
- the `$in: [null, '']` shapes were refused by every objectstack filter face, so a rule still in them starts running.

Measured on the installed 17.6.0 by-value readers (`@objectstack/formula`'s `matchesFilterCondition` and `ValueDataSource`) over null, an absent key, `''`, `[]` and set values: the `$or` entry and `$empty: true` disagree only on `[]`. Readable stores (this repository, objectstack, hotcrm and cloud) hold no criteria in either earlier shape.
9 changes: 9 additions & 0 deletions .changeset/10813-list-view-empty-operator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-list': minor
---

The list view's live query sends "Is empty" / "Is not empty" as the spec's `isempty` / `isnotempty` instead of an equality to `null` (objectui#10813). `@objectstack/spec` 17.6.0 and later lower that pair to `$empty`; an earlier reader lowers it to `$null`, as it does the saved view's `is_empty`.

`convertFilterGroupToAST` resolved the pair to `[FIELD, '=', null]` / `[FIELD, '!=', null]`, a null-only test, before `mapOperator` was consulted. The same rule saved into the view is persisted as `is_empty`, which `@objectstack/spec` 17.6.0 lowers to `$empty` (objectstack#20446): so one filter panel answered two record sets, depending on whether the view had been saved. The pair now takes the value-less path like `is_null`, and `mapOperator` gains `isempty` / `isnotempty` arms. The node is `[FIELD, 'isempty', null]`; the spec discards the third slot.

**What moves.** Nothing is stored by this path. Against a 17.6.0 or later server, on a text-like column a row holding `''` is now "empty", and on a multi-value column a row holding `[]` is, matching the server's per-type answer for the saved view. On a `provider: 'value'` list a row with no key at all is now "empty" too: the equality to `null` did not select it there.
6 changes: 6 additions & 0 deletions .changeset/9359-list-ast-valueless-canonical-fold.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,9 @@ objectui#9306's census), every pair emits the same node except
folds onto `icontains` before a row leaves it. The vocabulary
question this entry calls open is answered: the dropdown speaks the protocol's
ids, and camelCase is the deprecated alias form.

**Correction, 2026-10-03 (objectui#10813).** The two `isEmpty` / `isNotEmpty` arms that
"resolve to a null comparison ahead of `mapOperator`" are gone in this release. The empty pair
now takes the same value-less path as `is_null`, and `mapOperator` emits the spec's `isempty` /
`isnotempty`, which the spec lowers to `$empty`; every spelling of the pair still emits one node
(`.changeset/10813-list-view-empty-operator.md`).
2 changes: 1 addition & 1 deletion packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"@object-ui/types": "workspace:*",
"@objectstack/formula": "^17.5.0",
"@objectstack/lint": "^17.0.0",
"@objectstack/spec": "^17.5.0",
"@objectstack/spec": "^17.6.0",
"@sentry/react": "^10.70.0",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.43.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@
*
* ⇒ reading only `$null` would have closed the composed route and left the
* uncomposed one degrading exactly as before, for an author who picked "is not
* empty" in the dataset filter inspector (which writes the `$exists` pair).
* empty" in the dataset filter inspector, which wrote the `$exists` pair until
* objectui#10813; a filter stored before then still carries it, and the row
* now writes `{ $empty: false }` instead, which this dialect carries on its
* `[empty]` arm (objectui#11547).
*
* ## What this card did NOT change
*
Expand Down
7 changes: 5 additions & 2 deletions packages/app-shell/src/views/drillUrlFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ export const RANGE_OP_PARAM: Record<string, string> = { $gte: 'gte', $lte: 'lte'
* that hands its own resolved filter straight to the escape hatch
* (`ObjectMetricWidget`, whose drawer renders `OpenInListButton`) passes
* through no canonicaliser at all, so `$exists` reaches this function
* verbatim — and the dataset filter inspector's "is not empty" row writes
* exactly that pair.
* verbatim. The dataset filter inspector's "is not empty" row wrote
* exactly that pair until objectui#10813, so a filter stored before then
* still carries it; that row now writes `{ $empty: false }`, which is not
* one of these two keys: it reaches this function verbatim too, and the
* `[empty]` arm, {@link EMPTY_FILTER}, carries it (objectui#11547).
*
* ⚠️ A NON-boolean under either key says nothing about emptiness and writes
* nothing, which is what it did before this pair existed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,13 @@ describe('groupToCondition — the null predicates this inspector offers (object
).toEqual({ stage: { $null: true } });
});

it('leaves the $exists pair exactly as it was', () => {
expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $exists: false } });
expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $exists: true } });
it('keeps the empty pair a predicate of its own — `$empty`, not `$null` (objectui#10813)', () => {
// This file's repair left the empty pair on `$exists`; objectui#10813 moved
// it to the spec's one 「is empty」 operator. Either way it is NOT the null
// pair: `$empty` also counts `''` on a text column and `[]` on a
// multi-value one, so folding the two would rewrite the author's choice.
expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $empty: true } });
expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $empty: false } });
});

it('still drops an operator it does not map, rather than emitting a wrong filter', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ describe('class 1 — an incomplete stored value is not opened as a row the next
});

it('CONTROL: the value-less tokens carry no value to be incomplete, and stay opened', () => {
for (const other of [{ name: { $exists: false } }, { name: { $null: true } }]) {
for (const other of [{ name: { $empty: true } }, { name: { $null: true } }]) {
const { group, representable } = conditionToGroup(beside(other), FIELDS);
expect(representable, JSON.stringify(other)).toBe(true);
expect(editStage(group, 'lost')).toEqual({ $and: [{ stage: { $eq: 'lost' } }, other] });
Expand All @@ -224,7 +224,7 @@ describe('class 2 — a stored token is not opened as an operator the column\'s
{ stored: { amount: { $in: [1, 2] } }, type: 'number', readAs: 'in' },
// "Every token" includes the value-less arms: the boolean bucket offers
// only `equals` / `notEquals`.
{ stored: { flag: { $exists: true } }, type: 'boolean', readAs: 'is_not_empty' },
{ stored: { flag: { $empty: false } }, type: 'boolean', readAs: 'is_not_empty' },
{ stored: { flag: { $null: false } }, type: 'boolean', readAs: 'is_not_null' },
];

Expand Down Expand Up @@ -262,7 +262,7 @@ describe('class 2 — a stored token is not opened as an operator the column\'s
});

it('CONTROL: the value-less tokens on a column whose bucket offers them still open', () => {
for (const stored of [{ name: { $exists: true } }, { closed_at: { $null: false } }, { region: { $exists: false } }]) {
for (const stored of [{ name: { $empty: false } }, { closed_at: { $null: false } }, { region: { $empty: true } }]) {
const { group, representable } = conditionToGroup(stored, FIELDS);
expect(representable, JSON.stringify(stored)).toBe(true);
expect(groupToCondition(group)).toEqual(stored);
Expand Down Expand Up @@ -315,7 +315,16 @@ describe('the invariant, swept: every row the read half opens, the panel draws a
.flatMap((t) => SCALARS.map((v) => ({ stored: { [t]: v }, implicit: false }))),
...['$in', '$nin'].flatMap((t) => LISTS.map((v) => ({ stored: { [t]: v }, implicit: false }))),
...PAIRS.map((v) => ({ stored: { $between: v }, implicit: false })),
...[true, false].flatMap((b) => [{ stored: { $exists: b }, implicit: false }, { stored: { $null: b }, implicit: false }]),
// `$empty` is what the empty pair writes since objectui#10813; `$exists`,
// what it wrote before, stays in the domain — it is now REFUSED (no
// operator this inspector offers reads it back), which the invariant
// accepts, and a read that still opened it would be named by the
// byte-identical check below.
...[true, false].flatMap((b) => [
{ stored: { $empty: b }, implicit: false },
{ stored: { $exists: b }, implicit: false },
{ stored: { $null: b }, implicit: false },
]),
...[...SCALARS, ...LISTS].map((v) => ({ stored: v, implicit: true })),
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,38 @@ describe('datasetFilterCondition', () => {
] })).toEqual({ $and: [{ stage: { $eq: 'won' } }, { amount: { $gt: 1000 } }] });
});

it('maps isEmpty/isNotEmpty to $exists', () => {
it('maps is_empty / is_not_empty to the spec\'s one 「is empty」 operator, `$empty` (objectui#10813)', () => {
// They wrote `$exists` until objectui#10813 — the has-a-value test, which
// never counted `''` or `[]`. The per-type meaning is the spec's expansion,
// so the SAME token is written whatever the column's type.
expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_not_empty' }] }))
.toEqual({ closed_at: { $exists: true } });
.toEqual({ closed_at: { $empty: false } });
expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_empty' }] }))
.toEqual({ closed_at: { $empty: true } });
});

it('a stored `$exists` is no longer read back as the empty pair — it opens in the Source tab, bytes untouched (objectui#10813)', () => {
// Read back as `is_empty`, a sibling edit would rewrite it to `$empty` and
// move `''` / `[]` across the line. This inspector offers no `exists` row,
// so the filter goes to the Source tab rather than opening as a row the
// next commit would change.
for (const stored of [{ closed_at: { $exists: false } }, { closed_at: { $exists: true } }]) {
expect(conditionToGroup(stored).representable, JSON.stringify(stored)).toBe(false);
expect(conditionToGroup({ $and: [{ stage: { $eq: 'won' } }, stored] }).representable).toBe(false);
}
// CONTROL: the `$empty` pair this bridge writes opens as the rows.
expect(conditionToGroup({ closed_at: { $empty: true } }).group.conditions.map((c) => c.operator))
.toEqual(['is_empty']);
expect(conditionToGroup({ closed_at: { $empty: false } }).group.conditions.map((c) => c.operator))
.toEqual(['is_not_empty']);
});

it('a non-boolean `$empty` flag is not opened as a row (objectui#10813)', () => {
// `$empty` is declared `z.boolean()` and every evaluator refuses another
// flag; opened as a row, the next commit would make it runnable.
for (const flag of ['yes', 1, null]) {
expect(conditionToGroup({ closed_at: { $empty: flag } } as never).representable, JSON.stringify(flag)).toBe(false);
}
});

it('drops unmapped operators rather than emitting a bad filter', () => {
Expand Down Expand Up @@ -49,15 +78,16 @@ describe('datasetFilterCondition', () => {
] })).toEqual({ stage: { $eq: 'won' } });
// value-less operators are still kept
expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_not_empty', value: '' }] }))
.toEqual({ closed_at: { $exists: true } });
.toEqual({ closed_at: { $empty: false } });
});

it('round-trips representable conditions (condition → group → condition)', () => {
for (const c of [
{ status: { $eq: 'won' } },
{ $and: [{ stage: { $eq: 'won' } }, { amount: { $gt: 1000 } }] },
{ region: { $in: ['NA', 'EU'] } },
{ closed_at: { $exists: false } },
{ closed_at: { $empty: true } },
{ closed_at: { $empty: false } },
]) {
const { group, representable } = conditionToGroup(c);
expect(representable).toBe(true);
Expand Down
Loading
Loading