Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/lint-flag-record-change-trap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/lint": patch
---

fix(lint): flag every never-firing `record-`-prefixed trigger token, incl. `record-change` (#3427)

Generalizes the `flow-trigger-unknown-event` rule: it now flags ANY `record-`-prefixed
`triggerType` that is not a valid firing token
(`record-{before,after}-{create,insert,update,delete,write}`) — not just
`record-(before|after)-<bad-op>` typos. This closes the `record-change` trap: the
engine routes `record-change` ("Record changed (any)") to the record-change trigger,
which maps it to no hook so it never fires — now caught at `os validate` time instead
of only a runtime warn. Also covers bad-phase tokens like `record-during-update`.
Warning severity, unchanged.
18 changes: 13 additions & 5 deletions packages/lint/src/validate-flow-trigger-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ describe('validateFlowTriggerReadiness', () => {
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TRIGGER_UNKNOWN_EVENT);
expect(findings[0].severity).toBe('warning');
expect(findings[0].message).toContain("'updated'");
expect(findings[0].message).toContain("'record-after-updated'");
expect(findings[0].message).toMatch(/never fires/i);
expect(findings[0].path).toBe('flows[0].nodes[0].config.triggerType');
});
Expand Down Expand Up @@ -222,13 +222,21 @@ describe('validateFlowTriggerReadiness', () => {
}
});

it('does not flag bare record-<noun> shapes (e.g. record-change) with this rule', () => {
// `record-change` lacks a before/after phase, so it is out of this rule's
// scope (a separate concern); the UNKNOWN_EVENT rule must stay silent on it.
it('flags the phase-less `record-change` bare noun — it never fires', () => {
// `record-change` (once offered by the Studio picker as "Record changed
// (any)") has no before/after phase, so it maps to no hook and never fires.
const flow = recordFlow({ status: 'active' });
(flow.nodes[0] as { config: Record<string, unknown> }).config.triggerType = 'record-change';
const findings = validateFlowTriggerReadiness({ objects: [candidateObject], flows: [flow] });
expect(findings.some((f) => f.rule === FLOW_TRIGGER_UNKNOWN_EVENT)).toBe(false);
expect(findings.map((f) => f.rule)).toEqual([FLOW_TRIGGER_UNKNOWN_EVENT]);
expect(findings[0].message).toContain("'record-change'");
});

it('flags a bad-phase token (record-during-update)', () => {
const flow = recordFlow({ status: 'active' });
(flow.nodes[0] as { config: Record<string, unknown> }).config.triggerType = 'record-during-update';
const findings = validateFlowTriggerReadiness({ objects: [candidateObject], flows: [flow] });
expect(findings.map((f) => f.rule)).toEqual([FLOW_TRIGGER_UNKNOWN_EVENT]);
});

it('does not flag non-record triggerTypes (schedule/api/manual)', () => {
Expand Down
58 changes: 27 additions & 31 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ export const FLOW_TRIGGER_UNKNOWN_EVENT = 'flow-trigger-unknown-event';
type AnyRec = Record<string, unknown>;

/**
* Recognized record-change lifecycle ops. A start node's `triggerType` fires only
* when it matches `record-(before|after)-<op>` with `op` in this set — the exact
* grammar the record-change trigger's `triggerTypeToHookEvents` maps to ObjectQL
* hooks. `insert` is a synonym for `create`; `write` is the create-OR-update union
* (#3427). Kept in sync with that trigger (one small, stable contract).
* The record-change trigger fires only for a `triggerType` matching this exact
* grammar — the same set its `triggerTypeToHookEvents` maps to ObjectQL hooks.
* `insert` is a synonym for `create`; `write` is the create-OR-update union
* (#3427). Any OTHER `record-`-prefixed token — a typo (`record-after-updated`),
* a phase-less bare noun (`record-change`), or a bad phase (`record-during-update`)
* — binds to the trigger but maps to NO hook and never fires. Kept in sync with
* that trigger (one small, stable contract).
*/
const RECORD_TRIGGER_OPS = new Set(['create', 'insert', 'update', 'delete', 'write']);
/** A record-lifecycle-SHAPED token: `record-before-…` / `record-after-…`. */
const RECORD_EVENT_SHAPE = /^record-(?:before|after)-(.+)$/;
const VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/;

/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
function asArray(v: unknown): AnyRec[] {
Expand Down Expand Up @@ -142,29 +142,25 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
}
}

// 1c. Record-lifecycle-SHAPED triggerType (`record-before|after-…`) whose op
// is not one the trigger can map — a typo like `record-after-updated`. The
// engine still routes any `record-` token to the record-change trigger,
// which then maps it to NO hook and never fires (only a runtime warn). This
// is a definite never-fire defect, so surface it at authoring time. (Bare
// `record-<noun>` shapes without a before/after phase — e.g. `record-change`
// — are a separate concern and not flagged here.)
if (start && triggerType) {
const shape = RECORD_EVENT_SHAPE.exec(triggerType);
if (shape && !RECORD_TRIGGER_OPS.has(shape[1])) {
findings.push({
severity: 'warning',
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
where: `flow "${flowName}" › start node`,
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
message:
`triggerType '${triggerType}' names an unrecognized lifecycle event '${shape[1]}' — the flow binds to ` +
`the record-change trigger but never fires (the runtime stays silent about it).`,
hint:
`Use record-{before,after}-{create,update,delete,write}. 'write' fires on create OR update in one ` +
`flow (#3427); create/insert are synonyms.`,
});
}
// 1c. A `record-`-prefixed triggerType the trigger cannot map to any hook —
// a typo (`record-after-updated`), a phase-less bare noun (`record-change`,
// which the Studio picker once offered as "Record changed (any)"), or a bad
// phase (`record-during-update`). The engine routes any `record-` token to
// the record-change trigger, which then binds to NO hook and never fires
// (only a runtime warn). Surface the never-fire defect at authoring time.
if (start && isRecordTriggered && !VALID_RECORD_TRIGGER.test((triggerType ?? '').trim())) {
findings.push({
severity: 'warning',
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
where: `flow "${flowName}" › start node`,
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
message:
`triggerType '${triggerType}' is not a recognized record trigger — the flow binds to the ` +
`record-change trigger but never fires (the runtime stays silent about it).`,
hint:
`Use record-{before,after}-{create,update,delete,write}. 'write' fires on create OR update in one ` +
`flow (#3427); create/insert are synonyms. There is no "any change" token — pick the specific event(s).`,
});
}

// 2. Auto-triggered flow whose status is 'draft' — authored or defaulted
Expand Down