This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+22
View File
@@ -64,6 +64,14 @@ type TracearrImportState struct {
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
LastError string `json:"lastError,omitempty"`
// LastRebuildAt is when the household's For You rows were last rebuilt in full.
//
// It lives in this document rather than in a table of its own because it is the same
// kind of fact as the two stamps above it — where the For You pipeline has got to —
// and because the daily rebuild is now a scheduled task, which means the alternative
// was inferring "did today's rebuild happen" from run history that also records the
// ticks on which it correctly declined to run.
LastRebuildAt *time.Time `json:"lastRebuildAt,omitempty"`
}
type RecommendationProfile struct {
@@ -407,6 +415,20 @@ func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImport
return nil
}
// MarkForYouRebuild records that the household's rows have just been rebuilt.
//
// Read-modify-write rather than a whole-document put, because the importer owns the other
// two stamps in this document and an import running beside a rebuild must not lose its own.
func (s *Store) MarkForYouRebuild(ctx context.Context, at time.Time) error {
state, err := s.TracearrImportState(ctx)
if err != nil {
return err
}
stamp := at.UTC()
state.LastRebuildAt = &stamp
return s.SetTracearrImportState(ctx, state)
}
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id)
@@ -0,0 +1,98 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// IntegrationPolicyKey is the operator's global on/off switch for external services that
// have no configuration document of their own.
//
// It is deliberately *not* a second copy of every integration's switch. Sonarr and Radarr
// already store theirs in the arr integration policy and MDBList stores its own in the
// ratings settings; moving those here would mean a migration and, worse, a window in which
// two documents disagreed about whether a service was on. The rule is one stored truth per
// integration, kept where that integration's other configuration already lives — and this
// document is that home for the ones that have nowhere else, which today is Tracearr.
//
// The API's integrationEnabled/setIntegrationEnabled pair is the single reader and writer,
// so the console never has to know which document answers for which service.
const IntegrationPolicyKey = "integration_policy"
// IntegrationPolicy is a map of integration id to whether it is switched on.
//
// Absence means on. A household that upgrades into this feature has every service it had
// configured still working, which is the only safe reading: the alternative silently turns
// off recommendation imports on the day the console gained a switch for them.
type IntegrationPolicy struct {
Disabled map[string]bool `json:"disabled"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Enabled reports whether one integration is switched on. A service nobody has ever
// touched has no entry, and no entry is on.
func (p IntegrationPolicy) Enabled(id string) bool {
return !p.Disabled[strings.TrimSpace(id)]
}
func (s *Store) IntegrationPolicy(ctx context.Context) (IntegrationPolicy, error) {
empty := IntegrationPolicy{Disabled: map[string]bool{}}
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, IntegrationPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return empty, nil
}
if err != nil {
return empty, fmt.Errorf("store: read integration policy: %w", err)
}
var policy IntegrationPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return empty, fmt.Errorf("store: decode integration policy: %w", err)
}
if policy.Disabled == nil {
policy.Disabled = map[string]bool{}
}
return policy, nil
}
// SetIntegrationEnabled records one service's switch, leaving every other entry alone.
//
// Read-modify-write rather than a whole-document put, because the console sends one
// switch at a time and a put would let a page rendered before another integration existed
// silently re-enable it.
func (s *Store) SetIntegrationEnabled(ctx context.Context, id string, enabled bool) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("store: integration policy needs an id")
}
policy, err := s.IntegrationPolicy(ctx)
if err != nil {
return err
}
if enabled {
delete(policy.Disabled, id)
} else {
policy.Disabled[id] = true
}
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
IntegrationPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write integration policy: %w", err)
}
return nil
}
+34
View File
@@ -223,6 +223,40 @@ func (s *Store) MediaRatingsStats(ctx context.Context, staleBefore time.Time) (t
return total, stale, nil
}
// StaleRatingKeys is the oldest stored titles due to be re-checked, oldest first.
//
// Oldest first rather than by any measure of popularity, because the refresh is bounded
// per run: taking the oldest means every title comes round eventually, where taking the
// most-watched would leave the tail of the library permanently on scores from the year it
// was imported. The limit is what keeps a run's cost — and therefore the day's external
// allowance — a figure the operator can reason about.
func (s *Store) StaleRatingKeys(
ctx context.Context, staleBefore time.Time, limit int,
) ([]RatingKey, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.pool.Query(ctx, `
SELECT media_type, provider, provider_id
FROM external_media_ratings
WHERE fetched_at < $1
ORDER BY fetched_at ASC
LIMIT $2`, staleBefore, limit)
if err != nil {
return nil, fmt.Errorf("store: stale rating keys: %w", err)
}
defer rows.Close()
keys := []RatingKey{}
for rows.Next() {
var key RatingKey
if err := rows.Scan(&key.MediaType, &key.Provider, &key.ProviderID); err != nil {
return nil, fmt.Errorf("store: scan stale rating key: %w", err)
}
keys = append(keys, key)
}
return keys, rows.Err()
}
// SaveMediaRatings upserts a successfully loaded provider response.
func (s *Store) SaveMediaRatings(
ctx context.Context, mediaType, provider, providerID string, ratings json.RawMessage,
+85 -7
View File
@@ -7,8 +7,11 @@ import (
"time"
)
// MediaRequest is one viewer's ask, as recorded. It carries no status: see the schema
// comment on media_requests for why the state is derived per read rather than stored.
// MediaRequest is one viewer's ask, as recorded.
//
// The status a card shows is not here: it is derived per read from the *arrs and the
// library, for the reason the schema gives. The one exception is LastStatus, which is not
// the card's status but the memory of it — the only way to notice that something changed.
type MediaRequest struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
@@ -16,6 +19,19 @@ type MediaRequest struct {
Year int `json:"year,omitempty"`
PosterURL string `json:"posterUrl,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
// LastStatus is the state this request was in the last time anything looked, and it is
// the only piece of request state that is stored. See the schema comment: an arrival is
// a difference between two observations rather than a property of one, and the viewer is
// told about it once.
LastStatus string `json:"-"`
}
// OwnedMediaRequest is a stored ask with the person who made it, which the per-viewer read
// does not need to carry because it was asked for by user. The ready sweep looks at the
// whole household in one query, so there it is the point.
type OwnedMediaRequest struct {
MediaRequest
UserID string
}
// RequestUsage is the operator-facing use of the request feature. A recorded request is
@@ -59,16 +75,20 @@ func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRe
if userID == "" || req.ForeignID <= 0 {
return fmt.Errorf("store: media request needs a user and a foreign id")
}
// last_status is deliberately absent from the UPDATE. Asking again is somebody saying
// they still want it, not a reason to re-announce an arrival they were already told
// about — and re-seeding it here would make a second press of Request the way to make
// the gateway repeat itself.
_, err := s.pool.Exec(ctx, `
INSERT INTO media_requests
(emby_user_id, media_type, foreign_id, title, year, poster_url, requested_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
(emby_user_id, media_type, foreign_id, title, year, poster_url, last_status, requested_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (emby_user_id, media_type, foreign_id) DO UPDATE
SET title = EXCLUDED.title,
year = EXCLUDED.year,
poster_url = EXCLUDED.poster_url,
requested_at = now()`,
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL)
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL, req.LastStatus)
if err != nil {
return fmt.Errorf("store: save media request: %w", err)
}
@@ -78,7 +98,7 @@ func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRe
// MediaRequests returns one viewer's asks, most recent first.
func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaRequest, error) {
rows, err := s.pool.Query(ctx, `
SELECT media_type, foreign_id, title, year, poster_url, requested_at
SELECT media_type, foreign_id, title, year, poster_url, last_status, requested_at
FROM media_requests
WHERE emby_user_id = $1
ORDER BY requested_at DESC
@@ -92,7 +112,8 @@ func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaReques
for rows.Next() {
var req MediaRequest
if err := rows.Scan(
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL, &req.RequestedAt,
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL,
&req.LastStatus, &req.RequestedAt,
); err != nil {
return nil, fmt.Errorf("store: scan media request: %w", err)
}
@@ -118,3 +139,60 @@ func (s *Store) DeleteMediaRequest(
}
return nil
}
// AllMediaRequests reads the whole household's asks, newest first, with the person attached.
//
// The per-viewer read above is what a page needs; this is what the ready sweep needs, and
// the difference is worth one query rather than one per account: a household of six with
// eighty requests between them is one read, and the sweep has to look at all of them anyway
// because two people can be waiting for the same film.
//
// It is bounded like the per-viewer read. A sweep that fell behind on a household which had
// been asking for things for two years must not become an unbounded query on a timer.
func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRequest, error) {
if limit <= 0 {
limit = MediaRequestSweepLimit
}
rows, err := s.pool.Query(ctx, `
SELECT emby_user_id, media_type, foreign_id, title, year, poster_url, last_status, requested_at
FROM media_requests
ORDER BY requested_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("store: read all media requests: %w", err)
}
defer rows.Close()
requests := []OwnedMediaRequest{}
for rows.Next() {
var req OwnedMediaRequest
if err := rows.Scan(
&req.UserID, &req.MediaType, &req.ForeignID, &req.Title, &req.Year,
&req.PosterURL, &req.LastStatus, &req.RequestedAt,
); err != nil {
return nil, fmt.Errorf("store: scan media request: %w", err)
}
requests = append(requests, req)
}
return requests, rows.Err()
}
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
const MediaRequestSweepLimit = 500
// SetMediaRequestStatus records what a request was last seen doing.
//
// Written only when the state actually moved, so a sweep over a household where nothing has
// changed — which is almost every sweep — costs no writes at all.
func (s *Store) SetMediaRequestStatus(
ctx context.Context, userID, mediaType string, foreignID int, status string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE media_requests SET last_status = $4
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3`,
strings.TrimSpace(userID), mediaType, foreignID, status)
if err != nil {
return fmt.Errorf("store: set media request status: %w", err)
}
return nil
}
+151 -23
View File
@@ -29,17 +29,44 @@ const (
TriggerStartup = "startup"
)
// RunCounts is what a run did, in numbers.
//
// Four figures rather than a free map, because these are the four questions an operator
// asks of any piece of batch work — how much did it look at, how much did it change, how
// much did it decline, how much went wrong — and a schema-free bag would let two
// integrations answer them under different names. Anything an integration counts beyond
// these belongs in the run's own sentence.
//
// Processed is the load-bearing one: a run reporting zero processed counted nothing at
// all, and the console draws no figures rather than four zeroes.
type RunCounts struct {
Processed int `json:"processed"`
Changed int `json:"changed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
}
// Counted reports whether this run counted anything worth printing.
func (c RunCounts) Counted() bool {
return c.Processed != 0 || c.Changed != 0 || c.Skipped != 0 || c.Failed != 0
}
// TaskRun is one execution.
type TaskRun struct {
ID int64 `json:"id"`
TaskID string `json:"taskId"`
Trigger string `json:"trigger"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMS int64 `json:"durationMs"`
Detail string `json:"detail,omitempty"`
Error string `json:"error,omitempty"`
ID int64 `json:"id"`
TaskID string `json:"taskId"`
// IntegrationID names the external service this run belongs to, and is empty for the
// gateway's own housekeeping. It is what lets the integrations area read operational
// history out of the scheduler's own table instead of keeping a second one.
IntegrationID string `json:"integrationId,omitempty"`
Trigger string `json:"trigger"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMS int64 `json:"durationMs"`
Detail string `json:"detail,omitempty"`
Error string `json:"error,omitempty"`
Counts RunCounts `json:"counts"`
}
// TaskSettings is an operator's override for one task. IntervalSeconds of 0 means "the
@@ -55,24 +82,31 @@ type TaskSettings struct {
// BeginTaskRun opens a run and returns its id. The row exists before the work starts so a
// task killed by a restart leaves evidence it began — which is the only way to tell a job
// that hangs from one that was never scheduled.
func (s *Store) BeginTaskRun(ctx context.Context, taskID, trigger string) (int64, error) {
func (s *Store) BeginTaskRun(
ctx context.Context, taskID, integrationID, trigger string,
) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO scheduled_task_runs (task_id, trigger, status)
VALUES ($1, $2, $3) RETURNING id`, taskID, trigger, TaskRunning).Scan(&id)
INSERT INTO scheduled_task_runs (task_id, integration_id, trigger, status)
VALUES ($1, $2, $3, $4) RETURNING id`,
taskID, integrationID, trigger, TaskRunning).Scan(&id)
if err != nil {
return 0, fmt.Errorf("store: begin task run: %w", err)
}
return id, nil
}
// FinishTaskRun closes a run with its outcome.
func (s *Store) FinishTaskRun(ctx context.Context, id int64, status, detail, failure string) error {
// FinishTaskRun closes a run with its outcome and whatever it counted.
func (s *Store) FinishTaskRun(
ctx context.Context, id int64, status, detail, failure string, counts RunCounts,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE scheduled_task_runs
SET status = $2, finished_at = now(), detail = $3, error = $4,
processed = $5, changed = $6, skipped = $7, failed = $8,
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
WHERE id = $1`, id, status, detail, failure)
WHERE id = $1`, id, status, detail, failure,
counts.Processed, counts.Changed, counts.Skipped, counts.Failed)
if err != nil {
return fmt.Errorf("store: finish task run: %w", err)
}
@@ -103,7 +137,8 @@ func (s *Store) AbandonRunningTasks(ctx context.Context) (int64, error) {
func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (task_id)
id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
id, task_id, integration_id, trigger, status, started_at, finished_at,
duration_ms, detail, error, processed, changed, skipped, failed
FROM scheduled_task_runs
ORDER BY task_id, started_at DESC, id DESC`)
if err != nil {
@@ -113,9 +148,10 @@ func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error)
latest := map[string]TaskRun{}
for rows.Next() {
var run TaskRun
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error); err != nil {
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
return nil, fmt.Errorf("store: scan task run: %w", err)
}
latest[run.TaskID] = run
@@ -129,7 +165,8 @@ func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskR
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
SELECT id, task_id, integration_id, trigger, status, started_at, finished_at,
duration_ms, detail, error, processed, changed, skipped, failed
FROM scheduled_task_runs
WHERE ($1 = '' OR task_id = $1)
ORDER BY started_at DESC, id DESC
@@ -141,9 +178,10 @@ func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskR
runs := []TaskRun{}
for rows.Next() {
var run TaskRun
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error); err != nil {
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
return nil, fmt.Errorf("store: scan task run: %w", err)
}
runs = append(runs, run)
@@ -338,3 +376,93 @@ func (s *Store) PruneIntegrationDeliveries(ctx context.Context, keep int) (int64
}
return tag.RowsAffected(), nil
}
// --- integration run history ------------------------------------------------------
//
// The same rows as above, read with a different question in mind. An integration run *is*
// a scheduled task run: giving the integrations area a table of its own would mean two
// schedulers, two retention jobs and two places one piece of work could be recorded as
// having failed. What differs is only the axis — by service rather than by job.
// IntegrationRuns is the operational history for one external service, newest first.
func (s *Store) IntegrationRuns(
ctx context.Context, integrationID string, limit int,
) ([]TaskRun, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, task_id, integration_id, trigger, status, started_at, finished_at,
duration_ms, detail, error, processed, changed, skipped, failed
FROM scheduled_task_runs
WHERE integration_id <> '' AND ($1 = '' OR integration_id = $1)
ORDER BY started_at DESC, id DESC
LIMIT $2`, integrationID, limit)
if err != nil {
return nil, fmt.Errorf("store: list integration runs: %w", err)
}
defer rows.Close()
runs := []TaskRun{}
for rows.Next() {
var run TaskRun
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
return nil, fmt.Errorf("store: scan integration run: %w", err)
}
runs = append(runs, run)
}
return runs, rows.Err()
}
// IntegrationRunSummary answers the overview's whole row for one service.
//
// Last success and last failure are both carried because they are different questions and
// the answer to one is not the absence of the other: a service that failed an hour ago and
// has worked since is healthy, and one that succeeded last week and has failed every hour
// since is not. Neither is derivable from a single "last run".
type IntegrationRunSummary struct {
IntegrationID string `json:"integrationId"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
LastError string `json:"lastError,omitempty"`
Runs int `json:"runs"`
Failures int `json:"failures"`
}
// IntegrationRunSummaries is every service's summary in one query, because the overview
// draws one per row and a query per integration would grow with the catalogue.
func (s *Store) IntegrationRunSummaries(
ctx context.Context,
) (map[string]IntegrationRunSummary, error) {
rows, err := s.pool.Query(ctx, `
SELECT integration_id,
max(started_at) FILTER (WHERE status = $1),
max(started_at) FILTER (WHERE status = $2),
(array_remove(array_agg(error ORDER BY started_at DESC)
FILTER (WHERE status = $2), ''))[1],
count(*), count(*) FILTER (WHERE status = $2)
FROM scheduled_task_runs
WHERE integration_id <> ''
GROUP BY integration_id`, TaskSuccess, TaskFailed)
if err != nil {
return nil, fmt.Errorf("store: integration run summaries: %w", err)
}
defer rows.Close()
summaries := map[string]IntegrationRunSummary{}
for rows.Next() {
var summary IntegrationRunSummary
var lastError *string
if err := rows.Scan(&summary.IntegrationID, &summary.LastSuccessAt,
&summary.LastFailureAt, &lastError, &summary.Runs,
&summary.Failures); err != nil {
return nil, fmt.Errorf("store: scan integration run summary: %w", err)
}
if lastError != nil {
summary.LastError = *lastError
}
summaries[summary.IntegrationID] = summary
}
return summaries, rows.Err()
}
+33
View File
@@ -577,6 +577,17 @@ CREATE TABLE IF NOT EXISTS media_requests (
PRIMARY KEY (emby_user_id, media_type, foreign_id)
);
-- last_status is the one piece of request state that *is* stored, and only because a
-- transition cannot be derived from a single read. Everything else on a request card is
-- computed per read from the *arrs and the library; "it has just become ready" is not a
-- property of the present, it is the difference between two observations, and the viewer
-- has to be told about it exactly once.
--
-- It is seeded when the request is recorded rather than left blank for a sweep to fill in,
-- because a film that downloads in the three minutes before the first sweep would otherwise
-- have its arrival recorded as its opening state and nobody would ever be told.
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);
@@ -663,6 +674,28 @@ CREATE TABLE IF NOT EXISTS scheduled_task_runs (
error TEXT NOT NULL DEFAULT ''
);
-- Which integration a run belongs to, and what it actually did.
--
-- Deliberately more columns on this table rather than a second one: an integration run IS
-- a scheduled task run, read with a different question in mind. Operations history and
-- "what does the gateway do in the background" are the same rows; giving integrations
-- their own table would mean two schedulers, two retention jobs and two places a run can
-- be recorded as having failed.
--
-- The counters are nullable-by-default zeroes because most tasks count nothing: a
-- housekeeping prune has one number and it is already in `detail`. A run that counted
-- nothing is drawn without figures rather than as four zeroes, which is why the API sends
-- them only when `processed` is non-zero.
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS integration_id TEXT NOT NULL DEFAULT '';
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS processed INT NOT NULL DEFAULT 0;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS changed INT NOT NULL DEFAULT 0;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS skipped INT NOT NULL DEFAULT 0;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS failed INT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS scheduled_task_runs_integration_idx
ON scheduled_task_runs (integration_id, started_at DESC)
WHERE integration_id <> '';
CREATE INDEX IF NOT EXISTS scheduled_task_runs_task_idx
ON scheduled_task_runs (task_id, started_at DESC);
CREATE INDEX IF NOT EXISTS scheduled_task_runs_time_idx