@atscript/ui-table 0.1.139 → 0.1.141

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;
@@ -520,8 +528,9 @@ function fieldsOf(expr, out = []) {
520
528
  else if (!out.includes(key)) out.push(key);
521
529
  return out;
522
530
  }
531
+ /** @internal Module-shared with the URL parser; not a package export. */
523
532
  function unsupported(reason, expr) {
524
- const fields = fieldsOf(expr);
533
+ const fields = fieldsOf(expr, []);
525
534
  return {
526
535
  reason: fields.length > 1 ? "cross-field" : reason,
527
536
  expr,
@@ -699,6 +708,7 @@ function invert(cond) {
699
708
  * anything else would invert into an OR of ANDs, which the model cannot hold.
700
709
  */
701
710
  function collectNot(child, out) {
711
+ if (isPlainObject(child) && Object.keys(child).length === 1 && "$not" in child) return collect(child.$not, out);
702
712
  const expr = { $not: child };
703
713
  const b = classifyBranch(child);
704
714
  if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
@@ -742,30 +752,27 @@ function warnUnsupported(issue) {
742
752
  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
753
  }
744
754
  /**
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:
750
- *
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.
755
+ * Split a Uniquery `FilterExpr` into what the table's field-filter model
756
+ * holds exactly (`filters`), what it cannot hold but carries as residual
757
+ * conditions (`residual`, with `carry`), what it leaves out (`unsupported`)
758
+ * and what names a field outside `knownFields` (`unknown`). Nothing is
759
+ * approximated: `filters AND residual` selects `expr` minus the
760
+ * `unsupported` and `unknown` pieces.
759
761
  *
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.
762
+ * Never throws, never warns — the caller decides how to report.
763
763
  *
764
- * Returns `{}` for an empty/missing expression. Never throws.
764
+ * @since 0.1.140
765
765
  */
766
- function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
767
- const acc = {};
768
- if (!expr) return acc;
766
+ function decomposeUniqueryFilter(expr, opts = {}) {
767
+ const filters = {};
768
+ const result = {
769
+ filters,
770
+ residual: [],
771
+ unsupported: [],
772
+ unknown: []
773
+ };
774
+ if (!expr) return result;
775
+ const knownFields = opts.knownFields;
769
776
  const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
770
777
  const isKnown = (field) => known === null || known.has(field);
771
778
  const out = {
@@ -783,21 +790,170 @@ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = w
783
790
  }];
784
791
  }
785
792
  const positives = /* @__PURE__ */ new Map();
793
+ const unknown = /* @__PURE__ */ new Map();
786
794
  for (const term of out.terms) {
787
- 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);
795
+ if (!isKnown(term.field)) {
796
+ unknown.set(term.expr, [term.field]);
797
+ continue;
798
+ }
799
+ const list = filters[term.field] ??= [];
800
+ if (isNegative(term)) {
792
801
  list.push(...term.conds);
793
- } else if (!sameConds(kept, term.conds)) out.issues.push({
802
+ continue;
803
+ }
804
+ const groups = positives.get(term.field);
805
+ if (!groups) {
806
+ positives.set(term.field, [term]);
807
+ list.push(...term.conds);
808
+ } else if (!groups.some((g) => sameConds(g.conds, term.conds))) groups.push(term);
809
+ }
810
+ const carried = [];
811
+ for (const [field, groups] of positives) {
812
+ if (groups.length < 2) continue;
813
+ const leftOut = opts.carry ? groups : groups.slice(1);
814
+ if (opts.carry) {
815
+ const kept = new Set(groups[0].conds);
816
+ const rest = filters[field].filter((c) => !kept.has(c));
817
+ if (rest.length > 0) filters[field] = rest;
818
+ else delete filters[field];
819
+ }
820
+ for (const term of leftOut) out.issues.push({
794
821
  reason: "conjunction",
795
822
  expr: term.expr,
796
- fields: [term.field]
823
+ fields: [field]
797
824
  });
798
825
  }
799
- for (const issue of out.issues) if (issue.fields.length === 0 || issue.fields.some(isKnown)) onUnsupportedFilter(issue);
800
- return acc;
826
+ for (const issue of out.issues) {
827
+ const fields = issue.fields;
828
+ const outside = fields.filter((f) => !isKnown(f));
829
+ if (outside.length > 0) unknown.set(issue.expr, outside);
830
+ else if (opts.carry && fields.length > 0) carried.push(issue.expr);
831
+ else result.unsupported.push(issue);
832
+ }
833
+ result.residual = normalizeResidualFilters(carried);
834
+ result.unknown = [...unknown].map(([expr, fields]) => ({
835
+ expr,
836
+ fields
837
+ }));
838
+ return result;
839
+ }
840
+ /**
841
+ * Canonical identity of a filter expression — its `@uniqu/url` spelling.
842
+ * Two expressions with the same key select the same rows.
843
+ *
844
+ * @since 0.1.140
845
+ */
846
+ function filterExprKey(expr) {
847
+ try {
848
+ return (0, _uniqu_url_builder.buildUrl)({ filter: expr });
849
+ } catch {
850
+ return JSON.stringify(expr) ?? "";
851
+ }
852
+ }
853
+ /**
854
+ * A residual-condition list with empty expressions and duplicates (by
855
+ * {@link filterExprKey}) dropped, sorted by key. Sorted, not first-appearance:
856
+ * the URL parser moves \`$not\`-wrapped clauses (how \`mergeFilters\` spells
857
+ * a repeated same-field clause) ahead of plain ones, so appearance order would
858
+ * flip on every round trip.
859
+ *
860
+ * @internal Exported for `@atscript/vue-table`.
861
+ */
862
+ function normalizeResidualFilters(exprs) {
863
+ const byKey = /* @__PURE__ */ new Map();
864
+ for (const expr of exprs) {
865
+ if (!isPlainObject(expr)) continue;
866
+ const key = filterExprKey(expr);
867
+ if (key && !byKey.has(key)) byKey.set(key, expr);
868
+ }
869
+ return [...byKey.keys()].toSorted().map((key) => byKey.get(key));
870
+ }
871
+ /**
872
+ * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
873
+ *
874
+ * Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
875
+ * produces. For any other input, each AND-ed piece is either converted exactly
876
+ * or left out whole and reported — never approximated:
877
+ *
878
+ * - `$in` becomes equality conditions on the field, `$nin` inequality ones.
879
+ * - A same-field `$or` becomes that field's OR'd conditions.
880
+ * - A `$not` that inverts equality / emptiness becomes the inverse conditions.
881
+ * - Fields next to `$and` / `$or` / `$not` in one object are all kept.
882
+ * - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
883
+ * an AND-ed piece out only ever widens the match, so the result selects a
884
+ * superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
885
+ * to a dev-mode `console.warn` when no handler is given. To keep those
886
+ * pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
887
+ *
888
+ * Pieces that name a field outside `knownFields` (when provided) are ignored
889
+ * silently: they are not this table's (a host page flag, a hidden or stale
890
+ * column). Since 0.1.141 that includes a piece mixing known and unknown
891
+ * fields — {@link decomposeUniqueryFilter} lists them as `unknown`.
892
+ *
893
+ * Returns `{}` for an empty/missing expression. Never throws.
894
+ */
895
+ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
896
+ const { filters, unsupported } = decomposeUniqueryFilter(expr, { knownFields });
897
+ for (const issue of unsupported) onUnsupportedFilter(issue);
898
+ return filters;
899
+ }
900
+ //#endregion
901
+ //#region src/filters/format-filter-expr.ts
902
+ function show(v) {
903
+ if (v instanceof Date) return v.toISOString();
904
+ if (typeof v === "object" && v !== null) return JSON.stringify(v) ?? "";
905
+ return String(v);
906
+ }
907
+ /** One comparison, worded like the filter chips (the decoder's operator table). */
908
+ function leaf(label, op, value) {
909
+ if (op === "$eq" && value instanceof RegExp) op = "$regex";
910
+ if ((op === "$in" || op === "$nin") && Array.isArray(value)) return `${label} ${op === "$in" ? "is one of" : "is none of"} ${value.map(show).join(", ")}`;
911
+ const conds = decodeOperator(op, value);
912
+ if (conds?.length === 1) return filterTokenLabel(label, conds, label);
913
+ return (0, _uniqu_url_builder.buildUrl)({ filter: { [label]: { [op]: value } } }) || `${label}${op}`;
914
+ }
915
+ function join(children, kind) {
916
+ const parts = children.filter((c) => c.s);
917
+ if (parts.length === 0) return {
918
+ s: "",
919
+ kind: "leaf"
920
+ };
921
+ if (parts.length === 1) return parts[0];
922
+ const wrap = kind === "and" ? "or" : "and";
923
+ return {
924
+ s: parts.map((c) => c.kind === wrap ? `(${c.s})` : c.s).join(` ${kind} `),
925
+ kind
926
+ };
927
+ }
928
+ /**
929
+ * Human-readable rendering of a Uniquery filter expression, worded like the
930
+ * filter-field chips: `(Status equals shipped and Total greater than 500) or
931
+ * (Status equals pending and Total less or equal 50)`. `and` binds tighter
932
+ * than `or`; groups are parenthesized only where needed. Operators without a
933
+ * wording fall back to their `@uniqu/url` spelling.
934
+ *
935
+ * @param labelOf — display label for a field path (e.g. the column label);
936
+ * the path itself when omitted or when it returns `undefined`.
937
+ * @since 0.1.140
938
+ */
939
+ function formatFilterExpr(expr, labelOf) {
940
+ const visitor = {
941
+ comparison: (field, op, value) => ({
942
+ s: leaf(labelOf?.(field) ?? field, op, value),
943
+ kind: "leaf"
944
+ }),
945
+ and: (children) => join(children, "and"),
946
+ or: (children) => join(children, "or"),
947
+ not: (child) => ({
948
+ s: child.s ? `not (${child.s})` : "",
949
+ kind: "leaf"
950
+ })
951
+ };
952
+ try {
953
+ return (0, _uniqu_core.walkFilter)(expr, visitor)?.s ?? "";
954
+ } catch {
955
+ return JSON.stringify(expr) ?? "";
956
+ }
801
957
  }
802
958
  //#endregion
803
959
  //#region src/filters/date-shortcuts.ts
@@ -1002,6 +1158,137 @@ function resolveSystemPresets(input) {
1002
1158
  }, ...named];
