commit 46f3a68db8e7ef1da82078db2dc373765dfa6987
parent 7c0081629cdce9c3a87d048dc78016f45aed9179
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 3 Oct 2026 13:33:01 -0400
editor: Fetch older posts — button, action param, ops route, replay
fetchPostsAction gains older and floor (carried in the job spec, so the
job replays). It refuses full with older, a floor without older or that
is not a date, and a fetcher with no older walk, before a job exists. The
social channel panel shows a Fetch older posts button and an Older posts
line for a fetcher that has fetchOlder. POST /api/ops/fetch-posts
{ slug, full?, older?, floor?, limit?, queueKey? } is the adapter over
the action; pnpm ops fetch-posts posts to it. The archilyzer posts fetch
bin takes --older and --floor.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
15 files changed, 265 insertions(+), 9 deletions(-)
diff --git a/RUNNING_IN_DOCKER.md b/RUNNING_IN_DOCKER.md
@@ -254,6 +254,7 @@ pnpm ops channel-priority --json '{"slugs":["the-quartering"],"operation":"downl
pnpm ops lane --json '{"lane":"download","held":true}'
pnpm ops refresh-report --json '{"all":true}'
pnpm ops keep-videos --json '{"slug":"paramount-tactical","match":"TheQuartering","dryRun":true}'
+pnpm ops fetch-posts --json '{"slug":"example-x","older":true}' --wait
pnpm ops get channel the-quartering
pnpm ops list # every action name
```
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -252,7 +252,7 @@ export const COMMANDS: Command[] = [
script(["duplicates"], "duplicate-shorts.ts",
"[--threshold N] [--all-durations] [--blocking title|duration|both] [--near F] [--tolerance N] … on-demand duplicate detection (after index + stats)", 8192),
script(["posts", "fetch"], "fetch-posts.ts",
- "--slug <channel> [--full] [--limit N] fetch a social channel's posts into its posts corpus"),
+ "--slug <channel> [--full | --older [--floor YYYY-MM-DD]] [--limit N] fetch a social channel's posts into its posts corpus (--older: walk back below the oldest archived post)"),
script(["posts", "check"], "check-post-availability.ts",
"--slug <channel> [--mode stale|unchecked|all] [--limit N] which archived posts were deleted at the source"),
script(["diarize", "backfill"], "diarize-backfill.ts",
diff --git a/common/bin/fetch-posts.ts b/common/bin/fetch-posts.ts
@@ -2,11 +2,16 @@
// Fetch social posts for one channel into its on-disk posts corpus.
//
// pnpm --filter yt-dlp-transcript-common exec tsx bin/fetch-posts.ts \
-// --slug <channel-slug> [--full] [--limit N]
+// --slug <channel-slug> [--full | --older [--floor YYYY-MM-DD]] [--limit N]
//
// --full re-walks the account's whole history instead of stopping at the
// stored watermark. New posts are still deduped against the posts-archive, so
// it repairs a gap rather than creating duplicates.
+//
+// --older walks the account's history backwards from the oldest archived post
+// (X: search windows), below what the timeline reaches; --floor is the date it
+// stops at. Its position is saved apart from the timeline's, so a later run
+// continues it and a normal fetch is unaffected.
import { getPaths } from "../lib/paths";
import { getSettings } from "../lib/settings";
@@ -16,7 +21,9 @@ import { parseFlags } from "./_parseFlags";
const flags = parseFlags(process.argv.slice(2));
const slug = flags.slug;
if (!slug) {
- console.error("Usage: fetch-posts.ts --slug <channel-slug> [--full] [--limit N]");
+ console.error(
+ "Usage: fetch-posts.ts --slug <channel-slug> [--full | --older [--floor YYYY-MM-DD]] [--limit N]",
+ );
process.exit(2);
}
@@ -31,6 +38,8 @@ fetchPosts({
slug,
settings: getSettings(),
full: flags.full === "true",
+ older: flags.older === "true",
+ floor: flags.floor,
limit,
onLog: (line) => console.log(line),
})
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **An X channel can fetch posts older than its timeline reaches.** X's timeline only pages back so far, so a fetch could end, and call the history done, well short of an account's first post. The new **Fetch older posts** button on an X channel's page (or `pnpm ops fetch-posts --json '{"slug":"<channel>","older":true}'`) walks back from the oldest archived post through X search, three months at a time, and saves posts the same way a normal fetch does; posts already archived are skipped. It needs a login, as search does: without one it stops at once and the channel shows **Needs credentials**. A run saves its place as it goes and stops after three hours; the next run continues from there. The walk ends at the account's creation date, after a year of windows with no posts, or at a date you give as `"floor": "YYYY-MM-DD"`, and the page's **Older posts** line then says it is complete; running it again says so and fetches nothing. A normal **Fetch posts** is unaffected and still fetches new posts from the top. Bluesky channels have no such button: their fetch already reads the whole history.
- **`pnpm ops transcribe-bucket` transcribes a channel's downloaded-but-untranscribed videos**, as the channel page's **Transcribe N downloaded** button does, on the transcription queue. `"ids"` runs only some of them; each must be in the bucket, and a stray id is refused by name. `retry-bucket` is not the way to do this: it retries downloads, and counts a video whose audio is on disk as complete.
- **`pnpm ops retry-bucket` can run part of a bucket.** Its body takes `"ids"`, a list of video ids, and runs only those, as ticking them on the bucket's card does. Every id must be in the named bucket: one that is not is refused with a 400 naming it, and nothing runs. A job started with `ids` is not replayable, like a checkbox selection in the UI.
- **An X post fetch keeps what it has read when it is cancelled, times out or fails part-way, and the next fetch picks up where it stopped.** The gallery-dl fetcher used to receive an account's posts all at once when gallery-dl finished, so a long fetch that X's rate limit held past the 30-minute limit — or one you cancelled — ended with nothing saved. gallery-dl now hands over each post as it reads it: the fetch saves posts every 200 posts or every minute along with gallery-dl's own resume point, and the next fetch of the channel continues from that point instead of starting again. A fetch of new posts stops once it reaches 100 already-archived posts in a row instead of reading the whole timeline, and a fetch of an account's history may run for up to 3 hours (new-post fetches keep the 30-minute limit). gallery-dl's rate-limit waits now appear in the job's log as they happen.
diff --git a/editor/app/api/ops/_lib.test.ts b/editor/app/api/ops/_lib.test.ts
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { OpsInputError, optSubset } from "./_lib";
+import { OpsInputError, optPositiveInt, optSubset } from "./_lib";
// Run with:
// pnpm -C editor exec tsx --test "app/**/*.test.ts"
@@ -36,3 +36,19 @@ test("optSubset: an empty or non-string list is refused, never read as 'all'", (
);
}
});
+
+test("optPositiveInt: absent is undefined; a whole number above zero comes back", () => {
+ assert.equal(optPositiveInt({}, "limit"), undefined);
+ assert.equal(optPositiveInt({ limit: 200 }, "limit"), 200);
+});
+
+test("optPositiveInt: zero, negatives, fractions and strings are refused", () => {
+ for (const limit of [0, -5, 2.5, "10", null, Number.NaN]) {
+ assert.throws(
+ () => optPositiveInt({ limit }, "limit"),
+ (e: unknown) =>
+ e instanceof OpsInputError && /"limit" must be a whole number above zero/.test(e.message),
+ JSON.stringify(limit),
+ );
+ }
+});
diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts
@@ -146,6 +146,17 @@ export function optBool(body: OpsBody, key: string): boolean | undefined {
return v;
}
+// A whole number above zero, or undefined when absent. A type check, not a
+// rule: what the number means is the action's business.
+export function optPositiveInt(body: OpsBody, key: string): number | undefined {
+ const v = body[key];
+ if (v === undefined) return undefined;
+ if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
+ throw new OpsInputError(`"${key}" must be a whole number above zero`);
+ }
+ return v;
+}
+
export function reqStringArray(body: OpsBody, key: string): string[] {
const v = body[key];
if (
diff --git a/editor/app/api/ops/fetch-posts/route.ts b/editor/app/api/ops/fetch-posts/route.ts
@@ -0,0 +1,41 @@
+import { fetchPostsAction } from "../../../channels/[slug]/socialActions";
+import {
+ jobResponse,
+ ops,
+ optBool,
+ optPositiveInt,
+ optString,
+ reqSlug,
+} from "../_lib";
+
+export const dynamic = "force-dynamic";
+
+// POST { slug, full?, older?, floor?, limit?, queueKey? } -> { ok: true, jobId }
+//
+// A social channel's "Fetch posts" button, over HTTP — and its "Re-fetch full
+// history" (`full`) and "Fetch older posts" (`older`, with an optional `floor`
+// date, YYYY-MM-DD, that the walk stops at). The job runs on the platform's
+// queue, as the buttons' does.
+//
+// Every refusal is the action's own sentence: a channel that is not a social
+// one, `full` and `older` together, a fetcher with no older walk, a floor
+// without `older` or that is not a date.
+export async function POST(request: Request) {
+ return ops(
+ request,
+ ["slug", "full", "older", "floor", "limit", "queueKey"],
+ async (body) => {
+ const slug = reqSlug(body, "slug");
+ return jobResponse(
+ await fetchPostsAction(
+ slug,
+ optString(body, "queueKey"),
+ optBool(body, "full"),
+ optPositiveInt(body, "limit"),
+ optBool(body, "older"),
+ optString(body, "floor"),
+ ),
+ );
+ },
+ );
+}
diff --git a/editor/app/channels/[slug]/components/SocialChannelPanel.tsx b/editor/app/channels/[slug]/components/SocialChannelPanel.tsx
@@ -30,6 +30,10 @@ export type SocialChannelState = {
deletedCount: number;
checkedCount: number;
canCheckAvailability: boolean;
+ // The older-posts backfill (a fetcher's `fetchOlder`): whether this fetcher
+ // has one, and where it stands, as one line.
+ canFetchOlder: boolean;
+ olderStatus: string;
};
export function SocialChannelPanel({
@@ -63,9 +67,15 @@ export function SocialChannelPanel({
if (res.ok) void res.stream.cancel();
});
- const run = (full: boolean) =>
+ const run = (full: boolean, older = false) =>
startTransition(async () => {
- const res = await fetchPostsAction(slug, undefined, full);
+ const res = await fetchPostsAction(
+ slug,
+ undefined,
+ full,
+ undefined,
+ older || undefined,
+ );
// The job streams to the jobs page; nothing to consume here, but the
// stream must be released or its buffered chunks leak.
if (res.ok) void res.stream.cancel();
@@ -111,6 +121,9 @@ export function SocialChannelPanel({
: String(state.lastFetchedCount)
}
/>
+ {state.canFetchOlder && (
+ <Stat label="Older posts" value={state.olderStatus} />
+ )}
</dl>
{state.availableFetchers.length > 1 && (
@@ -181,6 +194,18 @@ export function SocialChannelPanel({
>
Re-fetch full history
</button>
+ {state.canFetchOlder && (
+ <button
+ type="button"
+ onClick={() => run(false, true)}
+ disabled={pending}
+ aria-label="fetch older posts"
+ title="Walk back from the oldest archived post through search, a few months at a time, for posts older than the timeline reaches. Needs a login. Progress is saved as it goes; a later run continues it, and it says so once the history is done."
+ className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
+ >
+ Fetch older posts
+ </button>
+ )}
</div>
{/* The two failure surfaces a social channel actually has. */}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -11,6 +11,7 @@ import {
} from "yt-dlp-transcript-common/lib/posts-server";
import { isPostGone } from "yt-dlp-transcript-common/lib/posts";
import { resolveSocialFetcher } from "yt-dlp-transcript-common/social/fetchers";
+import { describeOlderBackfill } from "yt-dlp-transcript-common/social/olderBackfill";
import "yt-dlp-transcript-common/social/blueskyFetcher";
import "yt-dlp-transcript-common/social/xGalleryDlFetcher";
import "yt-dlp-transcript-common/social/xPlaywrightFetcher";
@@ -186,6 +187,8 @@ export default async function ChannelDetailPage({
checkedCount: availabilityRecords.length,
canCheckAvailability:
typeof fetcher?.checkAvailability === "function",
+ canFetchOlder: typeof fetcher?.fetchOlder === "function",
+ olderStatus: describeOlderBackfill(fetchState?.older),
handle: config.socialHandle ?? slug,
accountUrl: config.url,
}}
diff --git a/editor/app/channels/[slug]/socialActions.ts b/editor/app/channels/[slug]/socialActions.ts
@@ -19,7 +19,12 @@ import {
readChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
-import { fetchPosts } from "yt-dlp-transcript-common/controller/fetchPosts";
+import {
+ fetchPosts,
+ FULL_AND_OLDER_REFUSAL,
+ olderPostsProblem,
+} from "yt-dlp-transcript-common/controller/fetchPosts";
+import { isUtcDay } from "yt-dlp-transcript-common/social/olderBackfill";
import {
checkPostAvailability,
type CheckPostAvailabilityMode,
@@ -27,6 +32,7 @@ import {
import {
getSocialFetcher,
listSocialFetchers,
+ resolveSocialFetcher,
} from "yt-dlp-transcript-common/social/fetchers";
import {
runManagedFunction,
@@ -121,18 +127,39 @@ export async function checkPostAvailabilityAction(
});
}
+// `older` walks the account's history backwards from the oldest archived post
+// (the fetcher's `fetchOlder` — X: search windows) instead of fetching new
+// posts; `floor` (YYYY-MM-DD) is the date that walk stops at. Both are refused
+// HERE, before a job exists, when they cannot run: with `full`, on a fetcher
+// that has no older walk, or with a floor that is not a date.
export async function fetchPostsAction(
slug: string,
queueKey?: string,
full?: boolean,
limit?: number,
+ older?: boolean,
+ floor?: string,
): Promise<StreamActionResult> {
+ if (full && older) return { ok: false, error: FULL_AND_OLDER_REFUSAL };
+ if (floor !== undefined && !older) {
+ return { ok: false, error: "A floor date applies only to an older-posts fetch." };
+ }
+ if (floor !== undefined && !isUtcDay(floor)) {
+ return { ok: false, error: `"${floor}" is not a date (YYYY-MM-DD).` };
+ }
const paths = getPaths();
const config = await readChannelConfig(paths, slug);
if (!config) return { ok: false, error: `No such channel: ${slug}` };
if (!isSocialChannel(config)) {
return { ok: false, error: `${slug} is not a social channel.` };
}
+ if (older) {
+ await registerBuiltinSocialFetchers();
+ const problem = olderPostsProblem(
+ resolveSocialFetcher(config.postFetcher, config.url),
+ );
+ if (problem) return { ok: false, error: problem };
+ }
// queueKeyForUrl() already routes x.com / bsky.app to platform:x.com /
// platform:bsky.app, so per-platform serialization and the existing 429
@@ -144,13 +171,19 @@ export async function fetchPostsAction(
queueKey: key,
paths,
channelSlug: slug,
- spec: { kind: "fetch-posts", slug, params: { queueKey, full, limit } },
+ spec: {
+ kind: "fetch-posts",
+ slug,
+ params: { queueKey, full, limit, older, floor },
+ },
fn: async (onLog, signal) => {
const result = await fetchPosts({
paths,
slug,
settings: getSettings(),
full,
+ older,
+ floor,
limit,
onLog,
signal,
diff --git a/editor/app/jobs/jobReplayRegistry.ts b/editor/app/jobs/jobReplayRegistry.ts
@@ -246,7 +246,14 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = {
},
"fetch-posts": (spec) => {
const { p, queueKey } = params(spec);
- return fetchPostsAction(spec.slug, queueKey, bool(p.full), num(p.limit));
+ return fetchPostsAction(
+ spec.slug,
+ queueKey,
+ bool(p.full),
+ num(p.limit),
+ bool(p.older),
+ str(p.floor),
+ );
},
"download-missing-subs": (spec) => {
const { p, queueKey } = params(spec);
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -192,6 +192,7 @@ test("a traversing slug is refused at the door, on every route that takes one",
["channel-priority", { slugs: ["../../escape"], tier: "paused" }],
["relocate", { slugs: ["../../escape"], root: "/tmp/ops-api-never" }],
["relocate-back", { slugs: ["../../escape"] }],
+ ["fetch-posts", { slug: "../../escape", older: true }],
];
for (const [action, data] of cases) {
const { status, body } = await ops(request, action, data);
@@ -267,6 +268,73 @@ test("retry-bucket and transcribe-bucket ids must be in the bucket: a stray is r
expect(await listJobIds()).toEqual(before);
});
+test("fetch-posts refuses a channel that is not social, full with older, and an older fetch its fetcher cannot do — before any job", async ({
+ request,
+}) => {
+ await resetData("title-filter-channel");
+ await settings();
+ // A social channel whose fetcher has no older-posts walk. Nothing below
+ // reaches a fetch: every case is refused before a job exists.
+ await writeChannelConfig("example-bsky", {
+ handling: "transcribe",
+ sourceKind: "social",
+ platform: "bluesky",
+ postFetcher: "bluesky-atproto",
+ socialHandle: "example.bsky.social",
+ name: "Example (Bluesky)",
+ url: "https://bsky.app/profile/example.bsky.social",
+ });
+ const before = await listJobIds();
+
+ // The sentence the action gives for a video channel.
+ const video = await ops(request, "fetch-posts", { slug: "test-filter" });
+ expect(video.status).toBe(400);
+ expect(video.body.error).toBe("test-filter is not a social channel.");
+ const videoOlder = await ops(request, "fetch-posts", { slug: "test-filter", older: true });
+ expect(videoOlder.status).toBe(400);
+ expect(videoOlder.body.error).toBe("test-filter is not a social channel.");
+
+ // Two walks at once, on any channel.
+ const both = await ops(request, "fetch-posts", {
+ slug: "example-bsky",
+ full: true,
+ older: true,
+ });
+ expect(both.status).toBe(400);
+ expect(both.body.error).toMatch(/different walks — run one at a time/);
+ const bothVideo = await ops(request, "fetch-posts", {
+ slug: "test-filter",
+ full: true,
+ older: true,
+ });
+ expect(bothVideo.status).toBe(400);
+ expect(bothVideo.body.error).toMatch(/different walks/);
+
+ // A fetcher with no older walk, a floor that is not a date, a floor
+ // without older, and a limit that is not a count.
+ const bsky = await ops(request, "fetch-posts", { slug: "example-bsky", older: true });
+ expect(bsky.status).toBe(400);
+ expect(bsky.body.error).toMatch(/cannot fetch older posts/);
+ const floor = await ops(request, "fetch-posts", {
+ slug: "example-bsky",
+ older: true,
+ floor: "2020-13-01",
+ });
+ expect(floor.status).toBe(400);
+ expect(floor.body.error).toMatch(/is not a date/);
+ const floorAlone = await ops(request, "fetch-posts", {
+ slug: "example-bsky",
+ floor: "2020-01-01",
+ });
+ expect(floorAlone.status).toBe(400);
+ expect(floorAlone.body.error).toMatch(/only to an older-posts fetch/);
+ const limit = await ops(request, "fetch-posts", { slug: "example-bsky", limit: 0 });
+ expect(limit.status).toBe(400);
+ expect(limit.body.error).toMatch(/"limit" must be a whole number above zero/);
+
+ expect(await listJobIds()).toEqual(before);
+});
+
test("channel-config round-trips a download filter and refuses a bad regex", async ({
page,
request,
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -8534,6 +8534,26 @@ phase deletes from the destination.
`expiry` is seconds (read as ms when too large for seconds); `lastAccessed` is PRTime (µs). The
Chromium family is NOT read here (its values are encrypted with a keyring key): the readers say so
and gallery-dl reads it.
+- **gallery-dl 1.32.9's `twitter:search`** (`extractor/twitter.py`, `TwitterSearchExtractor`)
+ matches `…/search?…q=<query>` (q may follow other params; `&f=live` after it is fine) and decodes q
+ as `unquote(q.replace("+", " "))` — so the query is percent-encoded whole and an encoded `+`
+ survives. It reads the query's single `from:<user>` to fill each record's `user` (with the
+ account's `date`, its creation time). Product defaults to "Latest"; `search-pagination` defaults
+ to `max_id`: after each page it REWRITES the query's `max_id:` (adding one if absent) to just below
+ the oldest tweet read and sets the API cursor to None — so **a search run logs no
+ `Cursor:` line and cannot be resumed with `extractor.twitter.cursor`**. It stops after
+ `search-stop` (default 3) empty pages. Quoted tweets are skipped unless `quoted` is set.
+- **The older-posts backfill** (`fetchPosts({ older })` → `SocialFetcher.fetchOlder`; for X
+ `fetchOlderViaSearch` in `xGalleryDlFetcher.ts`, window arithmetic in `social/olderBackfill.ts`).
+ Windows of 3 months in UTC days, `from:<h> since:<d> until:<d> include:nativeretweets`, the first
+ ending the day after the oldest archived post; one gallery-dl run per window, 15 s apart, one
+ 3-hour budget across them. Its resume point is its own: the oldest post id read in the window,
+ sent back as `max_id:<id>` (inclusive; gallery-dl's rewrite replaces it). State is
+ `posts-state.json` `older` (`OlderBackfillState`), apart from the timeline `cursor`; a normal fetch
+ copies `older` through untouched and an older run copies every other field through. Ends at the
+ later of the `floor` and the account's creation day, or after 4 empty windows in a row; then
+ `older.complete` and a re-run fetches nothing. No login = refused before spawning
+ (`needsCookies`). It does not stamp `lastSyncedAt`.
- **`node:sqlite`** (Node 22.23 here; unflagged since 22.13, an ExperimentalWarning once per process)
is loaded at run time through an assembled specifier with `webpackIgnore`
(`common/social/nodeSqlite.ts`), like `playwrightRuntime.ts`: the editor's Turbopack build has
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -100,6 +100,9 @@ const ACTIONS = [
// The TRANSCRIBE half of a channel's "downloaded, not transcribed" bucket
// (retry-bucket is the download retry and skips audio already on disk).
"transcribe-bucket",
+ // A social channel's post fetch: new posts, the full re-walk ("full"), or
+ // the walk back below the oldest archived post ("older").
+ "fetch-posts",
"build-index",
"build-deploy",
"build-site",
@@ -317,6 +320,12 @@ export function usage() {
' bucket on the transcription queue, as the channel page\'s Transcribe button',
' does: {"slug"}. "ids": [...] narrows it the same way as on retry-bucket.',
"",
+ 'fetch-posts fetches a social channel\'s new posts: {"slug"}. "full": true',
+ ' re-walks the whole timeline; "older": true walks back from the oldest',
+ " archived post through search (X; needs a login), saving its place for",
+ ' the next run, down to "floor": "YYYY-MM-DD" when given. "limit": N caps',
+ ' the posts one run reads. "full" and "older" together are refused.',
+ "",
'"preview": "<branch>" on deploy-site or build-deploy makes it a Cloudflare',
" Pages PREVIEW instead of production: the same bundle goes to a branch",
" alias, https://<branch>.<project>.pages.dev, and the live site is left",
diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs
@@ -378,3 +378,15 @@ test("build-homepage and deploy-homepage are POSTs to their own routes, named in
assert.match(usage(), /build-homepage builds the homepage package into homepage\/out/);
assert.match(usage(), /project archilyzer \(https:\/\/archilyzer\.pages\.dev\)/);
});
+
+// A social channel's post fetch: a POST to its route, the body passed through
+// untouched — the route and the action judge it ("full" with "older" included).
+test("fetch-posts is a POST to its route, named in the usage", () => {
+ const p = parseArgs(["fetch-posts", "--json", '{"slug":"example-x","older":true}', "--wait"]);
+ assert.equal(p.method, "POST");
+ assert.equal(p.path, "/api/ops/fetch-posts");
+ assert.deepEqual(p.body, { slug: "example-x", older: true });
+ assert.equal(p.wait, true);
+ assert.match(usage(), /Actions:.*transcribe-bucket, fetch-posts/);
+ assert.match(usage(), /"older": true walks back from the oldest/);
+});