thatcher 1.0.82 → 1.0.84

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.84",
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 = await Promise.all(decryptedRows.map(r => computeFormulaFields(r, spec.fields, entity)));
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 = await computeFormulaFields(decrypted, spec.fields, entity);
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
 
@@ -171,6 +172,7 @@ const PROJECT_ENTITY_DEFAULT = {
171
172
  status: { type: 'enum', options: ['planning', 'active', 'on_hold', 'completed', 'cancelled'], default: 'planning', label: 'Status' },
172
173
  start_date: { type: 'date', label: 'Start Date' },
173
174
  due_date: { type: 'date', label: 'Due Date' },
175
+ task_count: { type: 'formula', aggregate: { related_entity: 'task', ref_field: 'project_id', aggregate_field: 'id', fn: 'count' }, label: 'Task Count', readonly: true },
174
176
  },
175
177
  };
176
178
 
@@ -300,7 +302,7 @@ function withCustomEntityDefaults(masterConfig) {
300
302
  const KNOWN_FIELD_TYPES = new Set([
301
303
  'text', 'textarea', 'email', 'number', 'int', 'decimal', 'currency', 'date',
302
304
  'timestamp', 'enum', 'bool', 'boolean', 'multiselect', 'ref', 'multiref',
303
- 'file', 'attachment', 'json',
305
+ 'file', 'attachment', 'json', 'formula',
304
306
  ]);
305
307
 
306
308
  // 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,98 @@
1
+ import { evaluateFormula } from './formula-evaluator.js';
2
+
3
+ const AGGREGATE_FUNCTIONS = new Set(['count', 'sum', 'avg']);
4
+
5
+ // An aggregate formula's list() call re-enters computeFormulaFields for every
6
+ // related row, which could itself have an aggregate formula pointing back at
7
+ // the original entity -- a config mistake (A aggregates B, B aggregates A)
8
+ // would otherwise recurse without bound. A simple depth counter (not
9
+ // per-request-scoped -- formula evaluation has no request context threaded
10
+ // through it, same as every other computeFormulaFields caller) caps this at
11
+ // a depth no legitimate one-hop-or-two-hop aggregate design would ever need.
12
+ const MAX_AGGREGATE_DEPTH = 3;
13
+ let currentAggregateDepth = 0;
14
+
15
+ // Cross-entity aggregate formula (task_count-style): join through a field on
16
+ // the RELATED entity that points back to THIS record's id -- the reverse
17
+ // direction of a normal ref field, since the "one" side of a one-to-many
18
+ // relationship never names the "many" side directly. Reuses the exact same
19
+ // list(relatedEntity, {[ref_field]: id}) query shape resource_allocation's
20
+ // capacity check and the rollup report's ref-join already use, not new join
21
+ // logic. Runs WITHOUT a user context in scope -- the same unscoped-internal
22
+ // read pattern checkStockBalance/checkResourceCapacity/the same-record
23
+ // formula path all already establish for computeFormulaFields' call sites in
24
+ // busybase/store.js -- an aggregate is more visibly "reading across
25
+ // entities" than the same-record arithmetic case, so this is stated
26
+ // explicitly rather than left implicit: it is consistent with, not an
27
+ // expansion of, the existing unscoped-lookup contract.
28
+ async function evaluateAggregateFormula(aggregate, record, entityName) {
29
+ const { related_entity, ref_field, aggregate_field, fn } = aggregate || {};
30
+ if (typeof related_entity !== 'string' || typeof ref_field !== 'string' || typeof aggregate_field !== 'string') {
31
+ throw new Error('aggregate formula requires related_entity, ref_field, and aggregate_field');
32
+ }
33
+ if (!AGGREGATE_FUNCTIONS.has(fn)) {
34
+ throw new Error(`aggregate formula fn must be one of ${[...AGGREGATE_FUNCTIONS].join(', ')}`);
35
+ }
36
+
37
+ const { getSpec } = await import('@/config/spec-helpers');
38
+ const relatedSpec = getSpec(related_entity);
39
+ if (!relatedSpec) throw new Error(`aggregate formula references unknown entity "${related_entity}"`);
40
+ if (!relatedSpec.fields?.[ref_field]) {
41
+ throw new Error(`aggregate formula's ref_field "${ref_field}" does not exist on entity "${related_entity}"`);
42
+ }
43
+ if (aggregate_field !== 'id' && !relatedSpec.fields?.[aggregate_field]) {
44
+ throw new Error(`aggregate formula's aggregate_field "${aggregate_field}" does not exist on entity "${related_entity}"`);
45
+ }
46
+
47
+ if (currentAggregateDepth >= MAX_AGGREGATE_DEPTH) {
48
+ throw new Error(`aggregate formula exceeded max nesting depth (${MAX_AGGREGATE_DEPTH}) -- check for a cross-entity aggregate cycle`);
49
+ }
50
+
51
+ currentAggregateDepth++;
52
+ let relatedRows;
53
+ try {
54
+ const { list } = await import('./busybase/store');
55
+ relatedRows = await list(related_entity, { [ref_field]: record.id });
56
+ } finally {
57
+ currentAggregateDepth--;
58
+ }
59
+
60
+ if (fn === 'count') return relatedRows.length;
61
+ if (!relatedRows.length) return 0;
62
+ const values = relatedRows.map(r => Number(r[aggregate_field]) || 0);
63
+ const total = values.reduce((sum, v) => sum + v, 0);
64
+ if (fn === 'sum') return total;
65
+ return total / values.length; // avg
66
+ }
67
+
68
+ // Formula fields are never stored -- computed at read time so the result
69
+ // can never drift from its inputs the way a separately-writable computed
70
+ // column could. Runs right after decryption in busybase/store.js's
71
+ // get()/list(), the same choke point field-encryption already uses, so it
72
+ // is transparent to every existing caller of either function. Two shapes:
73
+ // fieldDef.formula (same-record arithmetic expression) and fieldDef.aggregate
74
+ // (cross-entity count/sum/avg) -- mutually exclusive per field, checked here.
75
+ export async function computeFormulaFields(record, specFields, entityName) {
76
+ if (!record || !specFields) return record;
77
+ let result = record;
78
+ for (const [key, fieldDef] of Object.entries(specFields)) {
79
+ if (fieldDef.type !== 'formula') continue;
80
+ if (!fieldDef.formula && !fieldDef.aggregate) continue;
81
+ if (result === record) result = { ...record };
82
+ try {
83
+ if (fieldDef.aggregate) {
84
+ result[key] = await evaluateAggregateFormula(fieldDef.aggregate, record, entityName);
85
+ } else {
86
+ const allowedFieldNames = new Set(Object.keys(specFields));
87
+ result[key] = evaluateFormula(fieldDef.formula, record, allowedFieldNames);
88
+ }
89
+ } catch {
90
+ // A malformed/unreferenceable formula must not crash the read path for
91
+ // every other field on the record -- degrade to null for this field
92
+ // only, the same fail-soft pattern current_stock/total_hours already
93
+ // use on their own computation failures in page-handler.js.
94
+ result[key] = null;
95
+ }
96
+ }
97
+ return result;
98
+ }
@@ -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'