@atscript/ui-table 0.1.138 → 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.mjs CHANGED
@@ -1,6 +1,7 @@
1
+ import { isLogicalKey, walkFilter } from "@uniqu/core";
2
+ import { buildUrl } from "@uniqu/url/builder";
1
3
  import { ClientError } from "@atscript/db-client";
2
4
  import { getDefaultClientFactory, str } from "@atscript/ui";
3
- import { buildUrl } from "@uniqu/url/builder";
4
5
  import { parseUrl } from "@uniqu/url";
5
6
  //#region src/filters/filter-conditions.ts
6
7
  /** Conditions that operate purely on nullability — value is ignored. */
@@ -134,6 +135,40 @@ function columnFilterType(columnType) {
134
135
  default: return "text";
135
136
  }
136
137
  }
138
+ const EXISTENCE_CONDITIONS = ["null", "notNull"];
139
+ const NO_CONDITIONS = [];
140
+ /**
141
+ * Filter conditions a column offers — the one answer every filter UI (column
142
+ * menu, filter dialog, filter bar, config dialog) reads.
143
+ *
144
+ * - Value-filterable (`filterable: true`) → {@link conditionsForType} for its
145
+ * display type.
146
+ * - Existence-only (`filterable: false`, `filterOps` includes `$exists` — a
147
+ * JSON-stored column) → `null` / `notNull`: whether a value is present,
148
+ * never what it is.
149
+ * - Otherwise → `[]`: the column takes no filter.
150
+ *
151
+ * `null` / `notNull` are dropped for non-nullable columns, so an existence-only
152
+ * column that is never empty offers nothing.
153
+ *
154
+ * @since 0.1.139
155
+ */
156
+ function columnFilterConditions(column) {
157
+ if (column.filterable) return conditionsForType(columnFilterType(column.type), column.nullable);
158
+ if (column.nullable && column.filterOps?.includes("$exists")) return EXISTENCE_CONDITIONS;
159
+ return NO_CONDITIONS;
160
+ }
161
+ /**
162
+ * Whether a column takes any filter at all — value comparisons or the
163
+ * existence-only `null` / `notNull` pair. Use it (not
164
+ * `column.filterable`, which is value comparison only) to decide whether to
165
+ * show a column in a filter UI.
166
+ *
167
+ * @since 0.1.139
168
+ */
169
+ function isColumnFilterable(column) {
170
+ return columnFilterConditions(column).length > 0;
171
+ }
137
172
  //#endregion
138
173
  //#region src/filters/escape-regex.ts
139
174
  /** Escape special regex characters in user input for safe embedding in $regex. */
@@ -175,6 +210,18 @@ function defaultCondition(columnType) {
175
210
  default: return "eq";
176
211
  }
177
212
  }
213
+ /**
214
+ * The condition a column's filter input starts with: its type's
215
+ * {@link defaultCondition} when the column offers it, otherwise the first
216
+ * condition it does offer (`null` on an existence-only column).
217
+ *
218
+ * @since 0.1.139
219
+ */
220
+ function columnDefaultCondition(column) {
221
+ const type = defaultCondition(columnFilterType(column.type));
222
+ const offered = columnFilterConditions(column);
223
+ return offered.length === 0 || offered.includes(type) ? type : offered[0];
224
+ }
178
225
  /** Prefix operators in match order (longest first). */
