forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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);