feat: the membership sweep, and the state that makes its failures readable (387 C3)
CI / lint (push) Failing after 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 1m28s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m35s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m55s

A daily sweep that walks each platform's roster into `platform_membership`.
Daily because memberships change on a BILLING cycle, not a download cadence.

**`membership_sync` is the part that earns its keep.** Without it three very
different situations are one indistinguishable state — the account subscribes
to nothing, the sweep never ran, the sweep failed — and all three leave zero
rows in `platform_membership`. "You are tracking 12 sources you no longer
subscribe to" is correct in the first case and an invitation to cancel things
the operator is actively paying for in the other two. So C4 gates its
CONCLUSIONS on `last_success_at`, not merely its display, and `roster_is_fresh`
is computed server-side so no caller can forget to.

Two timestamps rather than one: `last_attempt_at` moves every run,
`last_success_at` only on a clean walk. The gap between them is the signal —
a sweep hammering a broken credential every day must not look healthy because
it ran recently, and there is a test for exactly that.

Rejected shortcuts, both tempting: `MAX(platform_membership.last_seen_at)`
cannot tell "synced fine, found nothing" from "never synced"; `task_run` is
worse, since its retention prunes ok rows after 24h and a sweep that last
succeeded three days ago would leave no trace at all.

**The fetch completes before anything is written.** That ordering is the safety
property: a walk that dies mid-pagination writes nothing, so a failure can
never leave a roster half this week's and half last week's. `touch_membership`
never deletes, so a failure cannot empty the roster either — but "intact"
should mean intact, not merely non-empty.

Rule 89's four, each where it actually lives: recovery is "run it again"
(upsert, no deletes); retention is C1's age-out-never-delete, because
disappearing IS the signal; the wall-clock deadline is per-platform and
distinct from the per-REQUEST timeout the client already has (rule 156 — a
paginated roster answering every page slowly-but-within-timeout would never
trip that one and would sit on a worker indefinitely); duration comes from the
existing TaskRun signal plumbing.

**A bug caught in review, not production:** the broad `except Exception` would
have swallowed Celery's SoftTimeLimitExceeded — which is an ORDINARY Exception
subclass, not a BaseException — letting the sweep run past the soft limit into
the hard one, where it is SIGKILLed mid-transaction. A sweep that cannot be
stopped is worse than one that fails. Now re-raised explicitly, with a test
that also asserts SoftTimeLimitExceeded is still an Exception, so the re-raise
cannot quietly become dead code.

Rule 164 is why this ships with UI rather than backend-only: a roster that
never synced must be VISIBLE as such. The card says "never synced" in words and
states no count at all — rendering it as 0 is the precise conflation the whole
step exists to prevent — while a real zero behind a real sync is reported as
zero, because that one IS an answer. Pinned in both directions.

