-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathformula-context.test.ts
More file actions
107 lines (100 loc) · 5.63 KB
/
Copy pathformula-context.test.ts
File metadata and controls
107 lines (100 loc) · 5.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* #3426 — a `formula` field is a READ-time virtual: the engine evaluates it
* post-fetch on `find`/`findOne`, never on the write path, so it is absent from
* the raw after-create/after-update row a record-change flow is seeded with.
* `{record.full_name}` in a notify template (or a start condition) therefore
* resolved to an empty string. The trigger now re-reads the written record
* through the data engine, so the seeded `record` carries the same computed
* fields a data-API read returns.
*
* This exercises the whole stack (real ObjectQL + automation + record-change
* trigger) with a formula field, proving the seeded record resolves it. The
* notify node interpolates the very same variable map, so a formula that
* resolves for `update_record` here resolves for a notify `title`/`body` too.
*/
import { describe, it, expect } from 'vitest';
import { ObjectKernel } from '@objectstack/core';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
import { RecordChangeTriggerPlugin } from './plugin.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Memory driver storing full rows. Formula virtuals are computed by the
* ENGINE post-fetch, never by the driver — so `full_name` is never stored. */
function makeDriver(): any {
const store = new Map<string, Record<string, unknown>>();
let n = 0;
const matches = (row: any, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
for (const [k, v] of Object.entries(where)) {
if (k.startsWith('$')) continue;
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
if ((row[k] ?? null) !== (exp ?? null)) return false;
}
return true;
};
return {
name: 'memory', version: '0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async execute() { return null; }, async syncSchema() {},
async create(_o: string, data: any) {
n += 1; const id = data.id ?? `r_${n}`;
const full = { ...data, id };
store.set(id, full);
return { ...full };
},
async update(_o: string, id: string, data: any) { const cur = store.get(id) ?? {}; const u = { ...cur, ...data, id }; store.set(id, u); return { ...u }; },
async find(_o: string, ast: any) { return [...store.values()].filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); },
async findOne(_o: string, ast: any) { for (const r of store.values()) if (matches(r, ast?.where)) return { ...r }; return null; },
async delete(_o: string, id: string) { return store.delete(id); },
async count(_o: string, ast: any) { return (await this.find(_o, ast)).length; },
async upsert(_o: string, d: any) { return this.create(_o, d); },
async bulkCreate(_o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(_o, r))); },
async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
}
describe('record-change context hydrates read-time formula fields (#3426)', () => {
it('resolves a formula field ({record.full_name}) in a seeded flow record', async () => {
const kernel = new ObjectKernel({ logLevel: 'silent' });
await kernel.use(new ObjectQLPlugin());
await kernel.use(new AutomationServicePlugin());
await kernel.use(new RecordChangeTriggerPlugin());
await kernel.bootstrap();
const objectql = kernel.getService('objectql') as any;
const data = kernel.getService('data') as any;
const automation = kernel.getService<AutomationEngine>('automation');
objectql.registerDriver(makeDriver(), true);
objectql.registry.registerObject({
name: 'crm_lead', label: 'Lead',
fields: {
first_name: { name: 'first_name', label: 'First', type: 'text' },
last_name: { name: 'last_name', label: 'Last', type: 'text' },
// Read-time formula virtual — never present on the raw written row.
full_name: {
name: 'full_name', label: 'Full name', type: 'formula',
expression: { dialect: 'cel', source: 'record.first_name + " " + record.last_name' },
},
greeting: { name: 'greeting', label: 'Greeting', type: 'text' },
},
}, 'test', 'test');
// On create, stamp `greeting` from the formula field. Before the fix this
// stamped '' because `{record.full_name}` was blank in the seeded record.
automation.registerFlow('lead_greeting', {
name: 'lead_greeting', label: 'Greeting', type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'crm_lead', triggerType: 'record-after-create' } },
{ id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'crm_lead', filter: { id: '{record.id}' }, fields: { greeting: 'Hello, {record.full_name}!' } } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [ { id: 'e1', source: 'start', target: 'stamp' }, { id: 'e2', source: 'stamp', target: 'end' } ],
} as any);
const created = await data.insert('crm_lead', { first_name: 'Ada', last_name: 'Lovelace' });
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
await sleep(200);
const row = await data.findOne('crm_lead', { where: { id } });
expect(row?.full_name).toBe('Ada Lovelace'); // read-path sanity
expect(row?.greeting).toBe('Hello, Ada Lovelace!'); // flow saw the formula
}, 15000);
});