1import * as Schema from "effect/Schema";
2import type { File } from "expo-file-system";
3
4import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model";
5
6const INCOMING_SHARE_DIRECTORY = "incoming-shares";
7
8export class IncomingShareStorageError extends Schema.TaggedError<IncomingShareStorageError>()(
9 "IncomingShareStorageError",
10 {
11 operation: Schema.Literals(["load", "write", "remove"]),
12 shareId: Schema.NullOr(Schema.String),
13 cause: Schema.Defect(),
14 },
15) {
16 override get message(): string {
17 return `Incoming share storage operation ${this.operation} failed for ${this.shareId ?? "unknown"}.`;
18 }
19}
20
21const isIncomingShareStorageError = Schema.is(IncomingShareStorageError);
22
23function fileName(shareId: string): string {
24 return `${encodeURIComponent(shareId)}.json`;
25}
26
27async function getDirectory() {
28 const { Directory, Paths } = await import("expo-file-system");
29 const directory = new Directory(Paths.document, INCOMING_SHARE_DIRECTORY);
30 directory.create({ idempotent: true, intermediates: true });
31 return directory;
32}
33
34async function getFile(shareId: string, suffix = "") {
35 const { File } = await import("expo-file-system");
36 return new File(await getDirectory(), `${fileName(shareId)}${suffix}`);
37}
38
39async function readSavedDraft(file: File) {
40 try {
41 return { file, draft: decodeIncomingShareDraft(JSON.parse(await file.text()) as unknown) };
42 } catch (cause) {
43 const { File } = await import("expo-file-system");
44 const backup = new File(file.parentDirectory, `${file.name}.bak`);
45 if (!backup.exists) throw cause;
46 return {
47 file: backup,
48 draft: decodeIncomingShareDraft(JSON.parse(await backup.text()) as unknown),
49 };
50 }
51}
52
53export async function loadIncomingShareDrafts(options?: {
54 readonly strict?: boolean;
55}): Promise<ReadonlyArray<IncomingShareDraft>> {
56 try {
57 const { File } = await import("expo-file-system");
58 const drafts: IncomingShareDraft[] = [];
59 for (const entry of (await getDirectory()).list()) {
60 if (!(entry instanceof File)) continue;
61 if (entry.name.endsWith(".json.bak")) {
62 if (new File(entry.parentDirectory, entry.name.slice(0, -4)).exists) continue;
63 } else if (!entry.name.endsWith(".json")) {
64 continue;
65 }
66 try {
67 drafts.push((await readSavedDraft(entry)).draft);
68 } catch (cause) {
69 const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause });
70 if (options?.strict) {
71 throw error;
72 }
73 console.warn("[incoming-share] ignored invalid persisted share", error);
74 }
75 }
76 return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
77 } catch (cause) {
78 if (isIncomingShareStorageError(cause)) {
79 throw cause;
80 }
81 throw new IncomingShareStorageError({ operation: "load", shareId: null, cause });
82 }
83}
84
85export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise<void> {
86 try {
87 const file = await getFile(draft.id);
88 const backup = await getFile(draft.id, ".bak");
89 const temp = await getFile(draft.id, ".tmp");
90 // Inbox mutations are serialized, so each share can reuse its staging path.
91 temp.create({ intermediates: true, overwrite: true });
92 await temp.write(JSON.stringify(draft));
93 // Expo can remove or partially copy the destination before a move fails.
94 if (file.exists) {
95 const saved = await readSavedDraft(file).catch(() => undefined);
96 if (!saved || saved.file === file) file.copySync(backup, { overwrite: true });
97 }
98 temp.moveSync(file, { overwrite: true });
99 try {
100 if (backup.exists) backup.delete();
101 } catch {
102 // The installed JSON is authoritative even if backup cleanup fails.
103 }
104 } catch (cause) {
105 throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause });
106 }
107}
108
109export async function removeIncomingShareDraft(shareId: string): Promise<void> {
110 try {
111 // Delete the backup first so a failed removal cannot resurrect a consumed share.
112 for (const suffix of [".tmp", ".bak", ""]) {
113 const file = await getFile(shareId, suffix);
114 if (file.exists) file.delete();
115 }
116 } catch (cause) {
117 throw new IncomingShareStorageError({ operation: "remove", shareId, cause });
118 }
119}