179
226
  const PREFIX_OPS = [
180
227
  ["!=", "ne"],
@@ -207,12 +254,26 @@ const PREFIX_OPS = [
207
254
  * number/date/boolean → eq
208
255
  *
209
256
  * Returns undefined for empty/invalid input or if the parsed operator
210
- * is not available for the column type.
257
+ * is not available for the column type. To honour what a specific column
258
+ * offers (an existence-only column takes only `<empty>` / `!<empty>`), use
259
+ * {@link parseColumnFilterInput}.
211
260
  */
212
261
  function parseFilterInput(text, columnType, nullable = true) {
262
+ return parseInput(text, columnType, conditionsForType(columnType, nullable));
263
+ }
264
+ /**
265
+ * {@link parseFilterInput} for a column: the type comes from the column and
266
+ * the accepted operators are {@link columnFilterConditions} — the ones the
267
+ * column's filter UI offers.
268
+ *
269
+ * @since 0.1.139
270
+ */
271
+ function parseColumnFilterInput(text, column) {
272
+ return parseInput(text, columnFilterType(column.type), columnFilterConditions(column));
273
+ }
274
+ function parseInput(text, columnType, available) {
213
275
  const trimmed = text.trim();
214
276
  if (trimmed === "") return void 0;
215
- const available = conditionsForType(columnType, nullable);
216
277
  const isNumber = columnType === "number";
217
278
  const build = (type, value) => {
218
279
  if (!available.includes(type)) return void 0;
@@ -288,6 +349,10 @@ function formatFilterCondition(condition) {
288
349
  //#region src/filters/filters-to-uniquery.ts
289
350
  /** Exclusion condition types — AND'd together per field. */
290
351
  const EXCLUSION_TYPES = new Set(["ne", "notNull"]);
352
+ /** Whether conditions of `type` are exclusions (AND'd per field) rather than inclusions (OR'd). */
353
+ function isExclusionType(type) {
354
+ return EXCLUSION_TYPES.has(type);
355
+ }
291
356
  /**
292
357
  * Convert a single condition to a Uniquery filter expression.
293
358
  * Returns a ComparisonNode with the field as key.
@@ -336,7 +401,7 @@ function filtersToUniqueryFilter(fieldFilters) {
336
401
  for (const condition of conditions) {
337
402
  if (!isFilled(condition)) continue;
338
403
  const expr = conditionToExpr(field, condition);
339
- if (EXCLUSION_TYPES.has(condition.type)) (exclusions ??= []).push(expr);
404
+ if (isExclusionType(condition.type)) (exclusions ??= []).push(expr);
340
405
  else (inclusions ??= []).push(expr);
341
406
  }
342
407
  if (inclusions) pushGroup(topGroups, inclusions, "$or");
@@ -347,25 +412,33 @@ function filtersToUniqueryFilter(fieldFilters) {
347
412
  return { $and: topGroups };
348
413
  }
349
414
  //#endregion
415
+ //#region src/utils/dev.ts
416
+ /**
417
+ * `true` outside a production build — the gate for authoring-time warnings, so
418
+ * their strings and checks cost a shipped app nothing.
419
+ *
420
+ * Read from `process.env.NODE_ENV` rather than `import.meta.env`: bundlers
421
+ * replace this expression with a literal in both the ESM and the CJS output,
422
+ * while `import.meta` only exists in the former. Where neither a bundler nor a
423
+ * `process` shim is present the read throws — warnings stay on, which is the
424
+ * safe side of the trade.
425
+ */
426
+ function detectDev() {
427
+ try {
428
+ return process.env.NODE_ENV !== "production";
429
+ } catch {
430
+ return true;
431
+ }
432
+ }
433
+ const DEV = detectDev();
434
+ //#endregion
350
435
  //#region src/filters/uniquery-to-filters.ts
351
- const SUPPORTED_TYPES = new Set([
352
- "eq",
353
- "ne",
354
- "gt",
355
- "gte",
356
- "lt",
357
- "lte",
358
- "contains",
359
- "starts",
360
- "ends",
361
- "bw",
362
- "null",
363
- "notNull",
364
- "regex"
365
- ]);
366
436
  function isPrimitive(v) {
367
437
  return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
368
438
  }
439
+ function isPlainObject(v) {
440
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof RegExp) && !(v instanceof Date);
441
+ }
369
442
  const REGEX_LITERAL = /^\/(.*)\/([a-z]*)$/s;
370
443
  const STARTS_ANCHOR = /^\^(.+)$/s;
371
444
  const ENDS_ANCHOR = /^(.+)\$$/s;
@@ -376,6 +449,7 @@ const ENDS_ANCHOR = /^(.+)\$$/s;
376
449
  * `regex` condition.
377
450
  */
378
451
  function regexToCondition(raw) {
452
+ if (raw instanceof RegExp) raw = raw.toString();
379
453
  if (typeof raw !== "string") return null;
380
454
  const m = REGEX_LITERAL.exec(raw);
381
455
  if (!m) return {
@@ -404,109 +478,469 @@ function regexToCondition(raw) {
404
478
  value: [raw]
405
479
  };
406
480
  }
407
- function pushIfPrimitive(out, type, v) {
408
- if (!isPrimitive(v)) return;
409
- out.push({
410
- type,
481
+ const nullCond = () => ({
482
+ type: "null",
483
+ value: []
484
+ });
485
+ const notNullCond = () => ({
486
+ type: "notNull",
487
+ value: []
488
+ });
489
+ /** Positive condition for one `$eq` / `$in` value — `null` means "is empty". */
490
+ function memberCondition(v) {
491
+ if (v === null) return nullCond();
492
+ return isPrimitive(v) ? {
493
+ type: "eq",
411
494
  value: [v]
412
- });
495
+ } : null;
413
496
  }
497
+ /** Negative condition for one `$ne` / `$nin` value — `null` means "is not empty". */
498
+ function nonMemberCondition(v) {
499
+ if (v === null) return notNullCond();
500
+ return isPrimitive(v) ? {
501
+ type: "ne",
502
+ value: [v]
503
+ } : null;
504
+ }
505
+ const RANGE_OPS = {
506
+ $gt: "gt",
507
+ $gte: "gte",
508
+ $lt: "lt",
509
+ $lte: "lte"
510
+ };
414
511
  /**
415
- * Decode a per-field operator object (e.g. `{ $gt: 5, $lte: 10 }`) into one or
416
- * more `FilterCondition`s. Unknown operators are silently dropped.
512
+ * Field paths a Uniquery filter expression references, deduped, in order of
513
+ * appearance (logical operators are walked, operator keys skipped).
417
514
  *
418
- * `$gte` + `$lte` on the same field collapse into a single `bw` condition,
419
- * matching the encoder's behaviour.
515
+ * @internal Exported for `@atscript/vue-table`.
420
516
  */
421
- function decodeFieldOps(ops) {
422
- const out = [];
423
- const hasGte = "$gte" in ops && isPrimitive(ops.$gte);
424
- const hasLte = "$lte" in ops && isPrimitive(ops.$lte);
425
- if (hasGte && hasLte) out.push({
426
- type: "bw",
427
- value: [ops.$gte, ops.$lte]
517
+ function filterExprFields(expr) {
518
+ return fieldsOf(expr, []);
519
+ }
520
+ function fieldsOf(expr, out) {
521
+ if (Array.isArray(expr)) {
522
+ for (const child of expr) fieldsOf(child, out);
523
+ return out;
524
+ }
525
+ if (!isPlainObject(expr)) return out;
526
+ for (const key in expr) if (key.startsWith("$")) fieldsOf(expr[key], out);
527
+ else if (!out.includes(key)) out.push(key);
528
+ return out;
529
+ }
530
+ function unsupported(reason, expr) {
531
+ const fields = fieldsOf(expr, []);
532
+ return {
533
+ reason: fields.length > 1 ? "cross-field" : reason,
534
+ expr,
535
+ fields
536
+ };
537
+ }
538
+ const isNegative = (term) => isExclusionType(term.conds[0].type);
539
+ /**
540
+ * Add decoded conditions as terms: negatives one term each (they AND), a
541
+ * positive list as one group (it ORs). Conditions share their polarity.
542
+ */
543
+ function addTerms(out, field, conds, expr) {
544
+ if (conds.length === 0) return;
545
+ if (isExclusionType(conds[0].type)) for (const cond of conds) out.terms.push({
546
+ field,
547
+ conds: [cond],
548
+ expr
428
549
  });
429
- else {
430
- if (hasGte) pushIfPrimitive(out, "gte", ops.$gte);
431
- if (hasLte) pushIfPrimitive(out, "lte", ops.$lte);
432
- }
433
- if ("$eq" in ops) pushIfPrimitive(out, "eq", ops.$eq);
434
- if ("$ne" in ops) pushIfPrimitive(out, "ne", ops.$ne);
435
- if ("$gt" in ops) pushIfPrimitive(out, "gt", ops.$gt);
436
- if ("$lt" in ops) pushIfPrimitive(out, "lt", ops.$lt);
437
- if ("$exists" in ops) {
438
- if (ops.$exists === false) out.push({
439
- type: "null",
440
- value: []
441
- });
442
- else if (ops.$exists === true) out.push({
443
- type: "notNull",
444
- value: []
445
- });
550
+ else out.terms.push({
551
+ field,
552
+ conds,
553
+ expr
554
+ });
555
+ }
556
+ /**
557
+ * `a>=x` AND `a<=y` is the model's `bw`, whether both halves sit in one
558
+ * operator object (the encoder's shape) or in two AND-ed pieces. Folds `cond`
559
+ * into an earlier lone opposite half on `field`; `false` when there is none.
560
+ */
561
+ function pairRange(out, field, cond) {
562
+ const opposite = cond.type === "gte" ? "lte" : cond.type === "lte" ? "gte" : null;
563
+ if (!opposite) return false;
564
+ const i = out.terms.findIndex((t) => t.field === field && t.conds.length === 1 && t.conds[0].type === opposite);
565
+ if (i < 0) return false;
566
+ const other = out.terms[i].conds[0];
567
+ const [lo, hi] = cond.type === "gte" ? [cond.value[0], other.value[0]] : [other.value[0], cond.value[0]];
568
+ out.terms[i] = {
569
+ field,
570
+ conds: [{
571
+ type: "bw",
572
+ value: [lo, hi]
573
+ }],
574
+ expr: { [field]: {
575
+ $gte: lo,
576
+ $lte: hi
577
+ } }
578
+ };
579
+ return true;
580
+ }
581
+ /**
582
+ * Decode one field entry (`field: value`) into terms. An operator object is an
583
+ * implicit AND of its operators — each becomes its own term.
584
+ */
585
+ function collectField(field, value, out) {
586
+ const whole = { [field]: value };
587
+ if (value === null || value === void 0) return addTerms(out, field, [nullCond()], whole);
588
+ if (isPrimitive(value)) return addTerms(out, field, [{
589
+ type: "eq",
590
+ value: [value]
591
+ }], whole);
592
+ if (value instanceof RegExp) return addTerms(out, field, [regexToCondition(value)], whole);
593
+ if (!isPlainObject(value)) {
594
+ out.issues.push(unsupported("operator", whole));
595
+ return;
446
596
  }
447
- if ("$regex" in ops) {
448
- const cond = regexToCondition(ops.$regex);
449
- if (cond) out.push(cond);
597
+ for (const op in value) {
598
+ const v = value[op];
599
+ if (v === void 0) continue;
600
+ const expr = { [field]: { [op]: v } };
601
+ const conds = decodeOperator(op, v);
602
+ if (!conds) out.issues.push(unsupported("operator", expr));
603
+ else if (conds.length !== 1 || !pairRange(out, field, conds[0])) addTerms(out, field, conds, expr);
450
604
  }
451
- return out;
452
605
  }
606
+ const one = (cond) => cond ? [cond] : null;
453
607
  /**
454
- * Walk a leaf `ComparisonNode` (one or more field=value entries) into the
455
- * shared `FieldFilters` accumulator. Drops unknown fields and operators.
608
+ * One operator of a field's operator object, as conditions of one polarity
609
+ * (see {@link addTerms}). `null` means no condition type expresses the
610
+ * operator / operand.
456
611
  */
457
- function walkLeaf(node, acc, knownFields) {
458
- for (const field in node) {
459
- if (field.startsWith("$")) continue;
460
- if (knownFields && !knownFields.has(field)) continue;
461
- const value = node[field];
462
- let conditions;
463
- if (value === null || value === void 0) conditions = [{
464
- type: "null",
465
- value: []
466
- }];
467
- else if (isPrimitive(value)) conditions = [{
468
- type: "eq",
469
- value: [value]
470
- }];
471
- else if (typeof value === "object" && !Array.isArray(value)) conditions = decodeFieldOps(value);
472
- else continue;
473
- if (conditions.length === 0) continue;
474
- const filtered = conditions.filter((c) => SUPPORTED_TYPES.has(c.type));
475
- if (filtered.length === 0) continue;
476
- if (acc[field]) acc[field] = [...acc[field], ...filtered];
477
- else acc[field] = filtered;
478
- }
479
- }
480
- function walkExpr(expr, acc, knownFields) {
481
- if ("$and" in expr && expr.$and) {
482
- for (const child of expr.$and) walkExpr(child, acc, knownFields);
612
+ function decodeOperator(op, v) {
613
+ if (op in RANGE_OPS) return one(isPrimitive(v) ? {
614
+ type: RANGE_OPS[op],
615
+ value: [v]
616
+ } : null);
617
+ switch (op) {
618
+ case "$eq": return one(memberCondition(v));
619
+ case "$ne": return one(nonMemberCondition(v));
620
+ case "$regex": return one(regexToCondition(v));
621
+ case "$exists":
622
+ if (v === false) return one(nullCond());
623
+ return v === true ? one(notNullCond()) : null;
624
+ case "$in": {
625
+ const conds = Array.isArray(v) ? v.map(memberCondition) : [];
626
+ return conds.length > 0 && conds.every(Boolean) ? conds : null;
627
+ }
628
+ case "$nin": {
629
+ if (!Array.isArray(v)) return null;
630
+ const conds = v.map(nonMemberCondition);
631
+ return conds.every(Boolean) ? conds : null;
632
+ }
633
+ default: return null;
634
+ }
635
+ }
636
+ /**
637
+ * Decode an `$or` branch / `$not` operand on its own. Usable only when it
638
+ * converts fully and stays on one field; `null` when it constrains nothing.
639
+ * A branch that spans fields comes back with a placeholder reason —
640
+ * {@link unsupported} names it `"cross-field"` from the fields it sees.
641
+ */
642
+ function classifyBranch(expr) {
643
+ const sub = {
644
+ terms: [],
645
+ issues: []
646
+ };
647
+ collect(expr, sub);
648
+ if (sub.issues.length > 0) return { reason: sub.issues[0].reason };
649
+ if (sub.terms.length === 0) return null;
650
+ const field = sub.terms[0].field;
651
+ if (sub.terms.some((t) => t.field !== field)) return { reason: "operator" };
652
+ return {
653
+ field,
654
+ terms: sub.terms
655
+ };
656
+ }
657
+ /**
658
+ * `$or` fits the model only as a same-field OR of positive branches. Each
659
+ * branch must decode to exactly one positive group; the groups merge.
660
+ */
661
+ function collectOr(branches, out) {
662
+ const expr = { $or: branches };
663
+ if (!Array.isArray(branches) || branches.length === 0) {
664
+ out.issues.push(unsupported("operator", expr));
483
665
  return;
484
666
  }
485
- if ("$or" in expr && expr.$or) {
486
- for (const child of expr.$or) walkExpr(child, acc, knownFields);
667
+ if (branches.length === 1) return collect(branches[0], out);
668
+ const groups = [];
669
+ let reason;
670
+ for (const branch of branches) {
671
+ const b = classifyBranch(branch);
672
+ if (b === null) return;
673
+ if ("reason" in b) reason ??= b.reason;
674
+ else if (b.terms.length > 1) reason ??= "conjunction";
675
+ else if (isNegative(b.terms[0])) reason ??= "negation";
676
+ else groups.push(b.terms[0]);
677
+ }
678
+ const field = groups[0]?.field;
679
+ if (reason !== void 0 || groups.some((t) => t.field !== field)) {
680
+ out.issues.push(unsupported(reason ?? "operator", expr));
681
+ return;
682
+ }
683
+ out.terms.push({
684
+ field,
685
+ conds: groups.flatMap((t) => t.conds),
686
+ expr
687
+ });
688
+ }
689
+ const INVERSE = {
690
+ eq: "ne",
691
+ ne: "eq",
692
+ null: "notNull",
693
+ notNull: "null"
694
+ };
695
+ function invert(cond) {
696
+ const type = INVERSE[cond.type];
697
+ return type ? {
698
+ type,
699
+ value: [...cond.value]
700
+ } : null;
701
+ }
702
+ /**
703
+ * `$not` fits when it inverts equality / emptiness on one field — De Morgan
704
+ * turns NOT(a=1 OR a=2) into a≠1 AND a≠2 (negatives), and NOT(a≠1 AND a≠2)
705
+ * back into a=1 OR a=2 (one positive group). A positive group AND'd with
706
+ * anything else would invert into an OR of ANDs, which the model cannot hold.
707
+ */
708
+ function collectNot(child, out) {
709
+ if (isPlainObject(child) && Object.keys(child).length === 1 && "$not" in child) return collect(child.$not, out);
710
+ const expr = { $not: child };
711
+ const b = classifyBranch(child);
712
+ if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
713
+ const inverted = b.terms.flatMap((t) => t.conds).map(invert);
714
+ if (inverted.every(Boolean)) return addTerms(out, b.field, inverted, expr);
715
+ }
716
+ out.issues.push(unsupported("negation", expr));
717
+ }
718
+ function collectAnd(children, out) {
719
+ if (Array.isArray(children)) for (const child of children) collect(child, out);
720
+ else out.issues.push(unsupported("operator", { $and: children }));
721
+ }
722
+ const LOGICAL = {
723
+ $and: collectAnd,
724
+ $or: collectOr,
725
+ $not: collectNot
726
+ };
727
+ /**
728
+ * Split an expression into AND-ed terms. Every member of a node is an implicit
729
+ * AND, in key order — comparison fields may sit next to `$and` / `$or` /
730
+ * `$not` (Mongo semantics), and none of them is skipped.
731
+ */
732
+ function collect(expr, out) {
733
+ if (!isPlainObject(expr)) {
734
+ out.issues.push(unsupported("operator", expr));
487
735
  return;
488
736
  }
489
- if ("$not" in expr && expr.$not) return;
490
- walkLeaf(expr, acc, knownFields);
737
+ for (const key in expr) {
738
+ const value = expr[key];
739
+ if (!key.startsWith("$")) collectField(key, value, out);
740
+ else if (value === void 0) continue;
741
+ else if (isLogicalKey(key)) LOGICAL[key](value, out);
742
+ else out.issues.push(unsupported("operator", { [key]: value }));
743
+ }
744
+ }
745
+ function sameConds(a, b) {
746
+ return a.length === b.length && a.every((c, i) => c.type === b[i].type && c.value.length === b[i].value.length && c.value.every((v, j) => v === b[i].value[j]));
747
+ }
748
+ function warnUnsupported(issue) {
749
+ if (!DEV) return;
750
+ 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.`);
751
+ }
752
+ /**
753
+ * Split a Uniquery `FilterExpr` into what the table's field-filter model
754
+ * holds exactly (`filters`), what it cannot hold but carries as residual
755
+ * conditions (`residual`, with `carry`), and what it leaves out
756
+ * (`unsupported`). Nothing is approximated: `filters AND residual` selects
757
+ * `expr` minus the `unsupported` pieces and pieces on fields outside
758
+ * `knownFields`.
759
+ *
760
+ * Never throws, never warns — the caller decides how to report.
761
+ *
762
+ * @since 0.1.140
763
+ */
764
+ function decomposeUniqueryFilter(expr, opts = {}) {
765
+ const filters = {};
766
+ const result = {
767
+ filters,
768
+ residual: [],
769
+ unsupported: []
770
+ };
771
+ if (!expr) return result;
772
+ const knownFields = opts.knownFields;
773
+ const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
774
+ const isKnown = (field) => known === null || known.has(field);
775
+ const out = {
776
+ terms: [],
777
+ issues: []
778
+ };
779
+ try {
780
+ collect(expr, out);
781
+ } catch {
782
+ out.terms = [];
783
+ out.issues = [{
784
+ reason: "operator",
785
+ expr,
786
+ fields: []
787
+ }];
788
+ }
789
+ const positives = /* @__PURE__ */ new Map();
790
+ for (const term of out.terms) {
791
+ if (!isKnown(term.field)) continue;
792
+ const list = filters[term.field] ??= [];
793
+ if (isNegative(term)) {
794
+ list.push(...term.conds);
795
+ continue;
796
+ }
797
+ const groups = positives.get(term.field);
798
+ if (!groups) {
799
+ positives.set(term.field, [term]);
800
+ list.push(...term.conds);
801
+ } else if (!groups.some((g) => sameConds(g.conds, term.conds))) groups.push(term);
802
+ }
803
+ const carried = [];
804
+ for (const [field, groups] of positives) {
805
+ if (groups.length < 2) continue;
806
+ const leftOut = opts.carry ? groups : groups.slice(1);
807
+ if (opts.carry) {
808
+ const kept = new Set(groups[0].conds);
809
+ const rest = filters[field].filter((c) => !kept.has(c));
810
+ if (rest.length > 0) filters[field] = rest;
811
+ else delete filters[field];
812
+ }
813
+ for (const term of leftOut) out.issues.push({
814
+ reason: "conjunction",
815
+ expr: term.expr,
816
+ fields: [field]
817
+ });
818
+ }
819
+ for (const issue of out.issues) {
820
+ const fields = issue.fields;
821
+ if (fields.length > 0 && !fields.some(isKnown)) continue;
822
+ if (opts.carry && fields.length > 0 && fields.every(isKnown)) carried.push(issue.expr);
823
+ else result.unsupported.push(issue);
824
+ }
825
+ result.residual = normalizeResidualFilters(carried);
826
+ return result;
827
+ }
828
+ /**
829
+ * Canonical identity of a filter expression — its `@uniqu/url` spelling.
830
+ * Two expressions with the same key select the same rows.
831
+ *
832
+ * @since 0.1.140
833
+ */
834
+ function filterExprKey(expr) {
835
+ try {
836
+ return buildUrl({ filter: expr });
837
+ } catch {
838
+ return JSON.stringify(expr) ?? "";
839
+ }
840
+ }
841
+ /**
842
+ * A residual-condition list with empty expressions and duplicates (by
843
+ * {@link filterExprKey}) dropped, sorted by key. Sorted, not first-appearance:
844
+ * the URL parser moves \`$not\`-wrapped clauses (how \`mergeFilters\` spells
845
+ * a repeated same-field clause) ahead of plain ones, so appearance order would
846
+ * flip on every round trip.
847
+ *
848
+ * @internal Exported for `@atscript/vue-table`.
849
+ */
850
+ function normalizeResidualFilters(exprs) {
851
+ const byKey = /* @__PURE__ */ new Map();
852
+ for (const expr of exprs) {
853
+ if (!isPlainObject(expr)) continue;
854
+ const key = filterExprKey(expr);
855
+ if (key && !byKey.has(key)) byKey.set(key, expr);
856
+ }
857
+ return [...byKey.keys()].toSorted().map((key) => byKey.get(key));
491
858
  }
492
859
  /**
493
860
  * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
494
861
  *
495
- * Inverse of `filtersToUniqueryFilter`. Drops:
496
- * - conditions on fields not in `knownFields` (when provided)
497
- * - operators not in the supported `FilterConditionType` union
498
- * - `$not` branches (no native UI representation; lossy on hand-crafted URLs)
862
+ * Inverse of `filtersToUniqueryFilter`, and exact for everything that encoder
863
+ * produces. For any other input, each AND-ed piece is either converted exactly
864
+ * or left out whole and reported — never approximated:
865
+ *
866
+ * - `$in` becomes equality conditions on the field, `$nin` inequality ones.
867
+ * - A same-field `$or` becomes that field's OR'd conditions.
868
+ * - A `$not` that inverts equality / emptiness becomes the inverse conditions.
869
+ * - Fields next to `$and` / `$or` / `$not` in one object are all kept.
870
+ * - Anything else (see {@link UnsupportedFilterReason}) is left out. Leaving
871
+ * an AND-ed piece out only ever widens the match, so the result selects a
872
+ * superset of `expr`. Each left-out piece goes to `onUnsupportedFilter`, or
873
+ * to a dev-mode `console.warn` when no handler is given. To keep those
874
+ * pieces instead, use {@link decomposeUniqueryFilter} with `carry`.
875
+ *
876
+ * Conditions on fields outside `knownFields` (when provided) are ignored
877
+ * silently: they are not this table's (a host page flag, a stale column). A
878
+ * piece that mixes known and unknown fields is reported.
499
879
  *
500
880
  * Returns `{}` for an empty/missing expression. Never throws.
501
881
  */
502
- function uniqueryFilterToFieldFilters(expr, knownFields) {
503
- const acc = {};
504
- if (!expr) return acc;
505
- const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
882
+ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
883
+ const { filters, unsupported } = decomposeUniqueryFilter(expr, { knownFields });
884
+ for (const issue of unsupported) onUnsupportedFilter(issue);
885
+ return filters;
886
+ }
887
+ //#endregion
888
+ //#region src/filters/format-filter-expr.ts
889
+ function show(v) {
890
+ if (v instanceof Date) return v.toISOString();
891
+ if (typeof v === "object" && v !== null) return JSON.stringify(v) ?? "";
892
+ return String(v);
893
+ }
894
+ /** One comparison, worded like the filter chips (the decoder's operator table). */
895
+ function leaf(label, op, value) {
896
+ if (op === "$eq" && value instanceof RegExp) op = "$regex";
897
+ if ((op === "$in" || op === "$nin") && Array.isArray(value)) return `${label} ${op === "$in" ? "is one of" : "is none of"} ${value.map(show).join(", ")}`;
898
+ const conds = decodeOperator(op, value);
899
+ if (conds?.length === 1) return filterTokenLabel(label, conds, label);
900
+ return buildUrl({ filter: { [label]: { [op]: value } } }) || `${label}${op}`;
901
+ }
902
+ function join(children, kind) {
903
+ const parts = children.filter((c) => c.s);
904
+ if (parts.length === 0) return {
905
+ s: "",
906
+ kind: "leaf"
907
+ };
908
+ if (parts.length === 1) return parts[0];
909
+ const wrap = kind === "and" ? "or" : "and";
910
+ return {
911
+ s: parts.map((c) => c.kind === wrap ? `(${c.s})` : c.s).join(` ${kind} `),
912
+ kind
913
+ };
914
+ }
915
+ /**
916
+ * Human-readable rendering of a Uniquery filter expression, worded like the
917
+ * filter-field chips: `(Status equals shipped and Total greater than 500) or
918
+ * (Status equals pending and Total less or equal 50)`. `and` binds tighter
919
+ * than `or`; groups are parenthesized only where needed. Operators without a
920
+ * wording fall back to their `@uniqu/url` spelling.
921
+ *
922
+ * @param labelOf — display label for a field path (e.g. the column label);
923
+ * the path itself when omitted or when it returns `undefined`.
924
+ * @since 0.1.140
925
+ */
926
+ function formatFilterExpr(expr, labelOf) {
927
+ const visitor = {
928
+ comparison: (field, op, value) => ({
929
+ s: leaf(labelOf?.(field) ?? field, op, value),
930
+ kind: "leaf"
931
+ }),
932
+ and: (children) => join(children, "and"),
933
+ or: (children) => join(children, "or"),
934
+ not: (child) => ({
935
+ s: child.s ? `not (${child.s})` : "",
936
+ kind: "leaf"
937
+ })
938
+ };
506
939
  try {
507
- walkExpr(expr, acc, known);
508
- } catch {}
509
- return acc;
940
+ return walkFilter(expr, visitor)?.s ?? "";
941
+ } catch {
942
+ return JSON.stringify(expr) ?? "";
943
+ }
510
944
  }
511
945
  //#endregion
512
946
  //#region src/filters/date-shortcuts.ts
@@ -1103,8 +1537,9 @@ function mergeSorters(forceSorters, userSorters) {
1103
1537
  //#endregion
1104
1538
  //#region src/query/merge-filters.ts
1105
1539
  /**
1106
- * AND-merge two filter expressions, producing a wire shape that survives
1107
- * `@uniqu/url`'s `mergeConjunction` parser collapse.
1540
+ * AND-merge filter expressions (`undefined` ones skipped), producing a wire
1541
+ * shape that survives `@uniqu/url`'s `mergeConjunction` parser collapse.
1542
+ * Variadic since 0.1.140.
1108
1543
  *
1109
1544
  * The collapse problem: when two `$and` siblings target the same field
1110
1545
  * with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
@@ -1117,10 +1552,10 @@ function mergeSorters(forceSorters, userSorters) {
1117
1552
  * and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
1118
1553
  * same AND. Non-colliding merges produce the canonical `$and` shape.
1119
1554
  */
1120
- function mergeFilters(a, b) {
1121
- if (!a) return b;
1122
- if (!b) return a;
1123
- return makeParserSafeAnd([a, b]);
1555
+ function mergeFilters(...exprs) {
1556
+ const list = exprs.filter((e) => !!e);
1557
+ if (list.length <= 1) return list[0];
1558
+ return makeParserSafeAnd(list);
1124
1559
  }
1125
1560
  /** Op-set for a field value: primitives are `$eq`, op-bags expose their keys. */
1126
1561
  function getOpsForFieldValue(value) {
@@ -1188,12 +1623,12 @@ function makeParserSafeAnd(children) {
1188
1623
  * Build a Uniquery object from table UI state.
1189
1624
  *
1190
1625
  * Pure function — no framework dependencies.
1191
- * Combines user filters with force filters, merges sorters,
1626
+ * Combines user filters (field filters, then residual conditions) with force
1627
+ * filters, merges sorters,
1192
1628
  * projects visible columns, and applies pagination.
1193
1629
  */
1194
1630
  function buildTableQuery(opts) {
1195
- const userFilter = filtersToUniqueryFilter(opts.filters);
1196
- const filter = mergeFilters(opts.forceFilters, userFilter);
1631
+ const filter = mergeFilters(opts.forceFilters, filtersToUniqueryFilter(opts.filters), ...opts.residualFilters ?? []);
1197
1632
  const userSorters = opts.ignoreSorters ? [] : opts.sorters;
1198
1633
  const sorters = opts.forceSorters?.length ? mergeSorters(opts.forceSorters, userSorters) : userSorters;
1199
1634
  const $sort = {};
@@ -1213,6 +1648,20 @@ function buildTableQuery(opts) {
1213
1648
  }
1214
1649
  //#endregion
1215
1650
  //#region src/query/url-query.ts
1651
+ /**
1652
+ * The URL control that marks a query string as a complete snapshot of the
1653
+ * table's filters and sorters. Restoring a URL that carries it clears every
1654
+ * filter and sorter the URL owns before applying its own, so nothing the URL
1655
+ * omits survives (empty `$sort` included). A URL without it is an overlay:
1656
+ * its filters and sorters are laid over the table's starting ones.
1657
+ *
1658
+ * The encoder writes it bare (`…&$snapshot`) on every URL; the decoder goes
1659
+ * by presence alone, whatever the value. `UrlQuerySync.snapshot: false` turns
1660
+ * both off.
1661
+ *
1662
+ * @since 0.1.139
1663
+ */
1664
+ const URL_SNAPSHOT_KEY = "$snapshot";
1216
1665
  function resolveAspectGate(value) {
1217
1666
  if (value === void 0 || value === true) return "all";
1218
1667
  if (value === false) return "none";
@@ -1239,6 +1688,17 @@ function gateOwns(gate, path) {
1239
1688
  if (gate === "none") return false;
1240
1689
  return gate.has(path);
1241
1690
  }
1691
+ /**
1692
+ * Does a URL under `gate` own the residual condition `expr`? Only when it
1693
+ * owns every field the condition references — a condition that touches a
1694
+ * private field stays private as a whole.
1695
+ *
1696
+ * @internal Exported for `@atscript/vue-table`.
1697
+ */
1698
+ function residualGateOwns(gate, expr) {
1699
+ const fields = filterExprFields(expr);
1700
+ return fields.length > 0 && fields.every((path) => gateOwns(gate, path));
1701
+ }
1242
1702
  function pickFilterPaths(filters, gate) {
1243
1703
  const out = {};
1244
1704
  for (const path in filters) if (gateOwns(gate, path)) out[path] = filters[path];
@@ -1251,7 +1711,9 @@ function pickFilterPaths(filters, gate) {
1251
1711
  * `$actions`, `forceFilters`, `forceSorters` — those are not user state) and
1252
1712
  * appends `$skip` / `$limit` for pagination.
1253
1713
  *
1254
- * Returns `""` (no leading `?`) for the default view.
1714
+ * Stamps {@link URL_SNAPSHOT_KEY} unless `defaults.sync.snapshot` is `false`
1715
+ * (or neither filters nor sorters sync), so the default view serializes to
1716
+ * `"$snapshot"`; with the marker off it serializes to `""` (no leading `?`).
1255
1717
  */
1256
1718
  function stateToUrlQueryString(state, defaults) {
1257
1719
  const filtersGate = resolveAspectGate(defaults.sync?.filters);
@@ -1263,6 +1725,7 @@ function stateToUrlQueryString(state, defaults) {
1263
1725
  visibleColumnPaths: [],
1264
1726
  sorters: sortersGate === "all" ? state.sorters : state.sorters.filter((s) => gateOwns(sortersGate, s.field)),
1265
1727
  filters,
1728
+ residualFilters: defaults.sync?.residual === false || !state.residualFilters?.length ? void 0 : state.residualFilters.filter((expr) => residualGateOwns(filtersGate, expr)),
1266
1729
  search: searchOff ? void 0 : state.searchTerm || void 0
1267
1730
  });
1268
1731
  if (!searchOff && state.searchTerm && state.ignoreSorters !== void 0 && state.ignoreSorters !== (defaults.defaultIgnoreSorters ?? false)) query.controls.$relevance = state.ignoreSorters ? 1 : 0;
@@ -1271,6 +1734,7 @@ function stateToUrlQueryString(state, defaults) {
1271
1734
  const page = state.page ?? 1;
1272
1735
  if (page > 1) query.controls.$skip = (page - 1) * itemsPerPage;
1273
1736
  }
1737
+ if (defaults.sync?.snapshot !== false && (filtersGate !== "none" || sortersGate !== "none")) query.controls[URL_SNAPSHOT_KEY] = "";
1274
1738
  return buildUrl(query);
1275
1739
  }
1276
1740
  /**
@@ -1281,10 +1745,14 @@ const CONSUMED_CONTROLS = new Set([
1281
1745
  "$sort",
1282
1746
  "$search",
1283
1747
  "$relevance",
1284
- "$skip"
1748
+ "$skip",
1749
+ URL_SNAPSHOT_KEY
1285
1750
  ]);
1286
- /** Characters that can only appear in a uniqu filter key, never in a page flag. */
1287
- const FILTER_OPERATOR_CHAR = /[<>!~]/;
1751
+ /**
1752
+ * Characters that can only appear in a uniqu filter key, never in a page flag:
1753
+ * comparison operators, `^` (OR), `( )` (groups) and `{ }` (`$in` lists).
1754
+ */
1755
+ const FILTER_OPERATOR_CHAR = /[<>!~()^{}]/;
1288
1756
  /**
1289
1757
  * Whether {@link urlQueryStringToState} would consume the query key `key` —
1290
1758
  * i.e. whether the key belongs to the table rather than to the page hosting
@@ -1309,7 +1777,11 @@ function urlQueryConsumesKey(key) {
1309
1777
  * Robust by design — schema drift and copy-paste errors must not break the
1310
1778
  * recipient's view:
1311
1779
  * - unknown fields (not in `knownFields`) → silently dropped
1312
- * - unsupported operators → silently dropped
1780
+ * - filter pieces field filters cannot express (cross-field OR, unknown
1781
+ * operator, …) → left out of `filters` and listed in `unsupported`, never
1782
+ * approximated (the parser does not warn — the caller decides). Unless
1783
+ * `sync.residual` is `false`, those whose fields are all known come back
1784
+ * in `residual` instead
1313
1785
  * - unknown controls (e.g. `$weird=42`) → silently ignored
1314
1786
  * - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
1315
1787
  *
@@ -1344,7 +1816,11 @@ function urlQueryStringToState(urlString, opts = {}) {
1344
1816
  filterKnown = /* @__PURE__ */ new Set();
1345
1817
  for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
1346
1818
  } else filterKnown = filtersGate;
1347
- const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown);
1819
+ const decomposed = filtersGate === "none" ? null : decomposeUniqueryFilter(parsed.filter, {
1820
+ knownFields: filterKnown,
1821
+ carry: opts.sync?.residual !== false
1822
+ });
1823
+ const filters = decomposed?.filters ?? {};
1348
1824
  const sorters = [];
1349
1825
  if (sortersGate !== "none") {
1350
1826
  const $sort = parsed.controls?.$sort;
@@ -1368,6 +1844,9 @@ function urlQueryStringToState(urlString, opts = {}) {
1368
1844
  sorters,
1369
1845
  searchTerm: !searchOff && typeof $search === "string" ? $search : ""
1370
1846
  };
1847
+ if (decomposed?.unsupported.length) out.unsupported = decomposed.unsupported;
1848
+ if (decomposed?.residual.length) out.residual = decomposed.residual;
1849
+ if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
1371
1850
  if (!searchOff) {
1372
1851
  const $relevance = parsed.controls?.$relevance;
1373
1852
  if ($relevance === "1" || $relevance === 1) out.ignoreSorters = true;
@@ -1918,4 +2397,4 @@ function sortRowsLocally(rows, sorters, getValue = (row, field) => row[field]) {
1918
2397
  });
1919
2398
  }
1920
2399
  //#endregion
1921
- export { APP_CONF_PREFIX, AppPrefsClient, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, ExportAbortError, MAX_DEFAULT_COLUMN_WIDTH_PX, NULL_OPS, PRESET_ASPECTS, PresetsClient, PresetsHttpError, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, USER_CONF_PREFIX, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, gateOwns, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, 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 };
2400
+ export { APP_CONF_PREFIX, AppPrefsClient, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DEV, DRAFT_PERSISTED_ASPECTS, ExportAbortError, MAX_DEFAULT_COLUMN_WIDTH_PX, NULL_OPS, PRESET_ASPECTS, PresetsClient, PresetsHttpError, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, URL_SNAPSHOT_KEY, USER_CONF_PREFIX, 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 };