@atscript/ui-table 0.1.137 → 0.1.139

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,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _uniqu_core = require("@uniqu/core");
2
3
  let _atscript_db_client = require("@atscript/db-client");
3
4
  let _atscript_ui = require("@atscript/ui");
4
5
  let _uniqu_url_builder = require("@uniqu/url/builder");
@@ -135,6 +136,40 @@ function columnFilterType(columnType) {
135
136
  default: return "text";
136
137
  }
137
138
  }
139
+ const EXISTENCE_CONDITIONS = ["null", "notNull"];
140
+ const NO_CONDITIONS = [];
141
+ /**
142
+ * Filter conditions a column offers — the one answer every filter UI (column
143
+ * menu, filter dialog, filter bar, config dialog) reads.
144
+ *
145
+ * - Value-filterable (`filterable: true`) → {@link conditionsForType} for its
146
+ * display type.
147
+ * - Existence-only (`filterable: false`, `filterOps` includes `$exists` — a
148
+ * JSON-stored column) → `null` / `notNull`: whether a value is present,
149
+ * never what it is.
150
+ * - Otherwise → `[]`: the column takes no filter.
151
+ *
152
+ * `null` / `notNull` are dropped for non-nullable columns, so an existence-only
153
+ * column that is never empty offers nothing.
154
+ *
155
+ * @since 0.1.139
156
+ */
157
+ function columnFilterConditions(column) {
158
+ if (column.filterable) return conditionsForType(columnFilterType(column.type), column.nullable);
159
+ if (column.nullable && column.filterOps?.includes("$exists")) return EXISTENCE_CONDITIONS;
160
+ return NO_CONDITIONS;
161
+ }
162
+ /**
163
+ * Whether a column takes any filter at all — value comparisons or the
164
+ * existence-only `null` / `notNull` pair. Use it (not
165
+ * `column.filterable`, which is value comparison only) to decide whether to
166
+ * show a column in a filter UI.
167
+ *
168
+ * @since 0.1.139
169
+ */
170
+ function isColumnFilterable(column) {
171
+ return columnFilterConditions(column).length > 0;
172
+ }
138
173
  //#endregion
139
174
  //#region src/filters/escape-regex.ts
140
175
  /** Escape special regex characters in user input for safe embedding in $regex. */
@@ -176,6 +211,18 @@ function defaultCondition(columnType) {
176
211
  default: return "eq";
177
212
  }
178
213
  }
214
+ /**
215
+ * The condition a column's filter input starts with: its type's
216
+ * {@link defaultCondition} when the column offers it, otherwise the first
217
+ * condition it does offer (`null` on an existence-only column).
218
+ *
219
+ * @since 0.1.139
220
+ */
221
+ function columnDefaultCondition(column) {
222
+ const type = defaultCondition(columnFilterType(column.type));
223
+ const offered = columnFilterConditions(column);
224
+ return offered.length === 0 || offered.includes(type) ? type : offered[0];
225
+ }
179
226
  /** Prefix operators in match order (longest first). */
