Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/flow-template-lint-and-hydrate-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@objectstack/lint": patch
"@objectstack/cli": patch
"@objectstack/trigger-record-change": patch
---

fix(#3426): build-time warning for unresolvable flow template paths + guard the formula re-read

Two follow-ups to #3426 (the formula/lookup `{record.<path>}` template gap that #3445 began closing).

**Build-time signal (the issue's fallback ask).** `os validate` now flags a
record-change flow node whose `{record.<path>}` template cannot resolve —
turning the previous SILENT blank into an advisory warning. Two cases, via the
new `@objectstack/lint` rule `validateFlowTemplatePaths`:

- `flow-template-unknown-field` — `{record.<x>}` where `<x>` is neither a
declared field nor a system column (a typo like `{record.full_naem}`).
- `flow-template-lookup-traversal` — `{record.<lookup>.<field>}`, a cross-object
hop the seeded record carries only as a scalar id (still unsupported; tracked
on #3426).

Deliberately quiet: formula fields, bare lookup ids, numeric indexes into
`multiple` lookups (#1872), `json` sub-paths, and system columns are NOT flagged,
and flows bound to an object this stack does not define are skipped (no schema to
compare against).

**Hydration re-read guards.** The `trigger-record-change` computed-field re-read
(#3445) is now (a) skipped when the object declares no `formula` field — the only
thing it adds — via the engine's optional `getObjectConfig`, and (b) memoized per
write on the shared HookContext, so N flows on one written record share ONE
re-read instead of N. Any uncertainty falls back to the prior unconditional
re-read (correctness over the optimization).
19 changes: 18 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { validateCapabilityReferences } from '@objectstack/lint';
import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateSecurityPosture } from '@objectstack/lint';
import { validateFlowTriggerReadiness } from '@objectstack/lint';
import { validateFlowTemplatePaths } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import {
Expand Down Expand Up @@ -428,6 +429,22 @@ export default class Validate extends Command {
}
}

// 3g-bis. Flow template path references (#3426): a `{record.<path>}` token
// in a node template that names an unknown field, or hops through a
// lookup relation the seeded record carries only as a scalar id, both
// render a SILENT empty string at runtime. Advisory: the head object
// may come from another package (skipped there), and the runtime still
// produces output (a blank), so nothing is fully broken.
if (!flags.json) printStep('Checking flow template references...');
const flowTemplateFindings = validateFlowTemplatePaths(normalized as Record<string, unknown>);
const flowTemplateWarnings = flowTemplateFindings.filter((f) => f.severity === 'warning');
if (!flags.json) {
for (const w of flowTemplateWarnings.slice(0, 50)) {
console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`));
console.log(chalk.dim(` ${w.hint}`));
}
}

// 3e2. [#3425] Readonly flow-write guardrail. A `runAs:user` update_record
// that writes a static-`readonly` field is a SILENT no-op — the engine
// strips it from the UPDATE payload (#2948) yet the step reports
Expand Down Expand Up @@ -545,7 +562,7 @@ export default class Validate extends Command {
valid: true,
manifest: config.manifest,
stats,
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories, ...capProviderWarnings],
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...flowTemplateWarnings, ...securityAdvisories, ...capProviderWarnings],
conversions: conversionNotices,
specVersionGap: specGap,
duration: timer.elapsed(),
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export type {
FlowTriggerReadinessSeverity,
} from './validate-flow-trigger-readiness.js';

export {
validateFlowTemplatePaths,
FLOW_TEMPLATE_UNKNOWN_FIELD,
FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
} from './validate-flow-template-paths.js';
export type {
FlowTemplatePathFinding,
FlowTemplatePathSeverity,
} from './validate-flow-template-paths.js';

export {
validateReadonlyFlowWrites,
FLOW_UPDATE_READONLY_FIELD,
Expand Down
192 changes: 192 additions & 0 deletions packages/lint/src/validate-flow-template-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
validateFlowTemplatePaths,
FLOW_TEMPLATE_UNKNOWN_FIELD,
FLOW_TEMPLATE_LOOKUP_TRAVERSAL,
} from './validate-flow-template-paths.js';

type AnyRec = Record<string, unknown>;

/** A crm_lead object with a scalar, a formula, a lookup, and a multi-lookup. */
const LEAD_OBJECT: AnyRec = {
name: 'crm_lead',
fields: {
first_name: { name: 'first_name', type: 'text' },
last_name: { name: 'last_name', type: 'text' },
company: { name: 'company', type: 'text' },
full_name: { name: 'full_name', type: 'formula' },
crm_account: { name: 'crm_account', type: 'lookup', reference_to: 'crm_account' },
target_channels: { name: 'target_channels', type: 'lookup', reference_to: 'channel', multiple: true },
payload: { name: 'payload', type: 'json' },
},
};

/** Build a record-change flow with one notify node carrying the given templates. */
function flowWith(notify: AnyRec, objectName = 'crm_lead'): AnyRec {
return {
objects: [LEAD_OBJECT],
flows: [
{
name: 'notify_lead',
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', config: { objectName, triggerType: 'record-created' } },
{ id: 'n1', type: 'notify', notify },
],
},
],
};
}

describe('validateFlowTemplatePaths', () => {
it('flags an unknown field in a {record.<x>} template (typo)', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: 'New lead: {record.full_naem}', body: 'x' }),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD);
expect(findings[0].severity).toBe('warning');
expect(findings[0].message).toContain('full_naem');
});

it('flags a lookup cross-object hop {record.<lookup>.<field>}', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: 'From {record.crm_account.name}', body: 'x' }),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TEMPLATE_LOOKUP_TRAVERSAL);
expect(findings[0].message).toContain('crm_account.name');
});

it('does NOT flag a formula field (valid, hydrated since #3445)', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: 'New lead: {record.full_name}', body: '{record.company}' }),
);
expect(findings).toHaveLength(0);
});

it('does NOT flag a plain scalar field', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: '{record.first_name} {record.last_name}', body: '{record.company}' }),
);
expect(findings).toHaveLength(0);
});

it('does NOT flag a bare lookup id (no sub-path)', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: 'acct {record.crm_account}', body: 'x' }),
);
expect(findings).toHaveLength(0);
});

it('does NOT flag a numeric index into a multiple lookup (#1872)', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: 'ch {record.target_channels.0}', body: 'x' }),
);
expect(findings).toHaveLength(0);
});

it('does NOT flag a sub-path into a json field', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: '{record.payload.foo}', body: 'x' }),
);
expect(findings).toHaveLength(0);
});

it('does NOT flag system/audit columns', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: '{record.id} {record.created_at} {record.owner}', body: 'x' }),
);
expect(findings).toHaveLength(0);
});

it('ignores non-record tokens (flow vars, NOW(), $User)', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: '{some_var.field} {NOW()} {$User.Email}', body: 'x' }),
);
expect(findings).toHaveLength(0);
});

it('skips a flow whose object is not defined in this stack', () => {
const findings = validateFlowTemplatePaths({
objects: [LEAD_OBJECT],
flows: [
{
name: 'external',
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'sys_user', triggerType: 'record-created' } },
{ id: 'n1', type: 'notify', notify: { title: '{record.anything.deep}', body: 'x' } },
],
},
],
});
expect(findings).toHaveLength(0);
});

it('skips non-record-triggered flows', () => {
const findings = validateFlowTemplatePaths({
objects: [LEAD_OBJECT],
flows: [
{
name: 'manual',
type: 'screen',
nodes: [
{ id: 'start', type: 'start', config: {} },
{ id: 'n1', type: 'notify', notify: { title: '{record.full_naem}', body: 'x' } },
],
},
],
});
expect(findings).toHaveLength(0);
});

it('dedupes a repeated bad reference to one finding per node', () => {
const findings = validateFlowTemplatePaths(
flowWith({ title: '{record.full_naem}', body: 'again {record.full_naem}' }),
);
expect(findings).toHaveLength(1);
});

it('resolves objectName from the typed start block too', () => {
const findings = validateFlowTemplatePaths({
objects: [LEAD_OBJECT],
flows: [
{
name: 'typed_start',
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', start: { objectName: 'crm_lead', triggerType: 'record-created' } },
{ id: 'n1', type: 'notify', notify: { title: '{record.crm_account.name}', body: 'x' } },
],
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TEMPLATE_LOOKUP_TRAVERSAL);
});

it('detects references in freeform config and other node types (http url)', () => {
const findings = validateFlowTemplatePaths({
objects: [LEAD_OBJECT],
flows: [
{
name: 'webhook',
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'crm_lead', triggerType: 'record-created' } },
{ id: 'h1', type: 'http', http: { url: 'https://x.test/{record.full_naem}', method: 'GET' } },
],
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TEMPLATE_UNKNOWN_FIELD);
});

it('returns empty when there are no flows', () => {
expect(validateFlowTemplatePaths({ objects: [LEAD_OBJECT] })).toHaveLength(0);
});
});
Loading