Skip to content

Commit dc64ac3

Browse files
committed
Harden collaborative save against malformed values (#4816)
Build on the crash fixes with broader validation, FK hardening and a refactor of the save_workflow rescue path: - Harden collaborative save against malformed values - Extend UUID validation to all binary_id FKs; dedup validator - Make cron-cursor FK compound + same-workflow - Single advisory dangling-reference reconciler + boundary tests - Scope save_workflow rescue to the transaction only - Drop dangling-reference reconciler from the save path - Extract shared Validators.valid_uuid?/1 - Rewrite save_workflow rescue helpers - Extract save_workflow helpers to reduce complexity - Tidy save_workflow rescue helpers + cover CastError - Reuse shared store-test helpers in reconcile test
1 parent 3ab30c8 commit dc64ac3

26 files changed

Lines changed: 1397 additions & 85 deletions

.claude/guidelines/store-structure.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,22 @@ export const createMyStore = () => {
325325
5. **useSyncExternalStore** — All stores implement React 18's external store contract
326326
6. **Immutability** — All state updates via Immer's `produce()`
327327

328+
### Dangling-reference reconciliation: server authoritative, client advisory
329+
330+
Referential invariants (e.g. a cron trigger's `cron_cursor_job_id` must point at a
331+
live job in the same workflow) are enforced **authoritatively on the server** — the
332+
compound foreign key (`ON DELETE SET NULL (cron_cursor_job_id)`) plus the
333+
`Workflows.save_workflow/3` rescue. The client has exactly ONE advisory cleanup
334+
function, `adapters/reconcileDanglingReferences.ts`, invoked from every structural
335+
mutation that can orphan a reference (`removeJob`, `YAMLStateToYDoc.applyToYDoc`,
336+
and defensively before save in `saveWorkflow`/`saveAndSyncWorkflow`). It is a UX
337+
fast-path only: it cannot close the concurrent-editor race (a collaborator's delete
338+
that has not yet merged into this client's doc), and it is NOT the correctness
339+
guarantee. **Do not add per-path client cursor cleanup** — if a new structural
340+
mutation can orphan a reference, call `reconcileDanglingReferences` from it (pass
341+
`{ inTransaction: true }` when already inside a `ydoc.transact`); do not reimplement
342+
the check.
343+
328344
---
329345

330346
## Common Anti-Patterns

CHANGELOG.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ and this project adheres to
2424
are now logged once, in `Lightning.Credentials.Resolver`, at `info`/`warning`
2525
instead of `error`; only a genuinely missing project still logs at `error`.
2626
[#4814](https://github.com/OpenFn/lightning/issues/4814)
27+
- Extend UUID format validation to all `:binary_id` foreign keys on jobs,
28+
triggers, edges and workflows so a malformed id surfaces as a changeset error
29+
instead of an `Ecto.ChangeError` at insert; de-duplicate the validator by
30+
routing `Channels.SearchParams` onto the shared
31+
`Lightning.Validators.validate_uuid`.
32+
[#4816](https://github.com/OpenFn/lightning/issues/4816)
33+
- The cron-trigger cursor (`cron_cursor_job_id`) foreign key is now compound and
34+
same-workflow, matching workflow edges: a trigger's cursor may only reference
35+
a job in its own workflow. Cross-workflow cursors — previously accepted
36+
silently by the single-column FK — are now rejected with a changeset error on
37+
save and on provisioning/import. A migration nulls any pre-existing
38+
cross-workflow cursors (the cron lookup falls back to final-run state when the
39+
cursor is nil); this nilification is not reversible.
40+
[#4816](https://github.com/OpenFn/lightning/issues/4816)
2741

2842
### Fixed
2943

@@ -62,16 +76,28 @@ and this project adheres to
6276
- Workflow channel raises an exception when fetching trigger auth methods for an
6377
unpersisted trigger [#4819](https://github.com/OpenFn/lightning/issues/4819)
6478
- Collaborative session no longer crashes when saving a cron trigger whose
65-
`cron_cursor_job_id` references a job that no longer exists. The `Trigger`
66-
changeset now declares the foreign key constraint, and `removeJob` clears any
67-
cron cursor pointing at a deleted job, so the violation surfaces as a
68-
validation error instead of an `Ecto.ConstraintError`.
79+
`cron_cursor_job_id` references a deleted job. Two independent mechanisms now
80+
cooperate, with the server authoritative and the client advisory: server-side,
81+
the compound cron-cursor foreign key nulls the cursor when its job is deleted
82+
(and rejects cross-workflow cursors), and `save_workflow/3` rescues the
83+
resulting constraint error into a changeset error so the session stays up;
84+
client-side, a single advisory `reconcileDanglingReferences` pass nulls
85+
orphaned cursors before save as a UX fast-path. The client cleanup does not by
86+
itself produce the validation error and cannot close the concurrent-editor
87+
race — the server resolves that case authoritatively.
6988
[#4816](https://github.com/OpenFn/lightning/issues/4816)
7089
- Collaborative session no longer crashes when a workflow payload contains a
7190
malformed UUID (e.g. an unsubstituted template placeholder) for a job,
7291
trigger, or edge id. These ids are now validated in the changesets, so the bad
7392
value returns a changeset error instead of raising an `Ecto.ChangeError`
7493
during insert. [#4816](https://github.com/OpenFn/lightning/issues/4816)
94+
- Collaborative workflow saves no longer crash the session/channel when the
95+
payload contains a malformed reference or value: `validate_uuid` now checks
96+
with `Ecto.UUID.dump/1` (the function that runs at insert) so 16-byte non-hex
97+
placeholders are rejected as changeset errors, and `Workflows.save_workflow/3`
98+
rescues a typed allow-list of Ecto exceptions (`Ecto.ChangeError`,
99+
`Ecto.Query.CastError`, `Ecto.ConstraintError`) and returns a changeset error
100+
instead of raising. [#4816](https://github.com/OpenFn/lightning/issues/4816)
75101

76102
## [2.16.6] - 2026-05-27
77103

assets/js/collaborative-editor/adapters/YAMLStateToYDoc.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
} from '../../yaml/types';
99
import type { Session } from '../types/session';
1010

11+
import { reconcileDanglingReferences } from './reconcileDanglingReferences';
12+
1113
/**
1214
* YAMLStateToYDoc
1315
*
@@ -156,6 +158,14 @@ export class YAMLStateToYDoc {
156158
positionsMap.set(id, pos);
157159
});
158160
}
161+
162+
// 6. Reconcile dangling references introduced by the bulk replace.
163+
// transformTrigger copies cron_cursor_job_id verbatim, so an imported /
164+
// AI-applied workflow whose cron cursor references a job absent from the
165+
// new jobs set would land dangling. Runs inside this single transaction —
166+
// do NOT open a new one (Yjs forbids nesting). This is advisory; the
167+
// server FK + save_workflow/3 rescue remain authoritative.
168+
reconcileDanglingReferences(ydoc, { inTransaction: true });
159169
});
160170
}
161171
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type * as Y from 'yjs';
2+
3+
import type { Session } from '../types/session';
4+
5+
/**
6+
* Reconcile dangling references in the Y.Doc. Currently: null any cron trigger
7+
* `cron_cursor_job_id` that points at a job no longer present in the jobs array.
8+
*
9+
* ADVISORY ONLY. This is a client-side UX fast-path so a structural mutation that
10+
* orphans a cron cursor does not leave the editor in a state that fails server
11+
* validation on save. It is NOT the correctness guarantee — the server-side
12+
* compound foreign key (`ON DELETE SET NULL (cron_cursor_job_id)`) and the
13+
* `Workflows.save_workflow/3` rescue are. In particular it CANNOT close the
14+
* concurrent-editor race: if User A deletes Job B and User B saves before A's
15+
* deletion has merged into B's doc, B's `cron_cursor_job_id` is still live in B's
16+
* view and this function leaves it alone. The server resolves that case
17+
* authoritatively.
18+
*
19+
* This is the single client-side home for dangling-reference reconciliation. Any
20+
* new structural mutation that can orphan a reference (e.g. a future bulk
21+
* job-removal or trigger-removal command) MUST call this function rather than
22+
* re-implementing per-path cleanup.
23+
*
24+
* Pattern 1 (Y.Doc → observer → Immer → notify): callers rely on the existing
25+
* observeDeep handlers to propagate the nulled cursor to React. No notify() here.
26+
*
27+
* @param ydoc the workflow document
28+
* @param options.inTransaction true when called from within an already-open
29+
* `ydoc.transact` (e.g. `removeJob`, `applyToYDoc`); avoids nesting a second
30+
* transaction, which Yjs forbids. When omitted/false the function opens its own
31+
* transaction.
32+
*/
33+
export function reconcileDanglingReferences(
34+
ydoc: Session.WorkflowDoc,
35+
options: { inTransaction?: boolean } = {}
36+
): void {
37+
const jobsArray = ydoc.getArray('jobs');
38+
const triggersArray = ydoc.getArray('triggers');
39+
40+
// Reads first (outside the write closure) so the transaction body does only
41+
// writes — mirrors removeJob's read-then-transact shape.
42+
const jobs = jobsArray.toArray() as Y.Map<unknown>[];
43+
const triggers = triggersArray.toArray() as Y.Map<unknown>[];
44+
45+
const jobIds = new Set(jobs.map(job => job.get('id') as string));
46+
47+
const danglingTriggers = triggers.filter(trigger => {
48+
const cursor = trigger.get('cron_cursor_job_id');
49+
return typeof cursor === 'string' && !jobIds.has(cursor);
50+
});
51+
52+
// Early-return when nothing dangles so we never emit an empty transaction on
53+
// every save.
54+
if (danglingTriggers.length === 0) return;
55+
56+
const apply = () => {
57+
danglingTriggers.forEach(trigger => {
58+
trigger.set('cron_cursor_job_id', null);
59+
});
60+
};
61+
62+
if (options.inTransaction) {
63+
apply();
64+
} else {
65+
ydoc.transact(apply);
66+
}
67+
}

assets/js/collaborative-editor/stores/createWorkflowStore.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import { z } from 'zod';
137137
import _logger from '#/utils/logger';
138138

139139
import type { WorkflowState as YAMLWorkflowState } from '../../yaml/types';
140+
import { reconcileDanglingReferences } from '../adapters/reconcileDanglingReferences';
140141
import { YAMLStateToYDoc } from '../adapters/YAMLStateToYDoc';
141142
import { channelRequest } from '../hooks/useChannel';
142143
import { notifications } from '../lib/notifications';
@@ -273,7 +274,7 @@ export const createWorkflowStore = () => {
273274
* @throws {Error} If Y.Doc is not initialized
274275
* @returns Y.Doc instance
275276
*/
276-
const ensureYDoc = (): Y.Doc => {
277+
const ensureYDoc = (): Session.WorkflowDoc => {
277278
if (!ydoc) {
278279
throw new Error(
279280
'Cannot modify workflow: Y.Doc not initialized. ' +
@@ -941,26 +942,20 @@ export const createWorkflowStore = () => {
941942
// Find all incoming edges (where this job is the target)
942943
const incomingEdgeIndices = getIncomingEdgeIndices(edges, id);
943944

944-
// Find triggers whose cron cursor points at the job being removed.
945-
const triggersArray = ydoc.getArray('triggers');
946-
const triggers = triggersArray.toArray() as Y.Map<unknown>[];
947-
const cursorTriggers = triggers.filter(
948-
trigger => trigger.get('cron_cursor_job_id') === id
949-
);
950-
951945
ydoc.transact(() => {
952946
// Delete incoming edges first (highest index to lowest)
953947
incomingEdgeIndices.forEach(edgeIndex => {
954948
edgesArray.delete(edgeIndex, 1);
955949
});
956950

957-
// Clear any cron cursor reference to the job being removed
958-
cursorTriggers.forEach(trigger => {
959-
trigger.set('cron_cursor_job_id', null);
960-
});
961-
962951
// Then delete the job
963952
jobsArray.delete(jobIndex, 1);
953+
954+
// Reconcile any cron cursor that pointed at the now-deleted job. The
955+
// reconciler reads the jobs array inside this open transaction, so the
956+
// deleted job is already absent. This is the single advisory owner for
957+
// dangling-reference cleanup — see adapters/reconcileDanglingReferences.
958+
reconcileDanglingReferences(ydoc, { inTransaction: true });
964959
});
965960
}
966961
// Observer handles: Y.Doc → Immer → notify
@@ -1023,6 +1018,12 @@ export const createWorkflowStore = () => {
10231018
// Observer handles: Y.Doc → Immer → notify
10241019
};
10251020

1021+
// NOTE: there is intentionally no removeTrigger / bulk job-removal command
1022+
// today. If one is ever added, it MUST call
1023+
// reconcileDanglingReferences(ydoc, { inTransaction: true }) inside its
1024+
// transaction (after the structural delete) so it cannot leave a dangling cron
1025+
// cursor. Do not re-implement per-path cursor cleanup — see
1026+
// adapters/reconcileDanglingReferences and store-structure.md.
10261027
const updateTrigger = (id: string, updates: Partial<Session.Trigger>) => {
10271028
const ydoc = ensureYDoc();
10281029

assets/test/collaborative-editor/adapters/YAMLStateToYDoc.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,70 @@ describe('YAMLStateToYDoc', () => {
244244

245245
expect(id).toBe('');
246246
});
247+
248+
test('reconciles a cron cursor pointing at a job absent from the imported set', () => {
249+
const workflowState: YAMLWorkflowState = {
250+
id: 'wf-1',
251+
name: 'Imported',
252+
jobs: [
253+
{
254+
id: 'job-a',
255+
name: 'A',
256+
adaptor: '@openfn/language-common@latest',
257+
body: 'fn()',
258+
},
259+
],
260+
triggers: [
261+
{
262+
id: 'trigger-1',
263+
type: 'cron',
264+
enabled: true,
265+
cron_expression: '* * * * *',
266+
// Cursor points at a job NOT in `jobs` above — the uncovered path.
267+
cron_cursor_job_id: 'job-ghost',
268+
},
269+
],
270+
edges: [],
271+
positions: null,
272+
};
273+
274+
YAMLStateToYDoc.applyToYDoc(ydoc, workflowState);
275+
276+
const triggersArray = ydoc.getArray('triggers');
277+
const triggerMap = triggersArray.get(0) as Y.Map<unknown>;
278+
expect(triggerMap.get('cron_cursor_job_id')).toBeNull();
279+
});
280+
281+
test('leaves a valid cron cursor untouched on import', () => {
282+
const workflowState: YAMLWorkflowState = {
283+
id: 'wf-1',
284+
name: 'Imported',
285+
jobs: [
286+
{
287+
id: 'job-a',
288+
name: 'A',
289+
adaptor: '@openfn/language-common@latest',
290+
body: 'fn()',
291+
},
292+
],
293+
triggers: [
294+
{
295+
id: 'trigger-1',
296+
type: 'cron',
297+
enabled: true,
298+
cron_expression: '* * * * *',
299+
cron_cursor_job_id: 'job-a',
300+
},
301+
],
302+
edges: [],
303+
positions: null,
304+
};
305+
306+
YAMLStateToYDoc.applyToYDoc(ydoc, workflowState);
307+
308+
const triggerMap = ydoc.getArray('triggers').get(0) as Y.Map<unknown>;
309+
expect(triggerMap.get('cron_cursor_job_id')).toBe('job-a');
310+
});
247311
});
248312

249313
describe('Edge transformations', () => {

0 commit comments

Comments
 (0)