1003
1159
  }
1004
1160
  //#endregion
1161
+ //#region src/presets/prune-preset-snapshot.ts
1162
+ /** `[kept, dropped]` in one pass. */
1163
+ function partition(list, keep) {
1164
+ const kept = [];
1165
+ const dropped = [];
1166
+ for (const x of list) (keep(x) ? kept : dropped).push(x);
1167
+ return [kept, dropped];
1168
+ }
1169
+ /** The columns aspect, an empty width map spelled by omission — the way capture writes it. */
1170
+ function columnsAspect(columnNames, columnWidths) {
1171
+ return columnWidths && Object.keys(columnWidths).length > 0 ? {
1172
+ columnNames,
1173
+ columnWidths
1174
+ } : { columnNames };
1175
+ }
1176
+ /**
1177
+ * Remove every entry of `snapshot` that names a field outside `known`, per
1178
+ * aspect:
1179
+ *
1180
+ * - `columns.columnNames` — unknown names go, the rest keep their order. When
1181
+ * none survives, it falls back to every column, never an empty grid.
1182
+ * - `columns.columnWidths` — unknown keys go.
1183
+ * - `filters` (displayed inputs) and `filterOps` — entries on a field outside
1184
+ * `known.server` go. A field's conditions are one AND-ed conjunct, so
1185
+ * dropping one only broadens the result.
1186
+ * - `sorters` — unknown entries go, the rest keep their priority.
1187
+ * - `itemsPerPage` — untouched.
1188
+ *
1189
+ * `dropped` is `null` when nothing user-visible went (a width-only drop is
1190
+ * hygiene). The input is never mutated.
1191
+ *
1192
+ * @since 0.1.141
1193
+ */
1194
+ function prunePresetSnapshot(snapshot, known) {
1195
+ const isColumn = (p) => known.columns.has(p);
1196
+ const isServer = (p) => known.server.has(p);
1197
+ const out = { ...snapshot };
1198
+ const dropped = {
1199
+ fields: [],
1200
+ columns: [],
1201
+ filterFields: [],
1202
+ filters: {},
1203
+ residual: [],
1204
+ sorters: []
1205
+ };
1206
+ if (snapshot.columns) {
1207
+ const [names, gone] = partition(snapshot.columns.columnNames, isColumn);
1208
+ const [widths] = partition(Object.entries(snapshot.columns.columnWidths ?? {}), ([p]) => isColumn(p));
1209
+ dropped.columns = gone;
1210
+ out.columns = columnsAspect(names.length > 0 || gone.length === 0 ? names : [...known.columns], Object.fromEntries(widths));
1211
+ }
1212
+ if (snapshot.filters) [out.filters, dropped.filterFields] = partition(snapshot.filters, isServer);
1213
+ if (snapshot.filterOps) {
1214
+ const [kept, gone] = partition(Object.entries(snapshot.filterOps), ([p]) => isServer(p));
1215
+ out.filterOps = Object.fromEntries(kept);
1216
+ dropped.filters = Object.fromEntries(gone);
1217
+ }
1218
+ if (snapshot.sorters) [out.sorters, dropped.sorters] = partition(snapshot.sorters, (s) => isColumn(s.field));
1219
+ dropped.fields = [...new Set([
1220
+ ...dropped.columns,
1221
+ ...dropped.filterFields,
1222
+ ...Object.keys(dropped.filters),
1223
+ ...dropped.sorters.map((s) => s.field)
1224
+ ])];
1225
+ return {
1226
+ snapshot: out,
1227
+ dropped: dropped.fields.length > 0 ? dropped : null
1228
+ };
1229
+ }
1230
+ /**
1231
+ * Split residual filter conditions, in one pass, into the ones that name
1232
+ * only `known` fields and the ones that do not. A condition naming any
1233
+ * unknown field is dropped WHOLE — never pruned inside an `$or` (that would
1234
+ * narrow the result) or a `$not` (that would invert it). Each condition is an
1235
+ * AND-ed conjunct, so dropping one only broadens the result.
1236
+ *
1237
+ * `dropped` holds the conditions in `residual` and the unknown paths in
1238
+ * `fields`; `null` when every condition is kept.
1239
+ *
1240
+ * @since 0.1.141
1241
+ */
1242
+ function pruneResidualFilters(exprs, known) {
1243
+ const kept = [];
1244
+ const residual = [];
1245
+ const fields = /* @__PURE__ */ new Set();
1246
+ for (const expr of exprs) {
1247
+ const unknown = filterExprFields(expr).filter((p) => !known.has(p));
1248
+ if (unknown.length === 0) {
1249
+ kept.push(expr);
1250
+ continue;
1251
+ }
1252
+ residual.push(expr);
1253
+ for (const p of unknown) fields.add(p);
1254
+ }
1255
+ if (residual.length === 0) return {
1256
+ kept,
1257
+ dropped: null
1258
+ };
1259
+ return {
1260
+ kept,
1261
+ dropped: {
1262
+ fields: [...fields],
1263
+ columns: [],
1264
+ filterFields: [],
1265
+ filters: {},
1266
+ residual,
1267
+ sorters: []
1268
+ }
1269
+ };
1270
+ }
1271
+ /**
1272
+ * Append what a prune dropped back onto a snapshot captured from (pruned)
1273
+ * table state, so overwriting a preset with a narrower view does not destroy
1274
+ * the parts the saver cannot see. Column names, filter inputs and sorters go
1275
+ * after the captured ones; field filters are added back. Only aspects
1276
+ * `captured` carries are touched; widths of hidden columns are not kept.
1277
+ *
1278
+ * @since 0.1.141
1279
+ */
1280
+ function restoreDroppedEntries(captured, dropped) {
1281
+ const out = { ...captured };
1282
+ if (captured.columns) out.columns = columnsAspect([...captured.columns.columnNames, ...dropped.columns], captured.columns.columnWidths);
1283
+ if (captured.filters) out.filters = [...captured.filters, ...dropped.filterFields];
1284
+ if (captured.filterOps) out.filterOps = {
1285
+ ...captured.filterOps,
1286
+ ...dropped.filters
1287
+ };
1288
+ if (captured.sorters) out.sorters = [...captured.sorters, ...dropped.sorters];
1289
+ return out;
1290
+ }
1291
+ //#endregion
1005
1292
  //#region src/presets/preset-dirty.ts
