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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-waves-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/attachments': minor
---

Added option to download attachments
14 changes: 13 additions & 1 deletion packages/attachments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,21 @@
"build": "tsc -b",
"build:prod": "tsc -b --sourceMap false",
"clean": "rm -rf lib tsconfig.tsbuildinfo",
"watch": "tsc -b -w"
"watch": "tsc -b -w",
"test": "pnpm build && vitest"
},
"peerDependencies": {
"@powersync/common": "workspace:^1.18.1"
},
"devDependencies": {
"@types/node": "^20.17.6",
"@vitest/browser": "^2.1.4",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-top-level-await": "^1.4.4",
"vitest": "^2.1.4",
"webdriverio": "^9.2.8"
}
}
16 changes: 15 additions & 1 deletion packages/attachments/src/AbstractAttachmentQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface AttachmentQueueOptions {
* Whether to mark the initial watched attachment IDs to be synced
*/
performInitialSync?: boolean;
/**
* Should attachments be downloaded
*/
downloadAttachments?: boolean;
/**
* How to handle download errors, return { retry: false } to ignore the download
*/
Expand All @@ -35,7 +39,8 @@ export const DEFAULT_ATTACHMENT_QUEUE_OPTIONS: Partial<AttachmentQueueOptions> =
attachmentDirectoryName: 'attachments',
syncInterval: 30_000,
cacheLimit: 100,
performInitialSync: true
performInitialSync: true,
downloadAttachments: true
};

export abstract class AbstractAttachmentQueue<T extends AttachmentQueueOptions = AttachmentQueueOptions> {
Expand Down Expand Up @@ -295,6 +300,9 @@ export abstract class AbstractAttachmentQueue<T extends AttachmentQueueOptions =
}

async downloadRecord(record: AttachmentRecord) {
if (!this.options.downloadAttachments) {
return false;
}
if (!record.local_uri) {
record.local_uri = this.getLocalFilePathSuffix(record.filename);
}
Expand Down Expand Up @@ -426,6 +434,9 @@ export abstract class AbstractAttachmentQueue<T extends AttachmentQueueOptions =
}

watchDownloads() {
if (!this.options.downloadAttachments) {
return;
}
this.idsToDownload(async (ids) => {
ids.map((id) => this.downloadQueue.add(id));
// No need to await this, the lock will ensure only one loop is running at a time
Expand All @@ -434,6 +445,9 @@ export abstract class AbstractAttachmentQueue<T extends AttachmentQueueOptions =
}

private async downloadRecords() {
if (!this.options.downloadAttachments) {
return;
}
if (this.downloading) {
return;
}
Expand Down
95 changes: 95 additions & 0 deletions packages/attachments/tests/attachments/AttachmentQueue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as commonSdk from '@powersync/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AbstractAttachmentQueue } from '../../src/AbstractAttachmentQueue';
import { AttachmentRecord, AttachmentState } from '../../src/Schema';
import { AbstractPowerSyncDatabase } from '@powersync/common';
import { StorageAdapter } from '../../src/StorageAdapter';

const record = {
id: 'test-1',
filename: 'test.jpg',
state: AttachmentState.QUEUED_DOWNLOAD
}

const mockPowerSync = {
currentStatus: { status: 'initial' },
registerListener: vi.fn(() => {}),
resolveTables: vi.fn(() => ['table1', 'table2']),
onChangeWithCallback: vi.fn(),
getAll: vi.fn(() => Promise.resolve([{id: 'test-1'}, {id: 'test-2'}])),
execute: vi.fn(() => Promise.resolve()),
getOptional: vi.fn((_query, params) => Promise.resolve(record)),
watch: vi.fn((query, params, callbacks) => {
callbacks?.onResult?.({ rows: { _array: [{id: 'test-1'}, {id: 'test-2'}] } });
}),
writeTransaction: vi.fn(async (callback) => {
await callback({
execute: vi.fn(() => Promise.resolve())
});
})
};

const mockStorage: StorageAdapter = {
downloadFile: vi.fn(),
uploadFile: vi.fn(),
deleteFile: vi.fn(),
writeFile: vi.fn(),
readFile: vi.fn(),
fileExists: vi.fn(),
makeDir: vi.fn(),
copyFile: vi.fn(),
getUserStorageDirectory: vi.fn()
};

class TestAttachmentQueue extends AbstractAttachmentQueue {
onAttachmentIdsChange(onUpdate: (ids: string[]) => void): void {
throw new Error('Method not implemented.');
}
newAttachmentRecord(record?: Partial<AttachmentRecord>): Promise<AttachmentRecord> {
throw new Error('Method not implemented.');
}
}

describe('attachments', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should not download attachments when downloadRecord is called with downloadAttachments false', async () => {
const queue = new TestAttachmentQueue({
powersync: mockPowerSync as any,
storage: mockStorage,
downloadAttachments: false
});

await queue.downloadRecord(record);

expect(mockStorage.downloadFile).not.toHaveBeenCalled();
});

it('should download attachments when downloadRecord is called with downloadAttachments true', async () => {
const queue = new TestAttachmentQueue({
powersync: mockPowerSync as any,
storage: mockStorage,
downloadAttachments: true
});

await queue.downloadRecord(record);

expect(mockStorage.downloadFile).toHaveBeenCalled();
});

// Testing the inverse of this test, i.e. when downloadAttachments is false, is not required as you can't wait for something that does not happen
it('should not download attachments with watchDownloads is called with downloadAttachments false', async () => {
const queue = new TestAttachmentQueue({
powersync: mockPowerSync as any,
storage: mockStorage,
downloadAttachments: true
});

queue.watchDownloads();
await vi.waitFor(() => {
expect(mockStorage.downloadFile).toBeCalledTimes(2);
});
});
});
19 changes: 19 additions & 0 deletions packages/attachments/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import topLevelAwait from 'vite-plugin-top-level-await';
import { defineConfig, UserConfigExport } from 'vitest/config';

const config: UserConfigExport = {
plugins: [topLevelAwait()],
test: {
isolate: false,
globals: true,
include: ['tests/**/*.test.ts'],
browser: {
enabled: true,
headless: true,
provider: 'webdriverio',
name: 'chrome' // browser name is required
}
}
};

export default defineConfig(config);
Loading
Loading