146 lines · 4.5 KB · TypeScript
RawDownload
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";import * as Schema from "effect/Schema"; import { writeFileAtomically } from "../lib/atomic-file";import {  decodeQueuedThreadMessage,  encodeQueuedThreadMessage,  type QueuedThreadMessage,} from "./thread-outbox-model"; const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; const inFlightWrites = new Set<Promise<void>>(); function trackInFlightWrite(operation: Promise<void>): Promise<void> {  inFlightWrites.add(operation);  void operation.catch(() => undefined).finally(() => inFlightWrites.delete(operation));  return operation;} /** * Awaits queued-message writes so an app update restart cannot tear down the * runtime while one is mid-file. */export async function flushThreadOutboxWrites(): Promise<void> {  while (inFlightWrites.size > 0) {    await Promise.allSettled(inFlightWrites);  }} export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()(  "ThreadOutboxStorageError",  {    operation: Schema.Literals(["load", "read-message", "write", "remove"]),    environmentId: Schema.NullOr(EnvironmentId),    threadId: Schema.NullOr(ThreadId),    messageId: Schema.NullOr(MessageId),    fileName: Schema.NullOr(Schema.String),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Thread outbox storage operation ${this.operation} failed for environment ${this.environmentId ?? "unknown"}, thread ${this.threadId ?? "unknown"}, message ${this.messageId ?? "unknown"}, file ${this.fileName ?? "unknown"}.`;  }} export interface ThreadOutboxStorage {  readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>;  readonly write: (message: QueuedThreadMessage) => Promise<void>;  readonly remove: (message: QueuedThreadMessage) => Promise<void>;} function messageFileName(messageId: MessageId): string {  return `${encodeURIComponent(messageId)}.json`;} async function getOutboxDirectory() {  const { Directory, Paths } = await import("expo-file-system");  const directory = new Directory(Paths.document, THREAD_OUTBOX_DIRECTORY);  directory.create({ idempotent: true, intermediates: true });  return directory;} async function getMessageFile(messageId: MessageId) {  const { File } = await import("expo-file-system");  return new File(await getOutboxDirectory(), messageFileName(messageId));} export const expoThreadOutboxStorage: ThreadOutboxStorage = {  load: async () => {    const messages: QueuedThreadMessage[] = [];    try {      const { File } = await import("expo-file-system");      const directory = await getOutboxDirectory();       for (const entry of directory.list()) {        if (!(entry instanceof File) || !entry.name.endsWith(".json")) {          continue;        }        try {          messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown));        } catch (cause) {          console.warn(            "[thread-outbox] ignored invalid persisted message",            new ThreadOutboxStorageError({              operation: "read-message",              environmentId: null,              threadId: null,              messageId: null,              fileName: entry.name,              cause,            }),          );        }      }    } catch (cause) {      throw new ThreadOutboxStorageError({        operation: "load",        environmentId: null,        threadId: null,        messageId: null,        fileName: null,        cause,      });    }    return messages;  },  write: async (message) => {    const fileName = messageFileName(message.messageId);    try {      await trackInFlightWrite(        (async () => {          const file = await getMessageFile(message.messageId);          await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message)));        })(),      );    } catch (cause) {      throw new ThreadOutboxStorageError({        operation: "write",        environmentId: message.environmentId,        threadId: message.threadId,        messageId: message.messageId,        fileName,        cause,      });    }  },  remove: async (message) => {    const fileName = messageFileName(message.messageId);    try {      const file = await getMessageFile(message.messageId);      if (file.exists) {        file.delete();      }    } catch (cause) {      throw new ThreadOutboxStorageError({        operation: "remove",        environmentId: message.environmentId,        threadId: message.threadId,        messageId: message.messageId,        fileName,        cause,      });    }  },};