@objectstack/formula 4.0.4
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/.turbo/turbo-build.log +22 -0
- package/LICENSE +202 -0
- package/README.md +9 -0
- package/dist/index.d.mts +240 -0
- package/dist/index.d.ts +240 -0
- package/dist/index.js +335 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +300 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/src/cel-engine.test.ts +85 -0
- package/src/cel-engine.ts +123 -0
- package/src/index.ts +18 -0
- package/src/normalize.test.ts +99 -0
- package/src/normalize.ts +103 -0
- package/src/registry.test.ts +47 -0
- package/src/registry.ts +94 -0
- package/src/seed-eval.test.ts +83 -0
- package/src/seed-eval.ts +81 -0
- package/src/stdlib.ts +81 -0
- package/src/types.ts +91 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { cel, F, P } from '@objectstack/spec';
|
|
3
|
+
|
|
4
|
+
import { normalizeExpression, normalizeExpressionTree } from './normalize';
|
|
5
|
+
|
|
6
|
+
describe('cel/F/P tagged templates', () => {
|
|
7
|
+
it('cel`...` produces an Expression envelope with dialect=cel', () => {
|
|
8
|
+
const e = cel`record.amount > 1000`;
|
|
9
|
+
expect(e).toEqual({ dialect: 'cel', source: 'record.amount > 1000' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('F is a cel alias for formulas', () => {
|
|
13
|
+
expect(F`1 + 1`).toEqual({ dialect: 'cel', source: '1 + 1' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('P is a cel alias for predicates', () => {
|
|
17
|
+
expect(P`x == 1`).toEqual({ dialect: 'cel', source: 'x == 1' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('JSON-escapes interpolated strings', () => {
|
|
21
|
+
const name = "O'Brien";
|
|
22
|
+
const e = cel`record.owner == ${name}`;
|
|
23
|
+
expect(e.source).toBe('record.owner == "O\'Brien"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('passes numbers and booleans through unchanged', () => {
|
|
27
|
+
const e = cel`record.x > ${100} && record.flag == ${true}`;
|
|
28
|
+
expect(e.source).toBe('record.x > 100 && record.flag == true');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('normalizeExpression', () => {
|
|
33
|
+
it('lifts bare strings to {dialect:cel,source} and adds ast', () => {
|
|
34
|
+
const r = normalizeExpression('1 + 1');
|
|
35
|
+
expect(r.ok).toBe(true);
|
|
36
|
+
if (r.ok) {
|
|
37
|
+
expect(r.value.dialect).toBe('cel');
|
|
38
|
+
expect(r.value.source).toBe('1 + 1');
|
|
39
|
+
expect(r.value.ast).toBeDefined();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('compiles existing source expressions adding ast', () => {
|
|
44
|
+
const r = normalizeExpression({ dialect: 'cel', source: 'record.x > 1' });
|
|
45
|
+
expect(r.ok).toBe(true);
|
|
46
|
+
if (r.ok) expect(r.value.ast).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns parse error with kind=parse', () => {
|
|
50
|
+
const r = normalizeExpression('1 +');
|
|
51
|
+
expect(r.ok).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('normalizeExpressionTree', () => {
|
|
56
|
+
it('walks nested objects and adds ast in place', () => {
|
|
57
|
+
const tree = {
|
|
58
|
+
view: {
|
|
59
|
+
visible: { dialect: 'cel', source: 'record.x > 1' },
|
|
60
|
+
},
|
|
61
|
+
action: {
|
|
62
|
+
disabled: { dialect: 'cel', source: 'record.locked' },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const err = normalizeExpressionTree(tree);
|
|
66
|
+
expect(err).toBeNull();
|
|
67
|
+
expect((tree.view.visible as { ast: unknown }).ast).toBeDefined();
|
|
68
|
+
expect((tree.action.disabled as { ast: unknown }).ast).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('walks arrays', () => {
|
|
72
|
+
const tree = {
|
|
73
|
+
rules: [
|
|
74
|
+
{ condition: { dialect: 'cel', source: 'record.a > 1' } },
|
|
75
|
+
{ condition: { dialect: 'cel', source: 'record.b == "x"' } },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const err = normalizeExpressionTree(tree);
|
|
79
|
+
expect(err).toBeNull();
|
|
80
|
+
for (const r of tree.rules) expect((r.condition as { ast: unknown }).ast).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reports the dotted path of the offending node on error', () => {
|
|
84
|
+
const tree = {
|
|
85
|
+
objects: { task: { hooks: { 0: { condition: { dialect: 'cel', source: '1 +' } } } } },
|
|
86
|
+
};
|
|
87
|
+
const err = normalizeExpressionTree(tree);
|
|
88
|
+
expect(err).not.toBeNull();
|
|
89
|
+
expect(err!.path).toContain('condition');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ignores non-Expression objects', () => {
|
|
93
|
+
const tree = { dialect: 'something', notAnExpression: true };
|
|
94
|
+
const err = normalizeExpressionTree(tree);
|
|
95
|
+
expect(err).toBeNull();
|
|
96
|
+
// Tree should be unchanged
|
|
97
|
+
expect((tree as { ast?: unknown }).ast).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time normalization helpers.
|
|
3
|
+
*
|
|
4
|
+
* The CLI `objectstack compile` step walks the assembled `objectstack.json`
|
|
5
|
+
* artifact and rewrites every Expression so that:
|
|
6
|
+
*
|
|
7
|
+
* 1. String shorthand input is replaced by `{ dialect: 'cel', source }`.
|
|
8
|
+
* 2. The persisted envelope carries an `ast` field produced by the dialect
|
|
9
|
+
* engine (M9.2 deliverable). Source is retained for round-trip / debug.
|
|
10
|
+
*
|
|
11
|
+
* Spec layer cannot do step 2 because it must remain dependency-free; this
|
|
12
|
+
* package owns the engine import and therefore the AST step.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
ExpressionInputSchema,
|
|
17
|
+
ExpressionSchema,
|
|
18
|
+
type Expression,
|
|
19
|
+
type ExpressionInput,
|
|
20
|
+
} from '@objectstack/spec';
|
|
21
|
+
|
|
22
|
+
import { ExpressionEngine } from './registry';
|
|
23
|
+
import type { EvalResult } from './types';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize an {@link ExpressionInput} (string shorthand OR full envelope) into
|
|
27
|
+
* a fully-resolved {@link Expression} carrying both `source` and `ast`.
|
|
28
|
+
*
|
|
29
|
+
* Returns an EvalResult so the caller can render a structured compile error
|
|
30
|
+
* pointing at the offending metadata path.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeExpression(input: ExpressionInput): EvalResult<Expression> {
|
|
33
|
+
const parsed = ExpressionInputSchema.safeParse(input);
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: { kind: 'parse', message: parsed.error.message },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const expr = parsed.data as Expression;
|
|
42
|
+
|
|
43
|
+
// Already AST-only — accept as-is.
|
|
44
|
+
if (expr.ast !== undefined && expr.source === undefined) {
|
|
45
|
+
return { ok: true, value: expr };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Source-bearing: ask the dialect engine to compile. Failures surface here
|
|
49
|
+
// as part of the build (no silent skip).
|
|
50
|
+
const compiled = ExpressionEngine.compile(expr);
|
|
51
|
+
if (!compiled.ok) {
|
|
52
|
+
return compiled;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
value: {
|
|
58
|
+
...expr,
|
|
59
|
+
ast: compiled.value,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Walk an arbitrary JSON tree and normalize every embedded Expression in
|
|
66
|
+
* place. Used by the build pipeline to traverse the assembled metadata
|
|
67
|
+
* artifact. Returns the first error encountered (paired with the dotted path
|
|
68
|
+
* for diagnostics) or `null` when fully clean.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeExpressionTree(
|
|
71
|
+
root: unknown,
|
|
72
|
+
path: string[] = [],
|
|
73
|
+
): { path: string; error: import('./types').EvalError } | null {
|
|
74
|
+
if (root === null || typeof root !== 'object') return null;
|
|
75
|
+
|
|
76
|
+
if (looksLikeExpression(root)) {
|
|
77
|
+
const r = normalizeExpression(root as ExpressionInput);
|
|
78
|
+
if (!r.ok) return { path: path.join('.'), error: r.error };
|
|
79
|
+
Object.assign(root as Record<string, unknown>, r.value);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(root)) {
|
|
84
|
+
for (let i = 0; i < root.length; i++) {
|
|
85
|
+
const r = normalizeExpressionTree(root[i], [...path, String(i)]);
|
|
86
|
+
if (r) return r;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [k, v] of Object.entries(root as Record<string, unknown>)) {
|
|
92
|
+
const r = normalizeExpressionTree(v, [...path, k]);
|
|
93
|
+
if (r) return r;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function looksLikeExpression(value: unknown): boolean {
|
|
99
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
100
|
+
const v = value as Record<string, unknown>;
|
|
101
|
+
if (typeof v.dialect !== 'string') return false;
|
|
102
|
+
return ExpressionSchema.safeParse(v).success;
|
|
103
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ExpressionEngine, getEngine, hasDialect } from './registry';
|
|
4
|
+
import type { Expression } from '@objectstack/spec';
|
|
5
|
+
|
|
6
|
+
describe('ExpressionEngine registry', () => {
|
|
7
|
+
it('routes cel dialect to celEngine', () => {
|
|
8
|
+
const expr: Expression = { dialect: 'cel', source: '1 + 1' };
|
|
9
|
+
const r = ExpressionEngine.evaluate(expr, {});
|
|
10
|
+
expect(r).toEqual({ ok: true, value: 2 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns dialect error for js stub', () => {
|
|
14
|
+
const expr: Expression = { dialect: 'js', source: 'foo' };
|
|
15
|
+
const r = ExpressionEngine.evaluate(expr, {});
|
|
16
|
+
expect(r.ok).toBe(false);
|
|
17
|
+
if (!r.ok) expect(r.error.kind).toBe('dialect');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns dialect error for cron stub', () => {
|
|
21
|
+
const expr: Expression = { dialect: 'cron', source: '* * * * *' };
|
|
22
|
+
const r = ExpressionEngine.evaluate(expr, {});
|
|
23
|
+
expect(r.ok).toBe(false);
|
|
24
|
+
if (!r.ok) expect(r.error.kind).toBe('dialect');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns dialect error for unknown dialect', () => {
|
|
28
|
+
const r = ExpressionEngine.evaluate({ dialect: 'xyz' as never, source: 'x' }, {});
|
|
29
|
+
expect(r.ok).toBe(false);
|
|
30
|
+
if (!r.ok) expect(r.error.kind).toBe('dialect');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('compile() emits AST for valid CEL source', () => {
|
|
34
|
+
const r = ExpressionEngine.compile({ dialect: 'cel', source: 'record.x > 1' });
|
|
35
|
+
expect(r.ok).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('getEngine returns registered engine', () => {
|
|
39
|
+
expect(getEngine('cel')?.dialect).toBe('cel');
|
|
40
|
+
expect(getEngine('js')?.dialect).toBe('js');
|
|
41
|
+
expect(getEngine('nonexistent')).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('hasDialect distinguishes real engines from stubs', () => {
|
|
45
|
+
expect(hasDialect('cel')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialect-pluggable Expression engine registry.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the per-call-site `compileFormula` / `evaluateFormula` direct
|
|
5
|
+
* imports of the deleted custom engine. Call sites now ask the registry to
|
|
6
|
+
* dispatch by `expression.dialect`.
|
|
7
|
+
*
|
|
8
|
+
* Stub dialects (`js`, `cron`) are registered at module load with explicit
|
|
9
|
+
* `dialect`-error responses so call sites get a clear message instead of
|
|
10
|
+
* silent `undefined` (the old engine's anti-pattern).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Expression } from '@objectstack/spec';
|
|
14
|
+
|
|
15
|
+
import { celEngine } from './cel-engine';
|
|
16
|
+
import type { DialectEngine, EvalContext, EvalResult } from './types';
|
|
17
|
+
|
|
18
|
+
const registry = new Map<string, DialectEngine>();
|
|
19
|
+
|
|
20
|
+
/** Register or replace a dialect engine. */
|
|
21
|
+
export function register(engine: DialectEngine): void {
|
|
22
|
+
registry.set(engine.dialect, engine);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Look up a dialect engine without dispatching. */
|
|
26
|
+
export function getEngine(dialect: string): DialectEngine | undefined {
|
|
27
|
+
return registry.get(dialect);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether a dialect has a real (non-stub) implementation registered. */
|
|
31
|
+
export function hasDialect(dialect: string): boolean {
|
|
32
|
+
return registry.has(dialect) && !registry.get(dialect)!.dialect.startsWith('stub:');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeStub(dialect: string, reason: string): DialectEngine {
|
|
36
|
+
return {
|
|
37
|
+
dialect,
|
|
38
|
+
compile: () => ({ ok: false, error: { kind: 'dialect', message: reason } }),
|
|
39
|
+
evaluate: () => ({ ok: false, error: { kind: 'dialect', message: reason } }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Real engines.
|
|
44
|
+
register(celEngine);
|
|
45
|
+
|
|
46
|
+
// Stubs — phased in by later milestones (M9.5+ for `js`, M9.6 for `cron`).
|
|
47
|
+
register(makeStub('js', "dialect 'js' not registered. Install @objectstack/plugin-js-vm"));
|
|
48
|
+
register(makeStub('cron', "dialect 'cron' not registered. Install @objectstack/plugin-cron"));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The unified evaluation entry point. Replaces the old direct calls to
|
|
52
|
+
* `evaluateFormula` from the deleted custom engine.
|
|
53
|
+
*/
|
|
54
|
+
export const ExpressionEngine = {
|
|
55
|
+
register,
|
|
56
|
+
getEngine,
|
|
57
|
+
hasDialect,
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compile-only — parse + type-check, returning the engine-native AST. Used
|
|
61
|
+
* by `objectstack compile` to normalize source into AST in artifacts.
|
|
62
|
+
*/
|
|
63
|
+
compile(expr: Expression): EvalResult<unknown> {
|
|
64
|
+
const engine = registry.get(expr.dialect);
|
|
65
|
+
if (!engine) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: { kind: 'dialect', message: `No engine registered for dialect '${expr.dialect}'` },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (typeof expr.source !== 'string') {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: { kind: 'parse', message: 'Expression.source required for compile()' },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return engine.compile(expr.source);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Evaluate an expression in the given context. Never throws — branch on
|
|
82
|
+
* `result.ok`. Errors carry a `kind` for caller-side classification.
|
|
83
|
+
*/
|
|
84
|
+
evaluate<T = unknown>(expr: Expression, ctx: EvalContext): EvalResult<T> {
|
|
85
|
+
const engine = registry.get(expr.dialect);
|
|
86
|
+
if (!engine) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: { kind: 'dialect', message: `No engine registered for dialect '${expr.dialect}'` },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return engine.evaluate<T>(expr, ctx);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveSeed, resolveSeedRecord } from './seed-eval';
|
|
4
|
+
import type { Expression } from '@objectstack/spec';
|
|
5
|
+
|
|
6
|
+
const cel = (source: string): Expression => ({ dialect: 'cel', source });
|
|
7
|
+
|
|
8
|
+
describe('resolveSeed', () => {
|
|
9
|
+
it('passes through primitives unchanged', () => {
|
|
10
|
+
expect(resolveSeed('hello', {})).toEqual({ ok: true, value: 'hello' });
|
|
11
|
+
expect(resolveSeed(42, {})).toEqual({ ok: true, value: 42 });
|
|
12
|
+
expect(resolveSeed(true, {})).toEqual({ ok: true, value: true });
|
|
13
|
+
expect(resolveSeed(null, {})).toEqual({ ok: true, value: null });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('passes through Date objects unchanged', () => {
|
|
17
|
+
const d = new Date('2026-01-01T00:00:00Z');
|
|
18
|
+
const r = resolveSeed(d, {});
|
|
19
|
+
expect(r).toEqual({ ok: true, value: d });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('evaluates Expression leaves with provided context', () => {
|
|
23
|
+
const pinned = new Date('2026-01-15T10:00:00Z');
|
|
24
|
+
const r = resolveSeed(cel('daysFromNow(30)'), { now: pinned });
|
|
25
|
+
expect(r.ok).toBe(true);
|
|
26
|
+
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T10:00:00.000Z');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('walks arrays', () => {
|
|
30
|
+
const r = resolveSeed([1, cel('2 + 2'), 'x'], {});
|
|
31
|
+
expect(r).toEqual({ ok: true, value: [1, 4, 'x'] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('walks nested objects', () => {
|
|
35
|
+
const r = resolveSeed(
|
|
36
|
+
{
|
|
37
|
+
name: 'Acme',
|
|
38
|
+
meta: { score: cel('10 * 2') },
|
|
39
|
+
},
|
|
40
|
+
{},
|
|
41
|
+
);
|
|
42
|
+
expect(r).toEqual({ ok: true, value: { name: 'Acme', meta: { score: 20 } } });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns first error encountered', () => {
|
|
46
|
+
const r = resolveSeed(
|
|
47
|
+
{
|
|
48
|
+
a: 1,
|
|
49
|
+
bad: cel('1 +'),
|
|
50
|
+
c: 3,
|
|
51
|
+
},
|
|
52
|
+
{},
|
|
53
|
+
);
|
|
54
|
+
expect(r.ok).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolveSeedRecord pins now() so multiple expressions see same clock', () => {
|
|
58
|
+
const r = resolveSeedRecord(
|
|
59
|
+
{
|
|
60
|
+
a: cel('now()'),
|
|
61
|
+
b: cel('now()'),
|
|
62
|
+
},
|
|
63
|
+
{},
|
|
64
|
+
);
|
|
65
|
+
expect(r.ok).toBe(true);
|
|
66
|
+
if (r.ok) {
|
|
67
|
+
const a = (r.value.a as Date).toISOString();
|
|
68
|
+
const b = (r.value.b as Date).toISOString();
|
|
69
|
+
expect(a).toBe(b);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('honors explicit ctx.now snapshot', () => {
|
|
74
|
+
const pinned = new Date('2026-06-01T12:00:00Z');
|
|
75
|
+
const r = resolveSeedRecord(
|
|
76
|
+
{ close_date: cel('daysFromNow(30)') },
|
|
77
|
+
{ now: pinned },
|
|
78
|
+
);
|
|
79
|
+
expect(r.ok).toBe(true);
|
|
80
|
+
if (r.ok)
|
|
81
|
+
expect((r.value.close_date as Date).toISOString()).toBe('2026-07-01T12:00:00.000Z');
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/seed-eval.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed-value resolver.
|
|
3
|
+
*
|
|
4
|
+
* `Dataset.records` accepts {@link SeedValue} = primitive | Expression | array
|
|
5
|
+
* | object — install-time resolution walks the tree and replaces any
|
|
6
|
+
* Expression node with its evaluated result. This is what makes
|
|
7
|
+
* `close_date: cel\`now() + duration("P30D")\`` resolve to *the customer's*
|
|
8
|
+
* "today + 30 days" instead of the developer's compile-time clock.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ExpressionSchema, type Expression } from '@objectstack/spec';
|
|
12
|
+
|
|
13
|
+
import type { EvalContext, EvalResult } from './types';
|
|
14
|
+
import { ExpressionEngine } from './registry';
|
|
15
|
+
|
|
16
|
+
export type SeedPrimitive = string | number | boolean | null | Date;
|
|
17
|
+
export type SeedValue = SeedPrimitive | Expression | SeedValue[] | { [key: string]: SeedValue };
|
|
18
|
+
|
|
19
|
+
/** Detect an Expression-shaped object without throwing on unrelated shapes. */
|
|
20
|
+
function isExpressionLike(value: unknown): value is Expression {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
22
|
+
const v = value as Record<string, unknown>;
|
|
23
|
+
if (typeof v.dialect !== 'string') return false;
|
|
24
|
+
return ExpressionSchema.safeParse(v).success;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively resolve a SeedValue. Records that contain Expression leaves are
|
|
29
|
+
* evaluated with `ctx`; other values are passed through unchanged.
|
|
30
|
+
*
|
|
31
|
+
* Returns the first failure encountered. Callers (seed loader) typically
|
|
32
|
+
* abort the whole record on failure rather than silently writing partial data.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveSeed(
|
|
35
|
+
value: SeedValue,
|
|
36
|
+
ctx: EvalContext,
|
|
37
|
+
): EvalResult<unknown> {
|
|
38
|
+
if (value === null || value === undefined) {
|
|
39
|
+
return { ok: true, value };
|
|
40
|
+
}
|
|
41
|
+
const t = typeof value;
|
|
42
|
+
if (t === 'string' || t === 'number' || t === 'boolean') {
|
|
43
|
+
return { ok: true, value };
|
|
44
|
+
}
|
|
45
|
+
if (value instanceof Date) {
|
|
46
|
+
return { ok: true, value };
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
const out: unknown[] = [];
|
|
50
|
+
for (const item of value) {
|
|
51
|
+
const r = resolveSeed(item, ctx);
|
|
52
|
+
if (!r.ok) return r;
|
|
53
|
+
out.push(r.value);
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, value: out };
|
|
56
|
+
}
|
|
57
|
+
if (isExpressionLike(value)) {
|
|
58
|
+
return ExpressionEngine.evaluate(value, ctx);
|
|
59
|
+
}
|
|
60
|
+
// Plain object — recurse field-by-field.
|
|
61
|
+
const out: Record<string, unknown> = {};
|
|
62
|
+
for (const [k, v] of Object.entries(value as Record<string, SeedValue>)) {
|
|
63
|
+
const r = resolveSeed(v, ctx);
|
|
64
|
+
if (!r.ok) return r;
|
|
65
|
+
out[k] = r.value;
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, value: out };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a single record (object of fields), pinning `ctx.now` so all
|
|
72
|
+
* expressions within see one logical clock.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveSeedRecord(
|
|
75
|
+
record: Record<string, SeedValue>,
|
|
76
|
+
ctx: EvalContext,
|
|
77
|
+
): EvalResult<Record<string, unknown>> {
|
|
78
|
+
const pinnedCtx: EvalContext = { ...ctx, now: ctx.now ?? new Date() };
|
|
79
|
+
const result = resolveSeed(record, pinnedCtx) as EvalResult<Record<string, unknown>>;
|
|
80
|
+
return result;
|
|
81
|
+
}
|
package/src/stdlib.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectStack standard CEL function library.
|
|
3
|
+
*
|
|
4
|
+
* Registered into the per-evaluation `Environment` by the CEL engine. All
|
|
5
|
+
* functions are pure given a pinned `now` — that determinism is what makes
|
|
6
|
+
* `objectstack build` artifacts byte-stable across runs.
|
|
7
|
+
*
|
|
8
|
+
* Function naming intentionally avoids the `os.` prefix because cel-js binds
|
|
9
|
+
* dotted names to receiver types. Instead, the `os` namespace in CEL holds
|
|
10
|
+
* *data* (`os.user`, `os.org`, `os.env`) supplied by the caller's
|
|
11
|
+
* {@link EvalContext}.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Environment } from '@marcbachmann/cel-js';
|
|
15
|
+
|
|
16
|
+
import type { EvalContext } from './types';
|
|
17
|
+
|
|
18
|
+
/** Truncate a Date to start-of-day in UTC. */
|
|
19
|
+
function startOfDayUtc(d: Date): Date {
|
|
20
|
+
const out = new Date(d.getTime());
|
|
21
|
+
out.setUTCHours(0, 0, 0, 0);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Add `n` days to a Date in UTC; returns a new Date. */
|
|
26
|
+
function addDaysUtc(d: Date, n: number): Date {
|
|
27
|
+
const out = new Date(d.getTime());
|
|
28
|
+
out.setUTCDate(out.getUTCDate() + n);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register the ObjectStack standard library into a CEL environment.
|
|
34
|
+
*
|
|
35
|
+
* The `now` resolver is closed over so each call uses the pinned
|
|
36
|
+
* `EvalContext.now` (or wall-clock fallback). Implementations are kept tiny
|
|
37
|
+
* and dependency-free — they're the contract surface for AI authors and must
|
|
38
|
+
* stay legible.
|
|
39
|
+
*/
|
|
40
|
+
export function registerStdLib(
|
|
41
|
+
env: Environment,
|
|
42
|
+
now: () => Date,
|
|
43
|
+
): Environment {
|
|
44
|
+
return env
|
|
45
|
+
.registerFunction('now(): google.protobuf.Timestamp', () => now())
|
|
46
|
+
.registerFunction(
|
|
47
|
+
'today(): google.protobuf.Timestamp',
|
|
48
|
+
() => startOfDayUtc(now()),
|
|
49
|
+
)
|
|
50
|
+
.registerFunction(
|
|
51
|
+
'daysFromNow(int): google.protobuf.Timestamp',
|
|
52
|
+
(n: bigint | number) => addDaysUtc(now(), Number(n)),
|
|
53
|
+
)
|
|
54
|
+
.registerFunction(
|
|
55
|
+
'daysAgo(int): google.protobuf.Timestamp',
|
|
56
|
+
(n: bigint | number) => addDaysUtc(now(), -Number(n)),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the variable scope for a single evaluation. Absent fields are simply
|
|
62
|
+
* not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
|
|
63
|
+
*/
|
|
64
|
+
export function buildScope(ctx: EvalContext): Record<string, unknown> {
|
|
65
|
+
const scope: Record<string, unknown> = {};
|
|
66
|
+
|
|
67
|
+
if (ctx.record !== undefined) scope.record = ctx.record;
|
|
68
|
+
if (ctx.previous !== undefined) scope.previous = ctx.previous;
|
|
69
|
+
if (ctx.input !== undefined) scope.input = ctx.input;
|
|
70
|
+
|
|
71
|
+
// Namespaced data — written as `os.user.id`, `os.env`, etc. in CEL.
|
|
72
|
+
const os: Record<string, unknown> = {};
|
|
73
|
+
if (ctx.user !== undefined) os.user = ctx.user;
|
|
74
|
+
if (ctx.org !== undefined) os.org = ctx.org;
|
|
75
|
+
if (ctx.env !== undefined) os.env = ctx.env;
|
|
76
|
+
if (Object.keys(os).length > 0) scope.os = os;
|
|
77
|
+
|
|
78
|
+
if (ctx.extra !== undefined) Object.assign(scope, ctx.extra);
|
|
79
|
+
|
|
80
|
+
return scope;
|
|
81
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @objectstack/formula — public types
|
|
3
|
+
*
|
|
4
|
+
* The expression engine surface is intentionally minimal:
|
|
5
|
+
*
|
|
6
|
+
* - {@link EvalContext}: input passed by call sites (hooks, seed loader, views).
|
|
7
|
+
* - {@link EvalResult}: discriminated union — never throws to the caller.
|
|
8
|
+
* - {@link DialectEngine}: contract any dialect (cel, js, cron) implements.
|
|
9
|
+
*
|
|
10
|
+
* The shape is shared across `cel`, `js` and `cron` so the kernel can route
|
|
11
|
+
* any persisted `Expression` to the correct engine without conditional logic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Expression } from '@objectstack/spec';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runtime context for evaluating an expression.
|
|
18
|
+
*
|
|
19
|
+
* Every field is optional — call sites populate only what they have. The CEL
|
|
20
|
+
* engine binds `record`, `previous`, `input`, `os` directly as top-level
|
|
21
|
+
* variables when present.
|
|
22
|
+
*/
|
|
23
|
+
export interface EvalContext {
|
|
24
|
+
/** Logical "now" snapshot — pinned per evaluation run for determinism. */
|
|
25
|
+
now?: Date;
|
|
26
|
+
/** Current authenticated subject (hook / action / view contexts). */
|
|
27
|
+
user?: {
|
|
28
|
+
id: string;
|
|
29
|
+
role?: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
};
|
|
33
|
+
/** Current organization (multi-tenant context). */
|
|
34
|
+
org?: {
|
|
35
|
+
id: string;
|
|
36
|
+
tier?: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
};
|
|
39
|
+
/** Deployment environment marker. */
|
|
40
|
+
env?: 'prod' | 'dev' | 'test' | string;
|
|
41
|
+
/** Record-shaped data: target row, hook record, view row, etc. */
|
|
42
|
+
record?: Record<string, unknown>;
|
|
43
|
+
/** Previous record state for update hooks. */
|
|
44
|
+
previous?: Record<string, unknown>;
|
|
45
|
+
/** Action / flow input payload. */
|
|
46
|
+
input?: Record<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* Optional kernel API for `os.exists / os.count / os.lookup`.
|
|
49
|
+
* Implemented opportunistically by call sites that have a query engine.
|
|
50
|
+
*/
|
|
51
|
+
api?: {
|
|
52
|
+
exists?: (object: string, predicate: Expression) => boolean;
|
|
53
|
+
count?: (object: string, predicate: Expression) => number;
|
|
54
|
+
lookup?: (object: string, id: string) => Record<string, unknown> | null;
|
|
55
|
+
};
|
|
56
|
+
/** Free-form bag for niche call sites; merged onto the variable scope. */
|
|
57
|
+
extra?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Result of a single evaluation. Never throws; callers branch on `ok`. */
|
|
61
|
+
export type EvalResult<T = unknown> =
|
|
62
|
+
| { ok: true; value: T }
|
|
63
|
+
| { ok: false; error: EvalError };
|
|
64
|
+
|
|
65
|
+
/** Structured error so AI callers can self-correct. */
|
|
66
|
+
export interface EvalError {
|
|
67
|
+
/**
|
|
68
|
+
* - `parse` source string failed to parse to AST
|
|
69
|
+
* - `type` static type-check failed
|
|
70
|
+
* - `runtime` evaluation threw (division by zero, missing field, …)
|
|
71
|
+
* - `bounds` exceeded execution limits (AST size, depth, …)
|
|
72
|
+
* - `dialect` no engine registered for `expression.dialect`
|
|
73
|
+
*/
|
|
74
|
+
kind: 'parse' | 'type' | 'runtime' | 'bounds' | 'dialect';
|
|
75
|
+
message: string;
|
|
76
|
+
/** Source position when known. */
|
|
77
|
+
pos?: { start: number; end: number };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Contract every dialect engine implements. */
|
|
81
|
+
export interface DialectEngine {
|
|
82
|
+
/** Dialect identifier — must match `Expression.dialect`. */
|
|
83
|
+
readonly dialect: string;
|
|
84
|
+
/**
|
|
85
|
+
* Parse + type-check + emit AST. Source-only — `expression.ast` is what
|
|
86
|
+
* actually gets persisted in `objectstack.json`.
|
|
87
|
+
*/
|
|
88
|
+
compile(source: string): EvalResult<unknown>;
|
|
89
|
+
/** Evaluate a fully-resolved expression in the given context. */
|
|
90
|
+
evaluate<T = unknown>(expr: Expression, ctx: EvalContext): EvalResult<T>;
|
|
91
|
+
}
|