( dataPatch: RowData, schema: TableSchema, existingExecutions: RowExecutions, callerPatch: Record<string, RowExecutionMetadata | null> | undefined, mergedData: RowData )
| 124 | * this would silently miss the propagation. |
| 125 | */ |
| 126 | export function deriveExecClearsForDataPatch( |
| 127 | dataPatch: RowData, |
| 128 | schema: TableSchema, |
| 129 | existingExecutions: RowExecutions, |
| 130 | callerPatch: Record<string, RowExecutionMetadata | null> | undefined, |
| 131 | mergedData: RowData |
| 132 | ): { |
| 133 | executionsPatch: Record<string, RowExecutionMetadata | null> | undefined |
| 134 | inFlightDownstreamGroups: string[] |
| 135 | } { |
| 136 | const dirtied = new Set(Object.keys(dataPatch)) |
| 137 | const groupsToClear = new Set<string>() |
| 138 | const inFlightDownstreamGroups: string[] = [] |
| 139 | |
| 140 | // Own-output clears: when the user wipes a workflow output column, drop |
| 141 | // that group's exec entry so the auto-fire reactor re-arms the cell. |
| 142 | // Also flags the cleared output column as dirty so transitive downstream |
| 143 | // groups see it. |
| 144 | for (const [columnId, value] of Object.entries(dataPatch)) { |
| 145 | const cleared = value === null || value === undefined || value === '' |
| 146 | if (!cleared) continue |
| 147 | const col = schema.columns.find((c) => getColumnId(c) === columnId) |
| 148 | if (col?.workflowGroupId) groupsToClear.add(col.workflowGroupId) |
| 149 | } |
| 150 | |
| 151 | // Left-to-right walk, propagating dirty columns forward. |
| 152 | const groups = schema.workflowGroups ?? [] |
| 153 | const afterRow = { data: mergedData } as TableRow |
| 154 | for (const group of groups) { |
| 155 | const deps = group.dependencies?.columns ?? [] |
| 156 | const depMatched = deps.some((d) => dirtied.has(d)) |
| 157 | if (!depMatched) continue |
| 158 | |
| 159 | // A dep column changed, but if the group's deps are no longer satisfied |
| 160 | // after the patch — a checkbox was unchecked or a text dep cleared — there's |
| 161 | // nothing to recompute. Leave the prior result alone instead of re-arming or |
| 162 | // cancelling it; only checking a box / filling a dep drives downstream work. |
| 163 | if (!areGroupDepsSatisfied(group, afterRow)) continue |
| 164 | |
| 165 | const exec = existingExecutions[group.id] |
| 166 | if (exec) { |
| 167 | const status = exec.status |
| 168 | if (status === 'completed' || status === 'error' || status === 'cancelled') { |
| 169 | groupsToClear.add(group.id) |
| 170 | } else if (status === 'queued' || status === 'running' || status === 'pending') { |
| 171 | inFlightDownstreamGroups.push(group.id) |
| 172 | } |
| 173 | } else { |
| 174 | // No exec entry yet — `mode: 'new'` already covers this group. We |
| 175 | // still propagate the dirty signal forward so later groups in the |
| 176 | // chain see this group's outputs as dirty too. |
| 177 | groupsToClear.add(group.id) |
| 178 | } |
| 179 | |
| 180 | // Propagate: this group is about to be re-computed, so groups whose |
| 181 | // deps reference its output columns are also dirty. |
| 182 | for (const out of group.outputs) dirtied.add(out.columnName) |
| 183 | } |
no test coverage detected