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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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'
|