Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/api/src/integrations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { gzipSync } from 'node:zlib';
import { eq } from 'drizzle-orm';
import { afterEach, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -281,6 +283,83 @@ describe('intervals.icu sync', () => {
expect(await t.sync()).toMatchObject({ status: 'ok', importedCount: 1 });
});

it('removes runs that intervals.icu now lists as another sport', async () => {
const activities: FakeActivity[] = [
{ id: 'i1', start_date: daysAgo(3), type: 'Run', name: 'Not a run' },
{ id: 'i2', start_date: daysAgo(2), type: 'Run', name: 'Deleted' },
{ id: 'i3', start_date: daysAgo(1), type: 'Run', name: 'Real run' },
];
const t = await connected(activities);
await importFit(t.app, t.cookie, { start: new Date(daysAgo(6)) });
await t.sync();
const byName = async () =>
Object.fromEntries((await t.runs()).map((r) => [r.name, r]));
const before = await byName();
expect(Object.keys(before)).toHaveLength(4);

// Shared with a club before anyone noticed it was a ride.
const club = (await (
await send(t.app, t.cookie, 'POST', '/api/clubs', {
name: 'Parkside',
timezone: 'Europe/London',
})
).json()) as { id: string };
await send(t.app, t.cookie, 'PATCH', `/api/clubs/${club.id}/me`, {
shareLevel: 'summaries',
});
const ride = before['Not a run'];
await send(
t.app,
t.cookie,
'PUT',
`/api/activities/${ride.id}/clubs/${club.id}`,
);
expect(t.db.select().from(schema.clubShare).all()).toHaveLength(1);
const [{ originalFile }] = t.db
.select({ originalFile: schema.activity.originalFile })
.from(schema.activity)
.where(eq(schema.activity.id, ride.id))
.all();
expect(
originalFile && existsSync(join(t.config.dataDir, originalFile)),
).toBe(true);

// A run deleted by hand stays deleted, whatever it becomes.
await send(
t.app,
t.cookie,
'DELETE',
`/api/activities/${before.Deleted.id}`,
);
activities[0].type = 'Ride';
activities[1].type = 'Ride';
const connection = await t.sync();
expect(connection.syncs[0]).toMatchObject({ imported: 0, removed: 1 });
// The upload and the real run are left alone.
expect(await t.runs()).toHaveLength(2);
expect(await byName()).toHaveProperty('Real run');
expect(await byName()).not.toHaveProperty('Not a run');
expect(t.db.select().from(schema.clubShare).all()).toEqual([]);
expect(
t.db
.select()
.from(schema.personalBest)
.where(eq(schema.personalBest.activityId, ride.id))
.all(),
).toEqual([]);
expect(existsSync(join(t.config.dataDir, originalFile ?? ''))).toBe(false);

// Nothing more to remove next time.
expect((await t.sync()).syncs[0]).toMatchObject({ removed: 0 });

// Turned back into a run: imported again. The one deleted by hand isn't.
activities[0].type = 'Run';
activities[1].type = 'Run';
expect((await t.sync()).syncs[0]).toMatchObject({ imported: 1 });
expect(await byName()).toHaveProperty('Not a run');
expect(await byName()).not.toHaveProperty('Deleted');
});

it('keeps imported runs after disconnecting, and one account per person', async () => {
const t = await connected([
{ id: 'i1', start_date: daysAgo(1), type: 'Run' },
Expand Down
87 changes: 84 additions & 3 deletions apps/api/src/integrations/sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { and, count, desc, eq, inArray, ne, notInArray } from 'drizzle-orm';
import {
and,
count,
desc,
eq,
inArray,
isNotNull,
ne,
notInArray,
} from 'drizzle-orm';
import { ActivityFileError, parseActivityFile } from '@openrun/activity-files';
import { type Db, schema } from '@openrun/db';
import { defaultActivityName } from '@openrun/domain';
Expand All @@ -11,6 +20,7 @@ import {
removeDataFile,
writeDataFile,
} from '../activity-store.js';
import { withdrawShares } from '../clubs/shares.js';
import type { Config } from '../config.js';
import { getPrivacySettings } from '../privacy.js';
import { decryptSecret } from '../secrets.js';
Expand Down Expand Up @@ -43,7 +53,8 @@ const day = (d: Date) => d.toISOString().slice(0, 10);

/**
* Fetches runs from intervals.icu that haven't been seen before, oldest first,
* up to BATCH files, and imports them like uploads. Returns false if a sync
* up to BATCH files, and imports them like uploads. Runs that intervals.icu
* now lists as another sport are removed. Returns false if a sync
* of this connection was already running.
*
* Everything since the start date is listed every time: runs can reach
Expand All @@ -64,7 +75,13 @@ export async function syncIntegration(
.values({ integrationId: integration.id, startedAt: now })
.returning()
.all();
const counts = { imported: 0, duplicates: 0, failed: 0, unavailable: 0 };
const counts = {
imported: 0,
duplicates: 0,
failed: 0,
unavailable: 0,
removed: 0,
};
let more = false;
let error: string | null = null;
let status: Integration['status'] = 'ok';
Expand All @@ -83,6 +100,8 @@ export async function syncIntegration(
day(new Date(now.getTime() + DAY_MS)),
);

counts.removed = removeNonRuns(db, config, integration, listed);

const seen = seenIds(
db,
integration.id,
Expand Down Expand Up @@ -172,6 +191,67 @@ function seenIds(db: Db, integrationId: string, ids: string[]) {
return seen;
}

/**
* Removes runs imported earlier that intervals.icu now lists as another
* sport, such as a ride the watch recorded as a run. Other sports are never
* imported, so these go as if they'd never been runs, club shares included.
* Forgetting the item means that if it turns back into a run, the next sync
* imports it again. Runs deleted by hand stay deleted.
*/
function removeNonRuns(
db: Db,
config: Config,
integration: Integration,
listed: IntervalsActivity[],
) {
const notRuns = listed
.filter((a) => a.type && !RUN_TYPES.has(a.type))
.map((a) => a.id);
let removed = 0;
// SQLite allows a limited number of variables per statement.
for (let i = 0; i < notRuns.length; i += 500) {
const items = db
.select({
id: schema.integrationItem.id,
activityId: schema.integrationItem.activityId,
})
.from(schema.integrationItem)
.where(
and(
eq(schema.integrationItem.integrationId, integration.id),
eq(schema.integrationItem.status, 'imported'),
isNotNull(schema.integrationItem.activityId),
inArray(schema.integrationItem.externalId, notRuns.slice(i, i + 500)),
),
)
.all();
for (const item of items) {
const run = db
.select()
.from(schema.activity)
.where(
and(
eq(schema.activity.id, item.activityId ?? ''),
eq(schema.activity.userId, integration.userId),
eq(schema.activity.source, 'intervals'),
),
)
.get();
if (!run) continue;
db.transaction((tx) => {
withdrawShares(db, run.userId, { sourceIds: [run.id] });
tx.delete(schema.activity).where(eq(schema.activity.id, run.id)).run();
tx.delete(schema.integrationItem)
.where(eq(schema.integrationItem.id, item.id))
.run();
});
if (run.originalFile) removeDataFile(config.dataDir, run.originalFile);
removed++;
}
}
return removed;
}

async function importOne(
db: Db,
config: Config,
Expand Down Expand Up @@ -325,6 +405,7 @@ export function describeConnection(
duplicates: s.duplicates,
failed: s.failed,
unavailable: s.unavailable,
removed: s.removed,
more: s.more,
error: s.error,
})),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/intervals-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ export function describeSync(s: IntegrationSync) {
parts.push(
`${s.unavailable} from Strava, which intervals.icu can't pass on`,
);
if (s.removed)
parts.push(`${s.removed} removed, now another sport on intervals.icu`);
if (s.error) parts.push(s.error);
if (!parts.length) parts.push('Nothing new');
if (s.more) parts.push('more next time');
Expand Down
1 change: 1 addition & 0 deletions libs/db-migrations/migrations/0009_sync_removed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `integration_sync` ADD `removed` integer DEFAULT 0 NOT NULL;
Loading
Loading