mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: keep a subscription's events when its feed arrives cut off or drops most of them at once
This commit is contained in:
@@ -137,7 +137,9 @@
|
|||||||
"account_changed": "The account changed while the subscription was syncing. It syncs again in its own account.",
|
"account_changed": "The account changed while the subscription was syncing. It syncs again in its own account.",
|
||||||
"add_feed": "Add a feed",
|
"add_feed": "Add a feed",
|
||||||
"feed_too_large": "Feed is too large",
|
"feed_too_large": "Feed is too large",
|
||||||
|
"feed_truncated": "The feed was cut off before its end. Nothing was changed.",
|
||||||
"fetch_failed": "Could not fetch feed (HTTP {status})",
|
"fetch_failed": "Could not fetch feed (HTTP {status})",
|
||||||
|
"mass_removal": "The feed left out {count} of the {total} events at once. They were kept in case the feed is broken.",
|
||||||
"name_and_url_required": "Enter a name and a feed URL.",
|
"name_and_url_required": "Enter a name and a feed URL.",
|
||||||
"not_a_feed": "That URL did not return an iCalendar feed",
|
"not_a_feed": "That URL did not return an iCalendar feed",
|
||||||
"not_synced": "Not synced yet",
|
"not_synced": "Not synced yet",
|
||||||
|
|||||||
@@ -23,9 +23,17 @@ const calendarStore = vi.hoisted(() => ({
|
|||||||
fetchCalendars: vi.fn(async () => undefined),
|
fetchCalendars: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
const { CalendarAccountChangedError } = vi.hoisted(() => ({
|
||||||
|
CalendarAccountChangedError: class CalendarAccountChangedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('The account changed while the calendar was loading or saving.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock('../calendar-store', () => ({
|
vi.mock('../calendar-store', () => ({
|
||||||
useCalendarStore: { getState: () => calendarStore },
|
useCalendarStore: { getState: () => calendarStore },
|
||||||
calendarStoreGeneration: () => calendarStore.generation,
|
calendarStoreGeneration: () => calendarStore.generation,
|
||||||
|
CalendarAccountChangedError,
|
||||||
}));
|
}));
|
||||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -447,4 +455,86 @@ describe('refreshing a subscription', () => {
|
|||||||
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
|
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
|
||||||
expect(calendarStore.importEvents).not.toHaveBeenCalled();
|
expect(calendarStore.importEvents).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts the calendar store stopping for an account switch as no failure of the subscription', async () => {
|
||||||
|
calendarStore.importEvents.mockRejectedValueOnce(new CalendarAccountChangedError());
|
||||||
|
|
||||||
|
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
|
||||||
|
|
||||||
|
expect(calendarStore.importEvents).toHaveBeenCalledTimes(1);
|
||||||
|
expect(only()).toMatchObject({ lastSyncAt: 5, lastError: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A body cut off mid-VEVENT parses up to the cut: every mirror event after
|
||||||
|
// it looked gone upstream and was deleted.
|
||||||
|
it('changes nothing when the feed was cut off before its end', async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||||
|
ok: true, status: 200, headers: { get: () => null },
|
||||||
|
text: async () => 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:kept\r\nDTSTART:20270102T100000Z\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:sa',
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
|
||||||
|
|
||||||
|
expect(calendarApi.parseCalendarBlob).not.toHaveBeenCalled();
|
||||||
|
expect(calendarApi.deleteEvents).not.toHaveBeenCalled();
|
||||||
|
expect(calendarStore.importEvents).not.toHaveBeenCalled();
|
||||||
|
expect(only().lastError).toMatch(/cut off/);
|
||||||
|
expect(only().lastSyncAt).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a feed that leaves out much of a large mirror at once', () => {
|
||||||
|
const mirror = Array.from({ length: 10 }, (_, i) => ({ id: `m${i}`, uid: `u${i}`, calendarIds: { b: true } }));
|
||||||
|
const feedOf = (count: number) => mirror.slice(0, count).map((e) => ({ uid: e.uid, title: e.uid, start: '2027-01-02T10:00:00' }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(calendarApi.queryEvents).mockResolvedValue(mirror.map((e) => e.id));
|
||||||
|
vi.mocked(calendarApi.getEventIdentities).mockResolvedValue(mirror as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the events and says so when more than half of them left the feed', async () => {
|
||||||
|
vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue(feedOf(4) as never);
|
||||||
|
|
||||||
|
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
|
||||||
|
|
||||||
|
expect(calendarApi.deleteEvents).not.toHaveBeenCalled();
|
||||||
|
expect(calendarApi.updateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(only().lastError).toMatch(/6 of the 10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops them when half of them left', async () => {
|
||||||
|
vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue(feedOf(5) as never);
|
||||||
|
|
||||||
|
await useCalendarSubscriptionsStore.getState().syncSubscription('feed');
|
||||||
|
|
||||||
|
expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['m5', 'm6', 'm7', 'm8', 'm9'], undefined, 'acc-1');
|
||||||
|
expect(only().lastError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('adding a subscription', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
client.accountId = 'acc-1';
|
||||||
|
calendarStore.generation = 0;
|
||||||
|
useCalendarSubscriptionsStore.setState({ syncing: {}, subscriptions: [] });
|
||||||
|
globalThis.fetch = vi.fn(async () => ({
|
||||||
|
ok: true, status: 200, headers: { get: () => null }, text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n',
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
vi.mocked(calendarApi.createCalendar).mockResolvedValue({ id: 'n1', name: 'Holidays' } as never);
|
||||||
|
vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue([{ uid: 'e1', title: 'New year', start: '2027-01-01T00:00:00' }] as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The mirror is in the account the add started in: removing it then would
|
||||||
|
// reach the next account's calendar of the same id.
|
||||||
|
it('keeps the subscription and its mirror when the calendar store stopped for an account switch', async () => {
|
||||||
|
calendarStore.importEvents.mockRejectedValueOnce(new CalendarAccountChangedError());
|
||||||
|
|
||||||
|
await expect(useCalendarSubscriptionsStore.getState().addSubscription({
|
||||||
|
name: 'Holidays', url: 'https://feeds.example/holidays.ics',
|
||||||
|
})).rejects.toBeInstanceOf(CalendarAccountChangedError);
|
||||||
|
|
||||||
|
expect(calendarApi.deleteCalendar).not.toHaveBeenCalled();
|
||||||
|
expect(useCalendarSubscriptionsStore.getState().subscriptions).toMatchObject([{ calendarId: 'n1', accountId: 'acc-1', lastError: null }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { generateAccountId } from '../lib/account-utils';
|
|||||||
import { feedChanged, feedFields } from '../lib/calendar-feed-diff';
|
import { feedChanged, feedFields } from '../lib/calendar-feed-diff';
|
||||||
import { readServerPrimaryAccountId } from '../lib/server-fingerprint';
|
import { readServerPrimaryAccountId } from '../lib/server-fingerprint';
|
||||||
import { useAccountStore } from './account-store';
|
import { useAccountStore } from './account-store';
|
||||||
import { calendarStoreGeneration, useCalendarStore } from './calendar-store';
|
import { CalendarAccountChangedError, calendarStoreGeneration, useCalendarStore } from './calendar-store';
|
||||||
import { t } from './locale-store';
|
import { t } from './locale-store';
|
||||||
|
|
||||||
export interface CalendarSubscription {
|
export interface CalendarSubscription {
|
||||||
@@ -53,6 +53,9 @@ export const DEFAULT_REFRESH_INTERVAL_MINUTES = 60;
|
|||||||
// 10 MB is far beyond any real feed; a bigger response is not a calendar.
|
// 10 MB is far beyond any real feed; a bigger response is not a calendar.
|
||||||
const MAX_FEED_BYTES = 10 * 1024 * 1024;
|
const MAX_FEED_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// A sync removes at most half of a mirror this size or larger at once.
|
||||||
|
const MASS_REMOVAL_MIN_EVENTS = 10;
|
||||||
|
|
||||||
interface SubscriptionsState {
|
interface SubscriptionsState {
|
||||||
subscriptions: CalendarSubscription[];
|
subscriptions: CalendarSubscription[];
|
||||||
syncing: Record<string, boolean>;
|
syncing: Record<string, boolean>;
|
||||||
@@ -205,6 +208,14 @@ class SubscriptionAccountChangedError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `err` says the account changed under the sync: its own check, or
|
||||||
|
* the calendar store's (importEvents stops so after an account switch).
|
||||||
|
*/
|
||||||
|
function accountChanged(err: unknown): boolean {
|
||||||
|
return err instanceof SubscriptionAccountChangedError || err instanceof CalendarAccountChangedError;
|
||||||
|
}
|
||||||
|
|
||||||
function assertScope(scope: SyncScope): void {
|
function assertScope(scope: SyncScope): void {
|
||||||
if (currentAccountId() !== scope.accountId || calendarStoreGeneration() !== scope.generation) {
|
if (currentAccountId() !== scope.accountId || calendarStoreGeneration() !== scope.generation) {
|
||||||
throw new SubscriptionAccountChangedError();
|
throw new SubscriptionAccountChangedError();
|
||||||
@@ -225,6 +236,11 @@ async function fetchAndParseFeed(url: string, scope: SyncScope): Promise<Partial
|
|||||||
if (!/BEGIN:VCALENDAR/i.test(text)) {
|
if (!/BEGIN:VCALENDAR/i.test(text)) {
|
||||||
throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed'));
|
throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed'));
|
||||||
}
|
}
|
||||||
|
// A body cut off on the way (a dropped connection, a proxy's limit) parses
|
||||||
|
// up to the cut, and every mirror event after it looked gone upstream.
|
||||||
|
if (!/END:VCALENDAR\s*$/i.test(text)) {
|
||||||
|
throw new Error(t('calendar.subscription.feed_truncated', 'The feed was cut off before its end. Nothing was changed.'));
|
||||||
|
}
|
||||||
assertScope(scope);
|
assertScope(scope);
|
||||||
const bytes = new TextEncoder().encode(text);
|
const bytes = new TextEncoder().encode(text);
|
||||||
const { blobId } = await uploadBytes(bytes, 'text/calendar', scope.accountId);
|
const { blobId } = await uploadBytes(bytes, 'text/calendar', scope.accountId);
|
||||||
@@ -247,17 +263,20 @@ async function fetchAndParseFeed(url: string, scope: SyncScope): Promise<Partial
|
|||||||
* another calendar. One made for this sync has nothing to drop or update.
|
* another calendar. One made for this sync has nothing to drop or update.
|
||||||
* Every request names the account the sync started in, and the sync stops
|
* Every request names the account the sync started in, and the sync stops
|
||||||
* once the client serves another (SubscriptionAccountChangedError).
|
* once the client serves another (SubscriptionAccountChangedError).
|
||||||
|
* Resolves to what the sync has to say for lastError (events it kept that
|
||||||
|
* the feed left out), else null.
|
||||||
*/
|
*/
|
||||||
async function syncFeedIntoCalendar(
|
async function syncFeedIntoCalendar(
|
||||||
calendarId: string,
|
calendarId: string,
|
||||||
url: string,
|
url: string,
|
||||||
linkedBefore: boolean,
|
linkedBefore: boolean,
|
||||||
scope: SyncScope,
|
scope: SyncScope,
|
||||||
): Promise<void> {
|
): Promise<string | null> {
|
||||||
const { accountId } = scope;
|
const { accountId } = scope;
|
||||||
const parsed = await fetchAndParseFeed(url, scope);
|
const parsed = await fetchAndParseFeed(url, scope);
|
||||||
let toImport = parsed;
|
let toImport = parsed;
|
||||||
let wrote = false;
|
let wrote = false;
|
||||||
|
let warning: string | null = null;
|
||||||
|
|
||||||
if (linkedBefore) {
|
if (linkedBefore) {
|
||||||
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
|
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
|
||||||
@@ -269,7 +288,18 @@ async function syncFeedIntoCalendar(
|
|||||||
const inMirror = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
const inMirror = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
||||||
toImport = parsed.filter((e) => !e.uid || !inMirror.has(e.uid));
|
toImport = parsed.filter((e) => !e.uid || !inMirror.has(e.uid));
|
||||||
|
|
||||||
const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
|
let stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
|
||||||
|
// A feed that drops most of a sizeable mirror at once is more likely
|
||||||
|
// broken (an error page that still parses, a server's partial answer)
|
||||||
|
// than emptied: the events stay, and the sync says so.
|
||||||
|
if (existing.length >= MASS_REMOVAL_MIN_EVENTS && stale.length > existing.length / 2) {
|
||||||
|
warning = t(
|
||||||
|
'calendar.subscription.mass_removal',
|
||||||
|
'The feed left out {count} of the {total} events at once. They were kept in case the feed is broken.',
|
||||||
|
{ count: stale.length, total: existing.length },
|
||||||
|
);
|
||||||
|
stale = [];
|
||||||
|
}
|
||||||
const toDelete: string[] = [];
|
const toDelete: string[] = [];
|
||||||
for (const e of stale) {
|
for (const e of stale) {
|
||||||
const others = { ...(e.calendarIds || {}) };
|
const others = { ...(e.calendarIds || {}) };
|
||||||
@@ -308,7 +338,7 @@ async function syncFeedIntoCalendar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SubscriptionAccountChangedError) throw err;
|
if (accountChanged(err)) throw err;
|
||||||
// Non-fatal: if we can't enumerate existing events, still import new ones.
|
// Non-fatal: if we can't enumerate existing events, still import new ones.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,6 +347,7 @@ async function syncFeedIntoCalendar(
|
|||||||
// importEvents shows what it brought in; deletions and changes alone need a reload.
|
// importEvents shows what it brought in; deletions and changes alone need a reload.
|
||||||
if (toImport.length > 0) await useCalendarStore.getState().importEvents(toImport, calendarId);
|
if (toImport.length > 0) await useCalendarStore.getState().importEvents(toImport, calendarId);
|
||||||
else if (wrote) await useCalendarStore.getState().refresh();
|
else if (wrote) await useCalendarStore.getState().refresh();
|
||||||
|
return warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||||
@@ -353,7 +384,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
|||||||
await useCalendarStore.getState().refresh();
|
await useCalendarStore.getState().refresh();
|
||||||
return { ...sub, lastSyncAt: Date.now() };
|
return { ...sub, lastSyncAt: Date.now() };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SubscriptionAccountChangedError) {
|
if (accountChanged(err)) {
|
||||||
// The mirror is in the account the sync started in, which the
|
// The mirror is in the account the sync started in, which the
|
||||||
// client no longer serves: a request to remove it would reach
|
// client no longer serves: a request to remove it would reach
|
||||||
// the next account. Kept as that account's subscription, it
|
// the next account. Kept as that account's subscription, it
|
||||||
@@ -473,17 +504,17 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
|||||||
await useCalendarStore.getState().fetchCalendars();
|
await useCalendarStore.getState().fetchCalendars();
|
||||||
}
|
}
|
||||||
const linkedBefore = calendarId === sub.calendarId;
|
const linkedBefore = calendarId === sub.calendarId;
|
||||||
await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore, scope);
|
const warning = await syncFeedIntoCalendar(calendarId, sub.url, linkedBefore, scope);
|
||||||
set({
|
set({
|
||||||
subscriptions: get().subscriptions.map((s) =>
|
subscriptions: get().subscriptions.map((s) =>
|
||||||
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
|
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: warning } : s,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (!linkedBefore) await useCalendarStore.getState().refresh();
|
if (!linkedBefore) await useCalendarStore.getState().refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Not this subscription's failure: the client serves another
|
// Not this subscription's failure: the client serves another
|
||||||
// account now, and the sync runs again in its own.
|
// account now, and the sync runs again in its own.
|
||||||
if (err instanceof SubscriptionAccountChangedError) return;
|
if (accountChanged(err)) return;
|
||||||
const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription');
|
const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription');
|
||||||
set({
|
set({
|
||||||
subscriptions: get().subscriptions.map((s) =>
|
subscriptions: get().subscriptions.map((s) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user