A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 296 lines 8.6 kB view raw
1import * as Automerge from "@automerge/automerge"; 2import { ifDefined } from "lit-html/directives/if-defined.js"; 3import { isUint8Array } from "iso-base/utils"; 4 5import "~/components/output/polymorphic/indexed-db/element.js"; 6 7import { computed, signal } from "~/common/signal.js"; 8import { 9 recursivelyCloneRecords, 10 removeUndefinedValuesFromRecord, 11} from "~/common/utils.js"; 12import { OutputTransformer } from "../../base.js"; 13import { defineElement } from "~/common/element.js"; 14import { 15 INITIAL_FACETS_DOCUMENT, 16 INITIAL_PLAYLIST_ITEMS_DOCUMENT, 17 INITIAL_SETTINGS_DOCUMENT, 18 INITIAL_TRACKS_DOCUMENT, 19} from "./constants.js"; 20 21/** 22 * @import { RenderArg } from "~/common/element.d.ts" 23 * @import { SignalReader } from "~/common/signal.d.ts"; 24 * @import { OutputElement } from "~/components/output/types.d.ts"; 25 */ 26 27/** 28 * @extends {OutputTransformer<Uint8Array>} 29 */ 30class AutomergeBytesOutputTransformer extends OutputTransformer { 31 constructor() { 32 super(); 33 34 const remote = this.base(); 35 const local = this.#localOutput.get; 36 37 /** 38 * @template T 39 * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>} localCollection 40 * @param {SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>} remoteCollection 41 * @param {Automerge.Doc<T>} initial 42 * @returns {SignalReader<{ doc: Automerge.Doc<T>; diverged: boolean; local: boolean; remote: boolean; remoteLoaded: boolean; }>} 43 */ 44 const state = (localCollection, remoteCollection, initial) => 45 computed(() => { 46 const lc = localCollection(); 47 const rc = remote.ready() ? remoteCollection() : undefined; 48 49 const l = loadDocument(lc?.state === "loaded" ? lc.data : undefined); 50 const r = rc?.state === "loaded" ? loadDocument(rc.data) : undefined; 51 const remoteLoaded = rc?.state === "loaded"; 52 53 if (!r) { 54 return l 55 ? { 56 doc: l, 57 diverged: true, 58 local: false, 59 remote: true, 60 remoteLoaded, 61 } 62 : { 63 doc: initial, 64 diverged: false, 65 local: false, 66 remote: false, 67 remoteLoaded, 68 }; 69 } else if (!l) { 70 return { 71 doc: r, 72 diverged: true, 73 local: true, 74 remote: false, 75 remoteLoaded, 76 }; 77 } 78 79 const lh = Automerge.getHeads(l)[0]; 80 const rh = Automerge.getHeads(r)[0]; 81 const diverged = lh !== rh; 82 83 return { 84 doc: diverged 85 ? Automerge.merge(Automerge.clone(l), Automerge.clone(r)) 86 : r, 87 diverged, 88 local: Automerge.hasHeads(r, [lh]), 89 remote: Automerge.hasHeads(l, [rh]), 90 remoteLoaded, 91 }; 92 }); 93 94 const facets = state( 95 computed(() => local()?.facets?.collection() ?? { state: "loading" }), 96 remote.facets.collection, 97 INITIAL_FACETS_DOCUMENT, 98 ); 99 100 const playlistItems = state( 101 computed(() => 102 local()?.playlistItems?.collection() ?? { state: "loading" } 103 ), 104 remote.playlistItems.collection, 105 INITIAL_PLAYLIST_ITEMS_DOCUMENT, 106 ); 107 108 const settings = state( 109 computed(() => local()?.settings?.collection() ?? { state: "loading" }), 110 remote.settings.collection, 111 INITIAL_SETTINGS_DOCUMENT, 112 ); 113 114 const tracks = state( 115 computed(() => local()?.tracks?.collection() ?? { state: "loading" }), 116 remote.tracks.collection, 117 INITIAL_TRACKS_DOCUMENT, 118 ); 119 120 this.facets = automergeEntry( 121 computed(() => local()?.facets), 122 remote.facets, 123 computed(() => facets().doc), 124 { 125 stripUndefined: true, 126 }, 127 ); 128 129 this.playlistItems = automergeEntry( 130 computed(() => local()?.playlistItems), 131 remote.playlistItems, 132 computed(() => playlistItems().doc), 133 ); 134 135 this.settings = automergeEntry( 136 computed(() => local()?.settings), 137 remote.settings, 138 computed(() => settings().doc), 139 ); 140 141 this.tracks = automergeEntry( 142 computed(() => local()?.tracks), 143 remote.tracks, 144 computed(() => tracks().doc), 145 ); 146 147 this.ready = () => true; 148 149 // Effects 150 this.effect(() => { 151 const l = local(); 152 if (!l) return; 153 154 this.effect(() => { 155 if (!facets().remoteLoaded) return; 156 const s = facets(); 157 if (s.diverged) { 158 const bytes = Automerge.save(s.doc); 159 if (l && s.local) l.facets.save(bytes); 160 if (s.remote) remote.facets.save(bytes); 161 } 162 }); 163 164 this.effect(() => { 165 if (!playlistItems().remoteLoaded) return; 166 const s = playlistItems(); 167 if (s.diverged) { 168 const bytes = Automerge.save(s.doc); 169 if (l && s.local) l.playlistItems.save(bytes); 170 if (s.remote) remote.playlistItems.save(bytes); 171 } 172 }); 173 174 this.effect(() => { 175 if (!settings().remoteLoaded) return; 176 const s = settings(); 177 if (s.diverged) { 178 const bytes = Automerge.save(s.doc); 179 if (l && s.local) l.settings.save(bytes); 180 if (s.remote) remote.settings.save(bytes); 181 } 182 }); 183 184 this.effect(() => { 185 if (!tracks().remoteLoaded) return; 186 const s = tracks(); 187 if (s.diverged) { 188 const bytes = Automerge.save(s.doc); 189 if (l && s.local) l.tracks.save(bytes); 190 if (s.remote) remote.tracks.save(bytes); 191 } 192 }); 193 }); 194 } 195 196 // SIGNALS 197 198 #localOutput = signal( 199 /** @type {OutputElement<Uint8Array | undefined> | undefined} */ (undefined), 200 ); 201 202 // LIFECYCLE 203 204 /** 205 * @override 206 */ 207 connectedCallback() { 208 super.connectedCallback(); 209 210 /** @type {OutputElement<Uint8Array | undefined> | null} */ 211 const local = this.root().querySelector("dop-indexed-db"); 212 if (!local) throw new Error("Can't find local output"); 213 214 // When defined 215 customElements.whenDefined(local.localName).then(() => { 216 this.#localOutput.value = local; 217 }); 218 } 219 220 // RENDER 221 222 /** 223 * @param {RenderArg} _ 224 */ 225 render({ html }) { 226 return html` 227 <dop-indexed-db 228 namespace="${ifDefined(this.getAttribute(`namespace`))}" 229 ></dop-indexed-db> 230 `; 231 } 232} 233 234export default AutomergeBytesOutputTransformer; 235 236//////////////////////////////////////////// 237// 🛠️ 238//////////////////////////////////////////// 239 240/** 241 * @template T 242 * @param {Uint8Array | undefined} value 243 * @returns {Automerge.Doc<T> | undefined} 244 */ 245export function loadDocument(value) { 246 if (isUint8Array(value)) { 247 return Automerge.load(value); 248 } else if (value == undefined) { 249 return undefined; 250 } else { 251 throw new Error("Invalid data type"); 252 } 253} 254 255/** 256 * @template {Record<string, any>} T 257 * @param {SignalReader<{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>, reload: () => Promise<void>, save: (bytes: Uint8Array) => Promise<void> } | undefined>} local 258 * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: Uint8Array | undefined }>, reload: () => Promise<void>, save: (bytes: Uint8Array) => Promise<void> }} remote 259 * @param {SignalReader<Automerge.Doc<{ collection: T[] }>>} document 260 * @param {{ stripUndefined?: boolean }} [opts] 261 * @returns {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T[] }>, reload: () => Promise<void>, save: (items: T[]) => Promise<void> }} 262 */ 263export function automergeEntry(local, remote, document, opts) { 264 return { 265 collection: computed(() => { 266 const col = local()?.collection(); 267 if (!col || col.state !== "loaded") { 268 return { state: col?.state ?? "loading" }; 269 } 270 return { state: "loaded", data: document().collection }; 271 }), 272 reload: remote.reload, 273 save: async (/** @type {T[]} */ newItems) => { 274 const doc = Automerge.change(document(), (d) => { 275 d.collection = newItems.map((item) => { 276 const cloned = recursivelyCloneRecords(item); 277 return opts?.stripUndefined 278 ? removeUndefinedValuesFromRecord(cloned) 279 : cloned; 280 }); 281 }); 282 283 const bytes = Automerge.save(doc); 284 await local()?.save(bytes); 285 }, 286 }; 287} 288 289//////////////////////////////////////////// 290// REGISTER 291//////////////////////////////////////////// 292 293export const CLASS = AutomergeBytesOutputTransformer; 294export const NAME = "dtob-automerge"; 295 296defineElement(NAME, CLASS);