1006
1293
  /**
1007
1294
  * JSON-stringify with object keys sorted alphabetically at every depth so
@@ -1394,8 +1681,9 @@ function mergeSorters(forceSorters, userSorters) {
1394
1681
  //#endregion
1395
1682
  //#region src/query/merge-filters.ts
1396
1683
  /**
1397
- * AND-merge two filter expressions, producing a wire shape that survives
1398
- * `@uniqu/url`'s `mergeConjunction` parser collapse.
1684
+ * AND-merge filter expressions (`undefined` ones skipped), producing a wire
1685
+ * shape that survives `@uniqu/url`'s `mergeConjunction` parser collapse.
1686
+ * Variadic since 0.1.140.
1399
1687
  *
1400
1688
  * The collapse problem: when two `$and` siblings target the same field
1401
1689
  * with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
@@ -1408,10 +1696,10 @@ function mergeSorters(forceSorters, userSorters) {
1408
1696
  * and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
1409
1697
  * same AND. Non-colliding merges produce the canonical `$and` shape.
1410
1698
  */
1411
- function mergeFilters(a, b) {
1412
- if (!a) return b;
1413
- if (!b) return a;
1414
- return makeParserSafeAnd([a, b]);
1699
+ function mergeFilters(...exprs) {
1700
+ const list = exprs.filter((e) => !!e);
1701
+ if (list.length <= 1) return list[0];
1702
+ return makeParserSafeAnd(list);
1415
1703
  }
1416
1704
  /** Op-set for a field value: primitives are `$eq`, op-bags expose their keys. */
1417
1705
  function getOpsForFieldValue(value) {
@@ -1479,12 +1767,12 @@ function makeParserSafeAnd(children) {
1479
1767
  * Build a Uniquery object from table UI state.
1480
1768
  *
1481
1769
  * Pure function — no framework dependencies.
1482
- * Combines user filters with force filters, merges sorters,
1770
+ * Combines user filters (field filters, then residual conditions) with force
1771
+ * filters, merges sorters,
1483
1772
  * projects visible columns, and applies pagination.
1484
1773
  */
1485
1774
  function buildTableQuery(opts) {
1486
- const userFilter = filtersToUniqueryFilter(opts.filters);
1487
- const filter = mergeFilters(opts.forceFilters, userFilter);
1775
+ const filter = mergeFilters(opts.forceFilters, filtersToUniqueryFilter(opts.filters), ...opts.residualFilters ?? []);
1488
1776
  const userSorters = opts.ignoreSorters ? [] : opts.sorters;
1489
1777
  const sorters = opts.forceSorters?.length ? mergeSorters(opts.forceSorters, userSorters) : userSorters;
1490
1778
  const $sort = {};
@@ -1544,6 +1832,17 @@ function gateOwns(gate, path) {
1544
1832
  if (gate === "none") return false;
1545
1833
  return gate.has(path);
1546
1834
  }
1835
+ /**
1836
+ * Does a URL under `gate` own the residual condition `expr`? Only when it
1837
+ * owns every field the condition references — a condition that touches a
1838
+ * private field stays private as a whole.
1839
+ *
1840
+ * @internal Exported for `@atscript/vue-table`.
1841
+ */
1842
+ function residualGateOwns(gate, expr) {
1843
+ const fields = filterExprFields(expr);
1844
+ return fields.length > 0 && fields.every((path) => gateOwns(gate, path));
1845
+ }
1547
1846
  function pickFilterPaths(filters, gate) {
1548
1847
  const out = {};
1549
1848
  for (const path in filters) if (gateOwns(gate, path)) out[path] = filters[path];
@@ -1570,6 +1869,7 @@ function stateToUrlQueryString(state, defaults) {
1570
1869
  visibleColumnPaths: [],
1571
1870
  sorters: sortersGate === "all" ? state.sorters : state.sorters.filter((s) => gateOwns(sortersGate, s.field)),
1572
1871
  filters,
1872
+ residualFilters: defaults.sync?.residual === false || !state.residualFilters?.length ? void 0 : state.residualFilters.filter((expr) => residualGateOwns(filtersGate, expr)),
1573
1873
  search: searchOff ? void 0 : state.searchTerm || void 0
1574
1874
  });
1575
1875
  if (!searchOff && state.searchTerm && state.ignoreSorters !== void 0 && state.ignoreSorters !== (defaults.defaultIgnoreSorters ?? false)) query.controls.$relevance = state.ignoreSorters ? 1 : 0;
@@ -1592,8 +1892,25 @@ const CONSUMED_CONTROLS = new Set([
1592
1892
  "$skip",
1593
1893
  URL_SNAPSHOT_KEY
1594
1894
  ]);
1595
- /** Characters that can only appear in a uniqu filter key, never in a page flag. */
1596
- const FILTER_OPERATOR_CHAR = /[<>!~]/;
1895
+ function toSet(paths) {
1896
+ if (!paths) return null;
1897
+ return paths instanceof Set ? paths : new Set(paths);
1898
+ }
1899
+ /**
1900
+ * `field=value` (or `field=null`) — the one filter shape a host page flag
1901
+ * shares, so a bare one on an unknown field is not reported as dropped.
1902
+ */
1903
+ function isBareEquality(expr) {
1904
+ const keys = Object.keys(expr);
1905
+ if (keys.length !== 1 || keys[0].startsWith("$")) return false;
1906
+ const v = expr[keys[0]];
1907
+ return v === null || typeof v !== "object";
1908
+ }
1909
+ /**
1910
+ * Characters that can only appear in a uniqu filter key, never in a page flag:
1911
+ * comparison operators, `^` (OR), `( )` (groups) and `{ }` (`$in` lists).
1912
+ */
1913
+ const FILTER_OPERATOR_CHAR = /[<>!~()^{}]/;
1597
1914
  /**
1598
1915
  * Whether {@link urlQueryStringToState} would consume the query key `key` —
1599
1916
  * i.e. whether the key belongs to the table rather than to the page hosting
@@ -1615,12 +1932,15 @@ function urlQueryConsumesKey(key) {
1615
1932
  /**
1616
1933
  * Parse a URL query string back into the table state subset.
1617
1934
  *
1618
- * Robust by design — schema drift and copy-paste errors must not break the
1619
- * recipient's view:
1620
- * - unknown fields (not in `knownFields`) → silently dropped
1935
+ * Robust by design — schema drift, fields hidden from the recipient and
1936
+ * copy-paste errors must not break the recipient's view:
1937
+ * - unknown fields (not in `knownFields`) → dropped, listed in `unknown` /
1938
+ * `unknownSorters` (the parser does not warn — the caller decides)
1621
1939
  * - filter pieces field filters cannot express (cross-field OR, unknown
1622
1940
  * operator, …) → left out of `filters` and listed in `unsupported`, never
1623
- * approximated (the parser does not warn — the caller decides)
1941
+ * approximated (the parser does not warn — the caller decides). Unless
1942
+ * `sync.residual` is `false`, those whose fields are all known come back
1943
+ * in `residual` instead
1624
1944
  * - unknown controls (e.g. `$weird=42`) → silently ignored
1625
1945
  * - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
1626
1946
  *
@@ -1648,30 +1968,35 @@ function urlQueryStringToState(urlString, opts = {}) {
1648
1968
  const sortersGate = resolveAspectGate(opts.sync?.sorters);
1649
1969
  const searchOff = opts.sync?.search === false;
1650
1970
  const paginationOff = opts.sync?.pagination === false;
1651
- const knownSet = opts.knownFields ? new Set(opts.knownFields) : null;
1971
+ const knownSet = toSet(opts.knownFields);
1972
+ const localSet = toSet(opts.localFields);
1973
+ const isHidden = (f) => !!knownSet && !knownSet.has(f) && !localSet?.has(f);
1652
1974
  let filterKnown;
1653
1975
  if (filtersGate === "all") filterKnown = knownSet ?? void 0;
1654
1976
  else if (filtersGate !== "none") if (knownSet) {
1655
- filterKnown = /* @__PURE__ */ new Set();
1656
- for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
1977
+ const owned = /* @__PURE__ */ new Set();
1978
+ for (const path of filtersGate) if (knownSet.has(path)) owned.add(path);
1979
+ filterKnown = owned;
1657
1980
  } else filterKnown = filtersGate;
1658
- const unsupported = [];
1659
- const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown, (issue) => unsupported.push(issue));
1981
+ const decomposed = filtersGate === "none" ? null : decomposeUniqueryFilter(parsed.filter, {
1982
+ knownFields: filterKnown,
1983
+ carry: opts.sync?.residual !== false
1984
+ });
1985
+ const filters = decomposed?.filters ?? {};
1660
1986
  const sorters = [];
1987
+ const unknownSorters = [];
1661
1988
  if (sortersGate !== "none") {
1662
1989
  const $sort = parsed.controls?.$sort;
1663
1990
  if ($sort && typeof $sort === "object") for (const field in $sort) {
1664
- if (knownSet && !knownSet.has(field)) continue;
1665
1991
  if (!gateOwns(sortersGate, field)) continue;
1666
1992
  const dir = $sort[field];
1667
- if (dir === 1) sorters.push({
1993
+ if (dir !== 1 && dir !== -1) continue;
1994
+ const sorter = {
1668
1995
  field,
1669
- direction: "asc"
1670
- });
1671
- else if (dir === -1) sorters.push({
1672
- field,
1673
- direction: "desc"
1674
- });
1996
+ direction: dir === 1 ? "asc" : "desc"
1997
+ };
1998
+ if (!knownSet || knownSet.has(field)) sorters.push(sorter);
1999
+ else if (isHidden(field)) unknownSorters.push(sorter);
1675
2000
  }
1676
2001
  }
1677
2002
  const $search = parsed.controls?.$search;
@@ -1680,7 +2005,24 @@ function urlQueryStringToState(urlString, opts = {}) {
1680
2005
  sorters,
1681
2006
  searchTerm: !searchOff && typeof $search === "string" ? $search : ""
1682
2007
  };
1683
- if (unsupported.length > 0) out.unsupported = unsupported;
2008
+ const unsupported$1 = decomposed?.unsupported ?? [];
2009
+ const unknown = [];
2010
+ for (const piece of decomposed?.unknown ?? []) {
2011
+ const hidden = piece.fields.filter(isHidden);
2012
+ if (hidden.length > 0) {
2013
+ if (!isBareEquality(piece.expr)) unknown.push({
2014
+ expr: piece.expr,
2015
+ fields: hidden
2016
+ });
2017
+ continue;
2018
+ }
2019
+ const issue = unsupported("cross-field", piece.expr);
2020
+ if (issue.fields.some((f) => filterKnown?.has(f))) unsupported$1.push(issue);
2021
+ }
2022
+ if (unsupported$1.length) out.unsupported = unsupported$1;
2023
+ if (decomposed?.residual.length) out.residual = decomposed.residual;
2024
+ if (unknown.length) out.unknown = unknown;
2025
+ if (unknownSorters.length) out.unknownSorters = unknownSorters;
1684
2026
  if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
1685
2027
  if (!searchOff) {
1686
2028
  const $relevance = parsed.controls?.$relevance;
@@ -2265,15 +2607,19 @@ exports.conditionsForType = conditionsForType;
2265
2607
  exports.csvCell = csvCell;
2266
2608
  exports.dateShortcuts = dateShortcuts;
2267
2609
  exports.debounce = debounce;
2610
+ exports.decomposeUniqueryFilter = decomposeUniqueryFilter;
2268
2611
  exports.defaultCondition = defaultCondition;
2269
2612
  exports.derivePresetAspects = derivePresetAspects;
2270
2613
  exports.deserializeDraft = deserializeDraft;
2271
2614
  exports.draftMatchesPreset = draftMatchesPreset;
2272
2615
  exports.escapeRegex = escapeRegex;
2273
2616
  exports.filledFilterCount = filledFilterCount;
2617
+ exports.filterExprFields = filterExprFields;
2618
+ exports.filterExprKey = filterExprKey;
2274
2619
  exports.filterTokenLabel = filterTokenLabel;
2275
2620
  exports.filtersToUniqueryFilter = filtersToUniqueryFilter;
2276
2621
  exports.formatFilterCondition = formatFilterCondition;
2622
+ exports.formatFilterExpr = formatFilterExpr;
2277
2623
  exports.fromWireSnapshot = fromWireSnapshot;
2278
2624
  exports.gateOwns = gateOwns;
2279
2625
  exports.hasSecondValue = hasSecondValue;
@@ -2289,15 +2635,20 @@ exports.mergeDisplayColumns = mergeDisplayColumns;
2289
2635
  exports.mergeFilters = mergeFilters;
2290
2636
  exports.mergeSorters = mergeSorters;
2291
2637
  exports.normaliseSystemPresetId = normaliseSystemPresetId;
2638
+ exports.normalizeResidualFilters = normalizeResidualFilters;
2292
2639
  exports.pageAlignedBlocksFor = pageAlignedBlocksFor;
2293
2640
  exports.parseColumnFilterInput = parseColumnFilterInput;
2294
2641
  exports.parseFilterInput = parseFilterInput;
2295
2642
  exports.planFetch = planFetch;
2643
+ exports.prunePresetSnapshot = prunePresetSnapshot;
2644
+ exports.pruneResidualFilters = pruneResidualFilters;
2296
2645
  exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
2297
2646
  exports.reorderColumnNames = reorderColumnNames;
2647
+ exports.residualGateOwns = residualGateOwns;
2298
2648
  exports.resolveAspectGate = resolveAspectGate;
2299
2649
  exports.resolveExportValue = resolveExportValue;
2300
2650
  exports.resolveSystemPresets = resolveSystemPresets;
2651
+ exports.restoreDroppedEntries = restoreDroppedEntries;
2301
2652
  exports.rowsToPks = rowsToPks;
2302
2653
  exports.sameColumnSet = sameColumnSet;
2303
2654
  exports.serializeDraft = serializeDraft;