diff --git a/src/common/loader.js b/src/common/loader.js --- a/src/common/loader.js +++ b/src/common/loader.js @@ -37,7 +37,7 @@ * @typedef {object} LoaderConfig * @property {string} $type - The atproto $type * @property {string} label - Human-readable label for error messages (e.g. "Facet", "Theme") - * @property {() => { collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: LoadableItem[] }> }} source - The collection source + * @property {() => { collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: LoadableItem[] } | { state: "error" }> }} source - The collection source * @property {(item: LoadableItem) => void} render - Renders the loaded item */ @@ -93,6 +93,9 @@ } else { const source = config.source(); const col = source.collection(); + if (col.state === "error") { + return renderError(container, `Failed to load ${config.label.toLowerCase()}`); + } if (col.state !== "loaded") return; const collection = col.data; diff --git a/src/common/output.js b/src/common/output.js --- a/src/common/output.js +++ b/src/common/output.js @@ -89,7 +89,7 @@ /** * @template T - * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T }> }} output + * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T } | { state: "error" }> }} output * @returns {Promise} * * @example Resolves immediately when collection is already loaded diff --git a/specs/components/output/types.d.ts b/specs/components/output/types.d.ts --- a/specs/components/output/types.d.ts +++ b/specs/components/output/types.d.ts @@ -7,6 +7,11 @@ Track, } from "~/definitions/types.d.ts"; +export type OutputCollectionState = + | { state: "loading" } + | { state: "loaded"; data: T } + | { state: "error" }; + export type OutputElement = & DiffuseElement & OutputManagerDeputy; @@ -18,8 +23,7 @@ export type OutputManager = { facets: { collection: SignalReader< - | { state: "loading" } - | { state: "loaded"; data: Encoding extends null ? Facet[] : Encoding } + OutputCollectionState >; reload: () => Promise; save: ( @@ -28,8 +32,7 @@ }; playlistItems: { collection: SignalReader< - | { state: "loading" } - | { state: "loaded"; data: Encoding extends null ? PlaylistItem[] : Encoding } + OutputCollectionState >; reload: () => Promise; save: ( @@ -38,8 +41,7 @@ }; settings: { collection: SignalReader< - | { state: "loading" } - | { state: "loaded"; data: Encoding extends null ? Setting[] : Encoding } + OutputCollectionState >; reload: () => Promise; save: ( @@ -54,8 +56,7 @@ }; tracks: { collection: SignalReader< - | { state: "loading" } - | { state: "loaded"; data: Encoding extends null ? Track[] : Encoding } + OutputCollectionState >; reload: () => Promise; save: (tracks: Encoding extends null ? Track[] : Encoding) => Promise; diff --git a/src/components/output/common.js b/src/components/output/common.js --- a/src/components/output/common.js +++ b/src/components/output/common.js @@ -80,7 +80,7 @@ .empty()), ); const cs = signal( - /** @type {"loading" | "loaded" | "sleeping"} */ ("sleeping"), + /** @type {"loading" | "loaded" | "sleeping" | "error"} */ ("sleeping"), { compare: strictEquality }, ); @@ -89,7 +89,7 @@ .empty()), ); const pls = signal( - /** @type {"loading" | "loaded" | "sleeping"} */ ("sleeping"), + /** @type {"loading" | "loaded" | "sleeping" | "error"} */ ("sleeping"), { compare: strictEquality }, ); @@ -98,7 +98,7 @@ .empty()), ); const ss = signal( - /** @type {"loading" | "loaded" | "sleeping"} */ ("sleeping"), + /** @type {"loading" | "loaded" | "sleeping" | "error"} */ ("sleeping"), { compare: strictEquality }, ); @@ -106,36 +106,56 @@ /** @type {Encoding extends null ? Track[] : Encoding} */ (tracks.empty()), ); const ts = signal( - /** @type {"loading" | "loaded" | "sleeping"} */ ("sleeping"), + /** @type {"loading" | "loaded" | "sleeping" | "error"} */ ("sleeping"), { compare: strictEquality }, ); async function loadFacets() { if (init && (await init()) === false) return; cs.value = "loading"; - c.value = await facets.get(); - cs.value = "loaded"; + try { + c.value = await facets.get(); + cs.value = "loaded"; + } catch (err) { + console.error("Failed to load facets:", err); + cs.value = "error"; + } } async function loadPlaylistItems() { if (init && (await init()) === false) return; pls.value = "loading"; - pl.value = await playlistItems.get(); - pls.value = "loaded"; + try { + pl.value = await playlistItems.get(); + pls.value = "loaded"; + } catch (err) { + console.error("Failed to load playlist items:", err); + pls.value = "error"; + } } async function loadSettings() { if (init && (await init()) === false) return; ss.value = "loading"; - s.value = await settings.get(); - ss.value = "loaded"; + try { + s.value = await settings.get(); + ss.value = "loaded"; + } catch (err) { + console.error("Failed to load settings:", err); + ss.value = "error"; + } } async function loadTracks() { if (init && (await init()) === false) return; ts.value = "loading"; - t.value = await tracks.get(); - ts.value = "loaded"; + try { + t.value = await tracks.get(); + ts.value = "loaded"; + } catch (err) { + console.error("Failed to load tracks:", err); + ts.value = "error"; + } } return { @@ -144,6 +164,8 @@ if (untracked(() => cs.value === "sleeping")) loadFacets(); return cs.value === "loaded" ? { state: "loaded", data: c.value } + : cs.value === "error" + ? { state: "error" } : { state: "loading" }; }), reload: loadFacets, @@ -160,6 +182,8 @@ if (untracked(() => pls.value === "sleeping")) loadPlaylistItems(); return pls.value === "loaded" ? { state: "loaded", data: pl.value } + : pls.value === "error" + ? { state: "error" } : { state: "loading" }; }), reload: loadPlaylistItems, @@ -176,6 +200,8 @@ if (untracked(() => ss.value === "sleeping")) loadSettings(); return ss.value === "loaded" ? { state: "loaded", data: s.value } + : ss.value === "error" + ? { state: "error" } : { state: "loading" }; }), reload: loadSettings, @@ -192,6 +218,8 @@ if (untracked(() => ts.value === "sleeping")) loadTracks(); return ts.value === "loaded" ? { state: "loaded", data: t.value } + : ts.value === "error" + ? { state: "error" } : { state: "loading" }; }), reload: loadTracks, diff --git a/src/components/output/bytes/s3/worker.js b/src/components/output/bytes/s3/worker.js --- a/src/components/output/bytes/s3/worker.js +++ b/src/components/output/bytes/s3/worker.js @@ -15,18 +15,18 @@ * @type {S3OutputWorkerActions["get"]} */ export async function get({ bucket, name }) { - const client = createClient(bucket); - const path = bucket.path.replace(/(^\/+|\/+$)/g, ""); - const key = path - ? `${path}/${OBJECT_PREFIX}${name}` - : `${OBJECT_PREFIX}${name}`; - try { + const client = createClient(bucket); + const path = bucket.path.replace(/(^\/+|\/+$)/g, ""); + const key = path + ? `${path}/${OBJECT_PREFIX}${name}` + : `${OBJECT_PREFIX}${name}`; + const response = await client.getObject(key); const buffer = await response.arrayBuffer(); return new Uint8Array(buffer); - } catch (err) { - // Object doesn't exist yet, return undefined + } catch (_err) { + // Object doesn't exist yet, or client creation failed — return undefined return undefined; } } @@ -35,13 +35,18 @@ * @type {S3OutputWorkerActions["put"]} */ export async function put({ bucket, data, name }) { - const client = createClient(bucket); - const path = bucket.path.replace(/(^\/+|\/+$)/g, ""); - const key = path - ? `${path}/${OBJECT_PREFIX}${name}` - : `${OBJECT_PREFIX}${name}`; + try { + const client = createClient(bucket); + const path = bucket.path.replace(/(^\/+|\/+$)/g, ""); + const key = path + ? `${path}/${OBJECT_PREFIX}${name}` + : `${OBJECT_PREFIX}${name}`; - await client.putObject(key, data); + await client.putObject(key, data); + } catch (err) { + console.error("Failed to put S3 object:", err); + throw err; + } } //////////////////////////////////////////// diff --git a/src/components/transformer/output/bytes/automerge/element.js b/src/components/transformer/output/bytes/automerge/element.js --- a/src/components/transformer/output/bytes/automerge/element.js +++ b/src/components/transformer/output/bytes/automerge/element.js @@ -36,8 +36,8 @@ /** * @template T - * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>} localCollection - * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>} remoteCollection + * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>} localCollection + * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>} remoteCollection * @param {Automerge.Doc} initial * @returns {SignalReader<{ doc: Automerge.Doc; diverged: boolean; local: boolean; remote: boolean; remoteLoaded: boolean; }>} */ @@ -254,11 +254,11 @@ /** * @template {Record} T - * @param {SignalReader<{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>, reload: () => Promise, save: (bytes: Uint8Array) => Promise } | undefined>} local - * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>, reload: () => Promise, save: (bytes: Uint8Array) => Promise }} remote + * @param {SignalReader<{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>, reload: () => Promise, save: (bytes: Uint8Array) => Promise } | undefined>} local + * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>, reload: () => Promise, save: (bytes: Uint8Array) => Promise }} remote * @param {SignalReader>} document * @param {{ stripUndefined?: boolean }} [opts] - * @returns {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T[] }>, reload: () => Promise, save: (items: T[]) => Promise }} + * @returns {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T[] } | { state: "error" }>, reload: () => Promise, save: (items: T[]) => Promise }} */ export function automergeEntry(local, remote, document, opts) { return { diff --git a/src/components/transformer/output/bytes/dasl-sync/element.js b/src/components/transformer/output/bytes/dasl-sync/element.js --- a/src/components/transformer/output/bytes/dasl-sync/element.js +++ b/src/components/transformer/output/bytes/dasl-sync/element.js @@ -41,8 +41,8 @@ /** * @template {{ id: string; updatedAt: string }} T * @param {string} kind - * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>} localCollection - * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>} remoteCollection + * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>} localCollection + * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>} remoteCollection * @param {{ saveLocal: (bytes: Uint8Array) => Promise; saveRemote: (bytes: Uint8Array) => Promise }} sync */ const state = ( @@ -125,6 +125,9 @@ } finally { merging.value = { isBusy: false, lastCID: c.cid ?? "" }; } + }).catch((err) => { + console.error("Merge failed:", err); + merging.value = { isBusy: false, lastCID: merging.value.lastCID }; }); }); } else { @@ -410,10 +413,10 @@ /** * @template {{ id: string; updatedAt: string }} T * @param {{ save: (bytes: Uint8Array) => Promise | void }} local - * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>, reload: () => Promise, save: (bytes: Uint8Array) => Promise }} remote + * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined } | { state: "error" }>, reload: () => Promise, save: (bytes: Uint8Array) => Promise }} remote * @param {SignalReader} remoteReady * @param {SignalReader>} container - * @returns {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T[] }>, reload: () => Promise, save: (items: T[]) => Promise }} + * @returns {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T[] } | { state: "error" }>, reload: () => Promise, save: (items: T[]) => Promise }} */ managerProp(local, remote, remoteReady, container) { return { diff --git a/src/components/transformer/output/raw/atproto-sync/element.js b/src/components/transformer/output/raw/atproto-sync/element.js --- a/src/components/transformer/output/raw/atproto-sync/element.js +++ b/src/components/transformer/output/raw/atproto-sync/element.js @@ -64,6 +64,7 @@ if (!l) return { state: "loading" }; const c = l[name].collection(); if (c.state === "loading") return c; + if (c.state === "error") return c; return { state: "loaded", data: c.data ?? [] }; }), reload: async () => { diff --git a/src/components/transformer/output/refiner/passkey-encryption/element.js b/src/components/transformer/output/refiner/passkey-encryption/element.js --- a/src/components/transformer/output/refiner/passkey-encryption/element.js +++ b/src/components/transformer/output/refiner/passkey-encryption/element.js @@ -57,7 +57,9 @@ collection: computed(() => { const col = base.settings.collection(); - if (col?.state !== "loaded") return { state: "loading" }; + if (col?.state !== "loaded") { + return col?.state === "error" ? { state: "error" } : { state: "loading" }; + } const key = encryptionKey.get(); @@ -113,7 +115,9 @@ const col = base.tracks.collection(); if (col?.state !== "loaded") { - return { state: "loading", locked: [], unlocked: [] }; + return col?.state === "error" + ? { state: "error", locked: [], unlocked: [] } + : { state: "loading", locked: [], unlocked: [] }; } const key = encryptionKey.get(); @@ -258,7 +262,7 @@ const stopSettings = this.effect(() => { if (savedSettings) { stopSettings(); return; } const col = this.settings.collection(); - if (col.state === "loading") return; + if (col.state !== "loaded") return; savedSettings = true; this.settings.save(col.data); }); @@ -266,7 +270,7 @@ const stopTracks = this.effect(() => { if (savedTracks) { stopTracks(); return; } const col = this.tracks.collection(); - if (col.state === "loading") return; + if (col.state !== "loaded") return; savedTracks = true; this.tracks.save(col.data); });