Skip to content
Source
Sign inSign up

incoming-share-inbox.ts

188 lines6.4 KB

1import type { SharePayload } from "expo-sharing";
2​
3import { SerializedAsyncQueue } from "../../lib/serialized-async-queue";
4import {
5 hasIncomingShareContent,
6 type IncomingShareDestination,
7 type IncomingShareDraft,
8} from "./incoming-share-model";
9​
10export interface IncomingShareInboxDependencies {
11 readonly loadDrafts: () => Promise<ReadonlyArray<IncomingShareDraft>>;
12 readonly writeDraft: (draft: IncomingShareDraft) => Promise<void>;
13 readonly removeDraft: (shareId: string) => Promise<void>;
14 readonly getPayloads: () => ReadonlyArray<SharePayload>;
15 readonly clearPayloads: () => void;
16 readonly buildDraft: (input: {
17 readonly payloads: ReadonlyArray<SharePayload>;
18 readonly id: string;
19 readonly createdAt: string;
20 }) => Promise<{
21 readonly draft: IncomingShareDraft;
22 readonly cleanup: () => Promise<void>;
23 readonly rollback?: () => Promise<void>;
24 }>;
25 readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray<SharePayload>) => Promise<void>;
26 readonly idForPayloads: (payloads: ReadonlyArray<SharePayload>) => Promise<string>;
27 readonly now: () => string;
28 readonly onClearError?: (error: unknown) => void;
29 readonly onCleanupError?: (error: unknown) => void;
31​
32function sortAndDedupeIncomingShares(
33 drafts: ReadonlyArray<IncomingShareDraft>,
34): ReadonlyArray<IncomingShareDraft> {
35 const ids = new Set<string>();
36 return [...drafts]
37 .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
38 .filter((draft) => {
39 if (ids.has(draft.id)) {
40 return false;
41 }
42 ids.add(draft.id);
43 return true;
44 });
46​
47/** Serialization prevents stale loads or refreshes from restoring consumed items. */
48export class IncomingShareInbox {
49 private readonly operations = new SerializedAsyncQueue();
50​
51 constructor(private readonly dependencies: IncomingShareInboxDependencies) {}
52​
53 private runExclusive<T>(operation: () => Promise<T>): Promise<T> {
54 return this.operations.run(operation);
55 }
56​
57 private clearNativePayloads(): void {
58 try {
59 this.dependencies.clearPayloads();
60 } catch (error) {
61 this.dependencies.onClearError?.(error);
62 }
63 }
64​
65 private async cleanup(operation: () => Promise<void>): Promise<void> {
66 try {
67 await operation();
68 } catch (error) {
69 this.dependencies.onCleanupError?.(error);
70 }
71 }
72​
73 refresh(options: { readonly ingestNative: boolean }): Promise<ReadonlyArray<IncomingShareDraft>> {
74 return this.runExclusive(async () => {
75 const loaded = await this.dependencies.loadDrafts();
76 const persisted = sortAndDedupeIncomingShares(loaded);
77 if (!options.ingestNative) {
78 return persisted;
79 }
80​
81 const payloads = this.dependencies.getPayloads();
82 if (payloads.length === 0) {
83 return persisted;
84 }
85​
86 // Payload-derived ids reuse the inbox item after a crash between durable write and native acknowledgement.
87 const shareId = await this.dependencies.idForPayloads(payloads);
88 if (loaded.some((draft) => draft.id === shareId)) {
89 if (this.dependencies.cleanupReplayedPayloads) {
90 await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads));
91 }
92 this.clearNativePayloads();
93 return persisted;
94 }
95​
96 const built = await this.dependencies.buildDraft({
97 payloads,
98 id: shareId,
99 createdAt: this.dependencies.now(),
100 });
101 const { draft } = built;
102 if (!hasIncomingShareContent(draft)) {
103 await this.cleanup(built.cleanup);
104 this.clearNativePayloads();
105 throw new Error(
106 draft.warnings[0] ?? "The shared content is not supported by the composer.",
107 );
108 }
109​
110 // Persist before clearing the native handoff so termination always leaves a recoverable copy.
111 try {
112 await this.dependencies.writeDraft(draft);
113 } catch (error) {
114 if (built.rollback) {
115 await this.cleanup(built.rollback);
116 }
117 throw error;
118 }
119 await this.cleanup(built.cleanup);
120 this.clearNativePayloads();
121 return sortAndDedupeIncomingShares([draft, ...persisted]);
122 });
123 }
124​
125 consume(shareId: string): Promise<ReadonlyArray<IncomingShareDraft>> {
126 return this.runExclusive(async () => {
127 // Identical content can be shared intentionally; only stable payload-derived ids identify retries.
128 await this.dependencies.removeDraft(shareId);
129 return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts());
130 });
131 }
132​
133 reserve(
134 shareId: string,
135 destination: IncomingShareDestination,
136 ): Promise<ReadonlyArray<IncomingShareDraft>> {
137 return this.runExclusive(async () => {
138 const persisted = await this.dependencies.loadDrafts();
139 const target = persisted.find((draft) => draft.id === shareId);
140 if (!target) {
141 throw new Error("The shared content is no longer available.");
142 }
143 if (target.destination) {
144 if (
145 target.destination.environmentId !== destination.environmentId ||
146 target.destination.projectId !== destination.projectId
147 ) {
148 throw new Error("The shared content is already reserved for another project draft.");
149 }
150 return sortAndDedupeIncomingShares(persisted);
151 }
152​
153 const reserved = { ...target, destination };
154 await this.dependencies.writeDraft(reserved);
155 return sortAndDedupeIncomingShares(
156 persisted.map((draft) => (draft.id === shareId ? reserved : draft)),
157 );
158 });
159 }
160​
161 releaseReservation(
162 shareId: string,
163 expectedDestination: IncomingShareDestination,
164 ): Promise<ReadonlyArray<IncomingShareDraft>> {
165 return this.runExclusive(async () => {
166 const persisted = await this.dependencies.loadDrafts();
167 const target = persisted.find((draft) => draft.id === shareId);
168 if (!target) {
169 return sortAndDedupeIncomingShares(persisted);
170 }
171 if (!target.destination) {
172 return sortAndDedupeIncomingShares(persisted);
173 }
174 if (
175 target.destination.environmentId !== expectedDestination.environmentId ||
176 target.destination.projectId !== expectedDestination.projectId
177 ) {
178 throw new Error("The shared content reservation changed before it could be released.");
179 }
180​
181 const { destination: _destination, ...unreserved } = target;
182 await this.dependencies.writeDraft(unreserved);
183 return sortAndDedupeIncomingShares(
184 persisted.map((draft) => (draft.id === shareId ? unreserved : draft)),
185 );
186 });
187 }