thatcher 1.0.82 → 1.0.83

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.82",
3
+ "version": "1.0.83",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -198,7 +198,9 @@ export async function list(entity, where = {}, options = {}) {
198
198
  }
199
199
  const { decryptFields } = await import('../field-encryption.js');
200
200
  const decryptedRows = rows.map(r => decryptFields(r, spec.fields));
201
- return attachRefDisplays(entity, decryptedRows);
201
+ const { computeFormulaFields } = await import('../formula-fields.js');
202
+ const withFormulas = decryptedRows.map(r => computeFormulaFields(r, spec.fields));
203
+ return attachRefDisplays(entity, withFormulas);
202
204
  }
203
205
 
204
206
  export async function count(entity, where = {}, options = {}) {
@@ -231,7 +233,9 @@ export async function get(entity, id, options = {}) {
231
233
  }
232
234
  const { decryptFields } = await import('../field-encryption.js');
233
235
  const decrypted = decryptFields(row, spec.fields);
234
- const [withDisplay] = await attachRefDisplays(entity, [decrypted]);
236
+ const { computeFormulaFields } = await import('../formula-fields.js');
237
+ const withFormula = computeFormulaFields(decrypted, spec.fields);
238
+ const [withDisplay] = await attachRefDisplays(entity, [withFormula]);
235
239
  return withDisplay;
236
240
  }
237
241
 
@@ -147,6 +147,7 @@ const OPPORTUNITY_ENTITY_DEFAULT = {
147
147
  expected_close_date: { type: 'date', label: 'Expected Close Date' },
148
148
  owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
149
149
  probability: { type: 'number', min: 0, max: 100, label: 'Probability (%)' },
150
+ weighted_value: { type: 'formula', formula: 'value * probability / 100', label: 'Weighted Value', readonly: true },
150
151
  },
151
152
  };
152
153
 