Three independent gates decide whether a platform is swept — registered here,
client exposes `iter_memberships`, credential exists — each silent, so adding
SubscribeStar (D1) is one line and nothing else. A missing credential is not an
error: recording a failure would light up the UI for a feature never enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-10 23:40:48 -04:00
co-authored by Claude Opus 5
parent afcde8e457
commit 751e7ddb9f
12 changed files with 946 additions and 4 deletions
@@ -16,6 +16,7 @@
<TranslationCard />
<DiscordGroupingCard />
<PostAssociationsCard />
<MembershipRosterCard />
</div>
</section>
@@ -83,6 +84,7 @@ import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue'
import DiscordGroupingCard from './DiscordGroupingCard.vue'
import MembershipRosterCard from './MembershipRosterCard.vue'
import PostAssociationsCard from './PostAssociationsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
@@ -0,0 +1,103 @@
<template>
<MaintenanceTile
icon="mdi-account-heart-outline"
title="Subscription roster"
blurb="What your accounts actually subscribe to, as the platform reports it."
>
<div class="text-caption fc-muted mb-3">
FC reads the list of memberships from each connected platform so it can
tell which of your sources you still pay for. It only ever reads nothing
is added, removed or cancelled from here.
</div>
<div v-if="store.loading && !store.platforms.length" class="text-caption fc-muted">
Checking
</div>
<div
v-else-if="!store.platforms.length"
class="text-caption fc-muted"
>
No platform has been synced yet. Connect a credential in Subscriptions,
then use Sync now.
</div>
<div v-for="p in store.platforms" :key="p.platform" class="fc-roster">
<div class="fc-roster__head">
<strong>{{ p.platform }}</strong>
<!-- The three states this card exists to keep apart. "Never synced" is
words, never a number rendering it as 0 is the exact conflation
that would have C4 telling the operator to cancel things they pay
for. -->
<span v-if="!p.last_success_at" class="fc-roster__never">
never synced
</span>
<span v-else-if="!p.fresh" class="fc-roster__stale">
last synced {{ ago(p.last_success_at) }} too old to rely on
</span>
<span v-else class="fc-roster__ok">
synced {{ ago(p.last_success_at) }}
</span>
</div>
<div class="text-caption fc-muted">
<!-- Only stated when there is a successful sync behind it. A count
with no sync behind it is a guess wearing a number's clothes. -->
<template v-if="p.last_success_at">
{{ p.last_count }} membership{{ p.last_count === 1 ? '' : 's' }} found
</template>
<template v-else-if="p.last_attempt_at">
tried {{ ago(p.last_attempt_at) }}, no successful sync yet
</template>
</div>
<div v-if="p.last_error_type" class="fc-roster__err text-caption">
{{ p.last_error_type }}: {{ p.last_error_message }}
</div>
</div>
<div class="d-flex align-center mt-4" style="gap:12px">
<v-spacer />
<v-btn size="small" variant="text" :loading="store.loading" @click="refresh">
Sync now
</v-btn>
</div>
</MaintenanceTile>
</template>
<script setup>
import { onMounted } from 'vue'
import { useMembershipSyncStore } from '../../stores/membershipSync.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useMembershipSyncStore()
function ago (iso) {
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
async function refresh () {
await store.syncNow()
await store.load()
}
onMounted(() => { store.load() })
</script>
<style scoped>
.fc-roster {
padding: 8px 0;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.fc-roster__head { display: flex; align-items: baseline; gap: 8px; }
/* Never-synced and stale are the states that must not read as healthy, so
they take colour and the healthy one does not. */
.fc-roster__never { color: rgb(var(--v-theme-on-surface-variant)); font-style: italic; }
.fc-roster__stale { color: rgb(var(--v-theme-warning, var(--v-theme-accent))); }
.fc-roster__ok { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-roster__err { color: rgb(var(--v-theme-error)); }
</style>
+38
View File
@@ -0,0 +1,38 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { toast } from '../utils/toast.js'
// Backs the subscription-roster card (#387 C3). The whole reason this surface
// exists: a roster that never synced, or failed to, must be DISTINGUISHABLE
// from an account that subscribes to nothing. All three states look like zero
// rows, and only one of them means "you are tracking things you do not pay
// for" — so the UI must never render "never synced" as a number.
export const useMembershipSyncStore = defineStore('membershipSync', () => {
const api = useApi()
const platforms = ref([])
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function load () {
await run(async () => {
const body = await api.get('/api/sources/membership-sync')
platforms.value = body.platforms || []
})
}
async function syncNow () {
try {
await api.post('/api/sources/membership-sync', {})
toast({
text: 'Roster sync queued — runs in the background; reload to see the result.',
type: 'info'
})
} catch (e) {
toast({ text: `Sync failed to queue: ${e.message}`, type: 'error' })
}
}
return { platforms, loading, error, load, syncNow }
})
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import MembershipRosterCard from '../../src/components/settings/MembershipRosterCard.vue'
import { useMembershipSyncStore } from '../../src/stores/membershipSync.js'
import { mountWithStore } from '../support/mountComponent.js'
// #387 C3. The one thing this card must never do is render "never synced" as a
// number. Three situations produce zero memberships — the account subscribes to
// nothing, the sweep never ran, the sweep failed — and only the first means
// "you are tracking sources you do not pay for". Conflating them is how C4 ends
// up telling the operator to cancel things they are actively paying for.
const mountWith = (platforms) => mountWithStore(MembershipRosterCard, () => {
useMembershipSyncStore().platforms = platforms
})
describe('MembershipRosterCard', () => {
beforeEach(() => {
// The card loads on mount; stub it so assertions are about seeded state.
globalThis.fetch = vi.fn(async () => { throw new Error('offline') })
})
afterEach(() => vi.restoreAllMocks())
it('says "never synced" in words, and states no count', () => {
const w = mountWith([
{ platform: 'patreon', last_success_at: null, last_attempt_at: null,
last_count: null, fresh: false },
])
expect(w.text()).toContain('never synced')
// The count line must be absent entirely — not "0 memberships found".
expect(w.text()).not.toContain('memberships found')
})
it('does not claim a count when a sync has been tried but never succeeded', () => {
const w = mountWith([
{ platform: 'patreon', last_success_at: null,
last_attempt_at: new Date(Date.now() - 3600e3).toISOString(),
last_count: null, fresh: false, last_error_type: 'PatreonAuthError',
last_error_message: 'cookies expired' },
])
expect(w.text()).toContain('no successful sync yet')
expect(w.text()).not.toContain('memberships found')
// The error is surfaced, because "rotate your credential" is actionable.
expect(w.text()).toContain('PatreonAuthError')
})
it('marks a stale roster as too old to rely on', () => {
const w = mountWith([
{ platform: 'patreon',
last_success_at: new Date(Date.now() - 9 * 86400e3).toISOString(),
last_count: 12, fresh: false },
])
expect(w.text()).toContain('too old to rely on')
})
it('states the count only when a successful sync stands behind it', () => {
const w = mountWith([
{ platform: 'patreon',
last_success_at: new Date(Date.now() - 3600e3).toISOString(),
last_count: 12, fresh: true },
])
expect(w.text()).toContain('12 memberships found')
expect(w.text()).not.toContain('never synced')
expect(w.text()).not.toContain('too old')
})
it('a real zero is reported as zero, because that one IS an answer', () => {
const w = mountWith([
{ platform: 'patreon',
last_success_at: new Date(Date.now() - 3600e3).toISOString(),
last_count: 0, fresh: true },
])
expect(w.text()).toContain('0 memberships found')
expect(w.text()).not.toContain('never synced')
})
})