@ai-matrx/records 0.82.0 → 0.83.0

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.
@@ -327,6 +327,7 @@ var CATALOGUE_STATUSES = ["planned", "authoring", "checking", "waiting_on_lane_1
327
327
 
328
328
  // src/templates/validate.ts
329
329
  import { isUuidShape } from "@ai-matrx/kit/uuid";
330
+ import { FORMULA_FUNCTIONS as KIT_FORMULA_FUNCTIONS, parseFormula as parseFormulaText } from "@ai-matrx/kit/formula";
330
331
 
331
332
  // src/templates/confidential.ts
332
333
  var TABLE_KERNEL_ID = "11111111-0000-4000-8000-000000000001";
@@ -1922,7 +1923,7 @@ function validateUseCase(useCase) {
1922
1923
  `${primary.rows.length} rows makes the screen look empty. Seed at least ${MIN_ROWS_IN_PRIMARY_TABLE} \u2014 dozens is better.`
1923
1924
  );
1924
1925
  }
1925
- const resolveField = (tableToken, fieldKey) => tablesByToken.get(tableToken)?.fields.find((f) => f.key === fieldKey);
1926
+ const resolveField = (tableToken, fieldKey2) => tablesByToken.get(tableToken)?.fields.find((f) => f.key === fieldKey2);
1926
1927
  useCase.relationships?.forEach((rel, i) => {
1927
1928
  const at = `relationships[${i}]`;
1928
1929
  if (!tablesByToken.has(rel.fromTable)) {
@@ -2738,134 +2739,28 @@ function storeFilterMatches(row, filter, _timeZone) {
2738
2739
  return have === String(want);
2739
2740
  });
2740
2741
  }
2741
- var FORMULA_FUNCTIONS = /* @__PURE__ */ new Set([
2742
- "ABS",
2743
- "AND",
2744
- "ARRAYCOMPACT",
2745
- "ARRAYJOIN",
2746
- "AVERAGE",
2747
- "BLANK",
2748
- "CONCATENATE",
2749
- "CONTAINS",
2750
- "DATEADD",
2751
- "DATEDIFF",
2752
- "DATETIME_FORMAT",
2753
- "DAY",
2754
- "FIND",
2755
- "IF",
2756
- "ISBLANK",
2757
- "LEFT",
2758
- "LEN",
2759
- "LOWER",
2760
- "MAX",
2761
- "MIN",
2762
- "MONTH",
2763
- "NOT",
2764
- "OR",
2765
- "REGEX_MATCH",
2766
- "RIGHT",
2767
- "ROUND",
2768
- "SUBSTITUTE",
2769
- "SUM",
2770
- "SWITCH",
2771
- "TODAY",
2772
- "NOW",
2773
- "TRIM",
2774
- "UPPER",
2775
- "WORKDAY",
2776
- "YEAR"
2777
- ]);
2778
- function fxTokens(text) {
2779
- const out = [];
2780
- let i = 0;
2781
- while (i < text.length) {
2782
- const c = text[i];
2783
- if (/\s/.test(c)) i += 1;
2784
- else if (c === "{") {
2785
- const j = text.indexOf("}", i);
2786
- if (j < 0) return "a { has no closing }";
2787
- out.push({ t: "ref", v: text.slice(i + 1, j).trim() });
2788
- i = j + 1;
2789
- } else if (c === "'" || c === '"') {
2790
- const j = text.indexOf(c, i + 1);
2791
- if (j < 0) return "a quoted word has no closing quote";
2792
- out.push({ t: "str", v: text.slice(i + 1, j) });
2793
- i = j + 1;
2794
- } else if (/[0-9.]/.test(c)) {
2795
- const m = /^[0-9]*\.?[0-9]+/.exec(text.slice(i));
2796
- out.push({ t: "num", v: m[0] });
2797
- i += m[0].length;
2798
- } else if (/[A-Za-z_]/.test(c)) {
2799
- const m = /^[A-Za-z_][A-Za-z_0-9]*/.exec(text.slice(i));
2800
- out.push({ t: "name", v: m[0] });
2801
- i += m[0].length;
2802
- } else if ("+-*/(),%".includes(c)) {
2803
- out.push({ t: "op", v: c });
2804
- i += 1;
2805
- } else return `"${c}" is not part of a formula`;
2806
- }
2807
- return out;
2808
- }
2809
- function parseFormula(text) {
2810
- const toks = fxTokens(text);
2811
- if (typeof toks === "string") return toks;
2812
- let p = 0;
2813
- const peek = () => toks[p];
2814
- const fail = (why) => {
2815
- throw new Error(why);
2816
- };
2817
- const expr = () => {
2818
- let a = term();
2819
- while (peek()?.t === "op" && (peek().v === "+" || peek().v === "-")) {
2820
- const op = toks[p++].v;
2821
- a = { k: "bin", op, a, b: term() };
2822
- }
2823
- return a;
2824
- };
2825
- const term = () => {
2826
- let a = factor();
2827
- while (peek()?.t === "op" && (peek().v === "*" || peek().v === "/" || peek().v === "%")) {
2828
- const op = toks[p++].v;
2829
- a = { k: "bin", op, a, b: factor() };
2830
- }
2831
- return a;
2832
- };
2833
- const factor = () => {
2834
- const tk = toks[p++];
2835
- if (!tk) return fail("the formula ends too early");
2836
- if (tk.t === "num") return { k: "num", v: parseNumeric(tk.v) };
2837
- if (tk.t === "str") return { k: "str", v: tk.v };
2838
- if (tk.t === "ref") return { k: "ref", v: tk.v };
2839
- if (tk.t === "op" && tk.v === "-") return { k: "neg", a: factor() };
2840
- if (tk.t === "op" && tk.v === "(") {
2841
- const e = expr();
2842
- if (toks[p++]?.v !== ")") fail("a ( has no closing )");
2843
- return e;
2844
- }
2845
- if (tk.t === "name") {
2846
- const f = tk.v.toUpperCase();
2847
- if (!FORMULA_FUNCTIONS.has(f)) fail(`${tk.v} is not a formula function the store knows`);
2848
- if (toks[p++]?.v !== "(") fail(`${f} is followed by its arguments in ( )`);
2849
- const args = [];
2850
- if (peek()?.v !== ")") {
2851
- args.push(expr());
2852
- while (peek()?.v === ",") {
2853
- p += 1;
2854
- args.push(expr());
2855
- }
2856
- }
2857
- if (toks[p++]?.v !== ")") fail(`${f}( has no closing )`);
2858
- return { k: "call", f, args };
2859
- }
2860
- return fail(`"${tk.v}" is out of place`);
2861
- };
2862
- try {
2863
- const e = expr();
2864
- if (p !== toks.length) return `"${toks[p].v}" is out of place`;
2865
- return e;
2866
- } catch (err) {
2867
- return err.message;
2868
- }
2742
+ var FORMULA_FUNCTION_NAMES = new Set(KIT_FORMULA_FUNCTIONS.map((f) => f.name));
2743
+ function toFx(n) {
2744
+ switch (n.kind) {
2745
+ case "number":
2746
+ return { k: "num", v: parseNumeric(n.raw) };
2747
+ case "string":
2748
+ return { k: "str", v: n.value };
2749
+ case "boolean":
2750
+ return { k: "call", f: n.value ? "TRUE" : "FALSE", args: [] };
2751
+ case "reference":
2752
+ return { k: "ref", v: n.name };
2753
+ case "unary":
2754
+ return { k: "neg", a: toFx(n.operand) };
2755
+ case "binary":
2756
+ return { k: "bin", op: n.operator, a: toFx(n.left), b: toFx(n.right) };
2757
+ case "call":
2758
+ return { k: "call", f: n.name, args: n.args.map(toFx) };
2759
+ }
2760
+ }
2761
+ function parseGateFormula(text) {
2762
+ const parsed = parseFormulaText(text);
2763
+ return parsed.ok ? toFx(parsed.ast) : parsed.error;
2869
2764
  }
2870
2765
  function formulaRefs(n) {
2871
2766
  if (n.k === "ref") return [n.v];
@@ -2905,7 +2800,7 @@ function evalFormula(n, valueOf) {
2905
2800
  if (n.op === "*") return { ok: true, v: q(a.n * b.n, a.d * b.d) };
2906
2801
  if (b.n === 0n) return { ok: false, why: "it divides by zero on a row" };
2907
2802
  if (n.op === "/") return { ok: true, v: q(a.n * b.d, a.d * b.n) };
2908
- return { ok: false, why: "% is not mirrored" };
2803
+ return { ok: false, why: `${n.op} is not mirrored` };
2909
2804
  }
2910
2805
  case "call": {
2911
2806
  if (n.f === "ABS" && n.args.length === 1) {
@@ -3648,7 +3543,7 @@ function validateTemplate(raw, options = {}) {
3648
3543
  return isComputed(pf) ? computedOf(target, pf, { key: far.key, values: far.values }, depth + 1) : { ok: true, v: far.values?.[f.pick] ?? null };
3649
3544
  }
3650
3545
  if (f.parityType === "formula" && f.formula) {
3651
- const ast = parseFormula(f.formula);
3546
+ const ast = parseGateFormula(f.formula);
3652
3547
  if (typeof ast === "string") return { ok: false, why: ast };
3653
3548
  let why = null;
3654
3549
  const r2 = evalFormula(ast, (label2) => {
@@ -3678,7 +3573,7 @@ function validateTemplate(raw, options = {}) {
3678
3573
  if (!f) return false;
3679
3574
  if (isNumber(f)) return true;
3680
3575
  if (f.parityType !== "formula" || !f.formula) return false;
3681
- const ast = parseFormula(f.formula);
3576
+ const ast = parseGateFormula(f.formula);
3682
3577
  if (typeof ast === "string") return false;
3683
3578
  const refsStored = formulaRefs(ast).every((l) => {
3684
3579
  const rf = byToken.get(table)?.fields.find((x) => x.label === l);
@@ -4401,8 +4296,8 @@ function validateTemplate(raw, options = {}) {
4401
4296
  for (const k of rule.filled ?? []) if (!filled(val(k))) say(at, `"${row.key}" is ${st}, so "${fieldOf(table, k)?.label ?? k}" is filled; it is empty.`);
4402
4297
  for (const [k, v] of Object.entries(rule.equals ?? {})) {
4403
4298
  const got = val(k) ?? null;
4404
- const same3 = sameJson(got, v) || typeof v === "number" && parseNumeric(storeText(got) ?? "") !== null && qCmp(parseNumeric(storeText(got) ?? ""), parseNumeric(String(v))) === 0;
4405
- if (!same3) say(at, `"${row.key}" is ${st}, so "${fieldOf(table, k)?.label ?? k}" is ${JSON.stringify(v)}; it is ${JSON.stringify(got)}.`);
4299
+ const same4 = sameJson(got, v) || typeof v === "number" && parseNumeric(storeText(got) ?? "") !== null && qCmp(parseNumeric(storeText(got) ?? ""), parseNumeric(String(v))) === 0;
4300
+ if (!same4) say(at, `"${row.key}" is ${st}, so "${fieldOf(table, k)?.label ?? k}" is ${JSON.stringify(v)}; it is ${JSON.stringify(got)}.`);
4406
4301
  }
4407
4302
  }
4408
4303
  };
@@ -4985,7 +4880,7 @@ function validateTemplate(raw, options = {}) {
4985
4880
  say(fat, `"${f.label}" is a formula; write it in formula, naming fields in braces: {Total} - {Paid}.`);
4986
4881
  continue;
4987
4882
  }
4988
- const ast = parseFormula(f.formula);
4883
+ const ast = parseGateFormula(f.formula);
4989
4884
  if (typeof ast === "string") {
4990
4885
  say(fat, `The formula "${f.formula}" cannot be read: ${ast}.`);
4991
4886
  continue;
@@ -5740,7 +5635,7 @@ ${e.body}`.matchAll(MERGE_TOKEN)].map((m) => m[1]));
5740
5635
  }
5741
5636
  }
5742
5637
  const rows = t.rows;
5743
- const same3 = (x, y) => {
5638
+ const same4 = (x, y) => {
5744
5639
  const a = storeText(x);
5745
5640
  const b = storeText(y);
5746
5641
  if (a === null || b === null) return false;
@@ -5758,7 +5653,7 @@ ${e.body}`.matchAll(MERGE_TOKEN)].map((m) => m[1]));
5758
5653
  };
5759
5654
  for (const sf of t.fields.filter((ff) => !isComputed(ff) && !isRelation(ff) && ff.key !== l.key)) {
5760
5655
  const have = rows.filter((r) => filled(r.values?.[sf.key]));
5761
- if (have.length >= 3 && have.every((r) => same3(r.values?.[sf.key], far(r)))) {
5656
+ if (have.length >= 3 && have.every((r) => same4(r.values?.[sf.key], far(r)))) {
5762
5657
  say(`tables (${t.token}).fields (${sf.key})`, `"${sf.label}" repeats "${l.label}" on every row; drop it and use the lookup, which follows the linked record.`);
5763
5658
  }
5764
5659
  }
@@ -6786,6 +6681,370 @@ function normalizeComputedFields(spec, fixes) {
6786
6681
  }
6787
6682
  }
6788
6683
 
6684
+ // src/templates/describe/mechanical-shapes.ts
6685
+ var squash2 = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
6686
+ var singular2 = (s) => s.replace(/ies$/, "y").replace(/s$/, "");
6687
+ var same3 = (a, b) => squash2(a) !== "" && (squash2(a) === squash2(b) || singular2(squash2(a)) === singular2(squash2(b)));
6688
+ var tableOf = (spec, token) => allTables(spec).find((t) => t.token === token);
6689
+ function fieldKey(t, name) {
6690
+ if (!t || typeof name !== "string") return void 0;
6691
+ return t.fields.find((f) => f.key === name)?.key ?? t.fields.find((f) => same3(f.key, name))?.key ?? t.fields.find((f) => same3(f.label, name))?.key;
6692
+ }
6693
+ var VIEW_ALIASES = {
6694
+ groupBy: ["group_by", "groupby", "groupField", "group_field", "columnField", "column_field"],
6695
+ dateField: ["date_field", "datefield", "dateColumn", "date_column", "calendarField"],
6696
+ startField: ["start_field", "startDate", "start_date", "start"],
6697
+ endField: ["end_field", "endDate", "end_date", "end"],
6698
+ coverField: ["cover_field", "coverImage", "cover_image", "imageField", "image_field", "photoField", "photo_field"],
6699
+ swimlaneBy: ["swimlane_by", "swimlaneField", "swimlane_field", "lanes"]
6700
+ };
6701
+ var EQ_OPS = /* @__PURE__ */ new Set(["eq", "is", "equals", "equal", "=", "==", "===", "equal_to", "is_equal"]);
6702
+ var NEQ_OPS = /* @__PURE__ */ new Set(["neq", "ne", "is_not", "isnot", "not_equal", "!=", "<>", "not_equals", "not_in", "nin", "not_any_of"]);
6703
+ var IN_OPS = /* @__PURE__ */ new Set(["in", "is_any_of", "any_of", "one_of", "in_list", "is_one_of"]);
6704
+ var EMPTY_OPS = /* @__PURE__ */ new Set(["is_empty", "empty", "is_null", "null", "unset", "is_blank", "blank", "is_unset"]);
6705
+ function sortDirection(v) {
6706
+ const s = String(v ?? "").toLowerCase().replace(/[^a-z]/g, "");
6707
+ if (["asc", "ascending", "up", "az", "oldest", "lowest", "first"].includes(s)) return "asc";
6708
+ if (["desc", "descending", "down", "za", "newest", "highest", "last"].includes(s)) return "desc";
6709
+ return void 0;
6710
+ }
6711
+ function fixViewShapes(spec, fixes) {
6712
+ for (const view of spec.views ?? []) {
6713
+ const t = tableOf(spec, view.table);
6714
+ const at = `views.${view.token}`;
6715
+ for (const [canon, alts] of Object.entries(VIEW_ALIASES)) {
6716
+ if (view[canon] == null) {
6717
+ const alt = alts.find((a) => view[a] != null && typeof view[a] === "string");
6718
+ if (alt) {
6719
+ view[canon] = view[alt];
6720
+ delete view[alt];
6721
+ fixes.push(`${at}.${canon}: written as "${alt}"`);
6722
+ }
6723
+ }
6724
+ if (typeof view[canon] === "string") {
6725
+ const k = fieldKey(t, view[canon]);
6726
+ if (k && k !== view[canon]) {
6727
+ fixes.push(`${at}.${canon}: "${view[canon]}" is the field "${k}"`);
6728
+ view[canon] = k;
6729
+ }
6730
+ }
6731
+ }
6732
+ const rawSorts = view.sorts ?? view.sort ?? view.orderBy ?? view.order_by;
6733
+ if (rawSorts !== void 0) {
6734
+ const list = Array.isArray(rawSorts) ? rawSorts : typeof rawSorts === "string" ? rawSorts.split(",") : rawSorts && typeof rawSorts === "object" ? [rawSorts] : [];
6735
+ const out = [];
6736
+ for (const s of list) {
6737
+ let name, dir;
6738
+ if (typeof s === "string") {
6739
+ const m = /^\s*(-?)\s*([^\s]+)(?:\s+(asc|desc|ascending|descending))?\s*$/i.exec(s);
6740
+ if (m) {
6741
+ name = m[2];
6742
+ dir = m[3] ?? (m[1] ? "desc" : "asc");
6743
+ }
6744
+ } else if (s && typeof s === "object") {
6745
+ name = s.field ?? s.key ?? s.column ?? s.by ?? s.name;
6746
+ dir = s.direction ?? s.dir ?? s.order ?? s.sort ?? "asc";
6747
+ }
6748
+ const key = fieldKey(t, name) ?? (typeof name === "string" ? name : void 0);
6749
+ const d = sortDirection(dir);
6750
+ if (key && d) out.push({ field: key, direction: d });
6751
+ }
6752
+ const changed = JSON.stringify(rawSorts) !== JSON.stringify(out) || view.sort !== void 0 || view.orderBy !== void 0 || view.order_by !== void 0;
6753
+ delete view.sort;
6754
+ delete view.orderBy;
6755
+ delete view.order_by;
6756
+ if (out.length) view.sorts = out;
6757
+ else delete view.sorts;
6758
+ if (changed) fixes.push(`${at}.sorts: written in the store's shape [{field, direction}]`);
6759
+ }
6760
+ if (Array.isArray(view.filters)) {
6761
+ const filters = {};
6762
+ const anyOf = { ...view.anyOf ?? {} };
6763
+ const dropped = [];
6764
+ for (const c of view.filters) {
6765
+ if (!c || typeof c !== "object") continue;
6766
+ const name = c.field ?? c.key ?? c.column ?? c.name;
6767
+ const key = fieldKey(t, name) ?? (typeof name === "string" ? name : void 0);
6768
+ if (!key) continue;
6769
+ const f = t?.fields.find((x) => x.key === key);
6770
+ const op = String(c.op ?? c.operator ?? c.comparison ?? "eq").toLowerCase().trim().replace(/[\s-]+/g, "_");
6771
+ const has = "value" in c || "values" in c;
6772
+ const val = "value" in c ? c.value : c.values;
6773
+ const setEq = (v) => {
6774
+ if (key in filters || key in anyOf) dropped.push(`${key} (two conditions on one field)`);
6775
+ else filters[key] = v;
6776
+ };
6777
+ const setAny = (vals) => {
6778
+ if (key in filters || key in anyOf) dropped.push(`${key} (two conditions on one field)`);
6779
+ else if (vals.length === 1) filters[key] = vals[0];
6780
+ else anyOf[key] = vals;
6781
+ };
6782
+ const universe = f?.parityType === "checkbox" ? [true, false] : f?.choices?.length ? [...f.choices] : void 0;
6783
+ if (EMPTY_OPS.has(op)) setEq(null);
6784
+ else if (EQ_OPS.has(op) && has && !Array.isArray(val)) setEq(val);
6785
+ else if ((IN_OPS.has(op) || EQ_OPS.has(op)) && Array.isArray(val) && val.length) setAny(val);
6786
+ else if (NEQ_OPS.has(op) && has && universe) {
6787
+ const gone = new Set(Array.isArray(val) ? val : [val]);
6788
+ const left = universe.filter((x) => !gone.has(x));
6789
+ if (left.length) setAny(left);
6790
+ else dropped.push(`${key} ${op}`);
6791
+ } else dropped.push(`${key} ${op}`);
6792
+ }
6793
+ view.filters = filters;
6794
+ if (Object.keys(anyOf).length) view.anyOf = anyOf;
6795
+ fixes.push(`${at}.filters: an array of conditions written as the store's map (field \u2192 value; several values \u2192 anyOf)${dropped.length ? `; not expressible and dropped, so the view shows more rows than asked: ${dropped.join(", ")}` : ""}`);
6796
+ }
6797
+ }
6798
+ }
6799
+ function dropDuplicateViews(spec, fixes) {
6800
+ const seen = /* @__PURE__ */ new Map();
6801
+ const kept = [];
6802
+ for (const v of spec.views ?? []) {
6803
+ const { token: _t, name: _n, isDefault: _d, ...shape } = v;
6804
+ const sig = JSON.stringify(Object.keys(shape).sort().map((k) => [k, shape[k]]));
6805
+ const twin = seen.get(sig);
6806
+ if (!twin) {
6807
+ seen.set(sig, v);
6808
+ kept.push(v);
6809
+ continue;
6810
+ }
6811
+ if (v.isDefault) twin.isDefault = true;
6812
+ fixes.push(`views.${v.token}: "${v.name}" showed exactly what "${twin.name}" shows; dropped`);
6813
+ }
6814
+ if (kept.length !== (spec.views ?? []).length) spec.views = kept;
6815
+ }
6816
+ var KIND_KEYS = ["kind", "type", "fieldType", "field_type", "dataType", "data_type", "parity_type"];
6817
+ var KIND_SYNONYMS = {
6818
+ string: "text",
6819
+ shorttext: "text",
6820
+ singlelinetext: "text",
6821
+ varchar: "text",
6822
+ longtext: "long_text",
6823
+ textarea: "long_text",
6824
+ paragraph: "long_text",
6825
+ multiline: "long_text",
6826
+ richtext: "long_text",
6827
+ integer: "number",
6828
+ int: "number",
6829
+ decimal: "number",
6830
+ float: "number",
6831
+ numeric: "number",
6832
+ money: "currency",
6833
+ price: "currency",
6834
+ amount: "currency",
6835
+ boolean: "checkbox",
6836
+ bool: "checkbox",
6837
+ tick: "checkbox",
6838
+ yesno: "checkbox",
6839
+ date: "datetime",
6840
+ timestamp: "datetime",
6841
+ datetime: "datetime",
6842
+ time: "datetime",
6843
+ dropdown: "select",
6844
+ enum: "select",
6845
+ choice: "select",
6846
+ singleselect: "select",
6847
+ multiselect: "multi_select",
6848
+ tags: "multi_select",
6849
+ tel: "phone",
6850
+ telephone: "phone",
6851
+ mobile: "phone",
6852
+ mail: "email",
6853
+ emailaddress: "email",
6854
+ website: "url",
6855
+ link: "url",
6856
+ uri: "url"
6857
+ };
6858
+ function inferKind(label, key) {
6859
+ const s = `${key.replace(/_/g, " ")} ${label}`.toLowerCase();
6860
+ if (/\be-?mail\b/.test(s)) return "email";
6861
+ if (/\b(phone|mobile|cell|telephone|fax)\b/.test(s)) return "phone";
6862
+ if (/\b(url|website|web site|link)\b/.test(s)) return "url";
6863
+ if (/\b(amount|fee|price|cost|total|balance|budget|salary|wage|deposit|revenue|payment)\b/.test(s)) return "currency";
6864
+ if (/\b(date|when|due|deadline|birthday|dob|start|end|time|at)\b/.test(s) || /_(on|at)$/.test(key)) return "datetime";
6865
+ if (/\b(notes?|description|details|comments?|summary|instructions)\b/.test(s)) return "long_text";
6866
+ if (/\b(count|quantity|qty|hours|score|age|number of|size|weight|rating)\b/.test(s)) return "number";
6867
+ if (/\b(name|title|owner|label|code|id|reference)\b/.test(s)) return "text";
6868
+ return void 0;
6869
+ }
6870
+ var wordOf = (v) => {
6871
+ if (typeof v !== "string") return void 0;
6872
+ if (RESOLVABLE_FIELD_TYPE_WORDS.includes(v)) return v;
6873
+ const syn = KIND_SYNONYMS[squash2(v)];
6874
+ return syn && RESOLVABLE_FIELD_TYPE_WORDS.includes(syn) ? syn : void 0;
6875
+ };
6876
+ function fixFieldKinds(spec, fixes) {
6877
+ for (const t of spec.tables) {
6878
+ for (const f of t.fields) {
6879
+ const raw = f;
6880
+ if (typeof raw.parityType === "string" && RESOLVABLE_FIELD_TYPE_WORDS.includes(raw.parityType)) continue;
6881
+ const at = `tables.${t.token}.fields.${f.key}`;
6882
+ const alias = KIND_KEYS.find((k) => raw[k] != null && typeof raw[k] === "string");
6883
+ let kind = wordOf(raw.parityType) ?? (alias ? wordOf(raw[alias]) : void 0);
6884
+ let how = alias ? `its "${alias}" says "${raw[alias]}"` : raw.parityType ? `"${raw.parityType}" is not a type word` : "it names no type";
6885
+ if (!kind && !alias && !raw.parityType) {
6886
+ kind = inferKind(String(f.label ?? ""), String(f.key ?? ""));
6887
+ how = `it names no type; "${f.label}" plainly is one`;
6888
+ }
6889
+ if (!kind) continue;
6890
+ for (const k of KIND_KEYS) if (k !== "parityType" && raw[k] === raw[alias ?? ""] && alias) delete raw[k];
6891
+ f.parityType = kind;
6892
+ if ((kind === "phone" || kind === "email") && !raw.format) raw.format = kind;
6893
+ fixes.push(`${at}: type ${kind} (${how})`);
6894
+ }
6895
+ }
6896
+ }
6897
+ function fixFormulaRefs(spec, fixes) {
6898
+ for (const t of spec.tables) {
6899
+ for (const f of t.fields) {
6900
+ if (f.parityType !== "formula" || typeof f.formula !== "string") continue;
6901
+ const others = t.fields.filter((x) => x.key !== f.key);
6902
+ const labelFor = (name) => others.find((x) => x.label === name)?.label ?? others.find((x) => x.key === name)?.label ?? others.find((x) => same3(x.key, name))?.label ?? others.find((x) => same3(x.label, name))?.label;
6903
+ const text = f.formula.replace(/^\s*=\s*/, "");
6904
+ let out = "";
6905
+ let i = 0;
6906
+ while (i < text.length) {
6907
+ const c = text[i];
6908
+ if (c === "{") {
6909
+ const j = text.indexOf("}", i);
6910
+ if (j < 0) {
6911
+ out += text.slice(i);
6912
+ break;
6913
+ }
6914
+ const name = text.slice(i + 1, j).trim();
6915
+ out += `{${labelFor(name) ?? name}}`;
6916
+ i = j + 1;
6917
+ } else if (c === "'" || c === '"') {
6918
+ const j = text.indexOf(c, i + 1);
6919
+ if (j < 0) {
6920
+ out += text.slice(i);
6921
+ break;
6922
+ }
6923
+ out += text.slice(i, j + 1);
6924
+ i = j + 1;
6925
+ } else if (/[A-Za-z_]/.test(c)) {
6926
+ const m = /^[A-Za-z_][A-Za-z_0-9]*/.exec(text.slice(i))[0];
6927
+ const after = text.slice(i + m.length).trimStart()[0];
6928
+ const label = after === "(" ? void 0 : labelFor(m);
6929
+ out += label ? `{${label}}` : m;
6930
+ i += m.length;
6931
+ } else {
6932
+ out += c;
6933
+ i += 1;
6934
+ }
6935
+ }
6936
+ if (out !== f.formula) {
6937
+ fixes.push(`tables.${t.token}.fields.${f.key}.formula: fields named by label in braces ("${f.formula}" \u2192 "${out}")`);
6938
+ f.formula = out;
6939
+ }
6940
+ }
6941
+ }
6942
+ }
6943
+ function fixInverseKeys(spec, fixes) {
6944
+ const rels = spec.relationships ?? [];
6945
+ const taken = /* @__PURE__ */ new Set();
6946
+ for (const t of allTables(spec)) for (const f of t.fields) taken.add(`${t.token}.${f.key}`);
6947
+ for (const r of rels) if (r.inverseKey) taken.add(`${r.toTable}.${r.inverseKey}`);
6948
+ for (const r of rels) {
6949
+ if (!r.inverseKey) continue;
6950
+ const target = tableOf(spec, r.toTable);
6951
+ if (!target?.fields.some((f) => f.key === r.inverseKey)) continue;
6952
+ let k = `${r.fromTable}_${r.fromField}_back`;
6953
+ for (let n = 2; taken.has(`${r.toTable}.${k}`); n++) k = `${r.fromTable}_${r.fromField}_back_${n}`;
6954
+ fixes.push(`relationships: ${r.fromTable}.${r.fromField} \u2192 ${r.toTable}: inverseKey "${r.inverseKey}" is already a field of ${r.toTable}; renamed "${k}"`);
6955
+ taken.add(`${r.toTable}.${k}`);
6956
+ r.inverseKey = k;
6957
+ }
6958
+ }
6959
+ var STATUS_WORDS = /\b(open|booked|upcoming|unpaid|outstanding|active|ordered|pending|overdue|scheduled|confirmed|paid|completed|current|remaining)\b/i;
6960
+ function fixRollupFilters(spec, fixes) {
6961
+ for (const t of spec.tables) {
6962
+ for (const f of t.fields) {
6963
+ if (f.parityType !== "rollup" || f.filter && Object.keys(f.filter).length || !f.via) continue;
6964
+ const hit = STATUS_WORDS.exec(`${f.label} ${f.key.replace(/_/g, " ")}`);
6965
+ const via = t.fields.find((x) => x.key === f.via);
6966
+ const far = via?.relationTarget ? tableOf(spec, via.relationTarget) : void 0;
6967
+ if (!hit || !far) continue;
6968
+ const word = hit[1].toLowerCase();
6969
+ const cands = [];
6970
+ for (const x of far.fields) {
6971
+ if (["select", "multi_select"].includes(x.parityType)) {
6972
+ for (const c of x.choices ?? []) if (c.toLowerCase() === word) cands.push({ key: x.key, value: c });
6973
+ }
6974
+ if (x.parityType === "checkbox" && new RegExp(`(^|_)${word}($|_)`).test(x.key)) cands.push({ key: x.key, value: true });
6975
+ if (x.parityType === "checkbox" && word === "unpaid" && /(^|_)paid($|_)/.test(x.key)) cands.push({ key: x.key, value: false });
6976
+ }
6977
+ if (cands.length !== 1) continue;
6978
+ f.filter = { [cands[0].key]: cands[0].value };
6979
+ fixes.push(`tables.${t.token}.fields.${f.key}: "${hit[1]}" in its name now picks those records: filter ${JSON.stringify(f.filter)}`);
6980
+ }
6981
+ }
6982
+ }
6983
+ function fictional(p) {
6984
+ const h = [...p].reduce((n, ch) => (n * 31 + ch.charCodeAt(0)) % 1e3, 7);
6985
+ const nn = String(h % 100).padStart(2, "0");
6986
+ const uk = String(h).padStart(3, "0");
6987
+ if (/^\s*\+/.test(p) && !/^\s*\+1\b/.test(p)) return `+44 20 7946 0${uk}`;
6988
+ if (/^\s*0\d/.test(p)) return `020 7946 0${uk}`;
6989
+ return p.replace(/(\d{3})([\s.-]?)(\d{4})\s*$/, (_m, _a, sep) => `555${sep}01${nn}`);
6990
+ }
6991
+ function fixFictionalPhones(spec, fixes) {
6992
+ const json = JSON.stringify(spec);
6993
+ const hits = /* @__PURE__ */ new Set();
6994
+ const isPhoneField = (f) => f.parityType === "phone" || /(^|_)(phone|mobile|cell|fax|tel)(_|$)/.test(f.key);
6995
+ const biz = spec.business?.phone;
6996
+ if (typeof biz === "string") for (const p of phonesOutsideFictionalRange(biz, true)) hits.add(p);
6997
+ for (const t of spec.tables) for (const r of t.rows ?? []) for (const f of t.fields) {
6998
+ const v = r.values?.[f.key];
6999
+ if (typeof v === "string") for (const p of phonesOutsideFictionalRange(v, isPhoneField(f))) hits.add(p);
7000
+ }
7001
+ for (const p of phonesOutsideFictionalRange(json)) hits.add(p);
7002
+ let out = json;
7003
+ let n = 0;
7004
+ for (const p of hits) {
7005
+ const escaped = JSON.stringify(p).slice(1, -1);
7006
+ if (!out.includes(escaped)) continue;
7007
+ out = out.split(escaped).join(JSON.stringify(fictional(p)).slice(1, -1));
7008
+ n += 1;
7009
+ }
7010
+ if (!n) return;
7011
+ const next = JSON.parse(out);
7012
+ for (const k of Object.keys(spec)) delete spec[k];
7013
+ Object.assign(spec, next);
7014
+ fixes.push(`phones: ${n} number(s) that could reach a real phone replaced with fictional ones (555-0100\u20260199, UK Ofcom drama range)`);
7015
+ }
7016
+ function fixTeamBlockCopy(spec, fixes) {
7017
+ const block = SHARED_BLOCKS.team_member;
7018
+ const used = /* @__PURE__ */ new Set();
7019
+ for (const v of spec.views ?? []) for (const k of ["groupBy", "dateField", "startField", "endField", "coverField", "swimlaneBy", "measure"]) if (v.table && v[k]) used.add(`${v.table}.${v[k]}`);
7020
+ for (const fm of spec.forms ?? []) for (const q2 of fm.fields ?? []) used.add(`${fm.table}.${q2.fieldKey}`);
7021
+ for (const e of spec.extras ?? []) for (const q2 of e.questions ?? []) used.add(`${e.table}.${q2.fieldKey}`);
7022
+ for (const t of spec.tables) {
7023
+ const meaning = (f) => block.fields.find((bf) => bf.parityType === f.parityType && bf.sameAs.some((w) => same3(w, f.label) || same3(w, f.key)));
7024
+ const hits = t.fields.map((f) => ({ f, bf: meaning(f) })).filter((x) => x.bf);
7025
+ if (hits.length < block.copyThreshold) continue;
7026
+ const hired = hits.some((h) => h.bf.key === "start_date");
7027
+ const hasRole = hits.some((h) => h.bf.key === "role");
7028
+ const contactOfSomeone = t.fields.some((f) => f.parityType === "relation" && f.relationTarget && f.relationTarget !== block.table && spec.tables.some((o) => o.token === f.relationTarget));
7029
+ if (!(hired || hasRole && !contactOfSomeone)) continue;
7030
+ const order = ["full_name", "role", "work_email", "mobile", "start_date"];
7031
+ const keep = new Set(hits.filter((h) => ["full_name", "role"].includes(h.bf.key)).map((h) => h.f.key));
7032
+ const dropped = [];
7033
+ let left = hits.length;
7034
+ for (const h of [...hits].sort((a, b) => order.indexOf(b.bf.key) - order.indexOf(a.bf.key))) {
7035
+ if (left < block.copyThreshold) break;
7036
+ if (keep.has(h.f.key) || used.has(`${t.token}.${h.f.key}`) || t.titleField === h.f.key) continue;
7037
+ dropped.push(h.f.label);
7038
+ t.fields = t.fields.filter((x) => x.key !== h.f.key);
7039
+ for (const r of t.rows ?? []) if (r.values) delete r.values[h.f.key];
7040
+ left -= 1;
7041
+ }
7042
+ if (left >= block.copyThreshold) continue;
7043
+ if (!t.fields.some((f) => f.key === "notes")) t.fields.push({ key: "notes", label: "Notes", parityType: "long_text", sensitivity: "internal", contextPolicy: "include" });
7044
+ fixes.push(`tables.${t.token}: copied the shared team_member block; kept as an ordinary table (${t.fields.map((f) => f.label).join(", ")}); dropped ${dropped.join(", ") || "nothing"}`);
7045
+ }
7046
+ }
7047
+
6789
7048
  // src/templates/describe/autofix.ts
6790
7049
  var clone2 = (v) => JSON.parse(JSON.stringify(v));
6791
7050
  var SENSITIVITY_RANK2 = ["public", "internal", "confidential", "restricted"];
@@ -7107,7 +7366,15 @@ function switchJob(spec, fixes) {
7107
7366
  function autofixDescribeSpec(input, ctx = {}) {
7108
7367
  const spec = describeSpec(clone2(input));
7109
7368
  const autoFixes = [];
7369
+ fixFieldKinds(spec, autoFixes);
7110
7370
  normalizeComputedFields(spec, autoFixes);
7371
+ fixFormulaRefs(spec, autoFixes);
7372
+ fixViewShapes(spec, autoFixes);
7373
+ dropDuplicateViews(spec, autoFixes);
7374
+ fixInverseKeys(spec, autoFixes);
7375
+ fixRollupFilters(spec, autoFixes);
7376
+ fixTeamBlockCopy(spec, autoFixes);
7377
+ fixFictionalPhones(spec, autoFixes);
7111
7378
  fixMissingQuestionFields(spec, autoFixes);
7112
7379
  raiseSensitivity(spec, autoFixes);
7113
7380
  flagAskedOfSubject(spec, ctx, autoFixes);
@@ -7339,6 +7606,22 @@ var LOOKUP_SHAPE_FACTS = {
7339
7606
  rule: 'a lookup is a field that shows one value of the record a relation field points at: parityType "lookup", via = the key of a relation field of THIS table, pick = the key of a field on the far table. Write exactly these keys on the field itself \u2014 never a nested {lookup:{\u2026}} object.',
7340
7607
  example: { key: "client_phone", label: "Client phone", parityType: "lookup", via: "client", pick: "phone" }
7341
7608
  };
7609
+ var VIEW_FILTERS_SHAPE_FACTS = {
7610
+ rule: "a view's `filters` is ONE object of field key \u2192 value (a plain value is equality, null means empty); several allowed values of one field go in `anyOf` as field key \u2192 [values]; `sorts` is a list of {field, direction: asc|desc}; groupBy, dateField, startField, endField, coverField are field KEYS. Never write filters or sort as a list of {field, op, value}. The store cannot filter on 'not', 'contains', 'less than' or 'is not empty' \u2014 leave such a condition out.",
7611
+ example: { token: "open_requests", name: "Open requests", table: "request", kind: "kanban", groupBy: "stage", filters: { priority: "High" }, anyOf: { stage: ["New", "In progress"] }, sorts: [{ field: "due_on", direction: "asc" }] }
7612
+ };
7613
+ var FIELD_SHAPE_FACTS = {
7614
+ rule: "every field is {key, label, parityType, sensitivity, contextPolicy}; the type word goes in `parityType` (never `kind` or `type`) and is one of field_kinds.",
7615
+ example: { key: "visit_fee", label: "Visit fee", parityType: "currency", sensitivity: "internal", contextPolicy: "include" }
7616
+ };
7617
+ var FORMULA_SHAPE_FACTS = {
7618
+ rule: "a formula names every field by its LABEL in braces ({Quantity}), never by key; operators are + - * / % only (no >, <, = or comparisons), functions are IF, ROUND, SUM, DATEDIFF and the other store functions; it never reads a lookup or rollup, nor itself.",
7619
+ example: { key: "line_total", label: "Line total", parityType: "formula", formula: "{Quantity} * {Unit price}" }
7620
+ };
7621
+ var RELATIONSHIP_SHAPE_FACTS = {
7622
+ rule: "a relationship's inverseKey is the name the OTHER table sees this link under; it must not be a key that table already has as a field. A parent that lists its children owns ONE multi relation field (multi true); the child's single link to the parent is a second field, and their inverseKeys differ from every field key.",
7623
+ example: { fromTable: "request", fromField: "client", toTable: "client", flavor: "referenced", cardinality: "one", onDelete: "restrict", inverseKey: "requests_from" }
7624
+ };
7342
7625
  function describeVocabulary() {
7343
7626
  return {
7344
7627
  field_kinds: RESOLVABLE_FIELD_TYPE_WORDS,
@@ -7364,6 +7647,12 @@ function describeVocabulary() {
7364
7647
  view_shape: VIEW_SHAPE_FACTS,
7365
7648
  rollup_shape: ROLLUP_SHAPE_FACTS,
7366
7649
  lookup_shape: LOOKUP_SHAPE_FACTS,
7650
+ view_filters_shape: VIEW_FILTERS_SHAPE_FACTS,
7651
+ field_shape: FIELD_SHAPE_FACTS,
7652
+ formula_shape: FORMULA_SHAPE_FACTS,
7653
+ relationship_shape: RELATIONSHIP_SHAPE_FACTS,
7654
+ phone_rule: "seed phone numbers are fictional: 555-0100 to 555-0199 in North America (e.g. (813) 555-0142) or a UK Ofcom drama number (e.g. 020 7946 0123); never a real-looking number.",
7655
+ team_rule: "the team (name, role, work email, mobile, start date) is the shared team_member block; do not declare a table with those fields \u2014 keep a table of your own to just name and role, or point a relation field at team_member.",
7367
7656
  title_field: "a table's titleField is the key of a plain text-like field of that same table (a name, a title) \u2014 never a link to another table, a formula or a timed date"
7368
7657
  };
7369
7658
  }
@@ -7436,7 +7725,7 @@ export {
7436
7725
  DEFAULT_TEMPLATE_AGENT,
7437
7726
  DESIGNATED_REAL_NUMBERS,
7438
7727
  ENTITY_REFERENCE_KINDS,
7439
- FORMULA_FUNCTIONS,
7728
+ FORMULA_FUNCTION_NAMES,
7440
7729
  GALLERY_ONLY,
7441
7730
  GOOD_OUTCOME,
7442
7731
  INDUSTRY_GROUPS,
@@ -7519,7 +7808,7 @@ export {
7519
7808
  numberedStreet,
7520
7809
  packageDescribeResult,
7521
7810
  parseAt,
7522
- parseFormula,
7811
+ parseGateFormula,
7523
7812
  parseNumeric,
7524
7813
  pathOfAt,
7525
7814
  peopleOf,