@atscript/ui-table 0.1.139 → 0.1.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,8 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _uniqu_core = require("@uniqu/core");
3
+ let _uniqu_url_builder = require("@uniqu/url/builder");
3
4
  let _atscript_db_client = require("@atscript/db-client");
4
5
  let _atscript_ui = require("@atscript/ui");
5
- let _uniqu_url_builder = require("@uniqu/url/builder");
6
6
  let _uniqu_url = require("@uniqu/url");
7
7
  //#region src/filters/filter-conditions.ts
8
8
  /** Conditions that operate purely on nullability — value is ignored. */
@@ -509,8 +509,16 @@ const RANGE_OPS = {
509
509
  $lt: "lt",
510
510
  $lte: "lte"
511
511
  };
512
- /** Field paths an expression references, deduped, in order of appearance. */
513
- function fieldsOf(expr, out = []) {
512
+ /**
513
+ * Field paths a Uniquery filter expression references, deduped, in order of
514
+ * appearance (logical operators are walked, operator keys skipped).
515
+ *
516
+ * @internal Exported for `@atscript/vue-table`.
517
+ */
518
+ function filterExprFields(expr) {
519
+ return fieldsOf(expr, []);
520
+ }
521
+ function fieldsOf(expr, out) {
514
522
  if (Array.isArray(expr)) {
515
523
  for (const child of expr) fieldsOf(child, out);
516
524
  return out;
@@ -521,7 +529,7 @@ function fieldsOf(expr, out = []) {
521
529
  return out;
522
530
  }
523
531
  function unsupported(reason, expr) {
524
- const fields = fieldsOf(expr);
532
+ const fields = fieldsOf(expr, []);
525
533
  return {
526
534
  reason: fields.length > 1 ? "cross-field" : reason,
527
535
  expr,
@@ -699,6 +707,7 @@ function invert(cond) {
699
707
  * anything else would invert into an OR of ANDs, which the model cannot hold.
700
708
  */
701
709
  function collectNot(child, out) {
710
+ if (isPlainObject(child) && Object.keys(child).length === 1 && "$not" in child) return collect(child.$not, out);
702
711
  const expr = { $not: child };
703
712
  const b = classifyBranch(child);
704
713
  if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
@@ -742,30 +751,26 @@ function warnUnsupported(issue) {
742
751
  console.warn(`[ui-table] Filter left out (${issue.reason}): ${JSON.stringify(issue.expr)}. Field filters cannot express it, so the result is broader than the source filter.`);
743
752
  }
744
753
  /**
745
- * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
746
- *
747
- * Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
748
- * produces. For any other input, each AND-ed piece is either converted exactly
749
- * or left out whole and reported — never approximated:
754
+ * Split a Uniquery `FilterExpr` into what the table's field-filter model
755
+ * holds exactly (`filters`), what it cannot hold but carries as residual
756
+ * conditions (`residual`, with `carry`), and what it leaves out
757
+ * (`unsupported`). Nothing is approximated: `filters AND residual` selects
758
+ * `expr` minus the `unsupported` pieces and pieces on fields outside
759
+ * `knownFields`.
750
760
  *
751
- * - `$in` becomes equality conditions on the field, `$nin` inequality ones.
752
- * - A same-field `$or` becomes that field's OR'd conditions.
753
- * - A `$not` that inverts equality / emptiness becomes the inverse conditions.
754
- * - Fields next to `$and` / `$or` / `$not` in one object are all kept.
755
- * - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
756
- * an AND-ed piece out only ever widens the match, so the result selects a
757
- * superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
758
- * to a dev-mode `console.warn` when no handler is given.
759
- *
760
- * Conditions on fields outside `knownFields` (when provided) are ignored
761
- * silently: they are not this table's (a host page flag, a stale column). A
762
- * piece that mixes known and unknown fields is reported.
761
+ * Never throws, never warns — the caller decides how to report.
763
762
  *
764
- * Returns `{}` for an empty/missing expression. Never throws.
763
+ * @since 0.1.140
765
764
  */
766
- function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
767
- const acc = {};
768
- if (!expr) return acc;
765
+ function decomposeUniqueryFilter(expr, opts = {}) {
766
+ const filters = {};
767
+ const result = {
768
+ filters,
769
+ residual: [],
770
+ unsupported: []
771
+ };
772
+ if (!expr) return result;
773
+ const knownFields = opts.knownFields;
769
774
  const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
770
775
  const isKnown = (field) => known === null || known.has(field);
771
776
  const out = {
@@ -785,19 +790,158 @@ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = w
785
790
  const positives = /* @__PURE__ */ new Map();
786
791
  for (const term of out.terms) {
787
792
  if (!isKnown(term.field)) continue;
788
- const list = acc[term.field] ??= [];
789
- const kept = positives.get(term.field);
790
- if (isNegative(term) || !kept) {
791
- if (!isNegative(term)) positives.set(term.field, term.conds);
793
+ const list = filters[term.field] ??= [];
794
+ if (isNegative(term)) {
792
795
  list.push(...term.conds);
793
- } else if (!sameConds(kept, term.conds)) out.issues.push({
796
+ continue;
797
+ }
798
+ const groups = positives.get(term.field);
799
+ if (!groups) {
800
+ positives.set(term.field, [term]);
801
+ list.push(...term.conds);
802
+ } else if (!groups.some((g) => sameConds(g.conds, term.conds))) groups.push(term);
803
+ }
804
+ const carried = [];
805
+ for (const [field, groups] of positives) {
806
+ if (groups.length < 2) continue;
807
+ const leftOut = opts.carry ? groups : groups.slice(1);
808
+ if (opts.carry) {
809
+ const kept = new Set(groups[0].conds);
810
+ const rest = filters[field].filter((c) => !kept.has(c));
811
+ if (rest.length > 0) filters[field] = rest;
812
+ else delete filters[field];
813
+ }
814
+ for (const term of leftOut) out.issues.push({
794
815
  reason: "conjunction",
795
816
  expr: term.expr,
796
- fields: [term.field]
817
+ fields: [field]
797
818
  });
798
819
  }
799
- for (const issue of out.issues) if (issue.fields.length === 0 || issue.fields.some(isKnown)) onUnsupportedFilter(issue);
800
- return acc;
820
+ for (const issue of out.issues) {
821
+ const fields = issue.fields;
822
+ if (fields.length > 0 && !fields.some(isKnown)) continue;
823
+ if (opts.carry && fields.length > 0 && fields.every(isKnown)) carried.push(issue.expr);
824
+ else result.unsupported.push(issue);
825
+ }
826
+ result.residual = normalizeResidualFilters(carried);
827
+ return result;
828
+ }
829
+ /**
830
+ * Canonical identity of a filter expression — its `@uniqu/url` spelling.
831
+ * Two expressions with the same key select the same rows.
832
+ *
833
+ * @since 0.1.140
834
+ */
835
+ function filterExprKey(expr) {
836
+ try {
837
+ return (0, _uniqu_url_builder.buildUrl)({ filter: expr });
838
+ } catch {
839
+ return JSON.stringify(expr) ?? "";
840
+ }
841
+ }
842
+ /**
843
+ * A residual-condition list with empty expressions and duplicates (by
844
+ * {@link filterExprKey}) dropped, sorted by key. Sorted, not first-appearance:
845
+ * the URL parser moves \`$not\`-wrapped clauses (how \`mergeFilters\` spells
846
+ * a repeated same-field clause) ahead of plain ones, so appearance order would
847
+ * flip on every round trip.
848
+ *
849
+ * @internal Exported for `@atscript/vue-table`.
850
+ */
851
+ function normalizeResidualFilters(exprs) {
852
+ const byKey = /* @__PURE__ */ new Map();
853
+ for (const expr of exprs) {
854
+ if (!isPlainObject(expr)) continue;
855
+ const key = filterExprKey(expr);
856
+ if (key && !byKey.has(key)) byKey.set(key, expr);
857
+ }
858
+ return [...byKey.keys()].toSorted().map((key) => byKey.get(key));
859
+ }
860
+ /**
861
+ * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
862
+ *
863
+ * Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
864
+ * produces. For any other input, each AND-ed piece is either converted exactly
865
+ * or left out whole and reported — never approximated:
866
+ *
867
+ * - `$in` becomes equality conditions on the field, `$nin` inequality ones.
868
+ * - A same-field `$or` becomes that field's OR'd conditions.
869
+ * - A `$not` that inverts equality / emptiness becomes the inverse conditions.
870
+ * - Fields next to `$and` / `$or` / `$not` in one object are all kept.
871
+ * - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
872
+ * an AND-ed piece out only ever widens the match, so the result selects a
873
+ * superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
874
+ * to a dev-mode `console.warn` when no handler is given. To keep those
875
+ * pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
876
+ *
877
+ * Conditions on fields outside `knownFields` (when provided) are ignored
878
+ * silently: they are not this table's (a host page flag, a stale column). A
879
+ * piece that mixes known and unknown fields is reported.
880
+ *
881
+ * Returns `{}` for an empty/missing expression. Never throws.
882
+ */
883
+ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
884
+ const { filters, unsupported } = decomposeUniqueryFilter(expr, { knownFields });
885
+ for (const issue of unsupported) onUnsupportedFilter(issue);
886
+ return filters;
887
+ }
888
+ //#endregion
889
+ //#region src/filters/format-filter-expr.ts
890
+ function show(v) {
891
+ if (v instanceof Date) return v.toISOString();
892
+ if (typeof v === "object" && v !== null) return JSON.stringify(v) ?? "";
893
+ return String(v);
894
+ }
895
+ /** One comparison, worded like the filter chips (the decoder's operator table). */
896
+ function leaf(label, op, value) {
897
+ if (op === "$eq" && value instanceof RegExp) op = "$regex";
898
+ if ((op === "$in" || op === "$nin") && Array.isArray(value)) return `${label} ${op === "$in" ? "is one of" : "is none of"} ${value.map(show).join(", ")}`;
899
+ const conds = decodeOperator(op, value);
900
+ if (conds?.length === 1) return filterTokenLabel(label, conds, label);
901
+ return (0, _uniqu_url_builder.buildUrl)({ filter: { [label]: { [op]: value } } }) || `${label}${op}`;
902
+ }
903
+ function join(children, kind) {
904
+ const parts = children.filter((c) => c.s);
905
+ if (parts.length === 0) return {
906
+ s: "",
907
+ kind: "leaf"
908
+ };
909
+ if (parts.length === 1) return parts[0];
910
+ const wrap = kind === "and" ? "or" : "and";
911
+ return {
912
+ s: parts.map((c) => c.kind === wrap ? `(${c.s})` : c.s).join(` ${kind} `),
913
+ kind
914
+ };
915
+ }
916
+ /**
917
+ * Human-readable rendering of a Uniquery filter expression, worded like the
918
+ * filter-field chips: `(Status equals shipped and Total greater than 500) or
919
+ * (Status equals pending and Total less or equal 50)`. `and` binds tighter
920
+ * than `or`; groups are parenthesized only where needed. Operators without a
921
+ * wording fall back to their `@uniqu/url` spelling.
922
+ *
923
+ * @param labelOf — display label for a field path (e.g. the column label);
924
+ * the path itself when omitted or when it returns `undefined`.
925
+ * @since 0.1.140
926
+ */
927
+ function formatFilterExpr(expr, labelOf) {
928
+ const visitor = {
929
+ comparison: (field, op, value) => ({
930
+ s: leaf(labelOf?.(field) ?? field, op, value),
931
+ kind: "leaf"
932
+ }),
933
+ and: (children) => join(children, "and"),
934
+ or: (children) => join(children, "or"),
935
+ not: (child) => ({
936
+ s: child.s ? `not (${child.s})` : "",
937
+ kind: "leaf"
938
+ })
939
+ };
940
+ try {
941
+ return (0, _uniqu_core.walkFilter)(expr, visitor)?.s ?? "";
942
+ } catch {
943
+ return JSON.stringify(expr) ?? "";
944
+ }
801
945
  }
802
946
  //#endregion
803
947
  //#region src/filters/date-shortcuts.ts
@@ -1394,8 +1538,9 @@ function mergeSorters(forceSorters, userSorters) {
1394
1538
  //#endregion
1395
1539
  //#region src/query/merge-filters.ts
1396
1540
  /**
1397
- * AND-merge two filter expressions, producing a wire shape that survives
1398
- * `@uniqu/url`'s `mergeConjunction` parser collapse.
1541
+ * AND-merge filter expressions (`undefined` ones skipped), producing a wire
1542
+ * shape that survives `@uniqu/url`'s `mergeConjunction` parser collapse.
1543
+ * Variadic since 0.1.140.
1399
1544
  *
1400
1545
  * The collapse problem: when two `$and` siblings target the same field
1401
1546
  * with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
@@ -1408,10 +1553,10 @@ function mergeSorters(forceSorters, userSorters) {
1408
1553
  * and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
1409
1554
  * same AND. Non-colliding merges produce the canonical `$and` shape.
1410
1555
  */
1411
- function mergeFilters(a, b) {
1412
- if (!a) return b;
1413
- if (!b) return a;
1414
- return makeParserSafeAnd([a, b]);
1556
+ function mergeFilters(...exprs) {
1557
+ const list = exprs.filter((e) => !!e);
1558
+ if (list.length <= 1) return list[0];
1559
+ return makeParserSafeAnd(list);
1415
1560
  }
1416
1561
  /** Op-set for a field value: primitives are `$eq`, op-bags expose their keys. */
1417
1562
  function getOpsForFieldValue(value) {
@@ -1479,12 +1624,12 @@ function makeParserSafeAnd(children) {
1479
1624
  * Build a Uniquery object from table UI state.
1480
1625
  *
1481
1626
  * Pure function — no framework dependencies.
1482
- * Combines user filters with force filters, merges sorters,
1627
+ * Combines user filters (field filters, then residual conditions) with force
1628
+ * filters, merges sorters,
1483
1629
  * projects visible columns, and applies pagination.
1484
1630
  */
1485
1631
  function buildTableQuery(opts) {
1486
- const userFilter = filtersToUniqueryFilter(opts.filters);
1487
- const filter = mergeFilters(opts.forceFilters, userFilter);
1632
+ const filter = mergeFilters(opts.forceFilters, filtersToUniqueryFilter(opts.filters), ...opts.residualFilters ?? []);
1488
1633
  const userSorters = opts.ignoreSorters ? [] : opts.sorters;
1489
1634
  const sorters = opts.forceSorters?.length ? mergeSorters(opts.forceSorters, userSorters) : userSorters;
1490
1635
  const $sort = {};
@@ -1544,6 +1689,17 @@ function gateOwns(gate, path) {
1544
1689
  if (gate === "none") return false;
1545
1690
  return gate.has(path);
1546
1691
  }
1692
+ /**
1693
+ * Does a URL under `gate` own the residual condition `expr`? Only when it
1694
+ * owns every field the condition references — a condition that touches a
1695
+ * private field stays private as a whole.
1696
+ *
1697
+ * @internal Exported for `@atscript/vue-table`.
1698
+ */
1699
+ function residualGateOwns(gate, expr) {
1700
+ const fields = filterExprFields(expr);
1701
+ return fields.length > 0 && fields.every((path) => gateOwns(gate, path));
1702
+ }
1547
1703
  function pickFilterPaths(filters, gate) {
1548
1704
  const out = {};
1549
1705
  for (const path in filters) if (gateOwns(gate, path)) out[path] = filters[path];
@@ -1570,6 +1726,7 @@ function stateToUrlQueryString(state, defaults) {
1570
1726
  visibleColumnPaths: [],
1571
1727
  sorters: sortersGate === "all" ? state.sorters : state.sorters.filter((s) => gateOwns(sortersGate, s.field)),
1572
1728
  filters,
1729
+ residualFilters: defaults.sync?.residual === false || !state.residualFilters?.length ? void 0 : state.residualFilters.filter((expr) => residualGateOwns(filtersGate, expr)),
1573
1730
  search: searchOff ? void 0 : state.searchTerm || void 0
1574
1731
  });
1575
1732
  if (!searchOff && state.searchTerm && state.ignoreSorters !== void 0 && state.ignoreSorters !== (defaults.defaultIgnoreSorters ?? false)) query.controls.$relevance = state.ignoreSorters ? 1 : 0;
@@ -1592,8 +1749,11 @@ const CONSUMED_CONTROLS = new Set([
1592
1749
  "$skip",
1593
1750
  URL_SNAPSHOT_KEY
1594
1751
  ]);
1595
- /** Characters that can only appear in a uniqu filter key, never in a page flag. */
1596
- const FILTER_OPERATOR_CHAR = /[<>!~]/;
1752
+ /**
1753
+ * Characters that can only appear in a uniqu filter key, never in a page flag:
1754
+ * comparison operators, `^` (OR), `( )` (groups) and `{ }` (`$in` lists).
1755
+ */
1756
+ const FILTER_OPERATOR_CHAR = /[<>!~()^{}]/;
1597
1757
  /**
1598
1758
  * Whether {@link urlQueryStringToState} would consume the query key `key` —
1599
1759
  * i.e. whether the key belongs to the table rather than to the page hosting
@@ -1620,7 +1780,9 @@ function urlQueryConsumesKey(key) {
1620
1780
  * - unknown fields (not in `knownFields`) → silently dropped
1621
1781
  * - filter pieces field filters cannot express (cross-field OR, unknown
1622
1782
  * operator, …) → left out of `filters` and listed in `unsupported`, never
1623
- * approximated (the parser does not warn — the caller decides)
1783
+ * approximated (the parser does not warn — the caller decides). Unless
1784
+ * `sync.residual` is `false`, those whose fields are all known come back
1785
+ * in `residual` instead
1624
1786
  * - unknown controls (e.g. `$weird=42`) → silently ignored
1625
1787
  * - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
1626
1788
  *
@@ -1655,8 +1817,11 @@ function urlQueryStringToState(urlString, opts = {}) {
1655
1817
  filterKnown = /* @__PURE__ */ new Set();
1656
1818
  for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
1657
1819
  } else filterKnown = filtersGate;
1658
- const unsupported = [];
1659
- const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown, (issue) => unsupported.push(issue));
1820
+ const decomposed = filtersGate === "none" ? null : decomposeUniqueryFilter(parsed.filter, {
1821
+ knownFields: filterKnown,
1822
+ carry: opts.sync?.residual !== false
1823
+ });
1824
+ const filters = decomposed?.filters ?? {};
1660
1825
  const sorters = [];
1661
1826
  if (sortersGate !== "none") {
1662
1827
  const $sort = parsed.controls?.$sort;
@@ -1680,7 +1845,8 @@ function urlQueryStringToState(urlString, opts = {}) {
1680
1845
  sorters,
1681
1846
  searchTerm: !searchOff && typeof $search === "string" ? $search : ""
1682
1847
  };
1683
- if (unsupported.length > 0) out.unsupported = unsupported;
1848
+ if (decomposed?.unsupported.length) out.unsupported = decomposed.unsupported;
1849
+ if (decomposed?.residual.length) out.residual = decomposed.residual;
1684
1850
  if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
1685
1851
  if (!searchOff) {
1686
1852
  const $relevance = parsed.controls?.$relevance;
@@ -2265,15 +2431,19 @@ exports.conditionsForType = conditionsForType;
2265
2431
  exports.csvCell = csvCell;
2266
2432
  exports.dateShortcuts = dateShortcuts;
2267
2433
  exports.debounce = debounce;
2434
+ exports.decomposeUniqueryFilter = decomposeUniqueryFilter;
2268
2435
  exports.defaultCondition = defaultCondition;
2269
2436
  exports.derivePresetAspects = derivePresetAspects;
2270
2437
  exports.deserializeDraft = deserializeDraft;
2271
2438
  exports.draftMatchesPreset = draftMatchesPreset;
2272
2439
  exports.escapeRegex = escapeRegex;
2273
2440
  exports.filledFilterCount = filledFilterCount;
2441
+ exports.filterExprFields = filterExprFields;
2442
+ exports.filterExprKey = filterExprKey;
2274
2443
  exports.filterTokenLabel = filterTokenLabel;
2275
2444
  exports.filtersToUniqueryFilter = filtersToUniqueryFilter;
2276
2445
  exports.formatFilterCondition = formatFilterCondition;
2446
+ exports.formatFilterExpr = formatFilterExpr;
2277
2447
  exports.fromWireSnapshot = fromWireSnapshot;
2278
2448
  exports.gateOwns = gateOwns;
2279
2449
  exports.hasSecondValue = hasSecondValue;
@@ -2289,12 +2459,14 @@ exports.mergeDisplayColumns = mergeDisplayColumns;
2289
2459
  exports.mergeFilters = mergeFilters;
2290
2460
  exports.mergeSorters = mergeSorters;
2291
2461
  exports.normaliseSystemPresetId = normaliseSystemPresetId;
2462
+ exports.normalizeResidualFilters = normalizeResidualFilters;
2292
2463
  exports.pageAlignedBlocksFor = pageAlignedBlocksFor;
2293
2464
  exports.parseColumnFilterInput = parseColumnFilterInput;
2294
2465
  exports.parseFilterInput = parseFilterInput;
2295
2466
  exports.planFetch = planFetch;
2296
2467
  exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
2297
2468
  exports.reorderColumnNames = reorderColumnNames;
2469
+ exports.residualGateOwns = residualGateOwns;
2298
2470
  exports.resolveAspectGate = resolveAspectGate;
2299
2471
  exports.resolveExportValue = resolveExportValue;
2300
2472
  exports.resolveSystemPresets = resolveSystemPresets;
package/dist/index.d.cts CHANGED
@@ -175,6 +175,67 @@ interface UnsupportedFilter {
175
175
  /** Field paths the sub-expression references, in order of appearance. */
176
176
  fields: string[];
177
177
  }
178
+ /**
179
+ * Field paths a Uniquery filter expression references, deduped, in order of
180
+ * appearance (logical operators are walked, operator keys skipped).
181
+ *
182
+ * @internal Exported for `@atscript/vue-table`.
183
+ */
184
+ declare function filterExprFields(expr: unknown): string[];
185
+ /** Options for {@link decomposeUniqueryFilter}. @since 0.1.140 */
186
+ interface DecomposeUniqueryFilterOptions {
187
+ /**
188
+ * Field paths the table knows. Pieces on fields outside it are ignored
189
+ * silently; a piece mixing known and unknown fields is reported, never
190
+ * carried. Omit to accept every field.
191
+ */
192
+ knownFields?: Iterable<string>;
193
+ /**
194
+ * Keep left-out pieces whose fields are all known as `residual` instead of
195
+ * reporting them. Default `false` — the 0.1.139 split. See
196
+ * [Custom filter conditions](https://ui.atscript.dev/tables/filtering#custom-filter-conditions).
197
+ */
198
+ carry?: boolean;
199
+ }
200
+ /** Result of {@link decomposeUniqueryFilter}. @since 0.1.140 */
201
+ interface DecomposedUniqueryFilter {
202
+ /** The part field filters express exactly. */
203
+ filters: FieldFilters;
204
+ /** Carried pieces — AND-ed conditions, deduped, canonical order. `[]` unless `carry` is on. */
205
+ residual: FilterExpr[];
206
+ /** Pieces left out and lost — the result is broader by exactly these. */
207
+ unsupported: UnsupportedFilter[];
208
+ }
209
+ /**
210
+ * Split a Uniquery `FilterExpr` into what the table's field-filter model
211
+ * holds exactly (`filters`), what it cannot hold but carries as residual
212
+ * conditions (`residual`, with `carry`), and what it leaves out
213
+ * (`unsupported`). Nothing is approximated: `filters AND residual` selects
214
+ * `expr` minus the `unsupported` pieces and pieces on fields outside
215
+ * `knownFields`.
216
+ *
217
+ * Never throws, never warns — the caller decides how to report.
218
+ *
219
+ * @since 0.1.140
220
+ */
221
+ declare function decomposeUniqueryFilter(expr: FilterExpr | undefined, opts?: DecomposeUniqueryFilterOptions): DecomposedUniqueryFilter;
222
+ /**
223
+ * Canonical identity of a filter expression — its `@uniqu/url` spelling.
224
+ * Two expressions with the same key select the same rows.
225
+ *
226
+ * @since 0.1.140
227
+ */
228
+ declare function filterExprKey(expr: FilterExpr): string;
229
+ /**
230
+ * A residual-condition list with empty expressions and duplicates (by
231
+ * {@link filterExprKey}) dropped, sorted by key. Sorted, not first-appearance:
232
+ * the URL parser moves \`$not\`-wrapped clauses (how \`mergeFilters\` spells
233
+ * a repeated same-field clause) ahead of plain ones, so appearance order would
234
+ * flip on every round trip.
235
+ *
236
+ * @internal Exported for `@atscript/vue-table`.
237
+ */
238
+ declare function normalizeResidualFilters(exprs: readonly FilterExpr[]): FilterExpr[];
178
239
  /**
179
240
  * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
180
241
  *
@@ -189,7 +250,8 @@ interface UnsupportedFilter {
189
250
  * - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
190
251
  * an AND-ed piece out only ever widens the match, so the result selects a
191
252
  * superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
192
- * to a dev-mode `console.warn` when no handler is given.
253
+ * to a dev-mode `console.warn` when no handler is given. To keep those
254
+ * pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
193
255
  *
194
256
  * Conditions on fields outside `knownFields` (when provided) are ignored
195
257
  * silently: they are not this table's (a host page flag, a stale column). A
@@ -199,6 +261,20 @@ interface UnsupportedFilter {
199
261
  */
200
262
  declare function uniqueryFilterToFieldFilters(expr: FilterExpr | undefined, knownFields?: Iterable<string> | Set<string>, onUnsupportedFilter?: (issue: UnsupportedFilter) => void): FieldFilters;
201
263
  //#endregion
264
+ //#region src/filters/format-filter-expr.d.ts
265
+ /**
266
+ * Human-readable rendering of a Uniquery filter expression, worded like the
267
+ * filter-field chips: `(Status equals shipped and Total greater than 500) or
268
+ * (Status equals pending and Total less or equal 50)`. `and` binds tighter
269
+ * than `or`; groups are parenthesized only where needed. Operators without a
270
+ * wording fall back to their `@uniqu/url` spelling.
271
+ *
272
+ * @param labelOf — display label for a field path (e.g. the column label);
273
+ * the path itself when omitted or when it returns `undefined`.
274
+ * @since 0.1.140
275
+ */
276
+ declare function formatFilterExpr(expr: FilterExpr, labelOf?: (path: string) => string | undefined): string;
277
+ //#endregion
202
278
  //#region src/filters/date-shortcuts.d.ts
203
279
  /** A date shortcut produces a label and a [start, end] ISO date range. */
204
280
  interface DateShortcut {
@@ -694,6 +770,12 @@ interface BuildTableQueryOptions {
694
770
  ignoreSorters?: boolean;
695
771
  /** User-configured field filters. */
696
772
  filters: FieldFilters;
773
+ /**
774
+ * Residual filter conditions — AND-ed conjuncts the field-filter model
775
+ * cannot hold (a cross-field `$or`, a second range on one field, …). AND'd
776
+ * after `filters`. Since 0.1.140.
777
+ */
778
+ residualFilters?: FilterExpr[];
697
779
  /** Always-applied Uniquery filter (AND'd with user filters). */
698
780
  forceFilters?: FilterExpr;
699
781
  /** Full-text search term. */
@@ -712,7 +794,8 @@ interface BuildTableQueryOptions {
712
794
  * Build a Uniquery object from table UI state.
713
795
  *
714
796
  * Pure function — no framework dependencies.
715
- * Combines user filters with force filters, merges sorters,
797
+ * Combines user filters (field filters, then residual conditions) with force
798
+ * filters, merges sorters,
716
799
  * projects visible columns, and applies pagination.
717
800
  */
718
801
  declare function buildTableQuery(opts: BuildTableQueryOptions): Uniquery;
@@ -728,8 +811,9 @@ declare function mergeSorters(forceSorters: SortControl[], userSorters: SortCont
728
811
  //#endregion
729
812
  //#region src/query/merge-filters.d.ts
730
813
  /**
731
- * AND-merge two filter expressions, producing a wire shape that survives
732
- * `@uniqu/url`'s `mergeConjunction` parser collapse.
814
+ * AND-merge filter expressions (`undefined` ones skipped), producing a wire
815
+ * shape that survives `@uniqu/url`'s `mergeConjunction` parser collapse.
816
+ * Variadic since 0.1.140.
733
817
  *
734
818
  * The collapse problem: when two `$and` siblings target the same field
735
819
  * with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
@@ -742,12 +826,17 @@ declare function mergeSorters(forceSorters: SortControl[], userSorters: SortCont
742
826
  * and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
743
827
  * same AND. Non-colliding merges produce the canonical `$and` shape.
744
828
  */
745
- declare function mergeFilters(a: FilterExpr | undefined, b: FilterExpr | undefined): FilterExpr | undefined;
829
+ declare function mergeFilters(...exprs: (FilterExpr | undefined)[]): FilterExpr | undefined;
746
830
  //#endregion
747
831
  //#region src/query/url-query.d.ts
748
832
  /** State subset that round-trips through the URL bridge. */
749
833
  interface UrlQueryStateLike {
750
834
  filters: FieldFilters;
835
+ /**
836
+ * Residual filter conditions, AND-ed after `filters`. Only the ones the
837
+ * filter gate owns are written (see {@link residualGateOwns}). Since 0.1.140.
838
+ */
839
+ residualFilters?: FilterExpr[];
751
840
  sorters: SortControl[];
752
841
  /** 1-based page number. `1` is the default and is omitted from the URL. */
753
842
  page?: number;
@@ -788,11 +877,17 @@ interface UrlQueryStateSnapshot {
788
877
  */
789
878
  snapshot?: true;
790
879
  /**
791
- * Pieces of the URL's filter that `filters` leaves out because field filters
792
- * cannot express them — see `uniqueryFilterToFieldFilters`. Omitted when
793
- * the filter converted exactly. Since 0.1.139.
880
+ * Pieces of the URL's filter that are left out because field filters
881
+ * cannot express them — see `uniqueryFilterToFieldFilters`. Since 0.1.140
882
+ * only the pieces NOT carried in `residual`. Omitted when none. Since 0.1.139.
794
883
  */
795
884
  unsupported?: UnsupportedFilter[];
885
+ /**
886
+ * Filter pieces field filters cannot express, carried as residual
887
+ * conditions (see `decomposeUniqueryFilter`). Omitted when there are none
888
+ * or `sync.residual` is `false`. Since 0.1.140.
889
+ */
890
+ residual?: FilterExpr[];
796
891
  /**
797
892
  * Raw record offset from `$skip` (omitted when no `$skip` in URL). The
798
893
  * decoder does NOT compute a page index — that requires `itemsPerPage`,
@@ -836,6 +931,15 @@ interface UrlQuerySync {
836
931
  * Since 0.1.139.
837
932
  */
838
933
  snapshot?: boolean;
934
+ /**
935
+ * Whether filter pieces the field-filter model cannot hold travel as
936
+ * residual conditions. Default `true`: the encoder writes
937
+ * `UrlQueryStateLike.residualFilters` and the decoder returns them as
938
+ * `residual`. `false`: neither — the 0.1.139 behaviour, where such
939
+ * pieces are left out and reported. Inert when `filters` is off.
940
+ * Since 0.1.140.
941
+ */
942
+ residual?: boolean;
839
943
  }
840
944
  interface UrlQueryDefaults {
841
945
  /** Consumer's `:limit` prop. Used to omit `$limit` from the URL when state matches it. */
@@ -872,6 +976,14 @@ declare function resolveAspectGate(value: boolean | string[] | undefined): Aspec
872
976
  * @since 0.1.137
873
977
  */
874
978
  declare function gateOwns(gate: AspectGate, path: string): boolean;
979
+ /**
980
+ * Does a URL under `gate` own the residual condition `expr`? Only when it
981
+ * owns every field the condition references — a condition that touches a
982
+ * private field stays private as a whole.
983
+ *
984
+ * @internal Exported for `@atscript/vue-table`.
985
+ */
986
+ declare function residualGateOwns(gate: AspectGate, expr: FilterExpr): boolean;
875
987
  /**
876
988
  * Serialize the table state subset into a URL query string.
877
989
  *
@@ -917,7 +1029,9 @@ interface UrlQueryParseOptions {
917
1029
  * - unknown fields (not in `knownFields`) → silently dropped
918
1030
  * - filter pieces field filters cannot express (cross-field OR, unknown
919
1031
  * operator, …) → left out of `filters` and listed in `unsupported`, never
920
- * approximated (the parser does not warn — the caller decides)
1032
+ * approximated (the parser does not warn — the caller decides). Unless
1033
+ * `sync.residual` is `false`, those whose fields are all known come back
1034
+ * in `residual` instead
921
1035
  * - unknown controls (e.g. `$weird=42`) → silently ignored
922
1036
  * - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
923
1037
  *
@@ -1012,6 +1126,13 @@ interface TableStateData {
1012
1126
  filterFields: string[];
1013
1127
  /** Active field filters. */
1014
1128
  filters: FieldFilters;
1129
+ /**
1130
+ * Residual filter conditions — AND-ed Uniquery conjuncts the field-filter
1131
+ * model cannot hold (a cross-field `$or`, a second range on one field, …),
1132
+ * applied after `filters`. Restored from URLs, shown as "custom filter"
1133
+ * chips. Since 0.1.140.
1134
+ */
1135
+ residualFilters: FilterExpr[];
1015
1136
  /** Active sorters. */
1016
1137
  sorters: SortControl[];
1017
1138
  /**
@@ -1132,8 +1253,18 @@ interface TableStateMethods {
1132
1253
  loadingAt(absIdx: number): boolean;
1133
1254
  /** Returns the last error attached to the block covering `absIdx`, or null. */
1134
1255
  errorAt(absIdx: number): Error | null;
1135
- /** Clear all applied filters. Does not touch `filterFields`. */
1256
+ /**
1257
+ * Clear all applied filters — field filters and residual conditions. Does
1258
+ * not touch `filterFields`.
1259
+ */
1136
1260
  resetFilters(): void;
1261
+ /**
1262
+ * Replace the residual filter conditions (AND-ed Uniquery conjuncts).
1263
+ * Empty expressions and duplicates are dropped. Since 0.1.140.
1264
+ */
1265
+ setResidualFilters(exprs: FilterExpr[]): void;
1266
+ /** Remove the residual condition at `index`. Since 0.1.140. */
1267
+ removeResidualFilter(index: number): void;
1137
1268
  /** Open the config dialog (optionally to a specific tab). */
1138
1269
  showConfigDialog(tab?: ConfigTab): void;
1139
1270
  /** Append a field to the displayed filter fields (deduped). */
@@ -1430,4 +1561,4 @@ declare function sortRowsLocally<T extends Record<string, unknown>>(rows: T[], s
1430
1561
  //#region src/utils/dev.d.ts
1431
1562
  declare const DEV: boolean;
1432
1563
  //#endregion
1433
- export { APP_CONF_PREFIX, type AppConfData, AppPrefsClient, type AppPrefsClientConfig, type AppPrefsLoadResult, type AsPresetEntryData, type AsPresetEntryRow, type AsPresetsErrorCode, type AspectGate, type AspectMask, type BuildTableQueryOptions, type ColumnFilterType, type ColumnReorderPosition, type ColumnWidthEntry, type ColumnWidthsMap, type ConfigTab, type CsvOptions, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DEV, DRAFT_PERSISTED_ASPECTS, type DateShortcut, type DisplayColumnDef, type DraftPersistedAspect, ExportAbortError, type ExportPage, type ExportPageFetcher, type ExportScalar, type FetchPlan, type FetchPlanMode, type FieldFilters, type FilterCondition, type FilterConditionType, type FilterableColumn, MAX_DEFAULT_COLUMN_WIDTH_PX, type MergeResult, NULL_OPS, PRESET_ASPECTS, type PageAlignedBlock, type PlanFetchArgs, type PresetAspect, type PresetCapabilities, type PresetColumnWidthEntry, type PresetData, type PresetDraft, type PresetFilterOpEntry, type PresetLimitReachedBody, type PresetSnapshot, type PresetSnapshotWire, type PresetSorterEntry, PresetsClient, type PresetsClientConfig, PresetsHttpError, type PresetsListResult, type PresetsSaveAsOptions, type PresetsSaveResult, type QueryOptions, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, type SelectionMode, type SystemPreset, type SystemPresetInput, type TableStateData, type TableStateMethods, URL_SNAPSHOT_KEY, USER_CONF_PREFIX, type UnsupportedFilter, type UnsupportedFilterReason, type UrlQueryDefaults, type UrlQueryParseOptions, type UrlQueryStateLike, type UrlQueryStateSnapshot, type UrlQuerySync, type UserConfData, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnDefaultCondition, columnFilterConditions, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, gateOwns, hasSecondValue, isAuthError, isColumnFilterable, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseColumnFilterInput, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
1564
+ export { APP_CONF_PREFIX, type AppConfData, AppPrefsClient, type AppPrefsClientConfig, type AppPrefsLoadResult, type AsPresetEntryData, type AsPresetEntryRow, type AsPresetsErrorCode, type AspectGate, type AspectMask, type BuildTableQueryOptions, type ColumnFilterType, type ColumnReorderPosition, type ColumnWidthEntry, type ColumnWidthsMap, type ConfigTab, type CsvOptions, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DEV, DRAFT_PERSISTED_ASPECTS, type DateShortcut, type DecomposeUniqueryFilterOptions, type DecomposedUniqueryFilter, type DisplayColumnDef, type DraftPersistedAspect, ExportAbortError, type ExportPage, type ExportPageFetcher, type ExportScalar, type FetchPlan, type FetchPlanMode, type FieldFilters, type FilterCondition, type FilterConditionType, type FilterableColumn, MAX_DEFAULT_COLUMN_WIDTH_PX, type MergeResult, NULL_OPS, PRESET_ASPECTS, type PageAlignedBlock, type PlanFetchArgs, type PresetAspect, type PresetCapabilities, type PresetColumnWidthEntry, type PresetData, type PresetDraft, type PresetFilterOpEntry, type PresetLimitReachedBody, type PresetSnapshot, type PresetSnapshotWire, type PresetSorterEntry, PresetsClient, type PresetsClientConfig, PresetsHttpError, type PresetsListResult, type PresetsSaveAsOptions, type PresetsSaveResult, type QueryOptions, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, type SelectionMode, type SystemPreset, type SystemPresetInput, type TableStateData, type TableStateMethods, URL_SNAPSHOT_KEY, USER_CONF_PREFIX, type UnsupportedFilter, type UnsupportedFilterReason, type UrlQueryDefaults, type UrlQueryParseOptions, type UrlQueryStateLike, type UrlQueryStateSnapshot, type UrlQuerySync, type UserConfData, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnDefaultCondition, columnFilterConditions, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, decomposeUniqueryFilter, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterExprFields, filterExprKey, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, formatFilterExpr, fromWireSnapshot, gateOwns, hasSecondValue, isAuthError, isColumnFilterable, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, normalizeResidualFilters, pageAlignedBlocksFor, parseColumnFilterInput, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, residualGateOwns, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };