diff --git a/package.json b/package.json index 3e287bea..0d7e7dbf 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@react-native/eslint-config": "^0.72.2", "@tsconfig/react-native": "^3.0.5", "@types/jest": "^28.1.2", - "@types/react": "~17.0.21", + "@types/react": "^18.3.10", "@types/unzipper": "^0.10.9", "clang-format": "^1.8.0", "del-cli": "^5.0.0", @@ -97,7 +97,7 @@ "zx": "^7.2.3" }, "resolutions": { - "@types/react": "17.0.21" + "@types/react": "^18.3.10" }, "peerDependencies": { "@prisma/client": "*", diff --git a/scripts/bump-client.ts b/scripts/bump-client.ts index db8a30a4..d3c86555 100644 --- a/scripts/bump-client.ts +++ b/scripts/bump-client.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { $ } from 'zx'; + import { downloadEngine, ensureNpmTag, writeVersionFile } from './utils'; async function main() { diff --git a/src/CacheManager.ts b/src/CacheManager.ts new file mode 100644 index 00000000..91b03f77 --- /dev/null +++ b/src/CacheManager.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; + +export default class CacheManager { + private queue = new Map(); + private listeners = new Map void>>(); + private cache = new Map>(); + + private get(model: string) { + return this.cache.get(model); + } + + set(model: string, key: string, data: any) { + const cache = this.get(model); + this.cache.set(model, { ...cache, [key]: { data } }); + } + + getSnapshot(model: string, key: string) { + const cache = this.get(model) || {}; + return cache[key]?.data; + } + + exist(model: string, key: string) { + const cache = this.get(model) || {}; + return Boolean(cache[key]); + } + + subscribe(model: string, listener: () => Promise) { + if (!this.listeners.has(model)) { + this.listeners.set(model, new Set()); + } + + const listeners = this.listeners.get(model); + listeners?.add(listener); + + return () => listeners?.delete(listener); + } + + notifySubscribers(model: string) { + if (this.queue.has(model)) { + clearTimeout(this.queue.get(model)!); + } + + const timer = setTimeout(() => { + const listeners = this.listeners.get(model); + listeners?.forEach((listener) => listener()); + }, 100); // 100ms debounce, we can adjust this value as needed + + this.queue.set(model, timer); + } +} + +export const cache = new CacheManager(); + +// A custom hook for reading data from cache and subscribing to updates +export const usePrismaCache = ( + model: string, + key: string, + queryFn: () => Promise +) => { + const listener = useCallback( + (callback: () => void) => async () => { + const data = await queryFn(); + cache.set(model, key, data); + callback(); + }, + [] + ); + + const store = useSyncExternalStore>( + (cb) => cache.subscribe(model, listener(cb)), + () => cache.getSnapshot(model, key) + ); + + useEffect(() => { + if (!cache.exist(model, key)) { + cache.notifySubscribers(model); + } + }, []); + + return store; +}; diff --git a/src/ReactiveHooksExtension.ts b/src/ReactiveHooksExtension.ts index e56bc9e1..9441cc1b 100644 --- a/src/ReactiveHooksExtension.ts +++ b/src/ReactiveHooksExtension.ts @@ -1,36 +1,11 @@ import { Prisma } from '@prisma/client/extension'; -import { useEffect, useState } from 'react'; + +import { cache, usePrismaCache } from './CacheManager'; export const reactiveHooksExtension = () => Prisma.defineExtension((client) => { - const subscribedQueries: Record< - string, - { - callbacks: Record void>; - query: () => Promise; - } - > = {}; - - const refreshSubscriptions = async () => { - for (const key in subscribedQueries) { - const subscription = subscribedQueries[key]!; - - const data = await subscription.query(); - - for (const callbackKey in subscription.callbacks) { - const callback = subscription.callbacks[callbackKey]!; - callback(data); - } - } - }; - return client.$extends({ name: 'prisma-reactive-hooks-extension', - client: { - $refreshSubscriptions: async () => { - await refreshSubscriptions(); - } - }, model: { $allModels: { useFindMany( @@ -38,264 +13,170 @@ export const reactiveHooksExtension = () => args?: Prisma.Exact> ): Prisma.Result { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; - const prismaPromise = model.findMany(args); - - const [engineResponse, setEngineResponse] = useState< - Prisma.Result - >([] as any); - - useEffect(() => { - const key = `${model} :: findMany :: ${JSON.stringify(args)}`; - const callbackKey = `${model} :: findMany :: ${JSON.stringify( - args - )} :: ${Math.random()}`; - if (subscribedQueries[key] != null) { - subscribedQueries[key]!.callbacks[callbackKey] = - setEngineResponse; - } else { - subscribedQueries[key] = { - callbacks: { - [callbackKey]: setEngineResponse, - }, - query: () => model.findMany(args), - }; - } + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; + const key = `${table} :: findMany :: ${JSON.stringify(args)}`; - prismaPromise.then(setEngineResponse); + const queryFn = (): Promise> => + model.findMany(args); - return () => { - delete subscribedQueries[key]!.callbacks[callbackKey]; - }; - }, []); - - return engineResponse; + return usePrismaCache(table, key, queryFn); }, + useFindUnique( this: T, args?: Prisma.Exact> ): Prisma.Result { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; - const prismaPromise = model.findUnique(args); - - const [engineResponse, setEngineResponse] = useState(); - - useEffect(() => { - const key = `${model} :: findUnique :: ${JSON.stringify(args)}`; - const callbackKey = `${model} :: findUnique :: ${JSON.stringify( - args - )} :: ${Math.random()}`; - if (subscribedQueries[key] != null) { - subscribedQueries[key]!.callbacks[callbackKey] = - setEngineResponse; - } else { - subscribedQueries[key] = { - callbacks: { - [callbackKey]: setEngineResponse, - }, - query: () => model.findUnique(args), - }; - } + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; + const key = `${table} :: findUnique :: ${JSON.stringify(args)}`; - prismaPromise.then(setEngineResponse); + const queryFn = (): Promise> => + model.findUnique(args); - return () => { - delete subscribedQueries[key]!.callbacks[callbackKey]; - }; - }, []); - - return engineResponse; + return usePrismaCache(table, key, queryFn); }, + useFindFirst( this: T, args?: Prisma.Exact> ): Prisma.Result { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; - const prismaPromise = model.findFirst(args); - - const [engineResponse, setEngineResponse] = useState(); - - useEffect(() => { - const key = `${model} :: findFirst :: ${JSON.stringify(args)}`; - const callbackKey = `${model} :: findFirst :: ${JSON.stringify( - args - )} :: ${Math.random()}`; - if (subscribedQueries[key] != null) { - subscribedQueries[key]!.callbacks[callbackKey] = - setEngineResponse; - } else { - subscribedQueries[key] = { - callbacks: { - [callbackKey]: setEngineResponse, - }, - query: () => model.findFirst(args), - }; - } + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; + const key = `${table} :: findFirst :: ${JSON.stringify(args)}`; - prismaPromise.then(setEngineResponse); + const queryFn = (): Promise> => + model.findFirst(args); - return () => { - delete subscribedQueries[key]!.callbacks[callbackKey]; - }; - }, []); - - return engineResponse; + return usePrismaCache(table, key, queryFn); }, + useAggregate( this: T, args?: Prisma.Exact> ): Prisma.Result { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; - const prismaPromise = model.aggregate(args); - - const [engineResponse, setEngineResponse] = useState(); - - useEffect(() => { - const key = `${model} :: aggregate :: ${JSON.stringify(args)}`; - const callbackKey = `${model} :: aggregate :: ${JSON.stringify( - args - )} :: ${Math.random()}`; - if (subscribedQueries[key] != null) { - subscribedQueries[key]!.callbacks[callbackKey] = - setEngineResponse; - } else { - subscribedQueries[key] = { - callbacks: { - [callbackKey]: setEngineResponse, - }, - query: () => model.aggregate(args), - }; - } + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; + const key = `${table} :: aggregate :: ${JSON.stringify(args)}`; - prismaPromise.then(setEngineResponse); + const queryFn = (): Promise> => + model.aggregate(args); - return () => { - delete subscribedQueries[key]!.callbacks[callbackKey]; - }; - }, []); - - return engineResponse; + return usePrismaCache(table, key, queryFn); }, + useGroupBy( this: T, args?: Prisma.Exact> ): Prisma.Result { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; - const prismaPromise = model.groupBy(args); - - const [engineResponse, setEngineResponse] = useState(); - - useEffect(() => { - const key = `${model} :: groupBy :: ${JSON.stringify(args)}`; - const callbackKey = `${model} :: groupBy :: ${JSON.stringify( - args - )} :: ${Math.random()}`; - if (subscribedQueries[key] != null) { - subscribedQueries[key]!.callbacks[callbackKey] = - setEngineResponse; - } else { - subscribedQueries[key] = { - callbacks: { - [callbackKey]: setEngineResponse, - }, - query: () => model.groupBy(args), - }; - } + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; + const key = `${table} :: groupBy :: ${JSON.stringify(args)}`; - prismaPromise.then(setEngineResponse); + const queryFn = (): Promise> => + model.groupBy(args); - return () => { - delete subscribedQueries[key]!.callbacks[callbackKey]; - }; - }, []); - - return engineResponse; + return usePrismaCache(table, key, queryFn); }, + async create( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.create(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, + async createMany( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.createMany(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, + async delete( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.delete(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, + async deleteMany( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.deleteMany(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, + async update( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.update(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, + async updateMany( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.updateMany(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, + async upsert( this: T, args?: Prisma.Exact> ): Promise> { const ctx = Prisma.getExtensionContext(this); - const model = (ctx.$parent as any)[ctx.$name!]; + const table = ctx.$name!; + const model = (ctx.$parent as any)[table]; const prismaPromise = model.upsert(args); const data = await prismaPromise; - await refreshSubscriptions(); + cache.notifySubscribers(table); return data; }, diff --git a/yarn.lock b/yarn.lock index f4c510ab..1bbab6c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3336,7 +3336,7 @@ __metadata: "@react-native/eslint-config": "npm:^0.72.2" "@tsconfig/react-native": "npm:^3.0.5" "@types/jest": "npm:^28.1.2" - "@types/react": "npm:~17.0.21" + "@types/react": "npm:^18.3.10" "@types/unzipper": "npm:^0.10.9" clang-format: "npm:^1.8.0" del-cli: "npm:^5.0.0" @@ -4244,14 +4244,13 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:17.0.21": - version: 17.0.21 - resolution: "@types/react@npm:17.0.21" +"@types/react@npm:^18.3.10": + version: 18.3.10 + resolution: "@types/react@npm:18.3.10" dependencies: "@types/prop-types": "npm:*" - "@types/scheduler": "npm:*" csstype: "npm:^3.0.2" - checksum: 10/ca1a1164b9854a73347f384f3bde5234eede65586c09499643674a3f95dd5c292ed05792e9f4d7090a9b3811c038badb131853784f256dab33e95f92ea0c876e + checksum: 10/cdc946f15fcad62387e6485a2bbef3e45609708fd81e3ce30b03580077bb8a8a1bd0d19858d8602b33c3921bbb8907ddbc9411fd25294a2c2e944907dad8dd67 languageName: node linkType: hard @@ -4262,13 +4261,6 @@ __metadata: languageName: node linkType: hard -"@types/scheduler@npm:*": - version: 0.16.8 - resolution: "@types/scheduler@npm:0.16.8" - checksum: 10/6c091b096daa490093bf30dd7947cd28e5b2cd612ec93448432b33f724b162587fed9309a0acc104d97b69b1d49a0f3fc755a62282054d62975d53d7fd13472d - languageName: node - linkType: hard - "@types/semver@npm:^7.3.12, @types/semver@npm:^7.5.0": version: 7.5.8 resolution: "@types/semver@npm:7.5.8"