fix(backup): don't let a stale export pause a reconnected backup

- An export now pauses a revoked integration only if its row is
  unchanged since the export read it, using the trigger-maintained
  updatedAt column as the row version. If the user reconnected in the
  meantime, the stale failure no longer disables the fresh credentials
  or asks the user to reconnect again. updateExportStatus now reports
  whether a row was written.
- The backup status store ignores a response overtaken by a newer
  refresh, or by the status being flagged, cleared or set directly, so
  a slow response can't overwrite newer state.
- Unit tests cover the whole integration repository, the guarded pause
  and stale status responses. Coverage thresholds are raised to the
  new baseline.
This commit is contained in:
Josh Creek
2026-09-14 19:03:29 +01:00
parent fb95b43b30
commit 7786d46078
8 changed files with 398 additions and 26 deletions
@@ -15,6 +15,8 @@ export interface PokedexExportIntegration {
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
/** Set by a database trigger on every write, so it doubles as the row's version. */
updatedAt: string | null;
}
export interface PokedexExportIntegrationDB {
@@ -32,4 +34,5 @@ export interface PokedexExportIntegrationDB {
metadata: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
updatedAt: string | null;
}
@@ -30,7 +30,8 @@ class PokedexExportIntegrationRepository {
accessTokenExpiresAt: db.accessTokenExpiresAt,
metadata: db.metadata,
lastExportedAt: db.lastExportedAt,
lastError: db.lastError
lastError: db.lastError,
updatedAt: db.updatedAt ?? null
};
}
@@ -136,6 +137,10 @@ class PokedexExportIntegrationRepository {
}
}
/**
* Returns whether a row was updated. With `ifUpdatedAt`, the write only applies if the row is
* unchanged since it was read, so a stale export can't overwrite credentials a reconnect saved.
*/
async updateExportStatus(
id: string,
patch: {
@@ -145,20 +150,25 @@ class PokedexExportIntegrationRepository {
metadata?: Record<string, unknown> | null;
folderId?: string | null;
path?: string | null;
}
): Promise<void> {
const query = this.supabase
},
ifUpdatedAt?: string
): Promise<boolean> {
let query = this.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('id', id)
.eq('userId', this.userId);
const { error } = this.pokedexId
? await query.eq('pokedexId', this.pokedexId)
: await query.is('pokedexId', null);
if (ifUpdatedAt) query = query.eq('updatedAt', ifUpdatedAt);
const scoped = this.pokedexId
? query.eq('pokedexId', this.pokedexId)
: query.is('pokedexId', null);
const { data, error } = await scoped.select('id');
if (error) {
console.error('Failed to update export integration status:', error);
return false;
}
return (data?.length ?? 0) > 0;
}
}
+13 -6
View File
@@ -435,18 +435,25 @@ export async function exportPokedexIfConfigured(
successes++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A revoked grant never succeeds on retry, so pause this integration until the user reconnects.
const reconnectRequired = error instanceof ReconnectRequiredError;
let reconnectRequired = false;
if (error instanceof ReconnectRequiredError) {
// A revoked grant never succeeds on retry, so pause this integration until the user
// reconnects - but only if its row is unchanged since this export read it. A reconnect
// in the meantime saved new credentials, which this stale failure must not disable.
reconnectRequired = await scopedRepo.updateExportStatus(
integration._id,
{ lastError: message, enabled: false },
integration.updatedAt ?? undefined
);
} else {
await scopedRepo.updateExportStatus(integration._id, { lastError: message });
}
failures.push({
integrationId: integration._id,
provider: integration.provider,
error: message,
reconnectRequired
});
await scopedRepo.updateExportStatus(integration._id, {
lastError: message,
...(reconnectRequired ? { enabled: false } : {})
});
console.error('Failed to export pokedex:', integration.provider, message);
}
}
+13 -1
View File
@@ -14,25 +14,37 @@ export const backupsNeedingReconnect = writable<ExportProvider[]>([]);
type IntegrationSummary = { provider: ExportProvider; enabled: boolean };
// A response is only applied if no newer refresh has started and nothing has changed the status
// since it was requested, so a slow response can't overwrite newer or cleared state.
let refreshSequence = 0;
let mutationGeneration = 0;
export function setBackupStatus(integrations: IntegrationSummary[]): void {
mutationGeneration++;
backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider));
}
export async function refreshBackupStatus(): Promise<void> {
if (typeof window === 'undefined' || !navigator.onLine) return;
const sequence = ++refreshSequence;
const generation = mutationGeneration;
try {
const response = await fetch('/api/export-integrations', { credentials: 'include' });
if (!response.ok) return;
setBackupStatus((await response.json()) as IntegrationSummary[]);
const integrations = (await response.json()) as IntegrationSummary[];
if (sequence !== refreshSequence || generation !== mutationGeneration) return;
setBackupStatus(integrations);
} catch (error) {
console.error('Unable to check backup status', error);
}
}
export function markReconnectNeeded(providers: ExportProvider[]): void {
mutationGeneration++;
backupsNeedingReconnect.update((current) => [...new Set([...current, ...providers])]);
}
export function clearBackupStatus(): void {
mutationGeneration++;
backupsNeedingReconnect.set([]);
}