Implement incremental sync and offline recovery

This commit is contained in:
diyaa 2026-06-15 22:31:23 +02:00
parent fa7727d572
commit 295c6c1d9b
25 changed files with 2293 additions and 494 deletions

View File

@ -476,13 +476,15 @@ final class iPhoneLibraryViewModel {
}
)
let store = Self.makeRemoteLibraryStore()
let syncCursorStore = Self.makeRemoteLibrarySyncCursorStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let favoriteTrackStore = Self.makeFavoriteTrackStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
store: store,
syncCursorStore: syncCursorStore
)
let syncService = RemoteLibrarySyncService(
repository: repository,
@ -726,6 +728,14 @@ final class iPhoneLibraryViewModel {
return InMemoryRemoteTrackDownloadStateStore()
}
private static func makeRemoteLibrarySyncCursorStore() -> any RemoteLibrarySyncCursorStore {
if let store = try? FileRemoteLibrarySyncCursorStore() {
return store
}
return InMemoryRemoteLibrarySyncCursorStore()
}
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
if let store = try? FileOfflineAudioFileStore() {
return store

View File

@ -153,6 +153,36 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
}
func testFavoritesRemainIntactAcrossLibrarySync() async throws {
let track = makeRemoteTrack(
trackId: "remote-sync-favorite",
assetId: "asset-sync-favorite",
title: "Sync Favorite"
)
let favoriteStore = InMemoryFavoriteTrackStore()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: favoriteStore,
audioFiles: [localFilePath(for: track): Data([0x5])],
apiClient: StubVelodyAPIClient(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
)
)
)
await viewModel.loadIfNeeded()
await viewModel.toggleFavorite(trackID: track.trackId)
await viewModel.refreshSync()
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
XCTAssertEqual(savedFavorites.map(\.remoteTrackId), [track.trackId])
}
func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws {
let track = makeRemoteTrack(
trackId: "remote-repeat",
@ -285,4 +315,41 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
XCTAssertTrue(remoteTrack.isFavorite)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
}
func testFailedDownloadKeepsFavoriteAndShowsRetry() async throws {
let track = makeRemoteTrack(
trackId: "remote-interrupted-favorite",
assetId: "asset-interrupted-favorite",
title: "Interrupted Favorite"
)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: "",
downloadedAt: nil,
downloadStatus: .failed,
lastDownloadError: "The previous download did not finish. Try again."
),
],
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
FavoriteTrackRecord(
remoteTrackId: track.trackId,
favoritedAt: Date(timeIntervalSince1970: 7_000)
),
])
)
await viewModel.loadIfNeeded()
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .failed)
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Retry")
XCTAssertTrue(remoteTrack.canDownload)
XCTAssertTrue(remoteTrack.isFavorite)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
}
}

View File