@@ -300,7 +301,7 @@ function withCustomEntityDefaults(masterConfig) {
300
301
  const KNOWN_FIELD_TYPES = new Set([
301
302
  'text', 'textarea', 'email', 'number', 'int', 'decimal', 'currency', 'date',
302
303
  'timestamp', 'enum', 'bool', 'boolean', 'multiselect', 'ref', 'multiref',
303
- 'file', 'attachment', 'json',
304
+ 'file', 'attachment', 'json', 'formula',
304
305
  ]);
305
306
 
306
307
  // A custom field key colliding with a reserved system field would let a
@@ -0,0 +1,122 @@
1
+ // Minimal safe arithmetic expression evaluator for formula fields: tokenize
2
+ // -> recursive-descent parse -> evaluate, over a fixed grammar of + - * / ( )
3
+ // and bare field-name references only. Deliberately NOT eval/new Function/vm
4
+ // -- there is no code-injection surface because the parser only ever builds
5
+ // number/operator/identifier nodes and the evaluator only ever does
6
+ // arithmetic, never property access or function calls, regardless of what
7
+ // text the formula string contains.
8
+ const TOKEN_RE = /\s*(?:([0-9]+(?:\.[0-9]+)?)|([A-Za-z_][A-Za-z0-9_]*)|([+\-*/()]))/g;
9
+
10
+ function tokenize(formula) {
11
+ const tokens = [];
12
+ let idx = 0;
13
+ while (idx < formula.length) {
14
+ TOKEN_RE.lastIndex = idx;
15
+ const m = TOKEN_RE.exec(formula);
16
+ if (!m || m.index !== idx) {
17
+ throw new Error(`Invalid character in formula at position ${idx}: "${formula[idx]}"`);
18
+ }
19
+ idx = TOKEN_RE.lastIndex;
20
+ if (m[1] !== undefined) tokens.push({ type: 'number', value: Number(m[1]) });
21
+ else if (m[2] !== undefined) tokens.push({ type: 'ident', value: m[2] });
22
+ else if (m[3] !== undefined) tokens.push({ type: 'op', value: m[3] });
23
+ }
24
+ return tokens;
25
+ }
26
+
27
+ // Recursive-descent parser for: expr := term (('+' | '-') term)*
28
+ // term := factor (('*' | '/') factor)*
29
+ // factor := number | ident | '(' expr ')'
30
+ function parse(tokens) {
31
+ let pos = 0;
32
+ function peek() { return tokens[pos]; }
33
+ function next() { return tokens[pos++]; }
34
+
35
+ function parseFactor() {
36
+ const t = peek();
37
+ if (!t) throw new Error('Unexpected end of formula');
38
+ if (t.type === 'number') { next(); return { type: 'number', value: t.value }; }
39
+ if (t.type === 'ident') { next(); return { type: 'ident', value: t.value }; }
40
+ if (t.type === 'op' && t.value === '(') {
41
+ next();
42
+ const node = parseExpr();
43
+ const close = next();
44
+ if (!close || close.type !== 'op' || close.value !== ')') throw new Error('Expected closing parenthesis');
45
+ return node;
46
+ }
47
+ throw new Error(`Unexpected token: ${t.type === 'op' ? t.value : t.value}`);
48
+ }
49
+
50
+ function parseTerm() {
51
+ let node = parseFactor();
52
+ while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
53
+ const opTok = next();
54
+ node = { type: 'binop', op: opTok.value, left: node, right: parseFactor() };
55
+ }
56
+ return node;
57
+ }
58
+
59
+ function parseExpr() {
60
+ let node = parseTerm();
61
+ while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
62
+ const opTok = next();
63
+ node = { type: 'binop', op: opTok.value, left: node, right: parseTerm() };
64
+ }
65
+ return node;
66
+ }
67
+
68
+ const result = parseExpr();
69
+ if (pos !== tokens.length) throw new Error(`Unexpected trailing tokens starting at "${tokens[pos].value}"`);
70
+ return result;
71
+ }
72
+
73
+ // Every identifier the formula references must exist in allowedFieldNames
74
+ // (this entity's own spec.fields keys) -- checked up front so a formula
75
+ // naming an unknown/foreign field is rejected before evaluation is ever
76
+ // attempted, never silently treated as 0 or undefined.
77
+ function validateIdentifiers(node, allowedFieldNames) {
78
+ if (node.type === 'ident') {
79
+ if (!allowedFieldNames.has(node.value)) {
80
+ throw new Error(`Formula references unknown field "${node.value}"`);
81
+ }
82
+ return;
83
+ }
84
+ if (node.type === 'binop') {
85
+ validateIdentifiers(node.left, allowedFieldNames);
86
+ validateIdentifiers(node.right, allowedFieldNames);
87
+ }
88
+ }
89
+
90
+ function evaluateNode(node, record) {
91
+ if (node.type === 'number') return node.value;
92
+ if (node.type === 'ident') {
93
+ const v = Number(record[node.value]);
94
+ return Number.isFinite(v) ? v : 0;
95
+ }
96
+ if (node.type === 'binop') {
97
+ const l = evaluateNode(node.left, record);
98
+ const r = evaluateNode(node.right, record);
99
+ if (node.op === '+') return l + r;
100
+ if (node.op === '-') return l - r;
101
+ if (node.op === '*') return l * r;
102
+ if (node.op === '/') return r === 0 ? 0 : l / r;
103
+ }
104
+ throw new Error('Unreachable formula node');
105
+ }
106
+
107
+ // Parses AND validates a formula against a fixed set of allowed field names
108
+ // (this entity's own spec.fields keys), throwing on anything outside the
109
+ // arithmetic/identifier grammar or referencing an unknown field. Call once
110
+ // at spec-generation or first-use time to fail fast on a malformed formula
111
+ // definition, separately from per-record evaluation.
112
+ export function compileFormula(formula, allowedFieldNames) {
113
+ const tokens = tokenize(formula);
114
+ const ast = parse(tokens);
115
+ validateIdentifiers(ast, allowedFieldNames);
116
+ return ast;
117
+ }
118
+
119
+ export function evaluateFormula(formula, record, allowedFieldNames) {
120
+ const ast = compileFormula(formula, allowedFieldNames);
121
+ return evaluateNode(ast, record);
122
+ }
@@ -0,0 +1,28 @@
1
+ import { evaluateFormula } from './formula-evaluator.js';
2
+
3
+ // Formula fields are never stored -- computed at read time from the SAME
4
+ // record's own field values so the result can never drift from its inputs
5
+ // the way a separately-writable computed column could. Runs right after
6
+ // decryption in busybase/store.js's get()/list(), the same choke point
7
+ // field-encryption already uses, so it is transparent to every existing
8
+ // caller of either function.
9
+ export function computeFormulaFields(record, specFields) {
10
+ if (!record || !specFields) return record;
11
+ let result = record;
12
+ for (const [key, fieldDef] of Object.entries(specFields)) {
13
+ if (fieldDef.type !== 'formula' || !fieldDef.formula) continue;
14
+ const allowedFieldNames = new Set(Object.keys(specFields));
15
+ try {
16
+ if (result === record) result = { ...record };
17
+ result[key] = evaluateFormula(fieldDef.formula, record, allowedFieldNames);
18
+ } catch {
19
+ // A malformed/unreferenceable formula must not crash the read path for
20
+ // every other field on the record -- degrade to null for this field
21
+ // only, the same fail-soft pattern current_stock/total_hours already
22
+ // use on their own computation failures in page-handler.js.
23
+ if (result === record) result = { ...record };
24
+ result[key] = null;
25
+ }
26
+ }
27
+ return result;
28
+ }
@@ -18,6 +18,18 @@ export function sanitizeHtml(str) {
18
18
  export async function validateField(fieldDef, value, options = {}) {
19
19
  const { fieldName, entityName, existingValue } = options;
20
20
 
21
+ // A formula field is never stored -- computed fresh from the record's own
22
+ // other fields at every read (busybase/store.js's computeFormulaFields).
23
+ // Unlike a generic readOnly field (which silently ignores a client-
24
+ // supplied value), a write attempt against a formula field is rejected
25
+ // outright: the caller almost certainly intended to set an input field,
26
+ // not the derived output, and silently swallowing that mistake would hide
27
+ // a real bug rather than surface it.
28
+ if (fieldDef.type === 'formula') {
29
+ if (value === null || value === undefined || value === '') return { valid: true };
30
+ return { valid: false, error: `Field '${fieldName}' is computed and cannot be set directly` };
31
+ }
32
+
21
33
  if (fieldDef.auto || fieldDef.auto_generate || fieldDef.readOnly) {
22
34
  return { valid: true };
23
35
  }
@@ -73,6 +73,7 @@ function formatFieldValue(k, v, entityName, f) {
73
73
  if (entityName === 'user' && k === 'email' && v) { const e = esc(v); return `<a href="mailto:${e}" class="text-primary hover:underline">${e}</a>` }
74
74
  if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${esc(v)}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
75
75
  if (f?.type === 'currency' && typeof v === 'number') return esc((f.currency_symbol || '$') + (v / 100).toFixed(2))
76
+ if (f?.type === 'formula') return v == null ? '-' : esc(String(Math.round(v * 100) / 100)) + ' <span class="pill pill-neutral" style="margin-left:4px;font-size:10px">computed</span>'
76
77
  if (f?.type === 'multiselect') {
77
78
  const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
78
79
  return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
@@ -274,7 +275,12 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
274
275
  if (permissionService.checkFieldAccess(user, spec || {}, k, 'edit')) fields[k] = f
275
276
  }
276
277
  const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
277
- const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
278
+ // f.readonly (lowercase) is the actual normalized key generateEntitySpec
279
+ // sets -- f.readOnly (camelCase) is never present on a spec object, so
280
+ // this exclusion was previously silently dead: a field marked readonly
281
+ // without ALSO being marked auto (e.g. a formula field) would render as
282
+ // an editable input despite the label claiming otherwise.
283
+ const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readonly && !f.auto_generate && f.type !== 'formula' && k !== 'password_hash').map(([k, f]) => {
278
284
  let val = item?.[k] ?? f.default ?? ''
279
285
  const req = f.required ? 'required' : ''
280
286
  const type = f.type === 'number' || f.type === 'int' || f.type === 'decimal' ? 'number' : f.type === 'email' ? 'email' : f.type === 'timestamp' || f.type === 'date' ? 'date' : f.type === 'bool' ? 'checkbox' : 'text'