mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 16:03:24 +00:00
* feat: add trigger_history table with source tracking Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate trigger history reads on scopes and harden its writers Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: filter trigger history scopes in SQL and match the cleared-handler diff Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: record a trigger restore from the trashbin in its history Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: record bulk http trigger creates and document the recording boundary Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: lock the trigger row when capturing its history preimage Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: only record an auto-disable that actually flipped the schedule Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: state the auto-disable invariant once instead of at four call sites Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: render trigger history changes as a structured field diff Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: make a server-initiated disable atomic with its history row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: note that the auto-disable savepoint takes no pool connection Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: note the flow fallback is the last chance to disable Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: never leave a trigger enabled because its history row failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: retry the disable history row instead of dropping it on first failure Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: use the design-system Button for the change-value expander Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hold the trigger row lock across its disable history row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the history-loss alert out of the listener cancellation race Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the history workspace through the trigger-workspace seam Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
71 lines
2.7 KiB
TypeScript
71 lines
2.7 KiB
TypeScript
/**
|
|
* The `changes` column of `trigger_history`, in the three shapes the backend
|
|
* writes it (see `windmill-common/src/trigger_history.rs`):
|
|
*
|
|
* null no field-level diff — a delete
|
|
* { truncated_fields: [...] } the diff exceeded the 32 KiB cap
|
|
* { <field>: { old?, new } } the fields that changed
|
|
*
|
|
* Parsing it into this closed set up front is what lets the viewer lay every
|
|
* entry out the same way, instead of rendering whatever tree came back.
|
|
*/
|
|
|
|
/**
|
|
* How one field moved. A missing or null side is treated as the field having no
|
|
* value: the writer omits `old` where it never read one, and the backend writes
|
|
* `new: null` for a cleared column, so both collapse to added/removed.
|
|
*/
|
|
export type FieldChange =
|
|
| { kind: 'added'; field: string; next: unknown }
|
|
| { kind: 'removed'; field: string; prev: unknown | undefined }
|
|
| { kind: 'changed'; field: string; prev: unknown; next: unknown }
|
|
|
|
export type ParsedChanges =
|
|
| { kind: 'none' }
|
|
| { kind: 'truncated'; fields: string[] }
|
|
| { kind: 'fields'; changes: FieldChange[] }
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
export function parseChanges(changes: unknown): ParsedChanges {
|
|
if (!isRecord(changes)) return { kind: 'none' }
|
|
|
|
const truncated = changes['truncated_fields']
|
|
if (Array.isArray(truncated)) {
|
|
return { kind: 'truncated', fields: truncated.map(String) }
|
|
}
|
|
|
|
const parsed: FieldChange[] = []
|
|
for (const [field, entry] of Object.entries(changes)) {
|
|
if (!isRecord(entry)) continue
|
|
// A null on either side is the field having no value, which reads as
|
|
// added/removed rather than as a change to or from `null`.
|
|
const prev = entry['old'] ?? undefined
|
|
const next = entry['new'] ?? undefined
|
|
if (next === undefined) {
|
|
parsed.push({ kind: 'removed', field, prev })
|
|
} else if (prev === undefined) {
|
|
parsed.push({ kind: 'added', field, next })
|
|
} else {
|
|
parsed.push({ kind: 'changed', field, prev, next })
|
|
}
|
|
}
|
|
// Alphabetical: jsonb hands back its own key order, which is by length then
|
|
// bytes and would shuffle as values change.
|
|
parsed.sort((a, b) => a.field.localeCompare(b.field))
|
|
|
|
return parsed.length === 0 ? { kind: 'none' } : { kind: 'fields', changes: parsed }
|
|
}
|
|
|
|
/** True when a value needs the tree viewer rather than a one-line rendering. */
|
|
export function isComplex(value: unknown): boolean {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
/** One-line rendering of a scalar, quoted so `""` and `"0"` stay visible. */
|
|
export function formatScalar(value: unknown): string {
|
|
return typeof value === 'string' ? JSON.stringify(value) : String(value)
|
|
}
|