thatcher 1.0.83 → 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.83",
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",
@@ -199,7 +199,7 @@ export async function list(entity, where = {}, options = {}) {
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));
202
+ const withFormulas = await Promise.all(decryptedRows.map(r => computeFormulaFields(r, spec.fields, entity)));
203
203
  return attachRefDisplays(entity, withFormulas);
204
204
  }
205
205
 
@@ -234,7 +234,7 @@ export async function get(entity, id, options = {}) {
234
234
  const { decryptFields } = await import('../field-encryption.js');
235
235
  const decrypted = decryptFields(row, spec.fields);
236
236
  const { computeFormulaFields } = await import('../formula-fields.js');
237
- const withFormula = computeFormulaFields(decrypted, spec.fields);
237
+ const withFormula = await computeFormulaFields(decrypted, spec.fields, entity);
238
238
  const [withDisplay] = await attachRefDisplays(entity, [withFormula]);
239
239
  return withDisplay;
240
240
  }
@@ -172,6 +172,7 @@ const PROJECT_ENTITY_DEFAULT = {
172
172
  status: { type: 'enum', options: ['planning', 'active', 'on_hold', 'completed', 'cancelled'], default: 'planning', label: 'Status' },
173
173
  start_date: { type: 'date', label: 'Start Date' },
174
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 },
175
176
  },
176
177
  };
177
178
 
@@ -1,26 +1,96 @@
1
1
  import { evaluateFormula } from './formula-evaluator.js';
2
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) {
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) {
10
76
  if (!record || !specFields) return record;
11
77
  let result = record;
12
78
  for (const [key, fieldDef] of Object.entries(specFields)) {
13
- if (fieldDef.type !== 'formula' || !fieldDef.formula) continue;
14
- const allowedFieldNames = new Set(Object.keys(specFields));
79
+ if (fieldDef.type !== 'formula') continue;
80
+ if (!fieldDef.formula && !fieldDef.aggregate) continue;
81
+ if (result === record) result = { ...record };
15
82
  try {
16
- if (result === record) result = { ...record };
17
- result[key] = evaluateFormula(fieldDef.formula, record, allowedFieldNames);
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
+ }
18
89
  } catch {
19
90
  // A malformed/unreferenceable formula must not crash the read path for
20
91
  // every other field on the record -- degrade to null for this field
21
92
  // only, the same fail-soft pattern current_stock/total_hours already
22
93
  // use on their own computation failures in page-handler.js.
23
- if (result === record) result = { ...record };
24
94
  result[key] = null;
25
95
  }
26
96
  }