"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { useSubscribe } from "src/replicache/useSubscribe"; import { DeepReadonlyObject, PushRequest, PushRequestV1, Replicache, WriteTransaction, } from "replicache"; import { mutations } from "./mutations"; import { Attributes } from "./attributes"; import { Attribute, Data, FilterAttributes } from "./attributes"; import { clientMutationContext } from "./clientMutationContext"; import { supabaseBrowserClient } from "supabase/browserClient"; import { callRPC } from "app/api/rpc/client"; import { UndoManager } from "@rocicorp/undo"; import { addShortcut } from "src/shortcuts"; import { createUndoManager } from "src/undoManager"; import { RealtimeChannel } from "@supabase/supabase-js"; export type Fact = { id: string; entity: string; attribute: A; data: Data; }; let ReplicacheContext = createContext({ undoManager: createUndoManager(), rootEntity: "" as string, rep: null as null | Replicache, initialFacts: [] as Fact[], permission_token: {} as PermissionToken, }); export function useReplicache() { return useContext(ReplicacheContext); } export type ReplicacheMutators = { [k in keyof typeof mutations]: ( tx: WriteTransaction, args: Parameters<(typeof mutations)[k]>[0], ) => Promise; }; export type PermissionToken = { id: string; root_entity: string; permission_token_rights: { created_at: string; entity_set: string; change_entity_set: boolean; create_token: boolean; token: string; read: boolean; write: boolean; }[]; }; export function ReplicacheProvider(props: { rootEntity: string; initialFacts: Fact[]; token: PermissionToken; name: string; children: React.ReactNode; initialFactsOnly?: boolean; disablePull?: boolean; }) { let [rep, setRep] = useState>(null); let [undoManager] = useState(createUndoManager()); useEffect(() => { return addShortcut([ { metaKey: true, key: "z", handler: () => { undoManager.undo(); }, }, { metaKey: true, shift: true, key: "z", handler: () => { undoManager.redo(); }, }, { metaKey: true, shift: true, key: "Z", handler: () => { undoManager.redo(); }, }, ]); }, [undoManager]); useEffect(() => { if (props.initialFactsOnly) return; let supabase = supabaseBrowserClient(); let newRep = new Replicache({ pullInterval: props.disablePull ? null : undefined, pushDelay: 500, mutators: Object.fromEntries( Object.keys(mutations).map((m) => { return [ m, async (tx: WriteTransaction, args: any) => { await mutations[m as keyof typeof mutations]( args, clientMutationContext(tx, { permission_token_id: props.token.id, undoManager, rep: newRep, ignoreUndo: args.ignoreUndo || tx.reason !== "initial", defaultEntitySet: props.token.permission_token_rights[0]?.entity_set, }), ); }, ]; }), ) as ReplicacheMutators, licenseKey: "l381074b8d5224dabaef869802421225a", pusher: async (pushRequest) => { const batchSize = 250; let smolpushRequest = { ...pushRequest, mutations: pushRequest.mutations.slice(0, batchSize), } as PushRequest; let response = ( await callRPC("push", { pushRequest: smolpushRequest, token: props.token, rootEntity: props.name, }) ).result; if (pushRequest.mutations.length > batchSize) setTimeout(() => { newRep.push(); }, 50); return { response, httpRequestInfo: { errorMessage: "", httpStatusCode: 200 }, }; }, puller: async (pullRequest) => { let res = await callRPC("pull", { pullRequest, token_id: props.token.id, }); return { response: res, httpRequestInfo: { errorMessage: "", httpStatusCode: 200 }, }; }, name: props.name, indexes: { eav: { jsonPointer: "/indexes/eav", allowEmpty: true }, vae: { jsonPointer: "/indexes/vae", allowEmpty: true }, }, }); setRep(newRep); let channel: RealtimeChannel | null = null; if (!props.disablePull) { channel = supabase.channel(`rootEntity:${props.name}`); channel.on("broadcast", { event: "poke" }, () => { newRep.pull(); }); channel.subscribe(); } return () => { newRep.close(); setRep(null); channel?.unsubscribe(); }; }, [props.name, props.initialFactsOnly, props.token, props.disablePull]); return ( {props.children} ); } type CardinalityResult = (typeof Attributes)[A]["cardinality"] extends "one" ? DeepReadonlyObject> | null : DeepReadonlyObject>[]; export function useEntity( entity: string | null, attribute: A, ): CardinalityResult { let { rep, initialFacts } = useReplicache(); let fallbackData = useMemo( () => initialFacts.filter( (f) => f.entity === entity && f.attribute === attribute, ), [entity, attribute, initialFacts], ); let data = useSubscribe( rep, async (tx) => { if (entity === null) return null; let initialized = await tx.get("initialized"); if (!initialized) return null; return ( ( await tx .scan>({ indexName: "eav", prefix: `${entity}-${attribute}`, }) // hack to handle rich bluesky-post type .toArray() ).filter((f) => f.attribute === attribute) ); }, { default: null, dependencies: [entity, attribute], }, ); let d = data || fallbackData; let a = Attributes[attribute]; return a.cardinality === "many" ? ((a.type === "ordered-reference" ? d.sort((a, b) => { return ( a as Fact> ).data.position > (b as Fact>) .data.position ? 1 : -1; }) : d) as CardinalityResult) : d.length === 0 && data === null ? (null as CardinalityResult) : (d[0] as CardinalityResult); } export function useReferenceToEntity< A extends keyof FilterAttributes<{ type: "reference" | "ordered-reference" }>, >(attribute: A, entity: string) { let { rep, initialFacts } = useReplicache(); let fallbackData = useMemo( () => initialFacts.filter( (f) => (f as Fact).data.value === entity && f.attribute === attribute, ), [entity, attribute, initialFacts], ); let data = useSubscribe( rep, async (tx) => { if (entity === null) return null; let initialized = await tx.get("initialized"); if (!initialized) return null; return ( await tx .scan>({ indexName: "vae", prefix: `${entity}-${attribute}` }) .toArray() ).filter((f) => f.attribute === attribute); }, { default: null, dependencies: [entity, attribute], }, ); return data || (fallbackData as Fact[]); }