commit 34f1af527cd9356195bbafea59789bc56506b6ee
parent f38ebca213269686ff379b3afb3791bc39a3ee80
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:35:46 -0400
channels: excludeFromSync is deleted
The field said "stop syncing, keep everything else". That is exactly one entry
in the priority document's per-operation override map —
`{tier:<base>, overrides:{sync:"paused"}}` — and the map can say it for any
operation, not just this one. So the flag goes, with its sanitizer, its
action, its toggle and both legacy skip branches.
Gone: `ChannelConfig.excludeFromSync` and its sanitizer clause,
`toggleChannelSyncInclusionAction`, `ChannelSyncToggle.tsx`,
`selectDueChannels`' legacy `continue`, `autoSyncEligible`'s legacy term,
`syncAllChannelsAction`'s "excluded from sync all" skip, and `stationWorkFor`'s
"excluded from sync" reason. `isChannelPaused(model, slug, "sync")` is the one
question all five now ask. `excludeFromBuild` and `excludeFromCleanup` are
untouched — different axes (publishing, and the /cleanup total).
THE PARSER DROPS THE KEY RATHER THAN CARRYING IT, which is what makes an
un-migrated config.json still parse: it comes back saying nothing about sync,
so the channel syncs — what it did before the flag was invented. That is also
why the migration script reads the raw config.json for the flag and why the
editor's legacy seed passes `{}` to `channelPriorityFromLegacy`: the seed is
after the lane ORDER, which lives in the stored trees, and the sync overrides
are the script's half.
`channel-sync-toggle.spec.ts` went with S3, which removed the control.
`syncScheduler.test.ts`'s legacy-flag case is now the assertion that a
leftover key IS dropped by the parser and skips nothing.
common 985/985; tsc clean in common, editor, export, mcp.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
11 files changed, 110 insertions(+), 126 deletions(-)
diff --git a/common/bin/migrate-channel-priority.ts b/common/bin/migrate-channel-priority.ts
@@ -1,9 +1,8 @@
#!/usr/bin/env tsx
import path from "node:path";
-import { readFile, rename, writeFile } from "node:fs/promises";
+import { readdir, readFile, rename, writeFile } from "node:fs/promises";
import { getPaths } from "../lib/paths";
import { getSettings, writeSettings } from "../lib/settings";
-import { listChannelConfigs } from "../controller/channels";
import { siteChannelIndex } from "../lib/site";
import {
channelPriorityFromLegacy,
@@ -39,6 +38,13 @@ import { parseFlags } from "./_parseFlags";
// configs; `getSettings` is synchronous and reads one file. Running it from
// there would put 68 reads on every settings read in the process.
//
+// WHY IT READS THE CONFIGS RAW, and not through `listChannelConfigs`.
+// `excludeFromSync` is DELETED from `ChannelConfig` in the same slice, and
+// `parseChannelConfig` is allow-list style — so the parser now drops the very
+// key this migration exists to read. The raw file is the only place the flag
+// still exists, which is also why the clearing pass below is a raw key delete
+// rather than a parse-and-rewrite.
+//
// IDEMPOTENT, TWICE OVER. `channelPriorityFromLegacy` returns the STORED
// document untouched once any lane root carries a `prio-*` id — compiled
// channel leaves are bare channel leaves, so a second run would otherwise
@@ -61,6 +67,31 @@ const flags = parseFlags(process.argv.slice(2));
const dryRun = flags["dry-run"] === "true";
const paths = getPaths();
+// One channel, as the migration needs it: the slug and the RAW parsed JSON of
+// its config.json. A directory with no readable config.json is skipped, the
+// same channels `listChannelConfigs` would have returned.
+type RawChannel = { slug: string; raw: Record<string, unknown> };
+
+async function readRawChannels(): Promise<RawChannel[]> {
+ const entries = await readdir(paths.channelsDir, {
+ withFileTypes: true,
+ }).catch(() => []);
+ const out: RawChannel[] = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const file = path.join(paths.channelsDir, entry.name, "config.json");
+ let raw: unknown;
+ try {
+ raw = JSON.parse(await readFile(file, "utf8"));
+ } catch {
+ continue;
+ }
+ if (!raw || typeof raw !== "object") continue;
+ out.push({ slug: entry.name, raw: raw as Record<string, unknown> });
+ }
+ return out.sort((a, b) => a.slug.localeCompare(b.slug));
+}
+
// Which legacy fact produced this channel's entry, for the table. The point of
// the column is that an operator can check the migration against the two lists
// they wrote by hand, so it names the LANE and the position, not just "a rank".
@@ -173,14 +204,18 @@ async function clearExcludeFromSync(slug: string): Promise<boolean> {
async function main(): Promise<void> {
const settings = getSettings();
- const configs = await listChannelConfigs(paths);
- const slugs = configs.map((c) => c.slug);
+ const channels = await readRawChannels();
+ const slugs = channels.map((c) => c.slug);
+ // `LegacyChannelRow` is structural and names only the one key, so the raw
+ // objects satisfy it directly.
+ const configs = channels.map(({ slug, raw }) => ({
+ slug,
+ config: { excludeFromSync: raw.excludeFromSync === true },
+ }));
const transcription = bareLeaves(settings.autoQueue.transcription?.root);
const download = bareLeaves(settings.autoQueue.download?.root);
const excludedSlugs = new Set(
- configs
- .filter((c) => (c.config as { excludeFromSync?: boolean }).excludeFromSync)
- .map((c) => c.slug),
+ configs.filter((c) => c.config.excludeFromSync).map((c) => c.slug),
);
console.log(`settings: ${paths.settingsFile}`);
diff --git a/common/controller/fetchPosts.ts b/common/controller/fetchPosts.ts
@@ -177,7 +177,9 @@ export async function fetchPosts(
// Reuse the existing lastSyncedAt field so the scheduler
// (common/jobs/syncScheduler.ts) paces social channels with zero changes —
- // it keys only off url / excludeFromSync / syncIntervalMinutes / lastSyncedAt.
+ // it keys only off url / syncIntervalMinutes / lastSyncedAt, plus the
+ // channel-priority document's `sync` tier (`isChannelPaused(model, slug,
+ // "sync")`), which is where the retired `excludeFromSync` flag went.
await writeChannelConfig(paths, slug, {
...config,
lastSyncedAt: new Date().toISOString(),
diff --git a/common/jobs/syncScheduler.test.ts b/common/jobs/syncScheduler.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import type { ChannelConfig } from "../lib/channelConfig";
+import { parseChannelConfig, type ChannelConfig } from "../lib/channelConfig";
import {
sanitizeChannelPriority,
type ChannelPriority,
@@ -191,17 +191,22 @@ test("focus does not rescue a channel paused for sync", () => {
assert.deepEqual(r.due, ["other"]);
});
-test("the legacy excludeFromSync flag still skips, beside the new predicate", () => {
- // S5 deletes this flag. Until its migration has run, it is the only thing
- // some channels carry, so both skips have to stand.
- const r = due(
- [
- channel("legacy", 1 * DAY, { excludeFromSync: true }),
- channel("modern", 1 * HOUR),
- ],
- model({ channels: {} }),
- );
- assert.deepEqual(r.due, ["modern"]);
+test("an un-migrated config carries nothing the scheduler can skip on", () => {
+ // `excludeFromSync` is DELETED (S5), and `parseChannelConfig` is allow-list
+ // style, so a config.json that still spells the key parses to a channel that
+ // says nothing about sync at all. That is the correct reading: the migration
+ // is what turns the flag into `overrides: {sync:"paused"}`, and until it has
+ // run the channel syncs — which is what it did before the flag was invented.
+ const parsed = parseChannelConfig({
+ handling: "youtube",
+ url: "https://example.com/c",
+ excludeFromSync: true,
+ lastSyncedAt: new Date(NOW - HOUR - 1 * DAY).toISOString(),
+ });
+ assert.ok(parsed);
+ assert.equal("excludeFromSync" in parsed, false);
+ const r = due([{ slug: "stale", config: parsed }], model({ channels: {} }));
+ assert.deepEqual(r.due, ["stale"]);
assert.deepEqual(r.skipped, []);
});
@@ -211,7 +216,6 @@ test("the schedule projection agrees with the scheduler about who is skipped", (
channel("sync-paused", 1 * DAY),
channel("base-paused", 1 * DAY),
channel("sync-only", 1 * HOUR),
- channel("legacy", 1 * DAY, { excludeFromSync: true }),
channel("no-url", 1 * DAY, { url: undefined }),
channel("interval-off", 1 * DAY, { syncIntervalMinutes: 0 }),
];
@@ -239,7 +243,10 @@ test("the schedule projection agrees with the scheduler about who is skipped", (
test("the projection is unchanged by an empty priority document", () => {
const view = buildScheduleView({
- channels: [channel("a", 1 * HOUR), channel("b", 1 * DAY, { excludeFromSync: true })],
+ channels: [
+ channel("a", 1 * HOUR),
+ channel("b", 1 * DAY, { syncIntervalMinutes: 0 }),
+ ],
scheduler: scheduler(),
state: emptySchedulerState(),
now: NOW,
diff --git a/common/jobs/syncScheduler.ts b/common/jobs/syncScheduler.ts
@@ -124,13 +124,11 @@ export function selectDueChannels(input: SelectDueInput): SelectDueResult {
for (const { slug, config } of channels) {
if (!config.url) continue; // not auto-sync material; no noise in the log
- // BOTH SKIPS STAND until S5 deletes the legacy one. `excludeFromSync` is
- // the flag the priority model replaces; the migration turns each of them
- // into `overrides: {sync: "paused"}`, and until it has run the flag is
- // still the only thing some channels carry. Silent `continue` either way —
+ // ONE SKIP. `excludeFromSync` is gone (S5); the document's `sync` tier is
+ // what it became, and the migration turned each of the 15 channels that
+ // carried the flag into `overrides: {sync: "paused"}`. Silent `continue` —
// "this channel does not auto-sync" is configuration, not a hold worth a
// line in the run log.
- if (config.excludeFromSync) continue;
if (isChannelPaused(priority, slug, "sync")) continue;
const interval = resolveIntervalMinutes(config, scheduler);
@@ -258,7 +256,6 @@ export function buildScheduleView(input: {
autoSyncEligible:
scheduler.enabled &&
!!config.url &&
- !config.excludeFromSync &&
!isChannelPaused(priority, slug, "sync") &&
interval > 0,
intervalMinutes: interval,
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -110,7 +110,14 @@ export type ChannelConfig = {
// the cheap newest-first paged walk.
lastFullSweepAt?: string;
excludeFromBuild?: boolean;
- excludeFromSync?: boolean;
+ // `excludeFromSync` IS GONE. It said "stop syncing, keep everything else",
+ // which is exactly what `channelPriority`'s per-operation override map says
+ // — `{tier:<base>, overrides:{sync:"paused"}}` — and the model can say it
+ // for every operation, not just this one. The sanitizer below drops the key
+ // rather than carrying it, so an un-migrated config.json still parses: the
+ // migration (common/bin/migrate-channel-priority.ts) reads it from the RAW
+ // file, not through this parser, precisely because this parser no longer
+ // knows the word.
// Opt this channel OUT of the aggregate "cleanable data" total shown on the
// /cleanup page and its sidebar badge. The per-channel cleanup sweeps remain
// fully available; this flag only removes the channel's reclaimable bytes from
@@ -123,7 +130,8 @@ export type ChannelConfig = {
// undefined -> inherit the global SiteSettings default interval
// 0 -> auto-sync disabled for this channel (still manually syncable)
// > 0 -> sync this often (clamped to [SYNC_INTERVAL_MIN/MAX_MINUTES])
- // A missing `url` or `excludeFromSync` also disables auto-sync.
+ // A missing `url` also disables auto-sync, as does a `sync` tier of `paused`
+ // in the channel-priority document (common/lib/channelPriority.ts).
syncIntervalMinutes?: number;
// Full-sweep cadence for this channel (see common/jobs/deepSync.ts). A sync
// upgrades itself to a full sweep when
@@ -275,9 +283,6 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
if (typeof r.excludeFromBuild === "boolean") {
config.excludeFromBuild = r.excludeFromBuild;
}
- if (typeof r.excludeFromSync === "boolean") {
- config.excludeFromSync = r.excludeFromSync;
- }
if (typeof r.excludeFromCleanup === "boolean") {
config.excludeFromCleanup = r.excludeFromCleanup;
}
diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts
@@ -617,9 +617,14 @@ export function focusSummary(
// --- The migration's pure half ----------------------------------------------
-// Just enough of a channel row to migrate it. `listChannelConfigs`' rows
-// satisfy this structurally, so the caller hands them straight over and this
-// file does not have to name `ChannelConfig`.
+// Just enough of a channel row to migrate it, named structurally so this file
+// does not have to import `ChannelConfig`.
+//
+// NOTE the one key is gone from `ChannelConfig` (S5) and `parseChannelConfig`
+// drops it, so a PARSED config no longer satisfies this usefully — the
+// migration script reads the raw config.json for it
+// (common/bin/migrate-channel-priority.ts), and the editor's legacy seed
+// passes `{}` because it is after the lane ORDER, not the flag.
export type LegacyChannelRow = {
slug: string;
config: { excludeFromSync?: boolean | undefined };
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -451,23 +451,6 @@ export async function toggleChannelBuildInclusionAction(
return undefined;
}
-export async function toggleChannelSyncInclusionAction(
- slug: string,
-): Promise<ActionResult> {
- const paths = getPaths();
- const existing = await readChannelConfig(paths, slug);
- if (!existing) return { error: `Channel "${slug}" not found` };
- const next = { ...existing };
- if (existing.excludeFromSync) {
- delete next.excludeFromSync;
- } else {
- next.excludeFromSync = true;
- }
- await writeChannelConfig(paths, slug, next);
- revalidatePath("/channels");
- return undefined;
-}
-
// Toggle whether this channel's reclaimable bytes count toward the aggregate
// "cleanable data" total on the /cleanup page (and its sidebar badge). The
// cleanup sweeps themselves stay available regardless; this only flips the
@@ -515,10 +498,10 @@ export async function syncAllChannelsAction(
// THE SAME ANSWER THE SCHEDULER GIVES, asked of the `sync` OPERATION.
//
// A manual pool sweep and the automatic one must agree about which channels
- // are in the pool: the group Sync buttons already ask the priority document
- // through `stationWorkFor`, and this slice removed the only control that
- // could set the flag this loop used to read. So the model is asked here too,
- // BESIDE the legacy flag — S5 deletes the second branch with the field.
+ // are in the pool: the group Sync buttons ask the priority document through
+ // `stationWorkFor` and the scheduler asks it in `selectDueChannels`, so this
+ // loop asks the same question. It is the ONLY question now — S5 deleted
+ // `excludeFromSync` and migrated the 15 channels that carried it.
const priority = getSettings().channelPriority;
const slugs = channels.map((c) => c.slug);
const focus = new Set(
@@ -542,7 +525,6 @@ export async function syncAllChannelsAction(
const config = bySlug.get(slug);
if (!config?.url) return "no url";
if (isChannelPaused(priority, slug, "sync")) return "paused for sync";
- if (config.excludeFromSync) return "excluded from sync all";
if (active.has(slug)) return "already running";
return null;
},
@@ -814,7 +796,16 @@ export async function saveChannelPriorityAction(
edit.kind !== "recompile" &&
isDefaultChannelPriority(stored) &&
!hasCompiledLaneRoots(settings.autoQueue)
- ? channelPriorityFromLegacy(configs, settings.autoQueue)
+ ? channelPriorityFromLegacy(
+ // THE SEED READS THE LANE ORDERS, NOT THE FLAG. `excludeFromSync` is
+ // deleted and `parseChannelConfig` drops the key, so nothing the
+ // editor can read still carries it — turning those 15 channels into
+ // `overrides: {sync:"paused"}` is the migration script's half, off
+ // the raw config.json. What this seed is for is the ORDER, and the
+ // order lives in the stored trees.
+ configs.map((c) => ({ slug: c.slug, config: {} })),
+ settings.autoQueue,
+ )
: stored;
const next = sanitizeChannelPriority(applyPriorityEdit(base, edit));
// A DEFAULT DOCUMENT COMPILES NOTHING, which is the same rule
diff --git a/editor/app/channels/components/ChannelSyncToggle.tsx b/editor/app/channels/components/ChannelSyncToggle.tsx
@@ -1,45 +0,0 @@
-"use client";
-
-import { toggleChannelSyncInclusionAction } from "../actions";
-
-export function ChannelSyncToggle({
- slug,
- excluded,
-}: {
- slug: string;
- excluded: boolean;
-}) {
- return (
- <form
- action={async () => {
- await toggleChannelSyncInclusionAction(slug);
- }}
- >
- <button
- type="submit"
- aria-label={`toggle sync inclusion for ${slug}`}
- aria-pressed={!excluded}
- title={
- excluded
- ? "Excluded from Sync all. Click to include."
- : "Included in Sync all. Click to exclude."
- }
- className={
- "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium border transition-colors " +
- (excluded
- ? "border-border text-muted-foreground hover:bg-muted"
- : "border-success/30 bg-success-soft text-success hover:bg-success/20")
- }
- >
- <span
- aria-hidden="true"
- className={
- "inline-block h-1.5 w-1.5 rounded-full " +
- (excluded ? "bg-muted-foreground" : "bg-success")
- }
- />
- {excluded ? "Skipped" : "Included"}
- </button>
- </form>
- );
-}
diff --git a/editor/app/channels/lib/channelGroupSections.test.ts b/editor/app/channels/lib/channelGroupSections.test.ts
@@ -260,20 +260,14 @@ test("a social channel is eligible for sync only", () => {
assert.deepEqual(s.speakers.eligible, []);
});
-test("sync skips a channel with no url or excluded from sync", () => {
+test("sync skips a channel with no url", () => {
const sections = build(
- siteOf({
- channels: [{ slug: "ok" }, { slug: "nourl" }, { slug: "excluded" }],
- }),
- [
- channel("ok"),
- channel("nourl", { url: undefined }),
- channel("excluded", { excludeFromSync: true }),
- ],
+ siteOf({ channels: [{ slug: "ok" }, { slug: "nourl" }] }),
+ [channel("ok"), channel("nourl", { url: undefined })],
);
assert.deepEqual(sections[0].sync.eligible, ["ok"]);
// Download needs a url too.
- assert.deepEqual(sections[0].download.eligible.sort(), ["excluded", "ok"]);
+ assert.deepEqual(sections[0].download.eligible.sort(), ["ok"]);
});
// The priority document answers the same question the flag does, for the `sync`
diff --git a/editor/app/channels/lib/channelGroupSections.ts b/editor/app/channels/lib/channelGroupSections.ts
@@ -130,12 +130,9 @@ export function stationWorkFor(
if (!config.url) return { eligible: false, work: 0, reason: "no url" };
// THE PAUSED SECTION. The tier document is asked for the `sync` OPERATION
// — `isChannelPaused(model, slug, "sync")` — which is precisely what the
- // retired `excludeFromSync` flag meant, read the other way round.
- //
- // The flag is still read BESIDE it, not instead of it: until S5's migration
- // rewrites the 15 channels that carry it, dropping the legacy read here
- // would silently re-include every one of them in a group's "Sync" sweep.
- // S5 deletes the second branch with the field.
+ // deleted `excludeFromSync` flag meant, read the other way round, and is
+ // now the only thing asked: S5 deleted the flag and migrated the 15
+ // channels that carried it.
// `?? defaultChannelPriority()` for the same reason `isGateHeld` reaches
// its key with optional chaining: this function is handed partial settings
// objects by unit tests and by any caller that has not been through
@@ -144,9 +141,6 @@ export function stationWorkFor(
if (isChannelPaused(priority, slug, "sync")) {
return { eligible: false, work: 0, reason: "paused for sync" };
}
- if (config.excludeFromSync) {
- return { eligible: false, work: 0, reason: "excluded from sync" };
- }
return { eligible: true, work: 0 };
}
diff --git a/editor/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts
@@ -152,9 +152,8 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> {
const queued: string[] = [];
const bySlug = new Map(channels.map((c) => [c.slug, c.config]));
for (const slug of toQueue) {
- // The scheduler's ELIGIBILITY rules are source-agnostic (url +
- // excludeFromSync + sync tier + interval + lastSyncedAt), but the
- // dispatch is not: a
+ // The scheduler's ELIGIBILITY rules are source-agnostic (url + sync
+ // tier + interval + lastSyncedAt), but the dispatch is not: a
// social channel must run a post fetch, not a yt-dlp video sync against
// its profile URL.
const result = isSocialChannel(bySlug.get(slug))