From 32c5c404666b6119d8411c7cef70aeebd780df74 Mon Sep 17 00:00:00 2001 From: Drew Haven Date: Wed, 2 Jul 2025 14:46:13 -0700 Subject: [PATCH] Sets up global cache and uses it to fetch sessions --- package-lock.json | 110 ++++++++++++++++++ package.json | 1 + src/components/RelationshipList.tsx | 6 +- .../documents/session/SessionEditForm.tsx | 17 ++- src/context/cache/CacheContext.tsx | 74 ++++++++++++ src/lib/recordCache.ts | 81 +++++++++++++ src/lib/types.ts | 12 ++ src/routes/__root.tsx | 5 +- src/routes/_app.tsx | 6 +- .../_authenticated/document.$documentId.tsx | 26 ++--- 10 files changed, 310 insertions(+), 28 deletions(-) create mode 100644 src/context/cache/CacheContext.tsx create mode 100644 src/lib/recordCache.ts diff --git a/package-lock.json b/package-lock.json index 285e630..8c1b360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "tailwindcss": "^4.0.6" }, "devDependencies": { + "@astrojs/ts-plugin": "^1.10.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", "@types/lodash": "^4.17.17", @@ -68,6 +69,52 @@ "dev": true, "license": "ISC" }, + "node_modules/@astrojs/compiler": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz", + "integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@astrojs/ts-plugin": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/ts-plugin/-/ts-plugin-1.10.4.tgz", + "integrity": "sha512-rapryQINgv5VLZF884R/wmgX3mM9eH1PC/I3kkPV9rP6lEWrRN1YClF3bGcDHFrf8EtTLc0Wqxne1Uetpevozg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.10.3", + "@astrojs/yaml2ts": "^0.2.2", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@volar/language-core": "~2.4.7", + "@volar/typescript": "~2.4.7", + "semver": "^7.3.8", + "vscode-languageserver-textdocument": "^1.0.11" + } + }, + "node_modules/@astrojs/ts-plugin/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@astrojs/yaml2ts": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.2.tgz", + "integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.5.0" + } + }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz", @@ -2249,6 +2296,35 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@volar/language-core": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.16.tgz", + "integrity": "sha512-mcoAFkYVQV4iiLYjTlbolbsm9hhDLtz4D4wTG+rwzSCUbEnxEec+KBlneLMlfdVNjkVEh8lUUSsCGNEQR+hFdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.16" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.16.tgz", + "integrity": "sha512-4rBiAhOw4MfFTpkvweDnjbDkixpmWNgBws95rpu2oFdMprkTtqFEb8pUOxQ/ruru8/zXSYLwRNXNozznjW9Vtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.16.tgz", + "integrity": "sha512-CrRuG20euPerYc4H0kvDWSSLTBo6qgSI1/0BjXL9ogjm5j6l0gIffvNzEvfmVjr8TAuoMPD0NxuEkteIapfZQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.16", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -3405,6 +3481,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4256,6 +4339,20 @@ } } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -4391,6 +4488,19 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/zod": { "version": "3.25.28", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz", diff --git a/package.json b/package.json index 24d6715..ca706ce 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "tailwindcss": "^4.0.6" }, "devDependencies": { + "@astrojs/ts-plugin": "^1.10.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", "@types/lodash": "^4.17.17", diff --git a/src/components/RelationshipList.tsx b/src/components/RelationshipList.tsx index 95e4094..b8cb467 100644 --- a/src/components/RelationshipList.tsx +++ b/src/components/RelationshipList.tsx @@ -83,7 +83,7 @@ export function RelationshipList({ queryClient.invalidateQueries({ queryKey: ["relationship", relationshipType, root.id], }); - setItems((prev) => [...prev, doc]); + setItems((prev) => [doc, ...prev]); } catch (e: any) { setError(e?.message || "Failed to add document to relationship."); } finally { @@ -99,9 +99,7 @@ export function RelationshipList({ if (relationshipId) { console.debug("Removing from existing relationship", relationshipId); await pb.collection("relationships").update(relationshipId, { - secondary: items - .map((item) => item.id) - .filter((id) => id !== documentId), + "secondary-": documentId, }); } queryClient.invalidateQueries({ diff --git a/src/components/documents/session/SessionEditForm.tsx b/src/components/documents/session/SessionEditForm.tsx index 25bca14..9705db3 100644 --- a/src/components/documents/session/SessionEditForm.tsx +++ b/src/components/documents/session/SessionEditForm.tsx @@ -1,15 +1,20 @@ import { AutoSaveTextarea } from "@/components/AutoSaveTextarea"; +import { useCache } from "@/context/cache/CacheContext"; import { pb } from "@/lib/pocketbase"; import type { Session } from "@/lib/types"; export const SessionEditForm = ({ session }: { session: Session }) => { + const cache = useCache(); async function saveStrongStart(strongStart: string) { - await pb.collection("documents").update(session.id, { - data: { - ...session.data, - strongStart, - }, - }); + const freshRecord: Session = await pb + .collection("documents") + .update(session.id, { + data: { + ...session.data, + strongStart, + }, + }); + cache.set(freshRecord); } return ( diff --git a/src/context/cache/CacheContext.tsx b/src/context/cache/CacheContext.tsx new file mode 100644 index 0000000..27389a8 --- /dev/null +++ b/src/context/cache/CacheContext.tsx @@ -0,0 +1,74 @@ +import { createContext, use, useContext, useMemo, useRef } from "react"; +import type { ReactNode } from "react"; +import { RecordCache } from "@/lib/recordCache"; +import { pb } from "@/lib/pocketbase"; +import { type DocumentId, type AnyDocument, CollectionIds } from "@/lib/types"; +import { useQueryClient } from "@tanstack/react-query"; + +/** + * Context value for the record cache singleton. + */ +export type CacheContextValue = RecordCache; + +const CacheContext = createContext(undefined); + +/** + * Provider for the record cache context. Provides a singleton RecordCache instance to children. + */ +export function CacheProvider({ children }: { children: ReactNode }) { + const cacheRef = useRef(undefined); + if (!cacheRef.current) { + cacheRef.current = new RecordCache(); + } + return ( + + {children} + + ); +} + +/** + * Hook to access the record cache context. Throws if used outside of CacheProvider. + */ +export function useCache(): CacheContextValue { + const ctx = useContext(CacheContext); + if (!ctx) throw new Error("useCache must be used within a CacheProvider"); + return ctx; +} + +export function useDocument(documentId: DocumentId): AnyDocument { + const cache = useCache(); + const queryClient = useQueryClient(); + + async function fetchItems() { + const cacheValue = cache.getDocument(documentId); + + if (cacheValue) { + console.info(`Serving ${documentId} from cache.`); + return cacheValue; + } + + const { doc } = await queryClient.fetchQuery({ + queryKey: [CollectionIds.Documents, documentId], + queryFn: async () => { + const doc: AnyDocument = await pb + .collection("documents") + .getOne(documentId, { + expand: + "relationships_via_primary,relationships_via_primary.secondary", + }); + + console.info(`Saving ${documentId} to cache.`); + cache.set(doc); + + return { doc }; + }, + }); + + return doc; + } + + const items = useMemo(fetchItems, [documentId, cache, queryClient]); + + return use(items); +} diff --git a/src/lib/recordCache.ts b/src/lib/recordCache.ts new file mode 100644 index 0000000..cc7efd6 --- /dev/null +++ b/src/lib/recordCache.ts @@ -0,0 +1,81 @@ +import { + type CollectionId, + type AnyDocument, + CollectionIds, + type Relationship, + type DocumentId, + type RelationshipId, +} from "./types"; + +type CacheKey = `${C}:${string}`; +type DocumentKey = CacheKey; +type RelationshipKey = CacheKey; + +export class RecordCache { + private cache: Record< + `${CollectionId}:${string}`, + { + record: AnyDocument | Relationship; + subscriptions: ((record: AnyDocument | Relationship | null) => void)[]; + } + > = {}; + + private makeKey( + collectionId: C, + id: string, + ): CacheKey { + return `${collectionId}:${id}`; + } + + private docKey = (id: DocumentId): DocumentKey => + this.makeKey(CollectionIds.Documents, id); + + private relationKey = (id: RelationshipId): RelationshipKey => + this.makeKey(CollectionIds.Relationships, id); + + get = (key: CacheKey): AnyDocument | Relationship | undefined => + this.cache[key]?.record; + + set = (record: AnyDocument | Relationship) => { + const key = this.makeKey(record.collectionName, record.id); + if (this.cache[key] === undefined) { + this.cache[key] = { + record, + subscriptions: [], + }; + } + const entry = this.cache[key]; + + entry.record = record; + + for (const subscription of entry.subscriptions) { + subscription(record); + } + + for (const doc of record.expand?.secondary ?? []) { + this.set(doc); + } + for (const rel of record.expand?.relationships_via_primary ?? []) { + this.set(rel); + } + }; + + remove = (key: CacheKey) => { + const entry = this.cache[key]; + delete this.cache[key]; + for (const subscription of entry.subscriptions) { + subscription(null); + } + }; + + getDocument = (id: DocumentId): AnyDocument | undefined => + this.get(this.docKey(id)) as AnyDocument | undefined; + + getRelationship = (id: RelationshipId): Relationship | undefined => + this.get(this.relationKey(id)) as Relationship | undefined; + + removeDocument = (id: DocumentId) => this.remove(this.docKey(id)); + + removeRelationship = (id: RelationshipId) => + this.remove(this.relationKey(id)); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8867037..cf866e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,9 +5,18 @@ export type Id = string & { __type: T }; export type UserId = Id<"User">; export type CampaignId = Id<"Campaign">; export type DocumentId = Id<"Document">; +export type RelationshipId = Id<"Relationship">; export type ISO8601Date = string & { __type: "iso8601date" }; +export const CollectionIds = { + Campaigns: "campaigns", + Documents: "documents", + Relationships: "relationships", +} as const; + +export type CollectionId = (typeof CollectionIds)[keyof typeof CollectionIds]; + export type Campaign = RecordModel & { id: CampaignId; name: string; @@ -32,6 +41,8 @@ export type RelationshipType = (typeof RelationshipType)[keyof typeof RelationshipType]; export type Relationship = RecordModel & { + id: RelationshipId; + collectionName: typeof CollectionIds.Relationships; primary: DocumentId; secondary: DocumentId[]; type: RelationshipType; @@ -57,6 +68,7 @@ export type DocumentData = { export type Document = RecordModel & { id: DocumentId; + collectionName: typeof CollectionIds.Documents; campaign: CampaignId; type: Type; data: Data; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 6fd10a0..a0f02ea 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { AuthProvider } from "@/context/auth/AuthContext"; +import { CacheProvider } from "@/context/cache/CacheContext"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { Outlet, createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; @@ -7,7 +8,9 @@ export const Route = createRootRoute({ component: () => ( <> - + + + diff --git a/src/routes/_app.tsx b/src/routes/_app.tsx index 0d1405d..3d82f93 100644 --- a/src/routes/_app.tsx +++ b/src/routes/_app.tsx @@ -1,5 +1,7 @@ +import { Loader } from "@/components/Loader"; import { useAuth } from "@/context/auth/AuthContext"; import { Link, Outlet, createFileRoute } from "@tanstack/react-router"; +import { Suspense } from "react"; export const Route = createFileRoute("/_app")({ component: RouteComponent, @@ -71,7 +73,9 @@ function RouteComponent() { return ( <> - + }> + + ); } diff --git a/src/routes/_app/_authenticated/document.$documentId.tsx b/src/routes/_app/_authenticated/document.$documentId.tsx index aa7bd62..0edfbc0 100644 --- a/src/routes/_app/_authenticated/document.$documentId.tsx +++ b/src/routes/_app/_authenticated/document.$documentId.tsx @@ -1,40 +1,34 @@ import { RelationshipList } from "@/components/RelationshipList"; import { DocumentEditForm } from "@/components/documents/DocumentEditForm"; -import { pb } from "@/lib/pocketbase"; import { displayName, relationshipsForDocument } from "@/lib/relationships"; -import { RelationshipType, type AnyDocument } from "@/lib/types"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react"; import { createFileRoute, Link } from "@tanstack/react-router"; +import { useDocument } from "@/context/cache/CacheContext"; +import type { DocumentId } from "@/lib/types"; export const Route = createFileRoute( "/_app/_authenticated/document/$documentId", )({ - loader: async ({ params }) => { - const doc = await pb.collection("documents").getOne(params.documentId); - return { - document: doc, - }; - }, component: RouteComponent, }); function RouteComponent() { - const { document } = Route.useLoaderData() as { - document: AnyDocument; - }; + const { documentId } = Route.useParams(); - const relationshipList = relationshipsForDocument(document); + const doc = useDocument(documentId as DocumentId); + + const relationshipList = relationshipsForDocument(doc); return ( -
+
Print - + {relationshipList.map((relationshipType) => ( @@ -51,7 +45,7 @@ function RouteComponent() {