Files
windmill/frontend/src/lib/components/triggers/triggerHistoryChanges.ts
T
Ruben FiszelandClaude Opus 5 633d7bcb2e feat: add trigger_history table with source tracking (#10696)
* 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>
2026-08-14 17:57:11 +02:00

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)
}