@ -4,6 +4,7 @@ import VelodyDomain
import VelodyNetworking
import VelodyPlayback
import VelodyPersistence
import VelodyUtilities
@testable import VelodyiPhone
@MainActor
@ -449,9 +450,35 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .missing)
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing")
XCTAssertTrue(remoteTrack.canDownload)
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Re-download")
XCTAssertFalse(remoteTrack.canPlay)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)")
}
func testDownloadedTrackAppearsInAvailableOfflineState() async throws {
let track = makeRemoteTrack(
trackId: "remote-recovered-download",
assetId: "asset-recovered-download",
title: "Recovered Download"
)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])]
)
await viewModel.loadIfNeeded()
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .downloaded)
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Downloaded")
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (1)")
}
}
@MainActor
@ -479,7 +506,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
let registerCount = await counter.currentCount()
XCTAssertEqual(registerCount, 1)
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
@ -513,7 +541,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
let registerCount = await counter.currentCount()
XCTAssertEqual(registerCount, 1)
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
@ -525,6 +554,10 @@ private actor RegisterCallCounter {
func increment() {
count += 1
}
func currentCount() -> Int {
count
}
}
private struct TestRegisterAPIClient: VelodyAPIClient {
@ -563,6 +596,12 @@ private struct TestRegisterAPIClient: VelodyAPIClient {
try await stubClient.fetchSyncBootstrap()
}
func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse {
try await stubClient.fetchSyncChanges(cursor: cursor)
}
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {

View File

@ -395,18 +395,7 @@
"/api/v1/sync/bootstrap": {
"get": {
"operationId": "SyncController_bootstrap_v1",
"parameters": [
{
"name": "deviceId",
"required": false,
"in": "query",
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"parameters": [],
"responses": {
"200": {
"description": "",
@ -434,23 +423,24 @@
"operationId": "SyncController_changes_v1",
"parameters": [
{
"name": "deviceId",
"required": false,
"in": "query",
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "after",
"name": "cursor",
"required": false,
"in": "query",
"schema": {
"example": "0",
"type": "string"
}
},
{
"name": "limit",
"required": false,
"in": "query",
"schema": {
"minimum": 1,
"maximum": 500,
"example": 100,
"type": "number"
}
}
],
"responses": {
@ -918,26 +908,37 @@
"tracks"
]
},
"LibraryTrackDto": {
"SyncBootstrapResponseDto": {
"type": "object",
"properties": {
"id": {
"nextCursor": {
"type": "string",
"format": "uuid"
"example": "7"
},
"title": {
"type": "string",
"example": "Placeholder Track"
"tracks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RemoteLibraryTrackDto"
}
},
"artist": {
"serverTime": {
"type": "string",
"example": "Velody"
}
"example": "2026-06-15T12:00:00.000Z"
}
},
"required": [
"nextCursor",
"tracks",
"serverTime"
]
},
"SyncEventDto": {
"type": "object",
"properties": {
"cursor": {
"type": "string",
"example": "3"
},
"entityType": {
"type": "string",
"example": "TRACK"
@ -948,56 +949,33 @@
},
"action": {
"type": "string",
"example": "CREATED"
"example": "UPDATED"
},
"eventId": {
"track": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/RemoteLibraryTrackDto"
}
]
},
"deletedTrackId": {
"type": "object",
"format": "uuid",
"nullable": true
},
"createdAt": {
"type": "string",
"example": "0"
"example": "2026-06-15T12:00:00.000Z"
}
},
"required": [
"cursor",
"entityType",
"entityId",
"action",
"eventId"
]
},
"SyncBootstrapResponseDto": {
"type": "object",
"properties": {
"nextCursor": {
"type": "string",
"example": "0"
},
"tracks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryTrackDto"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SyncEventDto"
}
},
"deletedTrackIds": {
"type": "array",
"items": {
"type": "string"
}
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
}
},
"required": [
"nextCursor",
"tracks",
"events",
"deletedTrackIds",
"serverTime"
"createdAt"
]
},
"SyncChangesResponseDto": {
@ -1005,13 +983,20 @@
"properties": {
"nextCursor": {
"type": "string",
"example": "0"
"example": "7"
},
"tracks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryTrackDto"
}
"hasMore": {
"type": "boolean",
"example": false
},
"requiresBootstrap": {
"type": "boolean",
"example": false
},
"reason": {
"type": "object",
"nullable": true,
"example": "cursor_too_old"
},
"events": {
"type": "array",
@ -1019,22 +1004,16 @@
"$ref": "#/components/schemas/SyncEventDto"
}
},
"deletedTrackIds": {
"type": "array",
"items": {
"type": "string"
}
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
"example": "2026-06-15T12:00:00.000Z"
}
},
"required": [
"nextCursor",
"tracks",
"hasMore",
"requiresBootstrap",
"events",
"deletedTrackIds",
"serverTime"
]
}

View File

@ -0,0 +1,128 @@
ALTER TABLE "users"
ADD COLUMN "library_cursor" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "library_events"
ADD COLUMN "cursor" BIGINT,
ADD COLUMN "payload" JSONB;
WITH ranked_events AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "user_id"
ORDER BY "created_at" ASC, "id" ASC
)::BIGINT AS next_cursor
FROM "library_events"
)
UPDATE "library_events" AS "event"
SET "cursor" = ranked_events.next_cursor
FROM ranked_events
WHERE ranked_events."id" = "event"."id";
UPDATE "library_events" AS "event"
SET "payload" = CASE
WHEN track."status" = 'DELETED'::"TrackStatus" THEN jsonb_build_object(
'deletedTrackId',
track."id"
)
WHEN audio_asset."id" IS NOT NULL THEN jsonb_build_object(
'track',
jsonb_build_object(
'trackId',
track."id",
'title',
track."title",
'artist',
track."artist",
'durationSeconds',
GREATEST(
0,
ROUND(COALESCE(track."duration_ms", audio_asset."duration_ms", 0)::numeric / 1000.0)
)::INTEGER,
'sha256',
audio_asset."sha256",
'assetId',
audio_asset."id",
'createdAt',
to_char(track."created_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
'updatedAt',
to_char(track."updated_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
'artwork',
CASE
WHEN artwork_asset."id" IS NULL THEN NULL
ELSE jsonb_build_object(
'artworkId',
artwork_asset."id",
'sha256',
artwork_asset."sha256",
'mimeType',
artwork_asset."mime_type",
'width',
artwork_asset."width",
'height',
artwork_asset."height"
)
END
)
)
ELSE '{}'::jsonb
END
FROM "tracks" AS track
LEFT JOIN "audio_assets" AS audio_asset
ON audio_asset."id" = track."primary_audio_asset_id"
LEFT JOIN "artwork_assets" AS artwork_asset
ON artwork_asset."id" = track."artwork_asset_id"
WHERE "event"."entity_type" = 'TRACK'::"EntityType"
AND "event"."entity_id" = track."id";
UPDATE "library_events"
SET "payload" = '{}'::jsonb
WHERE "payload" IS NULL;
ALTER TABLE "library_events"
ALTER COLUMN "cursor" SET NOT NULL,
ALTER COLUMN "payload" SET NOT NULL;
CREATE UNIQUE INDEX "library_events_user_id_cursor_key"
ON "library_events"("user_id", "cursor");
CREATE INDEX "library_events_user_id_cursor_idx"
ON "library_events"("user_id", "cursor");
UPDATE "users" AS "user"
SET "library_cursor" = COALESCE(cursor_summary."max_cursor", 0)
FROM (
SELECT
"user_id",
MAX("cursor") AS "max_cursor"
FROM "library_events"
GROUP BY "user_id"
) AS cursor_summary
WHERE cursor_summary."user_id" = "user"."id";
ALTER TABLE "device_sync_cursors"
ADD COLUMN "user_id" UUID,
ADD COLUMN "cursor" BIGINT NOT NULL DEFAULT 0;
UPDATE "device_sync_cursors" AS "sync_cursor"
SET
"user_id" = device."user_id",
"cursor" = "sync_cursor"."last_event_id"
FROM "devices" AS device
WHERE device."id" = "sync_cursor"."device_id";
ALTER TABLE "device_sync_cursors"
ALTER COLUMN "user_id" SET NOT NULL;
ALTER TABLE "device_sync_cursors"
DROP COLUMN "last_event_id",
DROP COLUMN "last_full_sync_at";
CREATE INDEX "device_sync_cursors_user_id_idx"
ON "device_sync_cursors"("user_id");
ALTER TABLE "device_sync_cursors"
ADD CONSTRAINT "device_sync_cursors_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;

View File

@ -12,6 +12,7 @@ model User {
slug String @unique
displayName String @map("display_name")
isDefault Boolean @default(false) @map("is_default")
libraryCursor BigInt @default(0) @map("library_cursor")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
devices Device[]
@ -20,6 +21,7 @@ model User {
artworkAssets ArtworkAsset[]
uploadSessions UploadSession[]
libraryEvents LibraryEvent[]
syncCursors DeviceSyncCursor[]
@@map("users")
}
@ -145,24 +147,30 @@ model UploadSession {
model LibraryEvent {
id BigInt @id @default(autoincrement())
userId String @db.Uuid @map("user_id")
cursor BigInt
entityType EntityType @map("entity_type")
entityId String @db.Uuid @map("entity_id")
action EventAction
payload Json
payloadVersion Int @default(1) @map("payload_version")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@unique([userId, cursor])
@@index([userId, cursor])
@@map("library_events")
}
model DeviceSyncCursor {
deviceId String @id @db.Uuid @map("device_id")
lastEventId BigInt @default(0) @map("last_event_id")
lastFullSyncAt DateTime? @map("last_full_sync_at")
userId String @db.Uuid @map("user_id")
cursor BigInt @default(0)
updatedAt DateTime @updatedAt @map("updated_at")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@index([userId])
@@map("device_sync_cursors")
}

View File

@ -41,6 +41,12 @@ export class LibraryService {
const { userId: ownerUserId } =
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
return this.getRemoteLibraryTracksForUser(ownerUserId);
}
async getRemoteLibraryTracksForUser(
ownerUserId: string,
): Promise<RemoteLibraryTrackDto[]> {
const tracks = await this.prismaService.track.findMany({
where: {
userId: ownerUserId,

View File

@ -2,7 +2,6 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
SyncBootstrapQueryDto,
SyncBootstrapResponseDto,
SyncChangesQueryDto,
SyncChangesResponseDto,
@ -21,9 +20,7 @@ export class SyncController {
@Get('bootstrap')
@ApiOkResponse({ type: SyncBootstrapResponseDto })
async bootstrap(
@Query() _query?: SyncBootstrapQueryDto,
): Promise<SyncBootstrapResponseDto> {
async bootstrap(): Promise<SyncBootstrapResponseDto> {
return this.syncService.bootstrap();
}
@ -32,6 +29,6 @@ export class SyncController {
async changes(
@Query() query: SyncChangesQueryDto,
): Promise<SyncChangesResponseDto> {
return this.syncService.changes(query.after ?? '0');
return this.syncService.changes(query.cursor ?? '0', query.limit);
}
}

View File

@ -1,5 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, Matches } from 'class-validator';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
import { RemoteLibraryTrackDto } from '../library/library.dto';
export class LibraryTrackDto {
@ApiProperty({ format: 'uuid', required: false })
@ -13,54 +15,83 @@ export class LibraryTrackDto {
}
export class SyncEventDto {
@ApiProperty({ example: '3' })
cursor!: string;
@ApiProperty({ example: 'TRACK' })
entityType!: string;
@ApiProperty({ format: 'uuid' })
entityId!: string;
@ApiProperty({ example: 'CREATED' })
@ApiProperty({ example: 'UPDATED' })
action!: string;
@ApiProperty({ example: '0' })
eventId!: string;
}
@ApiProperty({
type: RemoteLibraryTrackDto,
required: false,
nullable: true,
})
track!: RemoteLibraryTrackDto | null;
export class SyncBootstrapResponseDto {
@ApiProperty({ example: '0' })
nextCursor!: string;
@ApiProperty({ type: [LibraryTrackDto] })
tracks!: LibraryTrackDto[];
@ApiProperty({ type: [SyncEventDto] })
events!: SyncEventDto[];
@ApiProperty({ type: [String] })
deletedTrackIds!: string[];
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
serverTime!: string;
}
export class SyncBootstrapQueryDto {
@ApiProperty({
format: 'uuid',
required: false,
description:
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
nullable: true,
})
@IsOptional()
@IsUUID()
deviceId?: string;
deletedTrackId!: string | null;
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
createdAt!: string;
}
export class SyncChangesQueryDto extends SyncBootstrapQueryDto {
export class SyncBootstrapResponseDto {
@ApiProperty({ example: '7' })
nextCursor!: string;
@ApiProperty({ type: [RemoteLibraryTrackDto] })
tracks!: RemoteLibraryTrackDto[];
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
serverTime!: string;
}
export class SyncChangesQueryDto {
@ApiProperty({ required: false, example: '0' })
@IsOptional()
@IsString()
@Matches(/^\d+$/)
after?: string;
cursor?: string;
@ApiProperty({ required: false, example: 100, minimum: 1, maximum: 500 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
}
export class SyncChangesResponseDto extends SyncBootstrapResponseDto {}
export class SyncChangesResponseDto {
@ApiProperty({ example: '7' })
nextCursor!: string;
@ApiProperty({ example: false })
hasMore!: boolean;
@ApiProperty({ example: false })
requiresBootstrap!: boolean;
@ApiProperty({
required: false,
nullable: true,
example: 'cursor_too_old',
})
reason!: string | null;
@ApiProperty({ type: [SyncEventDto] })
events!: SyncEventDto[];
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
serverTime!: string;
}

View File

@ -1,58 +1,300 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LibraryService } from '../library/library.service';
import { OwnerContext } from '../users/owner-context.service';
import { SyncService } from './sync.service';
function makeEvent(params: {
userId: string;
cursor: bigint;
entityType?: string;
entityId?: string;
action?: string;
payload?: Record<string, unknown>;
createdAt?: Date;
}) {
return {
id: params.cursor,
userId: params.userId,
cursor: params.cursor,
entityType: params.entityType ?? 'TRACK',
entityId: params.entityId ?? `track-${params.cursor.toString()}`,
action: params.action ?? 'UPDATED',
payloadVersion: 1,
payload: params.payload ?? {},
createdAt:
params.createdAt ??
new Date(`2026-06-15T12:00:0${params.cursor.toString()}.000Z`),
};
}
describe('SyncService', () => {
it('uses OwnerContext to scope the bootstrap cursor lookup', async () => {
const ownerContextMock = {
resolve: jest.fn().mockResolvedValue({
userId: 'bootstrap-owner-id',
}),
};
const prismaMock = {
it('returns the bootstrap snapshot and persists the device cursor', async () => {
const upsert = jest.fn();
const service = new SyncService(
{
libraryEvent: {
findFirst: jest.fn().mockResolvedValue({
id: 7n,
findFirst: jest.fn().mockResolvedValue({ cursor: 7n }),
},
deviceSyncCursor: {
upsert,
},
} as any,
{
getRemoteLibraryTracksForUser: jest.fn().mockResolvedValue([
{
trackId: 'track-123',
title: 'Remote Title',
artist: 'Remote Artist',
durationSeconds: 245,
sha256: 'a'.repeat(64),
assetId: 'asset-123',
createdAt: '2026-06-15T10:00:00.000Z',
updatedAt: '2026-06-15T10:05:00.000Z',
artwork: null,
},
]),
} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: 'owner-123',
}),
},
};
const libraryServiceMock = {
getBootstrapTracks: jest.fn().mockResolvedValue([]),
};
} as any,
);
const moduleRef = await Test.createTestingModule({
providers: [
SyncService,
{
provide: PrismaService,
useValue: prismaMock,
},
{
provide: LibraryService,
useValue: libraryServiceMock,
},
{
provide: OwnerContext,
useValue: ownerContextMock,
},
],
}).compile();
const service = moduleRef.get(SyncService);
await expect(service.changes('0')).resolves.toMatchObject({
await expect(service.bootstrap()).resolves.toMatchObject({
nextCursor: '7',
tracks: [
expect.objectContaining({
trackId: 'track-123',
}),
],
});
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
expect(prismaMock.libraryEvent.findFirst).toHaveBeenCalledWith({
expect(upsert).toHaveBeenCalledWith({
where: {
userId: 'bootstrap-owner-id',
deviceId: 'device-123',
},
orderBy: {
id: 'desc',
update: {
userId: 'owner-123',
cursor: 7n,
},
create: {
deviceId: 'device-123',
userId: 'owner-123',
cursor: 7n,
},
});
});
it('returns ordered changes after the requested cursor', async () => {
const ownerId = 'owner-123';
const foreignId = 'owner-999';
const events = [
makeEvent({
userId: foreignId,
cursor: 1n,
}),
makeEvent({
userId: ownerId,
cursor: 2n,
payload: {
track: {
trackId: 'track-2',
title: 'Two',
artist: 'Owner',
durationSeconds: 200,
sha256: 'b'.repeat(64),
assetId: 'asset-2',
createdAt: '2026-06-15T10:00:02.000Z',
updatedAt: '2026-06-15T10:00:02.000Z',
artwork: null,
},
},
}),
makeEvent({
userId: ownerId,
cursor: 3n,
action: 'DELETED',
payload: {
deletedTrackId: 'track-3',
},
}),
];
const upsert = jest.fn();
const findFirst = jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = events.filter((event) => event.userId === where.userId);
const direction = orderBy.cursor;
const sortedEvents = [...filteredEvents].sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
);
return sortedEvents[0] ?? null;
});
const findMany = jest.fn().mockImplementation(async ({ where, take }) => {
return events
.filter(
(event) =>
event.userId === where.userId && event.cursor > where.cursor.gt,
)
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
.slice(0, take);
});
const service = new SyncService(
{
libraryEvent: {
findFirst,
findMany,
},
deviceSyncCursor: {
upsert,
},
} as any,
{} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: ownerId,
}),
} as any,
);
const response = await service.changes('1');
expect(response.requiresBootstrap).toBe(false);
expect(response.hasMore).toBe(false);
expect(response.nextCursor).toBe('3');
expect(response.events.map((event) => event.cursor)).toEqual(['2', '3']);
expect(response.events[0]?.track?.trackId).toBe('track-2');
expect(response.events[1]?.deletedTrackId).toBe('track-3');
expect(upsert).toHaveBeenCalledWith({
where: {
deviceId: 'device-123',
},
update: {
userId: ownerId,
cursor: 3n,
},
create: {
deviceId: 'device-123',
userId: ownerId,
cursor: 3n,
},
});
});
it('paginates deterministically and reports hasMore', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-15T08:24:36.011Z'));
try {
const ownerId = 'owner-123';
const events = [1n, 2n, 3n].map((cursor) =>
makeEvent({
userId: ownerId,
cursor,
payload: {
track: {
trackId: `track-${cursor.toString()}`,
title: `Track ${cursor.toString()}`,
artist: 'Owner',
durationSeconds: 180,
sha256: 'c'.repeat(64),
assetId: `asset-${cursor.toString()}`,
createdAt: '2026-06-15T10:00:00.000Z',
updatedAt: '2026-06-15T10:00:00.000Z',
artwork: null,
},
},
}),
);
const findFirst = jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = events.filter((event) => event.userId === where.userId);
const direction = orderBy.cursor;
const sortedEvents = [...filteredEvents].sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
);
return sortedEvents[0] ?? null;
});
const findMany = jest.fn().mockImplementation(async ({ where, take }) => {
return events
.filter(
(event) =>
event.userId === where.userId && event.cursor > where.cursor.gt,
)
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
.slice(0, take);
});
const upsert = jest.fn();
const service = new SyncService(
{
libraryEvent: {
findFirst,
findMany,
},
deviceSyncCursor: {
upsert,
},
} as any,
{} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: ownerId,
}),
} as any,
);
const firstResponse = await service.changes('0', 2);
const replayResponse = await service.changes('0', 2);
expect(firstResponse.hasMore).toBe(true);
expect(firstResponse.nextCursor).toBe('2');
expect(firstResponse.events.map((event) => event.cursor)).toEqual(['1', '2']);
expect(replayResponse).toEqual(firstResponse);
} finally {
jest.useRealTimers();
}
});
it('requires bootstrap when the requested cursor is older than retained history', async () => {
const service = new SyncService(
{
libraryEvent: {
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
if (orderBy.cursor === 'asc') {
return makeEvent({
userId: where.userId,
cursor: 5n,
});
}
return makeEvent({
userId: where.userId,
cursor: 9n,
});
}),
findMany: jest.fn(),
},
deviceSyncCursor: {
upsert: jest.fn(),
},
} as any,
{} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: 'owner-123',
}),
} as any,
);
await expect(service.changes('3')).resolves.toMatchObject({
requiresBootstrap: true,
reason: 'cursor_too_old',
events: [],
hasMore: false,
nextCursor: '3',
});
});
});

