diff --git a/packages/elements/custom-elements.json b/packages/elements/custom-elements.json index 39b0360..7ab6708 100644 --- a/packages/elements/custom-elements.json +++ b/packages/elements/custom-elements.json @@ -842,8 +842,7 @@ "name": "defaultValue", "type": { "text": "Date | number | string | undefined" - }, - "default": "new Date()" + } }, { "kind": "field", diff --git a/packages/elements/demo/index.html b/packages/elements/demo/index.html index 306daf6..2df61a7 100644 --- a/packages/elements/demo/index.html +++ b/packages/elements/demo/index.html @@ -89,6 +89,7 @@ >( }); } +/** + * Defines the behavior of the joining of the `AsyncReadables` + */ +export interface JoinAsyncOptions { + /** + * 'bubbles': the first error encountered in the collection of stores is going to be automatically returned + * 'filter_out': all errors encountered in the collection of stores are going to be filtered out, returning only those stores that completed successfully + */ + errors?: "filter_out" | "bubble"; + /** + * 'bubbles': the first pending status encountered in the collection of stores is going to be automatically returned + * 'filter_out': all pending status encountered in the collection of stores are going to be filtered out, returning only those stores that completed successfully + */ + pendings?: "filter_out" | "bubble"; +} + /** * Joins an array of `AsyncReadables` into a single `AsyncReadable` of the array of values */ -export function joinAsync(stores: [AsyncReadable]): AsyncReadable<[T]>; +export function joinAsync( + stores: [AsyncReadable], + joinOptions?: JoinAsyncOptions +): AsyncReadable<[T]>; export function joinAsync( - stores: [AsyncReadable, AsyncReadable] + stores: [AsyncReadable, AsyncReadable], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U]>; export function joinAsync( - stores: [AsyncReadable, AsyncReadable, AsyncReadable] + stores: [AsyncReadable, AsyncReadable, AsyncReadable], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V]>; export function joinAsync( stores: [ @@ -74,7 +95,8 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W]>; export function joinAsync( stores: [ @@ -83,7 +105,8 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W, X]>; export function joinAsync( stores: [ @@ -93,7 +116,8 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W, X, Y]>; export function joinAsync( stores: [ @@ -104,7 +128,8 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W, X, Y, Z]>; export function joinAsync( stores: [ @@ -116,7 +141,8 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W, X, Y, Z, A]>; export function joinAsync( stores: [ @@ -129,7 +155,8 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W, X, Y, Z, A, B]>; export function joinAsync( stores: [ @@ -143,29 +170,48 @@ export function joinAsync( AsyncReadable, AsyncReadable, AsyncReadable - ] + ], + joinOptions?: JoinAsyncOptions ): AsyncReadable<[T, U, V, W, X, Y, Z, A, B, C]>; export function joinAsync( - stores: Array> + stores: Array>, + joinOptions?: JoinAsyncOptions ): AsyncReadable>; export function joinAsync( - stores: Array> + stores: Array>, + joinOptions?: JoinAsyncOptions ): AsyncReadable> { + let options = { + errors: "bubble", + pendings: "bubble", + }; + if (joinOptions) { + options = { + ...options, + ...joinOptions, + }; + } return derived(stores, (values): AsyncStatus => { - const firstError = values.find( - (v) => v && (v as AsyncStatus).status === "error" - ); - if (firstError) { - return firstError as AsyncStatus; + if (options.errors === "bubble") { + const firstError = values.find( + (v) => v && (v as AsyncStatus).status === "error" + ); + if (firstError) { + return firstError as AsyncStatus; + } } - const firstLoading = values.find( - (v) => v && (v as AsyncStatus).status === "pending" - ); - if (firstLoading) { - return firstLoading as AsyncStatus; + if (options.pendings === "bubble") { + const firstLoading = values.find( + (v) => v && (v as AsyncStatus).status === "pending" + ); + if (firstLoading) { + return firstLoading as AsyncStatus; + } } - const v = values.map((v) => (v as any).value as T); + const v = values + .filter((v) => v.status === "complete") + .map((v) => (v as any).value as T); return { status: "complete", value: v, diff --git a/packages/stores/src/holochain.ts b/packages/stores/src/holochain.ts index 30eccd3..b78120f 100644 --- a/packages/stores/src/holochain.ts +++ b/packages/stores/src/holochain.ts @@ -1,11 +1,17 @@ import { ActionCommittedSignal, EntryRecord, + getHashType, + HashType, + HoloHashMap, LinkTypeForSignal, + retype, ZomeClient, } from "@holochain-open-dev/utils"; import { + Action, ActionHash, + AgentPubKey, CreateLink, decodeHashFromBase64, Delete, @@ -28,12 +34,12 @@ import { retryUntilSuccess } from "./retry-until-success.js"; * Will do so by calling the given every 4 seconds calling the given fetch function, * and listening to `LinkCreated` and `LinkDeleted` signals * - * Useful for link types + * Useful for link types that **don't** target AgentPubKeys (see liveLinksTargetsAgentPubKeysStore) */ export function liveLinksTargetsStore< BASE extends HoloHash, TARGET extends HoloHash, - S extends ActionCommittedSignal + S extends ActionCommittedSignal & any >( client: ZomeClient, baseAddress: BASE, @@ -51,7 +57,10 @@ export function liveLinksTargetsStore< }; await fetch(); const interval = setInterval(() => fetch(), 4000); - const unsubs = client.onSignal((signal) => { + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + if (signal.type === "LinkCreated") { if ( linkType in signal.link_type && @@ -99,7 +108,7 @@ export function liveLinksTargetsStore< */ export function collectionStore< H extends HoloHash, - S extends ActionCommittedSignal + S extends ActionCommittedSignal & any >( client: ZomeClient, fetchCollection: () => Promise, @@ -116,7 +125,10 @@ export function collectionStore< }; await fetch(); const interval = setInterval(() => fetch(), 4000); - const unsubs = client.onSignal((signal) => { + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + if (signal.type === "LinkCreated") { if (linkType in signal.link_type) { hashes = uniquify([ @@ -184,7 +196,7 @@ export function immutableEntryStore( */ export function latestVersionOfEntryStore< T, - S extends ActionCommittedSignal + S extends ActionCommittedSignal & any >( client: ZomeClient, fetchLatestVersion: () => Promise | undefined> @@ -217,7 +229,10 @@ export function latestVersionOfEntryStore< }; fetch(); const interval = setInterval(() => fetch(), 4000); - const unsubs = client.onSignal(async (signal) => { + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + if ( signal.type === "EntryUpdated" && latestVersion && @@ -246,6 +261,66 @@ export function latestVersionOfEntryStore< }); } +/** + * Keeps an up to date list of all the revisions for an entry + * Makes requests only while it has some subscriber + * + * Will do so by calling the given every 4 seconds calling the given fetch function, + * and listening to `EntryUpdated` signals + * + * Useful for entries that can be updated + */ +export function allRevisionsOfEntryStore< + T, + S extends ActionCommittedSignal & any +>( + client: ZomeClient, + fetchAllRevisions: () => Promise>> +): AsyncReadable>> { + return asyncReadable(async (set) => { + let allRevisions: Array>; + const fetch = async () => { + const nAllRevisions = await fetchAllRevisions(); + if (!isEqual(allRevisions, nAllRevisions)) { + allRevisions = nAllRevisions; + set(allRevisions); + } + }; + await fetch(); + const interval = setInterval(() => fetch(), 4000); + const unsubs = client.onSignal(async (originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + + if ( + signal.type === "EntryUpdated" && + allRevisions && + allRevisions.find( + (revision) => + revision.actionHash.toString() === + signal.action.hashed.content.original_action_address.toString() + ) + ) { + const newRevision = new EntryRecord({ + entry: { + Present: { + entry_type: "App", + entry: encode(signal.app_entry), + }, + }, + signed_action: signal.action, + }); + allRevisions.push(newRevision); + set(allRevisions); + } + }); + return () => { + clearInterval(interval); + unsubs(); + }; + }); +} + /** * Keeps an up to date list of the targets for the deleted links in this DHT * Makes requests only while it has some subscriber @@ -258,7 +333,7 @@ export function latestVersionOfEntryStore< export function deletedLinksTargetsStore< BASE extends HoloHash, TARGET extends HoloHash, - S extends ActionCommittedSignal + S extends ActionCommittedSignal & any >( client: ZomeClient, baseAddress: BASE, @@ -280,7 +355,10 @@ export function deletedLinksTargetsStore< }; await fetch(); const interval = setInterval(() => fetch(), 4000); - const unsubs = client.onSignal((signal) => { + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + if (signal.type === "LinkDeleted") { if ( linkType in signal.link_type && @@ -321,7 +399,9 @@ export function deletedLinksTargetsStore< * * Useful for entries that can be deleted */ -export function deletesForEntryStore>( +export function deletesForEntryStore< + S extends ActionCommittedSignal & any +>( client: ZomeClient, originalActionHash: ActionHash, fetchDeletes: () => Promise>> @@ -337,7 +417,10 @@ export function deletesForEntryStore>( }; await fetch(); const interval = setInterval(() => fetch(), 4000); - const unsubs = client.onSignal(async (signal) => { + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + if ( signal.type === "EntryDeleted" && signal.action.hashed.content.deletes_address.toString() === @@ -359,3 +442,235 @@ export function uniquify(array: Array): Array { const uniqueArray = [...new Set(strArray)]; return uniqueArray.map((h) => decodeHashFromBase64(h) as H); } + +function uniquifyActions( + actions: Array> +): Array> { + const map = new HoloHashMap>(); + for (const a of actions) { + map.set(a.hashed.hash, a); + } + + return Array.from(map.values()); +} + +/** + * Keeps an up to date list of the links for the non-deleted links in this DHT + * Makes requests only while it has some subscriber + * + * Will do so by calling the given every 4 seconds calling the given fetch function, + * and listening to `LinkCreated` and `LinkDeleted` signals + * + * Useful for link types + */ +export function liveLinksStore< + BASE extends HoloHash, + S extends ActionCommittedSignal & any +>( + client: ZomeClient, + baseAddress: BASE, + fetchLinks: () => Promise>>, + linkType: LinkTypeForSignal +): AsyncReadable>> { + let innerBaseAddress = baseAddress; + if (getHashType(innerBaseAddress) === HashType.AGENT) { + innerBaseAddress = retype(innerBaseAddress, HashType.ENTRY) as BASE; + } + return asyncReadable(async (set) => { + let links: SignedActionHashed[]; + const fetch = async () => { + const nlinks = await fetchLinks(); + if (!isEqual(nlinks, links)) { + links = uniquifyActions(nlinks); + set(links); + } + }; + await fetch(); + const interval = setInterval(() => fetch(), 4000); + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + + if (signal.type === "LinkCreated") { + if ( + linkType in signal.link_type && + signal.action.hashed.content.base_address.toString() === + innerBaseAddress.toString() + ) { + links = uniquifyActions([...links, signal.action]); + set(links); + } + } else if (signal.type === "LinkDeleted") { + if ( + linkType in signal.link_type && + signal.create_link_action.hashed.content.base_address.toString() === + innerBaseAddress.toString() + ) { + links = uniquifyActions( + links.filter( + (h) => + h.hashed.hash.toString() !== + signal.create_link_action.hashed.hash.toString() + ) + ); + set(links); + } + } + }); + return () => { + clearInterval(interval); + unsubs(); + }; + }); +} + +/** + * Keeps an up to date list of the targets for the deleted links in this DHT + * Makes requests only while it has some subscriber + * + * Will do so by calling the given every 4 seconds calling the given fetch function, + * and listening to `LinkDeleted` signals + * + * Useful for link types and collections with some form of archive retrieving functionality + */ +export function deletedLinksStore< + BASE extends HoloHash, + S extends ActionCommittedSignal & any +>( + client: ZomeClient, + baseAddress: BASE, + fetchDeletedTargets: () => Promise< + Array< + [SignedActionHashed, Array>] + > + >, + linkType: LinkTypeForSignal +): AsyncReadable< + Array<[SignedActionHashed, Array>]> +> { + let innerBaseAddress = baseAddress; + if (getHashType(innerBaseAddress) === HashType.AGENT) { + innerBaseAddress = retype(innerBaseAddress, HashType.ENTRY) as BASE; + } + return asyncReadable(async (set) => { + let deletedTargets: Array< + [SignedActionHashed, Array>] + >; + const fetch = async () => { + const ndeletedTargets = await fetchDeletedTargets(); + if (!isEqual(deletedTargets, ndeletedTargets)) { + deletedTargets = ndeletedTargets; + set(deletedTargets); + } + }; + await fetch(); + const interval = setInterval(() => fetch(), 4000); + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + + if (signal.type === "LinkDeleted") { + if ( + linkType in signal.link_type && + signal.create_link_action.hashed.content.base_address.toString() === + innerBaseAddress.toString() + ) { + const alreadyDeletedTargetIndex = deletedTargets.findIndex( + ([cl]) => + cl.hashed.hash.toString() === + signal.create_link_action.hashed.hash.toString() + ); + + if (alreadyDeletedTargetIndex !== -1) { + deletedTargets[alreadyDeletedTargetIndex][1].push(signal.action); + } else { + deletedTargets = [ + ...deletedTargets, + [signal.create_link_action, [signal.action]], + ]; + } + set(deletedTargets); + } + } + }); + return () => { + clearInterval(interval); + unsubs(); + }; + }); +} + +/** + * Keeps an up to date list of the target AgentPubKeys for the non-deleted links in this DHT + * Makes requests only while it has some subscriber + * + * Will do so by calling the given every 4 seconds calling the given fetch function, + * and listening to `LinkCreated` and `LinkDeleted` signals + * + * Useful for link types that target AgentPubKeys + */ +export function liveLinksAgentPubKeysTargetsStore< + BASE extends HoloHash, + S extends ActionCommittedSignal +>( + client: ZomeClient, + baseAddress: BASE, + fetchTargets: () => Promise, + linkType: LinkTypeForSignal +): AsyncReadable> { + return asyncReadable(async (set) => { + let hashes: AgentPubKey[]; + const fetch = async () => { + const nhashes = await fetchTargets(); + if (!isEqual(nhashes, hashes)) { + hashes = uniquify(nhashes); + set(hashes); + } + }; + await fetch(); + const interval = setInterval(() => fetch(), 4000); + const unsubs = client.onSignal((originalSignal) => { + if (!(originalSignal as ActionCommittedSignal).type) return; + const signal = originalSignal as ActionCommittedSignal; + + if (signal.type === "LinkCreated") { + if ( + linkType in signal.link_type && + signal.action.hashed.content.base_address.toString() === + baseAddress.toString() + ) { + hashes = uniquify([ + ...hashes, + retype( + signal.action.hashed.content.target_address as AgentPubKey, + HashType.AGENT + ), + ]); + set(hashes); + } + } else if (signal.type === "LinkDeleted") { + if ( + linkType in signal.link_type && + signal.create_link_action.hashed.content.base_address.toString() === + baseAddress.toString() + ) { + hashes = uniquify( + hashes.filter( + (h) => + h.toString() !== + retype( + signal.create_link_action.hashed.content.target_address, + HashType.AGENT + ).toString() + ) + ); + set(hashes); + } + } + }); + return () => { + clearInterval(interval); + unsubs(); + }; + }); +} diff --git a/packages/stores/src/join-map.ts b/packages/stores/src/join-map.ts index f1ba669..c50cac8 100644 --- a/packages/stores/src/join-map.ts +++ b/packages/stores/src/join-map.ts @@ -2,12 +2,12 @@ import { HoloHashMap } from "@holochain-open-dev/utils"; import { HoloHash } from "@holochain/client"; import { Readable } from "svelte/store"; import { derived } from "./derived.js"; -import { asyncDerived, joinAsync } from "./async-derived.js"; +import { asyncDerived, joinAsync, JoinAsyncOptions } from "./async-derived.js"; import { AsyncReadable } from "./async-readable.js"; -type StoreValue = T extends Readable ? U : never; +export type StoreValue = T extends Readable ? U : never; -type AsyncStoreValue = T extends AsyncReadable ? U : never; +export type AsyncStoreValue = T extends AsyncReadable ? U : never; /** * Joins all the stores in a HoloHashMap of `Readables` @@ -29,12 +29,13 @@ export function joinMap>( * Joins all the stores in a HoloHashMap of `AsyncReadables` */ export function joinAsyncMap>( - holoHashMap: ReadonlyMap + holoHashMap: ReadonlyMap, + joinOptions?: JoinAsyncOptions ): AsyncReadable>> { const storeArray = Array.from(holoHashMap.entries()).map(([key, store]) => asyncDerived(store, (v) => [key, v] as [H, AsyncStoreValue]) ); - const arrayStore = joinAsync(storeArray); + const arrayStore = joinAsync(storeArray, joinOptions); return asyncDerived( arrayStore, (entries) => new HoloHashMap>(entries) diff --git a/packages/stores/src/map-and-join.ts b/packages/stores/src/map-and-join.ts index bce0455..50be4a6 100644 --- a/packages/stores/src/map-and-join.ts +++ b/packages/stores/src/map-and-join.ts @@ -1,5 +1,6 @@ import { mapValues } from "@holochain-open-dev/utils"; import { HoloHash } from "@holochain/client"; +import { JoinAsyncOptions } from "async-derived.js"; import { AsyncReadable } from "./async-readable.js"; import { joinAsyncMap } from "./join-map.js"; @@ -9,7 +10,8 @@ import { joinAsyncMap } from "./join-map.js"; */ export function mapAndJoin( map: ReadonlyMap, - fn: (value: T, key: H) => AsyncReadable + fn: (value: T, key: H) => AsyncReadable, + joinOptions?: JoinAsyncOptions ): AsyncReadable> { - return joinAsyncMap(mapValues(map, fn)); + return joinAsyncMap(mapValues(map, fn), joinOptions); } diff --git a/packages/stores/tests/join-map.test.js b/packages/stores/tests/join-map.test.js index 2709d4e..ee01517 100644 --- a/packages/stores/tests/join-map.test.js +++ b/packages/stores/tests/join-map.test.js @@ -52,6 +52,38 @@ it("asyncJoinMap", async () => { expect(Array.from(get(j).value.entries()).length).to.deep.equal(2); }); +it("asyncJoinMap with error filtering", async () => { + let first = true; + const lazyStoreMap = new LazyHoloHashMap((hash) => + asyncReadable(async (set) => { + await sleep(10); + if (first) { + first = false; + throw new Error("hi"); + } + set(2); + }) + ); + + const hashes = [new Uint8Array([0]), new Uint8Array([1])]; + + for (const h of hashes) { + lazyStoreMap.get(h); + } + + const j = joinAsyncMap(lazyStoreMap, { + errors: "filter_out", + }); + + const subscriber = j.subscribe(() => {}); + + expect(get(j)).to.deep.equal({ status: "pending" }); + await sleep(20); + + expect(get(j).status).to.equal("complete"); + expect(Array.from(get(j).value.entries()).length).to.deep.equal(1); +}); + it("mapAndJoin", async () => { const lazyStoreMap = new LazyHoloHashMap((hash) => asyncReadable(async (set) => { diff --git a/packages/utils/package.json b/packages/utils/package.json index 7a5aa3b..dc188a5 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@holochain-open-dev/utils", - "version": "0.16.2", + "version": "0.16.3", "description": "Common utilities to build Holochain web applications", "author": "guillem.cordoba@gmail.com", "main": "dist/index.js", diff --git a/packages/utils/src/entry.ts b/packages/utils/src/entry.ts new file mode 100644 index 0000000..347fe12 --- /dev/null +++ b/packages/utils/src/entry.ts @@ -0,0 +1,9 @@ +import { Entry } from "@holochain/client"; +import { encode } from "@msgpack/msgpack"; + +export function encodeAppEntry(appEntry: any): Entry { + return { + entry_type: "App", + entry: encode(appEntry), + }; +} diff --git a/packages/utils/src/hash.ts b/packages/utils/src/hash.ts index 906dec1..579aecd 100644 --- a/packages/utils/src/hash.ts +++ b/packages/utils/src/hash.ts @@ -1,4 +1,9 @@ -import { encodeHashToBase64, HoloHash } from "@holochain/client"; +import { + encodeHashToBase64, + Entry, + EntryHash, + HoloHash, +} from "@holochain/client"; // @ts-ignore import blake from "blakejs"; import { encode } from "@msgpack/msgpack"; @@ -44,6 +49,11 @@ export function retype(hash: HoloHash, type: HashType): HoloHash { ]); } +export function hashEntry(entry: Entry): EntryHash { + if (entry.entry_type === "Agent") return entry.entry; + return hash(entry, HashType.ENTRY); +} + export function isHash(hash: string): boolean { return !![ AGENT_PREFIX, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e956bc1..40162de 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ export * from "./hash.js"; export * from "./fake.js"; +export * from "./entry.js"; export * from "./action-committed-signal.js"; export * from "./zome-client.js"; export * from "./zome-mock.js";