From 74e13d11824aa5ba778a9bd1fc47ba47479f8a94 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 14:03:17 +0000 Subject: [PATCH] Move the running podcast sources to fifteen minutes #34 set `cadenceMinutes: 15` on the adapter, which decides what a source is created with and nothing else. `insertSource` is idempotent on the slug and its conflict clause updates name, description and updated_at -- not config, and not cadence_minutes. That is deliberate: cadence is on the list of things an operator may edit from the sources page, so re-asserting the adapter's number on every boot would silently undo a human's decision the next time the web app restarted. The cost of that is this migration. Both podcast sources were created while the adapter still said 60, so they kept 60, and #34 reached exactly nothing that was already running. Nothing failed and no run errored -- the catalogue walk simply proceeded at a quarter of the intended rate, which is the kind of wrong that is only visible if you go and look. Found by checking rather than by anything reporting it. Scoped by slug rather than by adapter, so a source somebody added themselves keeps the cadence they chose, and conditional on the value still being 60, so re-running never overwrites a later decision. Both properties are asserted: the test seeds an operator-tuned source and a hand-made one alongside the two seeded ones and checks neither moves, then re-runs the statement against deliberately changed values. `backfillPages` needed no such treatment -- run config is `{ ...adapter.defaults, ...stored }`, so a key the stored config has never carried resolves to the adapter's default on every run. 504 tests pass, biome clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166srGNRGyeDQbQnJpbNPNb --- .../db/migrations/0013_podcasts_cadence.sql | 29 ++++++++++ test/schema.test.js | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/db/migrations/0013_podcasts_cadence.sql diff --git a/packages/db/migrations/0013_podcasts_cadence.sql b/packages/db/migrations/0013_podcasts_cadence.sql new file mode 100644 index 0000000..087e5e5 --- /dev/null +++ b/packages/db/migrations/0013_podcasts_cadence.sql @@ -0,0 +1,29 @@ +-- The podcasts sources actually poll every fifteen minutes. +-- +-- #34 set `cadenceMinutes: 15` on the adapter, which decides what a source is +-- created with and nothing else. `insertSource` is idempotent on the slug and +-- its conflict clause updates `name`, `description` and `updated_at` -- not +-- `config`, and not `cadence_minutes`. That is deliberate: cadence is on the +-- list of things an operator may edit from the sources page, so re-asserting +-- the adapter's number on every boot would silently undo a human's decision the +-- next time the web app restarted. +-- +-- The cost of that is this file. The two podcast sources were created while the +-- adapter still said 60, so they kept 60, and the change in #34 reached exactly +-- nothing that was already running. Nothing failed and no run errored; the +-- catalogue walk simply proceeded at a quarter of the intended rate, which is +-- the kind of wrong that is only visible if you go and look. +-- +-- So the two rows are moved by hand, once. Scoped by slug rather than by +-- adapter so a source somebody added themselves against the same adapter keeps +-- whatever cadence they chose for it, and conditional on the current value +-- still being 60 so that re-running this never overwrites a later decision. +-- +-- A fresh deployment reaches the same place without this: seeding creates both +-- sources from the adapter, which now says 15. + +update sources + set cadence_minutes = 15, + updated_at = now() + where slug in ('podcasts-commercial', 'podcasts-self-hosted') + and cadence_minutes = 60; diff --git a/test/schema.test.js b/test/schema.test.js index 65ebd89..ec67128 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -933,3 +933,56 @@ describe('the weather collection migration', () => { expect(moved).toEqual({ sources: 1, items: 1, feeds: 1, left_behind: 0 }); }); }); + +describe('the podcasts cadence migration', () => { + test('moves the two seeded sources to fifteen minutes and nothing else', async () => { + // The gap 0013 closes: `insertSource` is idempotent on the slug but its + // conflict clause never touches cadence_minutes, so raising the adapter's + // cadence reached nothing that already existed. + const c = await one( + `insert into collections (slug, name) values ('podcasts', 'Podcasts') + on conflict (slug) do update set name = excluded.name returning id`, + ); + for (const [slug, cadence] of [ + ['podcasts-commercial', 60], + ['podcasts-self-hosted', 60], + // Somebody's own source against the same adapter, and one an operator has + // already tuned. Neither may be touched. + ['podcasts-mine', 60], + ['podcasts-self-hosted-tuned', 5], + ]) { + await db.query( + `insert into sources (collection_id, adapter, slug, name, cadence_minutes) + values ($1, 'podcasts', $2, $2, $3)`, + [c.id, slug, cadence], + ); + } + // Exactly the statement migration 0013 runs. + const stmt = `update sources set cadence_minutes = 15, updated_at = now() + where slug in ('podcasts-commercial', 'podcasts-self-hosted') + and cadence_minutes = 60`; + await db.query(stmt); + + const after = await one( + `select + (select cadence_minutes from sources where slug = 'podcasts-commercial') as commercial, + (select cadence_minutes from sources where slug = 'podcasts-self-hosted') as self_hosted, + (select cadence_minutes from sources where slug = 'podcasts-mine') as mine, + (select cadence_minutes from sources where slug = 'podcasts-self-hosted-tuned') as tuned`, + ); + expect(after).toEqual({ commercial: 15, self_hosted: 15, mine: 60, tuned: 5 }); + + // Re-running must not walk a later decision back to 15. + await db.query(`update sources set cadence_minutes = 60 where slug = 'podcasts-commercial'`); + await db.query(`update sources set cadence_minutes = 30 where slug = 'podcasts-self-hosted'`); + await db.query(stmt); + const again = await one( + `select + (select cadence_minutes from sources where slug = 'podcasts-commercial') as commercial, + (select cadence_minutes from sources where slug = 'podcasts-self-hosted') as self_hosted`, + ); + // The first was genuinely back at 60, so it moves again; the second was + // deliberately set to 30 and is left alone. + expect(again).toEqual({ commercial: 15, self_hosted: 30 }); + }); +});