180
227
  const PREFIX_OPS = [
181
228
  ["!=", "ne"],
@@ -208,12 +255,26 @@ const PREFIX_OPS = [
208
255
  * number/date/boolean → eq
209
256
  *
210
257
  * Returns undefined for empty/invalid input or if the parsed operator
211
- * is not available for the column type.
258
+ * is not available for the column type. To honour what a specific column
259
+ * offers (an existence-only column takes only `<empty>` / `!<empty>`), use
260
+ * {@link parseColumnFilterInput}.
212
261
  */
213
262
  function parseFilterInput(text, columnType, nullable = true) {
263
+ return parseInput(text, columnType, conditionsForType(columnType, nullable));
264
+ }
265
+ /**
266
+ * {@link parseFilterInput} for a column: the type comes from the column and
267
+ * the accepted operators are {@link columnFilterConditions} — the ones the
268
+ * column's filter UI offers.
269
+ *
270
+ * @since 0.1.139
271
+ */
272
+ function parseColumnFilterInput(text, column) {
273
+ return parseInput(text, columnFilterType(column.type), columnFilterConditions(column));
274
+ }
275
+ function parseInput(text, columnType, available) {
214
276
  const trimmed = text.trim();
215
277
  if (trimmed === "") return void 0;
216
- const available = conditionsForType(columnType, nullable);
217
278
  const isNumber = columnType === "number";
218
279
  const build = (type, value) => {
219
280
  if (!available.includes(type)) return void 0;
@@ -289,6 +350,10 @@ function formatFilterCondition(condition) {
289
350
  //#region src/filters/filters-to-uniquery.ts
290
351
  /** Exclusion condition types — AND'd together per field. */
291
352
  const EXCLUSION_TYPES = new Set(["ne", "notNull"]);
353
+ /** Whether conditions of `type` are exclusions (AND'd per field) rather than inclusions (OR'd). */
354
+ function isExclusionType(type) {
355
+ return EXCLUSION_TYPES.has(type);
356
+ }
292
357
  /**
293
358
  * Convert a single condition to a Uniquery filter expression.
294
359
  * Returns a ComparisonNode with the field as key.
@@ -337,7 +402,7 @@ function filtersToUniqueryFilter(fieldFilters) {
337
402
  for (const condition of conditions) {
338
403
  if (!isFilled(condition)) continue;
339
404
  const expr = conditionToExpr(field, condition);
340
- if (EXCLUSION_TYPES.has(condition.type)) (exclusions ??= []).push(expr);
405
+ if (isExclusionType(condition.type)) (exclusions ??= []).push(expr);
341
406
  else (inclusions ??= []).push(expr);
342
407
  }
343
408
  if (inclusions) pushGroup(topGroups, inclusions, "$or");
@@ -348,25 +413,33 @@ function filtersToUniqueryFilter(fieldFilters) {
348
413
  return { $and: topGroups };
349
414
  }
350
415
  //#endregion
416
+ //#region src/utils/dev.ts
417
+ /**
418
+ * `true` outside a production build — the gate for authoring-time warnings, so
419
+ * their strings and checks cost a shipped app nothing.
420
+ *
421
+ * Read from `process.env.NODE_ENV` rather than `import.meta.env`: bundlers
422
+ * replace this expression with a literal in both the ESM and the CJS output,
423
+ * while `import.meta` only exists in the former. Where neither a bundler nor a
424
+ * `process` shim is present the read throws — warnings stay on, which is the
425
+ * safe side of the trade.
426
+ */
427
+ function detectDev() {
428
+ try {
429
+ return process.env.NODE_ENV !== "production";
430
+ } catch {
431
+ return true;
432
+ }
433
+ }
434
+ const DEV = detectDev();
435
+ //#endregion
351
436
  //#region src/filters/uniquery-to-filters.ts
352
- const SUPPORTED_TYPES = new Set([
353
- "eq",
354
- "ne",
355
- "gt",
356
- "gte",
357
- "lt",
358
- "lte",
359
- "contains",
360
- "starts",
361
- "ends",
362
- "bw",
363
- "null",
364
- "notNull",
365
- "regex"
366
- ]);
367
437
  function isPrimitive(v) {
368
438
  return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
369
439
  }
440
+ function isPlainObject(v) {
441
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof RegExp) && !(v instanceof Date);
442
+ }
370
443
  const REGEX_LITERAL = /^\/(.*)\/([a-z]*)$/s;
371
444
  const STARTS_ANCHOR = /^\^(.+)$/s;
372
445
  const ENDS_ANCHOR = /^(.+)\$$/s;
@@ -377,6 +450,7 @@ const ENDS_ANCHOR = /^(.+)\$$/s;
377
450
  * `regex` condition.
378
451
  */
379
452
  function regexToCondition(raw) {
453
+ if (raw instanceof RegExp) raw = raw.toString();
380
454
  if (typeof raw !== "string") return null;
381
455
  const m = REGEX_LITERAL.exec(raw);
382
456
  if (!m) return {
@@ -405,108 +479,324 @@ function regexToCondition(raw) {
405
479
  value: [raw]
406
480
  };
407
481
  }
408
- function pushIfPrimitive(out, type, v) {
409
- if (!isPrimitive(v)) return;
410
- out.push({
411
- type,
482
+ const nullCond = () => ({
483
+ type: "null",
484
+ value: []
485
+ });
486
+ const notNullCond = () => ({
487
+ type: "notNull",
488
+ value: []
489
+ });
490
+ /** Positive condition for one `$eq` / `$in` value — `null` means "is empty". */
491
+ function memberCondition(v) {
492
+ if (v === null) return nullCond();
493
+ return isPrimitive(v) ? {
494
+ type: "eq",
412
495
  value: [v]
413
- });
496
+ } : null;
497
+ }
498
+ /** Negative condition for one `$ne` / `$nin` value — `null` means "is not empty". */
499
+ function nonMemberCondition(v) {
500
+ if (v === null) return notNullCond();
501
+ return isPrimitive(v) ? {
502
+ type: "ne",
503
+ value: [v]
504
+ } : null;
505
+ }
506
+ const RANGE_OPS = {
507
+ $gt: "gt",
508
+ $gte: "gte",
509
+ $lt: "lt",
510
+ $lte: "lte"
511
+ };
512
+ /** Field paths an expression references, deduped, in order of appearance. */
513
+ function fieldsOf(expr, out = []) {
514
+ if (Array.isArray(expr)) {
515
+ for (const child of expr) fieldsOf(child, out);
516
+ return out;
517
+ }
518
+ if (!isPlainObject(expr)) return out;
519
+ for (const key in expr) if (key.startsWith("$")) fieldsOf(expr[key], out);
520
+ else if (!out.includes(key)) out.push(key);
521
+ return out;
414
522
  }
523
+ function unsupported(reason, expr) {
524
+ const fields = fieldsOf(expr);
525
+ return {
526
+ reason: fields.length > 1 ? "cross-field" : reason,
527
+ expr,
528
+ fields
529
+ };
530
+ }
531
+ const isNegative = (term) => isExclusionType(term.conds[0].type);
415
532
  /**
416
- * Decode a per-field operator object (e.g. `{ $gt: 5, $lte: 10 }`) into one or
417
- * more `FilterCondition`s. Unknown operators are silently dropped.
418
- *
419
- * `$gte` + `$lte` on the same field collapse into a single `bw` condition,
420
- * matching the encoder's behaviour.
533
+ * Add decoded conditions as terms: negatives one term each (they AND), a
534
+ * positive list as one group (it ORs). Conditions share their polarity.
421
535
  */
422
- function decodeFieldOps(ops) {
423
- const out = [];
424
- const hasGte = "$gte" in ops && isPrimitive(ops.$gte);
425
- const hasLte = "$lte" in ops && isPrimitive(ops.$lte);
426
- if (hasGte && hasLte) out.push({
427
- type: "bw",
428
- value: [ops.$gte, ops.$lte]
536
+ function addTerms(out, field, conds, expr) {
537
+ if (conds.length === 0) return;
538
+ if (isExclusionType(conds[0].type)) for (const cond of conds) out.terms.push({
539
+ field,
540
+ conds: [cond],
541
+ expr
429
542
  });
430
- else {
431
- if (hasGte) pushIfPrimitive(out, "gte", ops.$gte);
432
- if (hasLte) pushIfPrimitive(out, "lte", ops.$lte);
433
- }
434
- if ("$eq" in ops) pushIfPrimitive(out, "eq", ops.$eq);
435
- if ("$ne" in ops) pushIfPrimitive(out, "ne", ops.$ne);
436
- if ("$gt" in ops) pushIfPrimitive(out, "gt", ops.$gt);
437
- if ("$lt" in ops) pushIfPrimitive(out, "lt", ops.$lt);
438
- if ("$exists" in ops) {
439
- if (ops.$exists === false) out.push({
440
- type: "null",
441
- value: []
442
- });
443
- else if (ops.$exists === true) out.push({
444
- type: "notNull",
445
- value: []
446
- });
543
+ else out.terms.push({
544
+ field,
545
+ conds,
546
+ expr
547
+ });
548
+ }
549
+ /**
550
+ * `a>=x` AND `a<=y` is the model's `bw`, whether both halves sit in one
551
+ * operator object (the encoder's shape) or in two AND-ed pieces. Folds `cond`
552
+ * into an earlier lone opposite half on `field`; `false` when there is none.
553
+ */
554
+ function pairRange(out, field, cond) {
555
+ const opposite = cond.type === "gte" ? "lte" : cond.type === "lte" ? "gte" : null;
556
+ if (!opposite) return false;
557
+ const i = out.terms.findIndex((t) => t.field === field && t.conds.length === 1 && t.conds[0].type === opposite);
558
+ if (i < 0) return false;
559
+ const other = out.terms[i].conds[0];
560
+ const [lo, hi] = cond.type === "gte" ? [cond.value[0], other.value[0]] : [other.value[0], cond.value[0]];
561
+ out.terms[i] = {
562
+ field,
563
+ conds: [{
564
+ type: "bw",
565
+ value: [lo, hi]
566
+ }],
567
+ expr: { [field]: {
568
+ $gte: lo,
569
+ $lte: hi
570
+ } }
571
+ };
572
+ return true;
573
+ }
574
+ /**
575
+ * Decode one field entry (`field: value`) into terms. An operator object is an
576
+ * implicit AND of its operators — each becomes its own term.
577
+ */
578
+ function collectField(field, value, out) {
579
+ const whole = { [field]: value };
580
+ if (value === null || value === void 0) return addTerms(out, field, [nullCond()], whole);
581
+ if (isPrimitive(value)) return addTerms(out, field, [{
582
+ type: "eq",
583
+ value: [value]
584
+ }], whole);
585
+ if (value instanceof RegExp) return addTerms(out, field, [regexToCondition(value)], whole);
586
+ if (!isPlainObject(value)) {
587
+ out.issues.push(unsupported("operator", whole));
588
+ return;
447
589
  }
448
- if ("$regex" in ops) {
449
- const cond = regexToCondition(ops.$regex);
450
- if (cond) out.push(cond);
590
+ for (const op in value) {
591
+ const v = value[op];
592
+ if (v === void 0) continue;
593
+ const expr = { [field]: { [op]: v } };
594
+ const conds = decodeOperator(op, v);
595
+ if (!conds) out.issues.push(unsupported("operator", expr));
596
+ else if (conds.length !== 1 || !pairRange(out, field, conds[0])) addTerms(out, field, conds, expr);
451
597
  }
452
- return out;
453
598
  }
599
+ const one = (cond) => cond ? [cond] : null;
454
600
  /**
455
- * Walk a leaf `ComparisonNode` (one or more field=value entries) into the
456
- * shared `FieldFilters` accumulator. Drops unknown fields and operators.
601
+ * One operator of a field's operator object, as conditions of one polarity
602
+ * (see {@link addTerms}). `null` means no condition type expresses the
603
+ * operator / operand.
457
604
  */
458
- function walkLeaf(node, acc, knownFields) {
459
- for (const field in node) {
460
- if (field.startsWith("$")) continue;
461
- if (knownFields && !knownFields.has(field)) continue;
462
- const value = node[field];
463
- let conditions;
464
- if (value === null || value === void 0) conditions = [{
465
- type: "null",
466
- value: []
467
- }];
468
- else if (isPrimitive(value)) conditions = [{
469
- type: "eq",
470
- value: [value]
471
- }];
472
- else if (typeof value === "object" && !Array.isArray(value)) conditions = decodeFieldOps(value);
473
- else continue;
474
- if (conditions.length === 0) continue;
475
- const filtered = conditions.filter((c) => SUPPORTED_TYPES.has(c.type));
476
- if (filtered.length === 0) continue;
477
- if (acc[field]) acc[field] = [...acc[field], ...filtered];
478
- else acc[field] = filtered;
479
- }
480
- }
481
- function walkExpr(expr, acc, knownFields) {
482
- if ("$and" in expr && expr.$and) {
483
- for (const child of expr.$and) walkExpr(child, acc, knownFields);
605
+ function decodeOperator(op, v) {
606
+ if (op in RANGE_OPS) return one(isPrimitive(v) ? {
607
+ type: RANGE_OPS[op],
608
+ value: [v]
609
+ } : null);
610
+ switch (op) {
611
+ case "$eq": return one(memberCondition(v));
612
+ case "$ne": return one(nonMemberCondition(v));
613
+ case "$regex": return one(regexToCondition(v));
614
+ case "$exists":
615
+ if (v === false) return one(nullCond());
616
+ return v === true ? one(notNullCond()) : null;
617
+ case "$in": {
618
+ const conds = Array.isArray(v) ? v.map(memberCondition) : [];
619
+ return conds.length > 0 && conds.every(Boolean) ? conds : null;
620
+ }
621
+ case "$nin": {
622
+ if (!Array.isArray(v)) return null;
623
+ const conds = v.map(nonMemberCondition);
624
+ return conds.every(Boolean) ? conds : null;
625
+ }
626
+ default: return null;
627
+ }
628
+ }
629
+ /**
630
+ * Decode an `$or` branch / `$not` operand on its own. Usable only when it
631
+ * converts fully and stays on one field; `null` when it constrains nothing.
632
+ * A branch that spans fields comes back with a placeholder reason —
633
+ * {@link unsupported} names it `"cross-field"` from the fields it sees.
634
+ */
635
+ function classifyBranch(expr) {
636
+ const sub = {
637
+ terms: [],
638
+ issues: []
639
+ };
640
+ collect(expr, sub);
641
+ if (sub.issues.length > 0) return { reason: sub.issues[0].reason };
642
+ if (sub.terms.length === 0) return null;
643
+ const field = sub.terms[0].field;
644
+ if (sub.terms.some((t) => t.field !== field)) return { reason: "operator" };
645
+ return {
646
+ field,
647
+ terms: sub.terms
648
+ };
649
+ }
650
+ /**
651
+ * `$or` fits the model only as a same-field OR of positive branches. Each
652
+ * branch must decode to exactly one positive group; the groups merge.
653
+ */
654
+ function collectOr(branches, out) {
655
+ const expr = { $or: branches };
656
+ if (!Array.isArray(branches) || branches.length === 0) {
657
+ out.issues.push(unsupported("operator", expr));
658
+ return;
659
+ }
660
+ if (branches.length === 1) return collect(branches[0], out);
661
+ const groups = [];
662
+ let reason;
663
+ for (const branch of branches) {
664
+ const b = classifyBranch(branch);
665
+ if (b === null) return;
666
+ if ("reason" in b) reason ??= b.reason;
667
+ else if (b.terms.length > 1) reason ??= "conjunction";
668
+ else if (isNegative(b.terms[0])) reason ??= "negation";
669
+ else groups.push(b.terms[0]);
670
+ }
671
+ const field = groups[0]?.field;
672
+ if (reason !== void 0 || groups.some((t) => t.field !== field)) {
673
+ out.issues.push(unsupported(reason ?? "operator", expr));
484
674
  return;
485
675
  }
486
- if ("$or" in expr && expr.$or) {
487
- for (const child of expr.$or) walkExpr(child, acc, knownFields);
676
+ out.terms.push({
677
+ field,
678
+ conds: groups.flatMap((t) => t.conds),
679
+ expr
680
+ });
681
+ }
682
+ const INVERSE = {
683
+ eq: "ne",
684
+ ne: "eq",
685
+ null: "notNull",
686
+ notNull: "null"
687
+ };
688
+ function invert(cond) {
689
+ const type = INVERSE[cond.type];
690
+ return type ? {
691
+ type,
692
+ value: [...cond.value]
693
+ } : null;
694
+ }
695
+ /**
696
+ * `$not` fits when it inverts equality / emptiness on one field — De Morgan
697
+ * turns NOT(a=1 OR a=2) into a≠1 AND a≠2 (negatives), and NOT(a≠1 AND a≠2)
698
+ * back into a=1 OR a=2 (one positive group). A positive group AND'd with
699
+ * anything else would invert into an OR of ANDs, which the model cannot hold.
700
+ */
701
+ function collectNot(child, out) {
702
+ const expr = { $not: child };
703
+ const b = classifyBranch(child);
704
+ if (b && !("reason" in b) && (b.terms.length === 1 || b.terms.every(isNegative))) {
705
+ const inverted = b.terms.flatMap((t) => t.conds).map(invert);
706
+ if (inverted.every(Boolean)) return addTerms(out, b.field, inverted, expr);
707
+ }
708
+ out.issues.push(unsupported("negation", expr));
709
+ }
710
+ function collectAnd(children, out) {
711
+ if (Array.isArray(children)) for (const child of children) collect(child, out);
712
+ else out.issues.push(unsupported("operator", { $and: children }));
713
+ }
714
+ const LOGICAL = {
715
+ $and: collectAnd,
716
+ $or: collectOr,
717
+ $not: collectNot
718
+ };
719
+ /**
720
+ * Split an expression into AND-ed terms. Every member of a node is an implicit
721
+ * AND, in key order — comparison fields may sit next to `$and` / `$or` /
722
+ * `$not` (Mongo semantics), and none of them is skipped.
723
+ */
724
+ function collect(expr, out) {
725
+ if (!isPlainObject(expr)) {
726
+ out.issues.push(unsupported("operator", expr));
488
727
  return;
489
728
  }
490
- if ("$not" in expr && expr.$not) return;
491
- walkLeaf(expr, acc, knownFields);
729
+ for (const key in expr) {
730
+ const value = expr[key];
731
+ if (!key.startsWith("$")) collectField(key, value, out);
732
+ else if (value === void 0) continue;
733
+ else if ((0, _uniqu_core.isLogicalKey)(key)) LOGICAL[key](value, out);
734
+ else out.issues.push(unsupported("operator", { [key]: value }));
735
+ }
736
+ }
737
+ function sameConds(a, b) {
738
+ 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]));
739
+ }
740
+ function warnUnsupported(issue) {
741
+ if (!DEV) return;
742
+ 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.`);
492
743
  }
493
744
  /**
494
745
  * Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
495
746
  *
496
- * Inverse of `filtersToUniqueryFilter`. Drops:
497
- * - conditions on fields not in `knownFields` (when provided)
498
- * - operators not in the supported `FilterConditionType` union
499
- * - `$not` branches (no native UI representation; lossy on hand-crafted URLs)
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.
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.
500
763
  *
501
764
  * Returns `{}` for an empty/missing expression. Never throws.
502
765
  */
503
- function uniqueryFilterToFieldFilters(expr, knownFields) {
766
+ function uniqueryFilterToFieldFilters(expr, knownFields, onUnsupportedFilter = warnUnsupported) {
504
767
  const acc = {};
505
768
  if (!expr) return acc;
506
769
  const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
770
+ const isKnown = (field) => known === null || known.has(field);
771
+ const out = {
772
+ terms: [],
773
+ issues: []
774
+ };
507
775
  try {
508
- walkExpr(expr, acc, known);
509
- } catch {}
776
+ collect(expr, out);
777
+ } catch {
778
+ out.terms = [];
779
+ out.issues = [{
780
+ reason: "operator",
781
+ expr,
782
+ fields: []
783
+ }];
784
+ }
785
+ const positives = /* @__PURE__ */ new Map();
786
+ 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);
792
+ list.push(...term.conds);
793
+ } else if (!sameConds(kept, term.conds)) out.issues.push({
794
+ reason: "conjunction",
795
+ expr: term.expr,
796
+ fields: [term.field]
797
+ });
798
+ }
799
+ for (const issue of out.issues) if (issue.fields.length === 0 || issue.fields.some(isKnown)) onUnsupportedFilter(issue);
510
800
  return acc;
511
801
  }
512
802
  //#endregion
@@ -1214,6 +1504,20 @@ function buildTableQuery(opts) {
1214
1504
  }
1215
1505
  //#endregion
1216
1506
  //#region src/query/url-query.ts
1507
+ /**
1508
+ * The URL control that marks a query string as a complete snapshot of the
1509
+ * table's filters and sorters. Restoring a URL that carries it clears every
1510
+ * filter and sorter the URL owns before applying its own, so nothing the URL
1511
+ * omits survives (empty `$sort` included). A URL without it is an overlay:
1512
+ * its filters and sorters are laid over the table's starting ones.
1513
+ *
1514
+ * The encoder writes it bare (`…&$snapshot`) on every URL; the decoder goes
1515
+ * by presence alone, whatever the value. `UrlQuerySync.snapshot: false` turns
1516
+ * both off.
1517
+ *
1518
+ * @since 0.1.139
1519
+ */
1520
+ const URL_SNAPSHOT_KEY = "$snapshot";
1217
1521
  function resolveAspectGate(value) {
1218
1522
  if (value === void 0 || value === true) return "all";
1219
1523
  if (value === false) return "none";
@@ -1252,7 +1556,9 @@ function pickFilterPaths(filters, gate) {
1252
1556
  * `$actions`, `forceFilters`, `forceSorters` — those are not user state) and
1253
1557
  * appends `$skip` / `$limit` for pagination.
1254
1558
  *
1255
- * Returns `""` (no leading `?`) for the default view.
1559
+ * Stamps {@link URL_SNAPSHOT_KEY} unless `defaults.sync.snapshot` is `false`
1560
+ * (or neither filters nor sorters sync), so the default view serializes to
1561
+ * `"$snapshot"`; with the marker off it serializes to `""` (no leading `?`).
1256
1562
  */
1257
1563
  function stateToUrlQueryString(state, defaults) {
1258
1564
  const filtersGate = resolveAspectGate(defaults.sync?.filters);
@@ -1272,6 +1578,7 @@ function stateToUrlQueryString(state, defaults) {
1272
1578
  const page = state.page ?? 1;
1273
1579
  if (page > 1) query.controls.$skip = (page - 1) * itemsPerPage;
1274
1580
  }
1581
+ if (defaults.sync?.snapshot !== false && (filtersGate !== "none" || sortersGate !== "none")) query.controls[URL_SNAPSHOT_KEY] = "";
1275
1582
  return (0, _uniqu_url_builder.buildUrl)(query);
1276
1583
  }
1277
1584
  /**
@@ -1282,7 +1589,8 @@ const CONSUMED_CONTROLS = new Set([
1282
1589
  "$sort",
1283
1590
  "$search",
1284
1591
  "$relevance",
1285
- "$skip"
1592
+ "$skip",
1593
+ URL_SNAPSHOT_KEY
1286
1594
  ]);
1287
1595
  /** Characters that can only appear in a uniqu filter key, never in a page flag. */
1288
1596
  const FILTER_OPERATOR_CHAR = /[<>!~]/;
@@ -1310,7 +1618,9 @@ function urlQueryConsumesKey(key) {
1310
1618
  * Robust by design — schema drift and copy-paste errors must not break the
1311
1619
  * recipient's view:
1312
1620
  * - unknown fields (not in `knownFields`) → silently dropped
1313
- * - unsupported operators → silently dropped
1621
+ * - filter pieces field filters cannot express (cross-field OR, unknown
1622
+ * operator, …) → left out of `filters` and listed in `unsupported`, never
1623
+ * approximated (the parser does not warn — the caller decides)
1314
1624
  * - unknown controls (e.g. `$weird=42`) → silently ignored
1315
1625
  * - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
1316
1626
  *
@@ -1345,7 +1655,8 @@ function urlQueryStringToState(urlString, opts = {}) {
1345
1655
  filterKnown = /* @__PURE__ */ new Set();
1346
1656
  for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
1347
1657
  } else filterKnown = filtersGate;
1348
- const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown);
1658
+ const unsupported = [];
1659
+ const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown, (issue) => unsupported.push(issue));
1349
1660
  const sorters = [];
1350
1661
  if (sortersGate !== "none") {
1351
1662
  const $sort = parsed.controls?.$sort;
@@ -1369,6 +1680,8 @@ function urlQueryStringToState(urlString, opts = {}) {
1369
1680
  sorters,
1370
1681
  searchTerm: !searchOff && typeof $search === "string" ? $search : ""
1371
1682
  };
1683
+ if (unsupported.length > 0) out.unsupported = unsupported;
1684
+ if (opts.sync?.snapshot !== false && parsed.controls && "$snapshot" in parsed.controls) out.snapshot = true;
1372
1685
  if (!searchOff) {
1373
1686
  const $relevance = parsed.controls?.$relevance;
1374
1687
  if ($relevance === "1" || $relevance === 1) out.ignoreSorters = true;
@@ -1923,6 +2236,7 @@ exports.APP_CONF_PREFIX = APP_CONF_PREFIX;
1923
2236
  exports.AppPrefsClient = AppPrefsClient;
1924
2237
  exports.DEFAULT_EXPORT_PAGE_SIZE = DEFAULT_EXPORT_PAGE_SIZE;
1925
2238
  exports.DEFAULT_ROW_HEIGHT_PX = DEFAULT_ROW_HEIGHT_PX;
2239
+ exports.DEV = DEV;
1926
2240
  exports.DRAFT_PERSISTED_ASPECTS = DRAFT_PERSISTED_ASPECTS;
1927
2241
  exports.ExportAbortError = ExportAbortError;
1928
2242
  exports.MAX_DEFAULT_COLUMN_WIDTH_PX = MAX_DEFAULT_COLUMN_WIDTH_PX;
@@ -1933,6 +2247,7 @@ exports.PresetsHttpError = PresetsHttpError;
1933
2247
  exports.RESERVED_ID_PREFIXES = RESERVED_ID_PREFIXES;
1934
2248
  exports.STANDARD_PRESET_ID = STANDARD_PRESET_ID;
1935
2249
  exports.SYSTEM_PRESET_PREFIX = SYSTEM_PRESET_PREFIX;
2250
+ exports.URL_SNAPSHOT_KEY = URL_SNAPSHOT_KEY;
1936
2251
  exports.USER_CONF_PREFIX = USER_CONF_PREFIX;
1937
2252
  exports.appConfId = appConfId;
1938
2253
  exports.arraysEqual = arraysEqual;
@@ -1941,6 +2256,8 @@ exports.buildTableQuery = buildTableQuery;
1941
2256
  exports.cellAsString = cellAsString;
1942
2257
  exports.clampTopIndex = clampTopIndex;
1943
2258
  exports.collectExportRows = collectExportRows;
2259
+ exports.columnDefaultCondition = columnDefaultCondition;
2260
+ exports.columnFilterConditions = columnFilterConditions;
1944
2261
  exports.columnFilterType = columnFilterType;
1945
2262
  exports.computeDefaultColumnWidth = computeDefaultColumnWidth;
1946
2263
  exports.conditionLabel = conditionLabel;
@@ -1961,6 +2278,7 @@ exports.fromWireSnapshot = fromWireSnapshot;
1961
2278
  exports.gateOwns = gateOwns;
1962
2279
  exports.hasSecondValue = hasSecondValue;
1963
2280
  exports.isAuthError = isAuthError;
2281
+ exports.isColumnFilterable = isColumnFilterable;
1964
2282
  exports.isDirtyAgainst = isDirtyAgainst;
1965
2283
  exports.isEmptyDraft = isEmptyDraft;
1966
2284
  exports.isFilled = isFilled;
@@ -1972,6 +2290,7 @@ exports.mergeFilters = mergeFilters;
1972
2290
  exports.mergeSorters = mergeSorters;
1973
2291
  exports.normaliseSystemPresetId = normaliseSystemPresetId;
1974
2292
  exports.pageAlignedBlocksFor = pageAlignedBlocksFor;
2293
+ exports.parseColumnFilterInput = parseColumnFilterInput;
1975
2294
  exports.parseFilterInput = parseFilterInput;
1976
2295
  exports.planFetch = planFetch;
1977
2296
  exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;