View File

@ -1,57 +1,209 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { RemoteLibraryTrackDto } from '../library/library.dto';
import { LibraryService } from '../library/library.service';
import { OwnerContext } from '../users/owner-context.service';
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
import {
SyncBootstrapResponseDto,
SyncChangesResponseDto,
SyncEventDto,
} from './sync.dto';
interface LibraryEventPayload {
track?: RemoteLibraryTrackDto | null;
deletedTrackId?: string | null;
}
const DEFAULT_SYNC_PAGE_SIZE = 100;
@Injectable()
export class SyncService {
constructor(
private readonly prismaService: PrismaService,
private readonly libraryService: LibraryService,
private readonly ownerContext: OwnerContext,
private readonly deviceAuthService: DeviceAuthService,
) {}
async bootstrap(): Promise<SyncBootstrapResponseDto> {
const latestCursor = await this.getLatestCursor();
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const [tracks, latestCursor] = await Promise.all([
this.libraryService.getRemoteLibraryTracksForUser(device.userId),
this.getLatestCursor(device.userId),
]);
return {
const response: SyncBootstrapResponseDto = {
nextCursor: latestCursor,
tracks: await this.libraryService.getBootstrapTracks(),
events: [],
deletedTrackIds: [],
tracks,
serverTime: new Date().toISOString(),
};
await this.updateDeviceSyncCursor(
device.deviceId,
device.userId,
response.nextCursor,
);
return response;
}
async changes(after: string): Promise<SyncChangesResponseDto> {
const latestCursor = await this.getLatestCursor();
const normalizedCursor =
BigInt(latestCursor) > BigInt(after) ? latestCursor : after;
async changes(
cursor: string,
limit = DEFAULT_SYNC_PAGE_SIZE,
): Promise<SyncChangesResponseDto> {
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const requestedCursor = BigInt(cursor);
const earliestRetainedCursor = await this.getEarliestRetainedCursor(
device.userId,
);
if (
requestedCursor > 0n &&
earliestRetainedCursor !== null &&
requestedCursor < earliestRetainedCursor - 1n
) {
return {
nextCursor: normalizedCursor,
tracks: [],
nextCursor: cursor,
hasMore: false,
requiresBootstrap: true,
reason: 'cursor_too_old',
events: [],
deletedTrackIds: [],
serverTime: new Date().toISOString(),
};
}
private async getLatestCursor(): Promise<string> {
const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
const latest = await this.prismaService.libraryEvent.findFirst({
const events = await this.prismaService.libraryEvent.findMany({
where: {
userId: owner.userId,
userId: device.userId,
cursor: {
gt: requestedCursor,
},
},
orderBy: {
id: 'desc',
cursor: 'asc',
},
take: limit + 1,
});
const hasMore = events.length > limit;
const visibleEvents = hasMore ? events.slice(0, limit) : events;
const nextCursor =
visibleEvents.at(-1)?.cursor.toString() ?? requestedCursor.toString();
const response: SyncChangesResponseDto = {
nextCursor,
hasMore,
requiresBootstrap: false,
reason: null,
events: visibleEvents.map((event) => this.toSyncEventDto(event)),
serverTime: new Date().toISOString(),
};
await this.updateDeviceSyncCursor(
device.deviceId,
device.userId,
response.nextCursor,
);
return response;
}
private async getLatestCursor(userId: string): Promise<string> {
const latest = await this.prismaService.libraryEvent.findFirst({
where: {
userId,
},
orderBy: {
cursor: 'desc',
},
select: {
cursor: true,
},
});
return latest?.id.toString() ?? '0';
return latest?.cursor.toString() ?? '0';
}
private async getEarliestRetainedCursor(
userId: string,
): Promise<bigint | null> {
const earliest = await this.prismaService.libraryEvent.findFirst({
where: {
userId,
},
orderBy: {
cursor: 'asc',
},
select: {
cursor: true,
},
});
return earliest?.cursor ?? null;
}
private async updateDeviceSyncCursor(
deviceId: string,
userId: string,
cursor: string,
): Promise<void> {
await this.prismaService.deviceSyncCursor.upsert({
where: {
deviceId,
},
update: {
userId,
cursor: BigInt(cursor),
},
create: {
deviceId,
userId,
cursor: BigInt(cursor),
},
});
}
private toSyncEventDto(event: {
cursor: bigint;
entityType: string;
entityId: string;
action: string;
payload: unknown;
createdAt: Date;
}): SyncEventDto {
const payload = this.parsePayload(event.payload);
const deletedTrackId =
payload.deletedTrackId ??
(event.action === 'DELETED' && event.entityType === 'TRACK'
? event.entityId
: null);
return {
cursor: event.cursor.toString(),
entityType: event.entityType,
entityId: event.entityId,
action: event.action,
track: payload.track ?? null,
deletedTrackId,
createdAt: event.createdAt.toISOString(),
};
}
private parsePayload(payload: unknown): LibraryEventPayload {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return {};
}
const record = payload as Record<string, unknown>;
const track =
record.track && typeof record.track === 'object' && !Array.isArray(record.track)
? (record.track as RemoteLibraryTrackDto)
: null;
const deletedTrackId =
typeof record.deletedTrackId === 'string' ? record.deletedTrackId : null;
return {
track,
deletedTrackId,
};
}
}

View File

@ -28,6 +28,7 @@ function createPrismaMock() {
slug: 'default-owner',
displayName: 'Default Owner',
isDefault: true,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
};
@ -38,6 +39,24 @@ function createPrismaMock() {
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
user: {
upsert: jest.fn().mockResolvedValue(defaultUser),
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
const current = users.get(where.id);
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
const updated = {
...current,
libraryCursor: BigInt(current.libraryCursor ?? 0) + incrementBy,
updatedAt: new Date(),
};
users.set(where.id, updated);
if (select?.libraryCursor) {
return {
libraryCursor: updated.libraryCursor,
};
}
return updated;
}),
},
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
@ -218,11 +237,17 @@ function createPrismaMock() {
nextLibraryEventId += 1n;
return record;
}),
findFirst: jest.fn().mockImplementation(async ({ where }) => {
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = [...libraryEvents.values()].filter((event) =>
where?.userId ? event.userId === where.userId : true,
);
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
const direction = orderBy?.cursor ?? 'desc';
return filteredEvents
.sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
)[0] ?? null;
}),
},
state: {
@ -463,15 +488,27 @@ describe('UploadsService', () => {
expect(finalizeResponse.assetId).toBeDefined();
expect(state.tracks.size).toBe(1);
expect(state.audioAssets.size).toBe(1);
expect(state.libraryEvents.size).toBe(1);
expect(state.libraryEvents.size).toBe(2);
const track = [...state.tracks.values()][0];
const audioAsset = [...state.audioAssets.values()][0];
const libraryEvent = [...state.libraryEvents.values()][0];
const libraryEvents = [...state.libraryEvents.values()].sort((lhs, rhs) =>
Number(lhs.cursor - rhs.cursor),
);
expect(track.userId).toBe(state.defaultUser.id);
expect(audioAsset.userId).toBe(state.defaultUser.id);
expect(libraryEvent.userId).toBe(state.defaultUser.id);
expect(libraryEvents.map((event) => event.entityType)).toEqual([
'TRACK',
'AUDIO_ASSET',
]);
expect(libraryEvents.map((event) => event.action)).toEqual([
'CREATED',
'CREATED',
]);
expect(libraryEvents[0]?.payload.track.trackId).toBe(track.id);
expect(libraryEvents[1]?.payload.track.assetId).toBe(audioAsset.id);
expect(state.users.get(state.defaultUser.id)?.libraryCursor).toBe(2n);
const session = state.uploadSessions.get(response.uploadId!);
expect(session.finalizedAt).toBeInstanceOf(Date);

View File

@ -7,6 +7,7 @@ import {
import {
EntityType,
EventAction,
Prisma,
type UploadSession,
UploadSessionStatus,
} from '@prisma/client';
@ -18,6 +19,7 @@ import { extname } from 'node:path';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { RemoteLibraryTrackDto } from '../library/library.dto';
import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import {
@ -38,6 +40,11 @@ interface PreparedArtworkAssetInput {
fileSizeBytes: bigint;
}
interface LibraryEventPayload {
track?: RemoteLibraryTrackDto;
deletedTrackId?: string;
}
@Injectable()
export class UploadsService {
constructor(
@ -367,6 +374,8 @@ export class UploadsService {
}
const createdTrack = !track;
let trackMetadataChanged = false;
if (!track) {
track = await tx.track.create({
data: {
@ -378,8 +387,34 @@ export class UploadsService {
status: 'ACTIVE',
},
});
} else {
const nextTrackDurationMs = body.durationMs ?? track.durationMs;
const shouldUpdateTrack =
track.title !== title ||
track.artist !== artist ||
(track.album ?? null) !== album ||
(track.durationMs ?? null) !== (nextTrackDurationMs ?? null) ||
track.status !== 'ACTIVE' ||
track.deletedAt !== null;
if (shouldUpdateTrack) {
track = await tx.track.update({
where: { id: track.id },
data: {
title,
artist,
album,
durationMs: nextTrackDurationMs,
status: 'ACTIVE',
deletedAt: null,
},
});
trackMetadataChanged = true;
}
}
const createdAudioAsset = !audioAsset;
let audioAssetChanged = createdAudioAsset;
if (audioAsset) {
const nextDurationMs = body.durationMs ?? audioAsset.durationMs;
const shouldUpdateAsset =
@ -404,6 +439,7 @@ export class UploadsService {
durationMs: nextDurationMs,
},
});
audioAssetChanged = true;
}
} else {
audioAsset = await tx.audioAsset.create({
@ -422,6 +458,7 @@ export class UploadsService {
});
}
let primaryAudioAssetChanged = false;
if (track.primaryAudioAssetId !== audioAsset.id) {
track = await tx.track.update({
where: { id: track.id },
@ -429,18 +466,21 @@ export class UploadsService {
primaryAudioAssetId: audioAsset.id,
},
});
primaryAudioAssetChanged = true;
}
const artworkAssetId = preparedArtwork
? (
await this.findOrCreateArtworkAsset(
const priorArtworkAssetId = track.artworkAssetId ?? null;
const artworkResult = preparedArtwork
? await this.findOrCreateArtworkAsset(
tx,
ownerUserId,
preparedArtwork,
)
).id
: null;
const artworkAsset = artworkResult?.artworkAsset ?? null;
const artworkAssetId = artworkAsset?.id ?? null;
let artworkLinkChanged = false;
if ((track.artworkAssetId ?? null) !== artworkAssetId) {
track = await tx.track.update({
where: { id: track.id },
@ -448,16 +488,57 @@ export class UploadsService {
artworkAssetId,
},
});
artworkLinkChanged = true;
}
await tx.libraryEvent.create({
data: {
const finalTrackSnapshot = this.buildRemoteLibraryTrackDto(
track,
audioAsset,
artworkAssetId ? artworkAsset : null,
);
const eventPayload: LibraryEventPayload = {
track: finalTrackSnapshot,
};
if (createdTrack || trackMetadataChanged) {
await this.appendLibraryEvent(tx, {
userId: ownerUserId,
entityType: EntityType.TRACK,
entityId: track.id,
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
},
payload: eventPayload,
});
}
if (audioAssetChanged || primaryAudioAssetChanged) {
await this.appendLibraryEvent(tx, {
userId: ownerUserId,
entityType: EntityType.AUDIO_ASSET,
entityId: audioAsset.id,
action: createdAudioAsset ? EventAction.CREATED : EventAction.UPDATED,
payload: eventPayload,
});
}
if (
artworkResult?.wasCreated ||
artworkResult?.wasUpdated ||
artworkLinkChanged ||
(priorArtworkAssetId !== null && artworkAssetId === null)
) {
await this.appendLibraryEvent(tx, {
userId: ownerUserId,
entityType: EntityType.ARTWORK_ASSET,
entityId: artworkAssetId ?? priorArtworkAssetId!,
action:
artworkAssetId == null
? EventAction.DELETED
: artworkResult?.wasCreated
? EventAction.CREATED
: EventAction.UPDATED,
payload: eventPayload,
});
}
await tx.uploadSession.update({
where: { id: currentSession.id },
@ -542,6 +623,87 @@ export class UploadsService {
}
}
private buildRemoteLibraryTrackDto(
track: {
id: string;
title: string;
artist: string;
durationMs: number | null;
createdAt: Date;
updatedAt: Date;
},
audioAsset: {
id: string;
sha256: string;
durationMs: number | null;
},
artworkAsset: {
id: string;
sha256: string;
mimeType: string;
width: number | null;
height: number | null;
} | null,
): RemoteLibraryTrackDto {
const durationMs = track.durationMs ?? audioAsset.durationMs ?? 0;
return {
trackId: track.id,
title: track.title,
artist: track.artist,
durationSeconds: Math.max(0, Math.round(durationMs / 1000)),
sha256: audioAsset.sha256,
assetId: audioAsset.id,
createdAt: track.createdAt.toISOString(),
updatedAt: track.updatedAt.toISOString(),
artwork: artworkAsset
? {
artworkId: artworkAsset.id,
sha256: artworkAsset.sha256,
mimeType: artworkAsset.mimeType,
width: artworkAsset.width,
height: artworkAsset.height,
}
: null,
};
}
private async appendLibraryEvent(
tx: Pick<PrismaService, 'user' | 'libraryEvent'>,
params: {
userId: string;
entityType: EntityType;
entityId: string;
action: EventAction;
payload: LibraryEventPayload;
},
): Promise<void> {
const owner = await tx.user.update({
where: {
id: params.userId,
},
data: {
libraryCursor: {
increment: 1,
},
},
select: {
libraryCursor: true,
},
});
await tx.libraryEvent.create({
data: {
userId: params.userId,
cursor: owner.libraryCursor,
entityType: params.entityType,
entityId: params.entityId,
action: params.action,
payload: params.payload as Prisma.InputJsonValue,
},
});
}
private toStatusResponse(
uploadSession: Pick<
UploadSession,
@ -636,12 +798,22 @@ export class UploadsService {
fileSizeBytes: artwork.fileSizeBytes,
},
});
return {
artworkAsset,
wasCreated: false,
wasUpdated: true,
};
}
return artworkAsset;
return {
artworkAsset,
wasCreated: false,
wasUpdated: false,
};
}
return tx.artworkAsset.create({
const createdArtworkAsset = await tx.artworkAsset.create({
data: {
userId,
sha256: artwork.sha256,
@ -652,6 +824,12 @@ export class UploadsService {
fileSizeBytes: artwork.fileSizeBytes,
},
});
return {
artworkAsset: createdArtworkAsset,
wasCreated: true,
wasUpdated: false,
};
}
private assertMp3Filename(filename: string): void {

View File

@ -8,6 +8,7 @@ describe('DefaultUserService', () => {
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
};
@ -48,6 +49,7 @@ describe('DefaultUserService', () => {
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
});

View File

@ -84,6 +84,7 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
function createPrismaMock() {
const users = new Map<string, any>();
const devices = new Map<string, any>();
const deviceSyncCursors = new Map<string, any>();
const tracks = new Map<string, any>();
const audioAssets = new Map<string, any>();
const artworkAssets = new Map<string, any>();
@ -91,21 +92,81 @@ function createPrismaMock() {
const libraryEvents = new Map<bigint, any>();
let nextLibraryEventId = 1n;
const defaultUser = {
const createUserRecord = (data: Record<string, any>) => {
const now = new Date();
return {
id: data.id ?? randomUUID(),
createdAt: data.createdAt ?? now,
updatedAt: data.updatedAt ?? now,
...data,
libraryCursor:
data.libraryCursor == null ? 0n : BigInt(data.libraryCursor),
};
};
const defaultUser = createUserRecord({
id: randomUUID(),
slug: 'default-owner',
displayName: 'Default Owner',
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
};
libraryCursor: 0n,
});
users.set(defaultUser.id, defaultUser);
const prismaMock: any = {
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
user: {
upsert: jest.fn().mockResolvedValue(defaultUser),
upsert: jest.fn().mockImplementation(async ({ where, update, create }) => {
const current =
[...users.values()].find((user) => user.slug === where.slug) ?? null;
if (current) {
const updated = createUserRecord({
...current,
...update,
id: current.id,
createdAt: current.createdAt,
updatedAt: new Date(),
libraryCursor: current.libraryCursor,
});
users.set(updated.id, updated);
return updated;
}
const created = createUserRecord(create);
users.set(created.id, created);
return created;
}),
create: jest.fn().mockImplementation(async ({ data }) => {
const created = createUserRecord(data);
users.set(created.id, created);
return created;
}),
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
const current = users.get(where.id);
if (!current) {
throw new Error(
`Test Prisma mock invariant failed: user ${where.id} not found for update`,
);
}
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
const updated = createUserRecord({
...current,
updatedAt: new Date(),
libraryCursor: current.libraryCursor + incrementBy,
});
users.set(where.id, updated);
if (select?.libraryCursor) {
return {
libraryCursor: updated.libraryCursor,
};
}
return updated;
}),
},
device: {
create: jest.fn().mockImplementation(async ({ data }) => {
@ -314,11 +375,44 @@ function createPrismaMock() {
nextLibraryEventId += 1n;
return record;
}),
findFirst: jest.fn().mockImplementation(async ({ where }) => {
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = [...libraryEvents.values()].filter((event) =>
where?.userId ? event.userId === where.userId : true,
);
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
const direction = orderBy?.cursor ?? 'desc';
return filteredEvents
.sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
)[0] ?? null;
}),
findMany: jest.fn().mockImplementation(async ({ where, take }) => {
return [...libraryEvents.values()]
.filter(
(event) =>
(where?.userId ? event.userId === where.userId : true) &&
(where?.cursor?.gt != null ? event.cursor > where.cursor.gt : true),
)
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
.slice(0, take);
}),
},
deviceSyncCursor: {
upsert: jest.fn().mockImplementation(async ({ where, update, create }) => {
const current = deviceSyncCursors.get(where.deviceId);
const nextRecord = current
? {
...current,
...update,
updatedAt: new Date(),
}
: {
...create,
updatedAt: new Date(),
};
deviceSyncCursors.set(where.deviceId, nextRecord);
return nextRecord;
}),
},
};
@ -327,7 +421,9 @@ function createPrismaMock() {
prismaMock,
state: {
defaultUser,
users,
devices,
deviceSyncCursors,
tracks,
audioAssets,
artworkAssets,
@ -696,12 +792,20 @@ describe('Velody API wiring (e2e)', () => {
syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
syncController.changes({ cursor: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]);
expect(bootstrapResponse.nextCursor).toBe('0');
expect(changesResponse.events).toEqual([]);
expect(changesResponse.nextCursor).toBe('0');
expect(changesResponse.hasMore).toBe(false);
expect(changesResponse.requiresBootstrap).toBe(false);
expect(prismaState.deviceSyncCursors.get(device.deviceId)).toMatchObject({
deviceId: device.deviceId,
userId: prismaState.defaultUser.id,
cursor: 0n,
});
});
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
@ -734,10 +838,24 @@ describe('Velody API wiring (e2e)', () => {
});
prismaState.libraryEvents.set(1n, {
id: 1n,
cursor: 1n,
userId: foreignUserId,
entityType: 'TRACK',
entityId: foreignTrackId,
action: 'CREATED',
payload: {
track: {
trackId: foreignTrackId,
title: 'Foreign Bootstrap Track',
artist: 'Elsewhere',
durationSeconds: 180,
sha256: 'f'.repeat(64),
assetId: randomUUID(),
createdAt: '2026-05-29T08:00:00.000Z',
updatedAt: '2026-05-29T08:01:00.000Z',
artwork: null,
},
},
payloadVersion: 1,
createdAt: new Date('2026-05-29T08:02:00.000Z'),
});
@ -746,7 +864,7 @@ describe('Velody API wiring (e2e)', () => {
syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
syncController.changes({ cursor: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]);
@ -1590,6 +1708,18 @@ describe('Velody API wiring (e2e)', () => {
it('makes an upload from one device visible to another linked device under the same owner', async () => {
const identityUserId = randomUUID();
prismaState.users.set(
identityUserId,
{
id: identityUserId,
slug: `identity-${identityUserId}`,
displayName: 'Identity Owner',
isDefault: false,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
},
);
const primaryDevice = seedDevice({
userId: identityUserId,
deviceAccessToken: 'linked-upload-primary-token',
@ -1713,7 +1843,7 @@ describe('Velody API wiring (e2e)', () => {
expect(duplicatePrepare.status).toBe('exists');
expect(duplicatePrepare.uploadId).toBeDefined();
expect(prismaState.audioAssets.size).toBe(1);
expect(prismaState.libraryEvents.size).toBe(1);
expect(prismaState.libraryEvents.size).toBe(2);
});
it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => {

View File

@ -280,96 +280,157 @@ public struct SyncCursor: Codable, Hashable, Sendable {
}
public struct SyncEvent: Codable, Hashable, Sendable {
public var cursor: SyncCursor
public var entityType: String
public var entityId: String
public var action: String
public var eventId: String
public var track: RemoteTrack?
public var deletedTrackId: String?
public var createdAt: String
public init(
cursor: SyncCursor,
entityType: String,
entityId: String,
action: String,
eventId: String
track: RemoteTrack? = nil,
deletedTrackId: String? = nil,
createdAt: String
) {
self.cursor = cursor
self.entityType = entityType
self.entityId = entityId
self.action = action
self.eventId = eventId
self.track = track
self.deletedTrackId = deletedTrackId
self.createdAt = createdAt
}
private enum CodingKeys: String, CodingKey {
case cursor
case entityType
case entityId
case action
case track
case deletedTrackId
case createdAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
cursor = SyncCursor(
value: try container.decode(String.self, forKey: .cursor)
)
entityType = try container.decode(String.self, forKey: .entityType)
entityId = try container.decode(String.self, forKey: .entityId)
action = try container.decode(String.self, forKey: .action)
track = try container.decodeIfPresent(RemoteTrack.self, forKey: .track)
deletedTrackId = try container.decodeIfPresent(String.self, forKey: .deletedTrackId)
createdAt = try container.decode(String.self, forKey: .createdAt)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(cursor.value, forKey: .cursor)
try container.encode(entityType, forKey: .entityType)
try container.encode(entityId, forKey: .entityId)
try container.encode(action, forKey: .action)
try container.encodeIfPresent(track, forKey: .track)
try container.encodeIfPresent(deletedTrackId, forKey: .deletedTrackId)
try container.encode(createdAt, forKey: .createdAt)
}
}
public struct SyncBootstrapResponse: Codable, Hashable, Sendable {
public var nextCursor: SyncCursor
public var tracks: [LibraryTrack]
public var events: [SyncEvent]
public var deletedTrackIds: [String]
public var tracks: [RemoteTrack]
public var serverTime: String
public init(
nextCursor: SyncCursor,
tracks: [LibraryTrack],
events: [SyncEvent],
deletedTrackIds: [String],
tracks: [RemoteTrack],
serverTime: String
) {
self.nextCursor = nextCursor
self.tracks = tracks
self.events = events
self.deletedTrackIds = deletedTrackIds
self.serverTime = serverTime
}
private enum CodingKeys: String, CodingKey {
case nextCursor
case tracks
case events
case deletedTrackIds
case serverTime
}
private struct WireTrack: Codable {
var id: String?
var title: String?
var artist: String?
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let wireTracks = try container.decode([WireTrack].self, forKey: .tracks)
nextCursor = SyncCursor(
value: try container.decode(String.self, forKey: .nextCursor)
)
tracks = wireTracks.map { track in
LibraryTrack(
id: track.id ?? UUID().uuidString,
title: track.title ?? "Unknown Title",
artist: track.artist ?? "Unknown Artist",
album: nil,
durationSeconds: nil,
localFilePath: "",
sha256: nil
)
}
events = try container.decode([SyncEvent].self, forKey: .events)
deletedTrackIds = try container.decode([String].self, forKey: .deletedTrackIds)
tracks = try container.decode([RemoteTrack].self, forKey: .tracks)
serverTime = try container.decode(String.self, forKey: .serverTime)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let wireTracks = tracks.map { track in
WireTrack(
id: track.id,
title: track.title,
artist: track.artist
)
try container.encode(nextCursor.value, forKey: .nextCursor)
try container.encode(tracks, forKey: .tracks)
try container.encode(serverTime, forKey: .serverTime)
}
}
public struct SyncChangesResponse: Codable, Hashable, Sendable {
public var nextCursor: SyncCursor
public var hasMore: Bool
public var requiresBootstrap: Bool
public var reason: String?
public var events: [SyncEvent]
public var serverTime: String
public init(
nextCursor: SyncCursor,
hasMore: Bool,
requiresBootstrap: Bool,
reason: String? = nil,
events: [SyncEvent],
serverTime: String
) {
self.nextCursor = nextCursor
self.hasMore = hasMore
self.requiresBootstrap = requiresBootstrap
self.reason = reason
self.events = events
self.serverTime = serverTime
}
private enum CodingKeys: String, CodingKey {
case nextCursor
case hasMore
case requiresBootstrap
case reason
case events
case serverTime
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
nextCursor = SyncCursor(
value: try container.decode(String.self, forKey: .nextCursor)
)
hasMore = try container.decode(Bool.self, forKey: .hasMore)
requiresBootstrap = try container.decode(Bool.self, forKey: .requiresBootstrap)
reason = try container.decodeIfPresent(String.self, forKey: .reason)
events = try container.decode([SyncEvent].self, forKey: .events)
serverTime = try container.decode(String.self, forKey: .serverTime)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(nextCursor.value, forKey: .nextCursor)
try container.encode(wireTracks, forKey: .tracks)
try container.encode(hasMore, forKey: .hasMore)
try container.encode(requiresBootstrap, forKey: .requiresBootstrap)
try container.encodeIfPresent(reason, forKey: .reason)
try container.encode(events, forKey: .events)
try container.encode(deletedTrackIds, forKey: .deletedTrackIds)
try container.encode(serverTime, forKey: .serverTime)
}
}

View File

@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable {
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO
@ -127,6 +131,20 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
)
}
public func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "sync", "changes"],
queryItems: [
URLQueryItem(name: "cursor", value: cursor.value),
],
includesDeviceAuthorization: true,
responseType: SyncChangesResponse.self
)
}
public func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
@ -467,15 +485,31 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
return SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [
LibraryTrack(
RemoteTrack(
trackId: UUID().uuidString,
title: "Velody Placeholder",
artist: "Private Library",
album: "Phase 1",
localFilePath: ""
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: UUID().uuidString,
createdAt: ISO8601DateFormatter().string(from: .now),
updatedAt: ISO8601DateFormatter().string(from: .now)
),
],
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse {
_ = cursor
return SyncChangesResponse(
nextCursor: SyncCursor(value: "0"),
hasMore: false,
requiresBootstrap: false,
events: [],
deletedTrackIds: [],
serverTime: ISO8601DateFormatter().string(from: .now)
)
}

View File

@ -77,4 +77,46 @@ final class RemoteLibraryDTOTests: XCTestCase {
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789")
}
func testSyncChangesResponseDecodesTrackPayloadAndCursor() throws {
let data = Data(
"""
{
"nextCursor": "12",
"hasMore": false,
"requiresBootstrap": false,
"events": [
{
"cursor": "12",
"entityType": "TRACK",
"entityId": "track-123",
"action": "UPDATED",
"track": {
"trackId": "track-123",
"title": "Remote Title",
"artist": "Remote Artist",
"durationSeconds": 245,
"sha256": "\(String(repeating: "a", count: 64))",
"assetId": "asset-456",
"createdAt": "2026-06-15T12:00:00.000Z",
"updatedAt": "2026-06-15T12:05:00.000Z",
"artwork": null
},
"deletedTrackId": null,
"createdAt": "2026-06-15T12:05:00.000Z"
}
],
"serverTime": "2026-06-15T12:05:00.000Z"
}
""".utf8
)
let decoded = try JSONDecoder().decode(SyncChangesResponse.self, from: data)
XCTAssertEqual(decoded.nextCursor.value, "12")
XCTAssertFalse(decoded.hasMore)
XCTAssertFalse(decoded.requiresBootstrap)
XCTAssertEqual(decoded.events.first?.cursor.value, "12")
XCTAssertEqual(decoded.events.first?.track?.trackId, "track-123")
}
}

View File

@ -44,6 +44,52 @@ final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase {
XCTAssertEqual(response.tracks.count, 0)
}
func testFetchSyncChangesSendsAuthorizationHeaderAndCursorQuery() async throws {
RecordingURLProtocol.handler = { request in
XCTAssertEqual(
request.value(forHTTPHeaderField: "Authorization"),
"Bearer test-device-access-token"
)
XCTAssertEqual(request.url?.path, "/api/v1/sync/changes")
XCTAssertEqual(request.url?.query, "cursor=12")
return (
HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!,
Data(
"""
{
"nextCursor": "12",
"hasMore": false,
"requiresBootstrap": false,
"events": [],
"serverTime": "2026-06-15T12:00:00.000Z"
}
""".utf8
)
)
}
let client = URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: URL(string: "http://127.0.0.1:3007")!,
appVersion: "Tests"
),
session: makeSession(),
deviceAccessTokenProvider: {
"test-device-access-token"
}
)
let response = try await client.fetchSyncChanges(cursor: SyncCursor(value: "12"))
XCTAssertEqual(response.nextCursor.value, "12")
XCTAssertEqual(response.events.count, 0)
}
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
RecordingURLProtocol.handler = { request in
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))

View File

@ -0,0 +1,94 @@
import Foundation
import VelodyDomain
public protocol RemoteLibrarySyncCursorStore: Actor {
func loadCursor() async throws -> SyncCursor?
func saveCursor(_ cursor: SyncCursor) async throws
func clearCursor() async throws
}
private struct StoredRemoteLibrarySyncCursor: Codable {
var value: String
}
public actor FileRemoteLibrarySyncCursorStore: RemoteLibrarySyncCursorStore {
private let fileURL: URL
private let fileManager: FileManager
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
fileURL: URL? = nil,
fileManager: FileManager = .default
) throws {
self.fileManager = fileManager
if let fileURL {
self.fileURL = fileURL
} else {
self.fileURL = try Self.defaultFileURL(fileManager: fileManager)
}
}
public func loadCursor() async throws -> SyncCursor? {
guard fileManager.fileExists(atPath: fileURL.path) else {
return nil
}
let data = try Data(contentsOf: fileURL)
let storedCursor = try decoder.decode(StoredRemoteLibrarySyncCursor.self, from: data)
return SyncCursor(value: storedCursor.value)
}
public func saveCursor(_ cursor: SyncCursor) async throws {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try encoder.encode(
StoredRemoteLibrarySyncCursor(value: cursor.value)
)
try data.write(to: fileURL, options: .atomic)
}
public func clearCursor() async throws {
guard fileManager.fileExists(atPath: fileURL.path) else {
return
}
try fileManager.removeItem(at: fileURL)
}
private static func defaultFileURL(fileManager: FileManager) throws -> URL {
guard let applicationSupportURL = fileManager.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
throw CocoaError(.fileNoSuchFile)
}
return applicationSupportURL
.appendingPathComponent("Velody", isDirectory: true)
.appendingPathComponent("remote-library-sync-cursor.json")
}
}
public actor InMemoryRemoteLibrarySyncCursorStore: RemoteLibrarySyncCursorStore {
private var cursor: SyncCursor?
public init(cursor: SyncCursor? = nil) {
self.cursor = cursor
}
public func loadCursor() async throws -> SyncCursor? {
cursor
}
public func saveCursor(_ cursor: SyncCursor) async throws {
self.cursor = cursor
}
public func clearCursor() async throws {
cursor = nil
}
}

View File

@ -37,7 +37,18 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator {
public func performInitialSync() async throws -> SyncResult {
let bootstrap = try await apiClient.fetchSyncBootstrap()
try await store.replaceTracks(bootstrap.tracks)
try await store.replaceTracks(
bootstrap.tracks.map { track in
LibraryTrack(
id: track.trackId,
title: track.title,
artist: track.artist,
durationSeconds: Double(track.durationSeconds),
localFilePath: "",
sha256: track.sha256
)
}
)
let persistedTracks = try await store.loadTracks()
return SyncResult(

View File

@ -13,13 +13,16 @@ public protocol RemoteLibraryRepository: Actor {
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
private let apiClient: any VelodyAPIClient
private let store: any RemoteLibraryStore
private let syncCursorStore: any RemoteLibrarySyncCursorStore
public init(
apiClient: any VelodyAPIClient,
store: any RemoteLibraryStore
store: any RemoteLibraryStore,
syncCursorStore: any RemoteLibrarySyncCursorStore
) {
self.apiClient = apiClient
self.store = store
self.syncCursorStore = syncCursorStore
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
@ -27,10 +30,13 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
}
public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
let response = try await apiClient.fetchRemoteLibrary(deviceId: deviceId)
let tracks = response.tracks.map(\.remoteTrack)
try await store.replaceRemoteTracks(tracks)
return tracks
_ = deviceId
if let currentCursor = try await syncCursorStore.loadCursor() {
return try await syncIncrementally(from: currentCursor)
}
return try await bootstrap()
}
public func downloadAudioAsset(
@ -46,4 +52,74 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
) async throws -> Data {
try await apiClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId)
}
private func bootstrap() async throws -> [RemoteTrack] {
let response = try await apiClient.fetchSyncBootstrap()
let tracks = orderTracks(response.tracks)
try await store.replaceRemoteTracks(tracks)
try await syncCursorStore.saveCursor(response.nextCursor)
return tracks
}
private func syncIncrementally(
from cursor: SyncCursor
) async throws -> [RemoteTrack] {
let cachedTracks = try await store.loadRemoteTracks()
var mergedTracks = Dictionary(
uniqueKeysWithValues: cachedTracks.map { ($0.trackId, $0) }
)
var currentCursor = cursor
while true {
let response = try await apiClient.fetchSyncChanges(cursor: currentCursor)
if response.requiresBootstrap {
return try await bootstrap()
}
mergedTracks = apply(events: response.events, to: mergedTracks)
currentCursor = response.nextCursor
if !response.hasMore {
break
}
}
let orderedTracks = orderTracks(Array(mergedTracks.values))
try await store.replaceRemoteTracks(orderedTracks)
try await syncCursorStore.saveCursor(currentCursor)
return orderedTracks
}
private func apply(
events: [SyncEvent],
to tracksByID: [String: RemoteTrack]
) -> [String: RemoteTrack] {
var nextTracksByID = tracksByID
for event in events {
if let deletedTrackID = event.deletedTrackId, !deletedTrackID.isEmpty {
nextTracksByID.removeValue(forKey: deletedTrackID)
continue
}
guard let track = event.track else {
continue
}
nextTracksByID[track.trackId] = track
}
return nextTracksByID
}
private func orderTracks(_ tracks: [RemoteTrack]) -> [RemoteTrack] {
tracks.sorted { lhs, rhs in
if lhs.createdAt == rhs.createdAt {
return lhs.trackId < rhs.trackId
}
return lhs.createdAt < rhs.createdAt
}
}
}

View File

@ -26,7 +26,7 @@ public actor RemoteLibrarySyncService {
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
let states = try await downloadStateStore.loadDownloadStates()
return try await reconcileDownloadedLocalFilePaths(in: states)
return try await reconcilePersistedDownloadStates(in: states)
}
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
@ -150,7 +150,7 @@ public actor RemoteLibrarySyncService {
)
}
private func reconcileDownloadedLocalFilePaths(
private func reconcilePersistedDownloadStates(
in states: [RemoteTrackDownloadState]
) async throws -> [RemoteTrackDownloadState] {
guard !states.isEmpty else {
@ -162,14 +162,16 @@ public actor RemoteLibrarySyncService {
for index in reconciledStates.indices {
let state = reconciledStates[index]
guard state.downloadStatus == .downloaded else {
continue
}
guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
persistedLocalFilePath: state.localFilePath,
assetId: state.assetId
) else {
)
switch state.downloadStatus {
case .notDownloaded, .failed:
continue
case .downloaded:
guard let resolvedLocalFilePath else {
continue
}
@ -177,6 +179,21 @@ public actor RemoteLibrarySyncService {
reconciledStates[index].localFilePath = resolvedLocalFilePath
didChange = true
}
case .downloading:
if let resolvedLocalFilePath {
if state.localFilePath != resolvedLocalFilePath {
reconciledStates[index].localFilePath = resolvedLocalFilePath
}
reconciledStates[index].downloadStatus = .downloaded
reconciledStates[index].lastDownloadError = nil
} else {
reconciledStates[index].localFilePath = ""
reconciledStates[index].downloadedAt = nil
reconciledStates[index].downloadStatus = .failed
reconciledStates[index].lastDownloadError = Self.interruptedDownloadErrorMessage
}
didChange = true
}
}
if didChange {
@ -186,6 +203,8 @@ public actor RemoteLibrarySyncService {
return reconciledStates
}
private static let interruptedDownloadErrorMessage = "The previous download did not finish. Try again."
private func cacheArtwork(
for tracks: [RemoteTrack],
deviceId: String

View File

@ -176,7 +176,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
let syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)),
store: remoteLibraryStore
store: remoteLibraryStore,
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
@ -226,8 +227,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
let remoteLibraryStore = InMemoryRemoteLibraryStore()
let audioData = sampleMp3Data(seed: track.assetId)
let apiClient = OfflineLibraryMockAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [makeRemoteTrackDTO(from: track)]
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "1"),
tracks: [track],
serverTime: "2026-05-30T08:00:00.000Z"
),
audioAssetData: audioData,
artworkDataByArtworkID: [
@ -240,7 +243,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
let syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: remoteLibraryStore
store: remoteLibraryStore,
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
@ -284,8 +288,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
]
let apiClient = OfflineLibraryMockAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: tracks.map { makeRemoteTrackDTO(from: $0) }
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "1"),
tracks: tracks,
serverTime: "2026-05-30T08:00:00.000Z"
),
audioAssetDataByAssetID: [
"asset-1": sampleMp3Data(seed: "asset-1"),
@ -304,7 +310,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
let firstRepository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
)
let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
@ -329,7 +336,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
let relaunchedRepository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore(cursor: SyncCursor(value: "1"))
)
let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
@ -404,18 +412,22 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
}
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
let remoteLibraryResponse: RemoteLibraryResponseDTO?
let bootstrapResponse: SyncBootstrapResponse
let audioAssetData: Data?
let audioAssetDataByAssetID: [String: Data]
let artworkDataByArtworkID: [String: Data]
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [],
serverTime: "2026-05-30T08:00:00.000Z"
),
audioAssetData: Data? = nil,
audioAssetDataByAssetID: [String: Data] = [:],
artworkDataByArtworkID: [String: Data] = [:]
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.bootstrapResponse = bootstrapResponse
self.audioAssetData = audioAssetData
self.audioAssetDataByAssetID = audioAssetDataByAssetID
self.artworkDataByArtworkID = artworkDataByArtworkID
@ -444,11 +456,17 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
}
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [],
bootstrapResponse
}
func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse {
SyncChangesResponse(
nextCursor: cursor,
hasMore: false,
requiresBootstrap: false,
events: [],
deletedTrackIds: [],
serverTime: "2026-05-30T08:00:00.000Z"
)
}
@ -457,7 +475,9 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
_ = deviceId
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
return RemoteLibraryResponseDTO(
tracks: bootstrapResponse.tracks.map { makeRemoteTrackDTO(from: $0) }
)
}
func downloadAudioAsset(

View File

@ -7,112 +7,277 @@ import VelodyPersistence
@testable import VelodySync
final class RemoteLibrarySyncServiceTests: XCTestCase {
func testSuccessfulSyncPersistsRemoteTracks() async throws {
func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws {
let store = InMemoryRemoteLibraryStore()
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
let cursorStore = InMemoryRemoteLibrarySyncCursorStore()
let track = makeRemoteTrack(trackId: "track-123")
let service = makeSyncService(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [
RemoteTrackDTO(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
),
]
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "4"),
tracks: [track],
serverTime: "2026-06-15T12:00:00.000Z"
)
),
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
store: store,
cursorStore: cursorStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedTracks = try await service.loadCachedRemoteTracks()
let storedCursor = try await cursorStore.loadCursor()
let downloadStates = try await service.loadDownloadStates()
XCTAssertEqual(tracks.count, 1)
XCTAssertEqual(cachedTracks, tracks)
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded)
XCTAssertEqual(tracks, [track])
XCTAssertEqual(cachedTracks, [track])
XCTAssertEqual(storedCursor, SyncCursor(value: "4"))
XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded)
}
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
let store = InMemoryRemoteLibraryStore(
tracks: [
RemoteTrack(
trackId: "track-123",
title: "Old",
artist: "Artist",
durationSeconds: 100,
sha256: String(repeating: "b", count: 64),
assetId: "asset-123",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
func testChangesForExistingCursorApplyWithoutDuplicateTracks() async throws {
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "New Title")
let secondTrack = makeRemoteTrack(
trackId: "track-456",
title: "Second Track",
createdAt: "2026-06-15T12:01:00.000Z"
)
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
cursor: SyncCursor(value: "4")
)
let apiClient = MockVelodyAPIClient(
changeResponses: [
SyncChangesResponse(
nextCursor: SyncCursor(value: "6"),
hasMore: false,
requiresBootstrap: false,
events: [
SyncEvent(
cursor: SyncCursor(value: "5"),
entityType: "TRACK",
entityId: updatedTrack.trackId,
action: "UPDATED",
track: updatedTrack,
createdAt: "2026-06-15T12:00:30.000Z"
),
SyncEvent(
cursor: SyncCursor(value: "6"),
entityType: "AUDIO_ASSET",
entityId: secondTrack.assetId,
action: "CREATED",
track: secondTrack,
createdAt: "2026-06-15T12:01:00.000Z"
),
],
serverTime: "2026-06-15T12:01:00.000Z"
),
]
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(
states: [
RemoteTrackDownloadState(
remoteTrackId: "track-123",
assetId: "asset-123",
downloadStatus: .downloaded
),
]
)
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
),
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
let service = makeSyncService(
apiClient: apiClient,
store: store,
cursorStore: cursorStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedTracks = try await service.loadCachedRemoteTracks()
let downloadStates = try await service.loadDownloadStates()
let storedCursor = try await cursorStore.loadCursor()
XCTAssertEqual(tracks, [])
XCTAssertEqual(cachedTracks, [])
XCTAssertEqual(downloadStates.count, 1)
XCTAssertEqual(tracks, [updatedTrack, secondTrack])
XCTAssertEqual(Set(tracks.map(\.trackId)), ["track-123", "track-456"])
XCTAssertEqual(storedCursor, SyncCursor(value: "6"))
let changeCursors = await apiClient.recordedChangeCursors()
XCTAssertEqual(changeCursors, ["4"])
}
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
let cachedTrack = RemoteTrack(
trackId: "track-123",
title: "Cached",
artist: "Artist",
durationSeconds: 100,
sha256: String(repeating: "c", count: 64),
assetId: "asset-123",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
func testPaginationAndReplayEventsDoNotDuplicateTracks() async throws {
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "Updated Once")
let secondUpdate = makeRemoteTrack(trackId: "track-123", title: "Updated Twice")
let secondTrack = makeRemoteTrack(
trackId: "track-456",
title: "Another Track",
createdAt: "2026-06-15T12:02:00.000Z"
)
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
cursor: SyncCursor(value: "3")
)
let apiClient = MockVelodyAPIClient(
changeResponses: [
SyncChangesResponse(
nextCursor: SyncCursor(value: "5"),
hasMore: true,
requiresBootstrap: false,
events: [
SyncEvent(
cursor: SyncCursor(value: "4"),
entityType: "TRACK",
entityId: updatedTrack.trackId,
action: "UPDATED",
track: updatedTrack,
createdAt: "2026-06-15T12:00:30.000Z"
),
SyncEvent(
cursor: SyncCursor(value: "5"),
entityType: "TRACK",
entityId: secondUpdate.trackId,
action: "UPDATED",
track: secondUpdate,
createdAt: "2026-06-15T12:01:00.000Z"
),
],
serverTime: "2026-06-15T12:01:00.000Z"
),
SyncChangesResponse(
nextCursor: SyncCursor(value: "6"),
hasMore: false,
requiresBootstrap: false,
events: [
SyncEvent(
cursor: SyncCursor(value: "6"),
entityType: "TRACK",
entityId: secondTrack.trackId,
action: "CREATED",
track: secondTrack,
createdAt: "2026-06-15T12:02:00.000Z"
),
],
serverTime: "2026-06-15T12:02:00.000Z"
),
]
)
let service = makeSyncService(
apiClient: apiClient,
store: store,
cursorStore: cursorStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
XCTAssertEqual(tracks, [secondUpdate, secondTrack])
XCTAssertEqual(Set(tracks.map(\.trackId)).count, 2)
let changeCursors = await apiClient.recordedChangeCursors()
XCTAssertEqual(changeCursors, ["3", "5"])
}
func testRequiresBootstrapFallbackReplacesCachedLibraryAndCursor() async throws {
let staleTrack = makeRemoteTrack(trackId: "track-stale", title: "Stale")
let freshTrack = makeRemoteTrack(trackId: "track-fresh", title: "Fresh")
let store = InMemoryRemoteLibraryStore(tracks: [staleTrack])
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
cursor: SyncCursor(value: "2")
)
let apiClient = MockVelodyAPIClient(
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "9"),
tracks: [freshTrack],
serverTime: "2026-06-15T12:05:00.000Z"
),
changeResponses: [
SyncChangesResponse(
nextCursor: SyncCursor(value: "2"),
hasMore: false,
requiresBootstrap: true,
reason: "cursor_too_old",
events: [],
serverTime: "2026-06-15T12:04:00.000Z"
),
]
)
let service = makeSyncService(
apiClient: apiClient,
store: store,
cursorStore: cursorStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let storedCursor = try await cursorStore.loadCursor()
XCTAssertEqual(tracks, [freshTrack])
XCTAssertEqual(storedCursor, SyncCursor(value: "9"))
let changeCursors = await apiClient.recordedChangeCursors()
XCTAssertEqual(changeCursors, ["2"])
}
func testCursorPersistsAcrossRepositoryInstances() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json")
let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json")
let bootstrapTrack = makeRemoteTrack(trackId: "track-bootstrap")
let changedTrack = makeRemoteTrack(trackId: "track-bootstrap", title: "Track Bootstrap Updated")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstClient = MockVelodyAPIClient(
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "2"),
tracks: [bootstrapTrack],
serverTime: "2026-06-15T12:00:00.000Z"
)
)
let firstService = makeSyncService(
apiClient: firstClient,
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
)
_ = try await firstService.syncRemoteLibrary(deviceId: "device-123")
let secondClient = MockVelodyAPIClient(
changeResponses: [
SyncChangesResponse(
nextCursor: SyncCursor(value: "3"),
hasMore: false,
requiresBootstrap: false,
events: [
SyncEvent(
cursor: SyncCursor(value: "3"),
entityType: "TRACK",
entityId: changedTrack.trackId,
action: "UPDATED",
track: changedTrack,
createdAt: "2026-06-15T12:01:00.000Z"
),
],
serverTime: "2026-06-15T12:01:00.000Z"
),
]
)
let secondService = makeSyncService(
apiClient: secondClient,
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
)
let tracks = try await secondService.syncRemoteLibrary(deviceId: "device-123")
let storedCursor = try await FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
.loadCursor()
XCTAssertEqual(tracks, [changedTrack])
XCTAssertEqual(storedCursor, SyncCursor(value: "3"))
let changeCursors = await secondClient.recordedChangeCursors()
XCTAssertEqual(changeCursors, ["2"])
}
func testNetworkFailureLeavesCachedLibraryAndCursorIntact() async throws {
let cachedTrack = makeRemoteTrack(trackId: "track-123", title: "Cached")
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
cursor: SyncCursor(value: "7")
)
let service = makeSyncService(
apiClient: MockVelodyAPIClient(
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
changeError: VelodyAPIError.requestFailed("Offline")
),
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
store: store,
cursorStore: cursorStore
)
await XCTAssertThrowsErrorAsync {
@ -120,7 +285,48 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
}
let cachedTracks = try await service.loadCachedRemoteTracks()
let storedCursor = try await cursorStore.loadCursor()
XCTAssertEqual(cachedTracks, [cachedTrack])
XCTAssertEqual(storedCursor, SyncCursor(value: "7"))
}
func testSyncFailurePreservesDownloadedStateAndLocalFile() async throws {
let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline")
let localFilePath = "/in-memory/\(track.assetId).mp3"
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: localFilePath,
downloadedAt: Date(timeIntervalSince1970: 3_000),
downloadStatus: .downloaded
),
])
let audioFileStore = InMemoryOfflineAudioFileStore(files: [
localFilePath: sampleMp3Data(seed: "network-safe"),
])
let service = makeSyncService(
apiClient: MockVelodyAPIClient(
changeError: VelodyAPIError.requestFailed("Offline")
),
store: InMemoryRemoteLibraryStore(tracks: [track]),
cursorStore: InMemoryRemoteLibrarySyncCursorStore(
cursor: SyncCursor(value: "7")
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
await XCTAssertThrowsErrorAsync {
_ = try await service.syncRemoteLibrary(deviceId: "device-123")
}
let recoveredState = try await service.loadDownloadStates().first
let fileExists = await audioFileStore.fileExists(at: localFilePath)
XCTAssertEqual(recoveredState?.downloadStatus, .downloaded)
XCTAssertEqual(recoveredState?.localFilePath, localFilePath)
XCTAssertTrue(fileExists)
}
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
@ -129,34 +335,29 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
audioAssetData: sampleMp3Data(seed: "download-success")
),
store: InMemoryRemoteLibraryStore()
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
let track = RemoteTrack(
let track = makeRemoteTrack(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
assetId: "asset-456"
)
let state = try await service.downloadTrack(track, deviceId: "device-123")
let storedStates = try await service.loadDownloadStates()
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
XCTAssertEqual(state.downloadStatus, .downloaded)
XCTAssertEqual(state.downloadStatus, RemoteTrackDownloadStatus.downloaded)
XCTAssertEqual(state.assetId, "asset-456")
XCTAssertFalse(state.localFilePath.isEmpty)
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded)
XCTAssertTrue(fileExists)
}
@ -164,35 +365,129 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
),
store: InMemoryRemoteLibraryStore()
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let track = RemoteTrack(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(sampleMp3Data(seed: "download-failure")),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
)
let track = makeRemoteTrack(trackId: "track-123", assetId: "asset-456")
await XCTAssertThrowsErrorAsync {
_ = try await service.downloadTrack(track, deviceId: "device-123")
}
let storedStates = try await service.loadDownloadStates()
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.failed)
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
}
func testRetryAfterFailureCanSucceedAndPersistDownloadedState() async throws {
let audioData = sampleMp3Data(seed: "retry-success")
let track = makeRemoteTrack(
trackId: "track-retry",
sha256: sha256Hex(audioData),
assetId: "asset-retry"
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let audioFileStore = InMemoryOfflineAudioFileStore()
let service = RemoteLibrarySyncService(
repository: SequencedDownloadRepository(
downloadResults: [
.failure(VelodyAPIError.server(statusCode: 503, message: "Try Again")),
.success(audioData),
]
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
await XCTAssertThrowsErrorAsync {
_ = try await service.downloadTrack(track, deviceId: "device-123")
}
let failedState = try await service.loadDownloadStates().first
XCTAssertEqual(failedState?.downloadStatus, .failed)
let recoveredState = try await service.downloadTrack(track, deviceId: "device-123")
let persistedState = try await downloadStateStore.loadDownloadStates().first
let fileExists = await audioFileStore.fileExists(at: recoveredState.localFilePath)
XCTAssertEqual(recoveredState.downloadStatus, .downloaded)
XCTAssertEqual(persistedState?.downloadStatus, .downloaded)
XCTAssertFalse(recoveredState.localFilePath.isEmpty)
XCTAssertTrue(fileExists)
}
func testLoadDownloadStatesRecoversInterruptedDownloadToFailedRetryStateWhenFileIsMissing() async throws {
let interruptedState = RemoteTrackDownloadState(
remoteTrackId: "track-123",
assetId: "asset-456",
localFilePath: "/in-memory/asset-456.mp3",
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloading
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState])
let service = makeSyncService(
apiClient: MockVelodyAPIClient(),
store: InMemoryRemoteLibraryStore(),
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
)
let recoveredStates = try await service.loadDownloadStates()
let recoveredState = try XCTUnwrap(recoveredStates.first)
let persistedState = try await downloadStateStore.loadDownloadStates().first
XCTAssertEqual(recoveredState.downloadStatus, .failed)
XCTAssertEqual(
recoveredState.lastDownloadError,
"The previous download did not finish. Try again."
)
XCTAssertEqual(recoveredState.localFilePath, "")
XCTAssertNil(recoveredState.downloadedAt)
XCTAssertEqual(persistedState, recoveredState)
}
func testLoadDownloadStatesRecoversInterruptedDownloadToDownloadedWhenFileExists() async throws {
let recoveredFilePath = "/in-memory/asset-456.mp3"
let recoveredDate = Date(timeIntervalSince1970: 2_000)
let interruptedState = RemoteTrackDownloadState(
remoteTrackId: "track-123",
assetId: "asset-456",
localFilePath: "",
downloadedAt: recoveredDate,
downloadStatus: .downloading,
lastDownloadError: "Interrupted"
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState])
let audioFileStore = InMemoryOfflineAudioFileStore(files: [
recoveredFilePath: sampleMp3Data(seed: "interrupted-file"),
])
let service = makeSyncService(
apiClient: MockVelodyAPIClient(),
store: InMemoryRemoteLibraryStore(),
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
let recoveredStates = try await service.loadDownloadStates()
let recoveredState = try XCTUnwrap(recoveredStates.first)
let persistedState = try await downloadStateStore.loadDownloadStates().first
XCTAssertEqual(recoveredState.downloadStatus, .downloaded)
XCTAssertEqual(recoveredState.localFilePath, recoveredFilePath)
XCTAssertEqual(recoveredState.downloadedAt, recoveredDate)
XCTAssertNil(recoveredState.lastDownloadError)
XCTAssertEqual(persistedState, recoveredState)
}
func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
@ -203,15 +498,11 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
let audioData = sampleMp3Data(seed: "relaunch-repair")
let track = RemoteTrack(
let track = makeRemoteTrack(
trackId: "track-123",
title: "1 Mai 2026",
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(audioData),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
assetId: "asset-456"
)
defer {
@ -220,11 +511,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
let firstService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
audioAssetData: audioData
),
store: InMemoryRemoteLibraryStore()
apiClient: MockVelodyAPIClient(audioAssetData: audioData),
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
@ -240,8 +529,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
let relaunchedService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
store: InMemoryRemoteLibraryStore()
apiClient: MockVelodyAPIClient(),
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: relaunchedAudioStore,
@ -255,7 +545,7 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
.loadDownloadStates()
.first
XCTAssertEqual(restoredState.downloadStatus, .downloaded)
XCTAssertEqual(restoredState.downloadStatus, RemoteTrackDownloadStatus.downloaded)
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
@ -270,37 +560,22 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
width: 1,
height: 1
)
let artworkStore = InMemoryArtworkStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [
RemoteTrackDTO(
let track = makeRemoteTrack(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z",
artwork: RemoteArtworkDTO(
artworkId: artwork.artworkId,
sha256: artwork.sha256,
mimeType: artwork.mimeType,
width: artwork.width,
height: artwork.height
artwork: artwork
)
),
]
let artworkStore = InMemoryArtworkStore()
let service = makeSyncService(
apiClient: MockVelodyAPIClient(
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "1"),
tracks: [track],
serverTime: "2026-06-15T12:00:00.000Z"
),
artworkData: sampleArtworkData()
),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore(),
store: InMemoryRemoteLibraryStore(),
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
artworkStore: artworkStore
)
@ -315,24 +590,53 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
}
}
private struct MockVelodyAPIClient: VelodyAPIClient {
let remoteLibraryResponse: RemoteLibraryResponseDTO?
let remoteLibraryError: VelodyAPIError?
let audioAssetData: Data?
let downloadError: VelodyAPIError?
let artworkData: Data?
let artworkDownloadError: VelodyAPIError?
private func makeSyncService(
apiClient: any VelodyAPIClient,
store: any RemoteLibraryStore,
cursorStore: any RemoteLibrarySyncCursorStore,
downloadStateStore: any RemoteTrackDownloadStateStore = InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: any OfflineAudioFileStore = InMemoryOfflineAudioFileStore(),
artworkStore: any ArtworkStore = InMemoryArtworkStore()
) -> RemoteLibrarySyncService {
RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store,
syncCursorStore: cursorStore
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
}
private actor MockVelodyAPIClient: VelodyAPIClient {
private let bootstrapResponse: SyncBootstrapResponse
private let changeResponses: [SyncChangesResponse]
private let changeError: VelodyAPIError?
private let audioAssetData: Data?
private let downloadError: VelodyAPIError?
private let artworkData: Data?
private let artworkDownloadError: VelodyAPIError?
private var changeResponseIndex = 0
private var changeCursors: [String] = []
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
remoteLibraryError: VelodyAPIError? = nil,
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [],
serverTime: "2026-06-15T12:00:00.000Z"
),
changeResponses: [SyncChangesResponse] = [],
changeError: VelodyAPIError? = nil,
audioAssetData: Data? = nil,
downloadError: VelodyAPIError? = nil,
artworkData: Data? = nil,
artworkDownloadError: VelodyAPIError? = nil
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.remoteLibraryError = remoteLibraryError
self.bootstrapResponse = bootstrapResponse
self.changeResponses = changeResponses
self.changeError = changeError
self.audioAssetData = audioAssetData
self.downloadError = downloadError
self.artworkData = artworkData
@ -347,7 +651,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
deviceId: UUID().uuidString,
deviceAccessToken: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-29T08:00:00.000Z"
serverTime: "2026-06-15T12:00:00.000Z"
)
}
@ -357,30 +661,43 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
_ = payload
return DeviceHeartbeatResponse(
ok: true,
serverTime: "2026-05-29T08:00:00.000Z"
serverTime: "2026-06-15T12:00:00.000Z"
)
}
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [],
bootstrapResponse
}
func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse {
changeCursors.append(cursor.value)
if let changeError {
throw changeError
}
guard !changeResponses.isEmpty else {
return SyncChangesResponse(
nextCursor: cursor,
hasMore: false,
requiresBootstrap: false,
events: [],
deletedTrackIds: [],
serverTime: "2026-05-29T08:00:00.000Z"
serverTime: "2026-06-15T12:00:00.000Z"
)
}
let response = changeResponses[min(changeResponseIndex, changeResponses.count - 1)]
changeResponseIndex += 1
return response
}
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
_ = deviceId
if let remoteLibraryError {
throw remoteLibraryError
}
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
return RemoteLibraryResponseDTO(tracks: [])
}
func downloadAudioAsset(
@ -457,6 +774,79 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
assetId: UUID().uuidString
)
}
func recordedChangeCursors() -> [String] {
changeCursors
}
}
private actor SequencedDownloadRepository: RemoteLibraryRepository {
private var downloadResults: [Result<Data, VelodyAPIError>]
private let tracks: [RemoteTrack]
init(
downloadResults: [Result<Data, VelodyAPIError>],
tracks: [RemoteTrack] = []
) {
self.downloadResults = downloadResults
self.tracks = tracks
}
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
tracks
}
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
_ = deviceId
return tracks
}
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
_ = assetId
_ = deviceId
guard !downloadResults.isEmpty else {
return Data()
}
let result = downloadResults.removeFirst()
switch result {
case let .success(data):
return data
case let .failure(error):
throw error
}
}
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
_ = artworkId
_ = deviceId
return Data()
}
}
private func makeRemoteTrack(
trackId: String,
title: String = "Remote Title",
artist: String = "Remote Artist",
durationSeconds: Int = 245,
sha256: String = String(repeating: "a", count: 64),
assetId: String? = nil,
createdAt: String = "2026-06-15T12:00:00.000Z",
updatedAt: String = "2026-06-15T12:05:00.000Z",
artwork: RemoteArtwork? = nil
) -> RemoteTrack {
RemoteTrack(
trackId: trackId,
title: title,
artist: artist,
durationSeconds: durationSeconds,
sha256: sha256,
assetId: assetId ?? "asset-\(trackId)",
createdAt: createdAt,
updatedAt: updatedAt,
artwork: artwork
)
}
private func sampleMp3Data(seed: String) -> Data {