mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user