| 1 | import * as Schema from "effect/Schema"; |
| 2 | |
| 3 | import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; |
| 4 | |
| 5 | const INCOMING_SHARE_DIRECTORY = "incoming-shares"; |
| 6 | |
| 7 | export class IncomingShareStorageError extends Schema.TaggedError<IncomingShareStorageError>()( |
| 8 | "IncomingShareStorageError", |
| 9 | { |
| 10 | operation: Schema.Literals(["load", "write", "remove"]), |
| 11 | shareId: Schema.NullOr(Schema.String), |
| 12 | cause: Schema.Defect(), |
| 13 | }, |
| 14 | ) { |
| 15 | override get message(): string { |
| 16 | return `Incoming share storage operation ${this.operation} failed for ${this.shareId ?? "unknown"}.`; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | function fileName(shareId: string): string { |
| 21 | return `${encodeURIComponent(shareId)}.json`; |
| 22 | } |
| 23 | |
| 24 | async function getDirectory() { |
| 25 | const { Directory, Paths } = await import("expo-file-system"); |
| 26 | const directory = new Directory(Paths.document, INCOMING_SHARE_DIRECTORY); |
| 27 | directory.create({ idempotent: true, intermediates: true }); |
| 28 | return directory; |
| 29 | } |
| 30 | |
| 31 | async function getFile(shareId: string) { |
| 32 | const { File } = await import("expo-file-system"); |
| 33 | return new File(await getDirectory(), fileName(shareId)); |
| 34 | } |
| 35 | |
| 36 | export async function loadIncomingShareDrafts(options?: { |
| 37 | readonly strict?: boolean; |
| 38 | }): Promise<ReadonlyArray<IncomingShareDraft>> { |
| 39 | try { |
| 40 | const { File } = await import("expo-file-system"); |
| 41 | const drafts: IncomingShareDraft[] = []; |
| 42 | for (const entry of (await getDirectory()).list()) { |
| 43 | if (!(entry instanceof File) || !entry.name.endsWith(".json")) { |
| 44 | continue; |
| 45 | } |
| 46 | try { |
| 47 | drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); |
| 48 | } catch (cause) { |
| 49 | const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause }); |
| 50 | if (options?.strict) { |
| 51 | throw error; |
| 52 | } |
| 53 | console.warn("[incoming-share] ignored invalid persisted share", error); |
| 54 | } |
| 55 | } |
| 56 | return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); |
| 57 | } catch (cause) { |
| 58 | if (cause instanceof IncomingShareStorageError) { |
| 59 | throw cause; |
| 60 | } |
| 61 | throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise<void> { |
| 66 | try { |
| 67 | const file = await getFile(draft.id); |
| 68 | if (!file.exists) { |
| 69 | file.create({ intermediates: true, overwrite: true }); |
| 70 | } |
| 71 | await file.write(JSON.stringify(draft)); |
| 72 | } catch (cause) { |
| 73 | throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | export async function removeIncomingShareDraft(shareId: string): Promise<void> { |
| 78 | try { |
| 79 | const file = await getFile(shareId); |
| 80 | if (file.exists) { |
| 81 | file.delete(); |
| 82 | } |
| 83 | } catch (cause) { |
| 84 | throw new IncomingShareStorageError({ operation: "remove", shareId, cause }); |
| 85 | } |
| 86 | } |