Compare commits
17 Commits
perf/postg
...
realtime-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad04c6e16e | ||
|
|
7083d0e947 | ||
|
|
9145e9e3da | ||
|
|
784f155962 | ||
|
|
680131616c | ||
|
|
d773f1ca67 | ||
|
|
0e96050122 | ||
|
|
b96cebeed4 | ||
|
|
77c6cce862 | ||
|
|
180e897655 | ||
|
|
6b610bb804 | ||
|
|
871785b347 | ||
|
|
37f8c1861e | ||
|
|
8b819d03de | ||
|
|
82663a265a | ||
|
|
d1e47bdcd5 | ||
|
|
7a55413950 |
@@ -35,6 +35,7 @@
|
||||
"build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"",
|
||||
"build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"",
|
||||
"build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"",
|
||||
"build:plugin-realtime": "turbo build --filter \"@payloadcms/plugin-realtime\"",
|
||||
"build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"",
|
||||
"build:plugin-search": "turbo build --filter \"@payloadcms/plugin-search\"",
|
||||
"build:plugin-sentry": "turbo build --filter \"@payloadcms/plugin-sentry\"",
|
||||
|
||||
43
packages/plugin-realtime/.gitignore
vendored
Normal file
43
packages/plugin-realtime/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
/.idea/*
|
||||
!/.idea/runConfigurations
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
|
||||
/dev/media
|
||||
24
packages/plugin-realtime/.swcrc
Normal file
24
packages/plugin-realtime/.swcrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
96
packages/plugin-realtime/README.md
Normal file
96
packages/plugin-realtime/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# PayloadQuery
|
||||
|
||||
Currently you can stream DB updates to the client using a polling strategy (refetching at intervals). For example, to get the number of posts with react-query:
|
||||
|
||||
```ts
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['postsCount'],
|
||||
queryFn: () => fetch('/api/posts/count').then((res) => res.json()),
|
||||
refetchInterval: 5000, // refetch every 5 seconds
|
||||
})
|
||||
```
|
||||
|
||||
This has some problems:
|
||||
|
||||
1. It's not really "real-time". You have to wait for the refetch interval to be met.
|
||||
2. It is not efficient, since even if nothing changed, it will make unnecessary requests.
|
||||
3. It is not type-safe.
|
||||
|
||||
To solve these problems, we are introducing `payloadQuery`.
|
||||
|
||||
To use it in React, you need to wrap your app with `PayloadQueryClientProvider`:
|
||||
|
||||
```ts
|
||||
import { PayloadQueryClientProvider } from '@payloadcms/plugin-realtime'
|
||||
|
||||
export function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PayloadQueryClientProvider>
|
||||
{children}
|
||||
</PayloadQueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Now you can use the `usePayloadQuery` hook anywhere in your app:
|
||||
|
||||
```ts
|
||||
import { usePayloadQuery } from '@payloadcms/plugin-realtime'
|
||||
|
||||
const { data, error, isLoading } = usePayloadQuery('count', { collection: 'posts' })
|
||||
```
|
||||
|
||||
This will automatically update the data when the DB changes!
|
||||
|
||||
You can use all 3 Local API reading methods: `find`, `findById`, and `count` (with all their possible parameters: where clauses, pagination, etc.).
|
||||
|
||||
## How it works
|
||||
|
||||
Under the hood, `PayloadQueryClientProvider` opens a single centralized connection that is kept alive, and is used to listen and receive updates via Server Sent Events (SSE). The initial request is made with a normal HTTP request that does not require keep-alive.
|
||||
|
||||
The same queries are cached on the client and server, so as not to repeat them unnecessarily.
|
||||
|
||||
On the server, the `afterChange` and `afterDelete` hooks are used to loop through all registered queries and fire them if they have been invalidated. Invalidation logic allows for incremental improvements. Instead of naively invalidating all queries for any change in the database, we can analyze the type of operation, the collection, or options such as the where clause.
|
||||
|
||||
# What if I don't use React?
|
||||
|
||||
This plugin was intentionally made framework-agnostic, in vanilla javascript:
|
||||
|
||||
```ts
|
||||
import { createPayloadClient } from '@payloadcms/plugin-realtime'
|
||||
|
||||
const { payloadQuery } = createPayloadClient()
|
||||
|
||||
const initialCount = await payloadQuery(
|
||||
'count',
|
||||
{ collection: 'posts' },
|
||||
{
|
||||
onChange: (result) => {
|
||||
if (result.data) {
|
||||
console.log(result.data.totalDocs) // do something with the result
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The React version is just a small wrapper around these functions. Feel free to bind it to your UI framework of choice!
|
||||
|
||||
## What if I use Vercel / Serverless?
|
||||
|
||||
Serverless platforms like Vercel don't allow indefinitely alive connections (either HTTP or websockets).
|
||||
|
||||
This API has a reconnection logic that can solve some problems, but even then when the server reconnects the queries stored in memory would be lost, so the client might miss some updates.
|
||||
|
||||
We want to explore later how to solve this, either by storing it in the payload database or in some external service or server.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Discuss overall strategy and API (this is a very primitive PoC yet)
|
||||
- [ ] Add tests
|
||||
- [ ] Add docs
|
||||
- [ ] Make it fully type-safe. Currently the parameter options are type-safe depending on the method chosen (`find`, `findById` or `count`). But I would like to make the responses type-safe as well, like in an ORM. My idea is to pass an auto-generated parser to the `ClientProvider` that follows the collections interface.
|
||||
- [ ] To be discussed: Currently this is a plugin. I think it would make sense to move the vanilla implementation to core and the react hook to the `/ui` package.
|
||||
- [ ] Provide similar methods or hooks for type-safe mutations
|
||||
- [ ] Reliability in serverless. Store in our own DB, or an external service or server?
|
||||
- [ ] Our intention is to do much more in the realtime domain, such as showing who is editing a document in the admin panel. We would have to decide what name we want to give to this API that is a part of that broader umbrella. I am currently using the term `PayloadQuery` because of the similarity to react-query, but it actually does quite a bit more than react-query. There are many related terms that come to mind: `RCP`, `ORM`, `real-time`, `reactive`, etc.
|
||||
18
packages/plugin-realtime/eslint.config.js
Normal file
18
packages/plugin-realtime/eslint.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
|
||||
|
||||
/** @typedef {import('eslint').Linter.Config} Config */
|
||||
|
||||
/** @type {Config[]} */
|
||||
export const index = [
|
||||
...rootEslintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
...rootParserOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default index
|
||||
78
packages/plugin-realtime/package.json
Normal file
78
packages/plugin-realtime/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-realtime",
|
||||
"version": "1.0.0",
|
||||
"description": "Get real-time updates for your Payload CMS data",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/plugin-realtime"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Payload",
|
||||
"email": "info@payloadcms.com",
|
||||
"url": "https://payloadcms.com"
|
||||
}
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/exports/index.ts",
|
||||
"types": "./src/exports/index.ts",
|
||||
"default": "./src/exports/index.ts"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./src/exports/client.ts",
|
||||
"types": "./src/exports/client.ts",
|
||||
"default": "./src/exports/client.ts"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./src/exports/rsc.ts",
|
||||
"types": "./src/exports/rsc.ts",
|
||||
"default": "./src/exports/rsc.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --outDir dist --rootDir ./src",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/exports/index.js",
|
||||
"types": "./dist/exports/index.d.ts",
|
||||
"default": "./dist/exports/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.js"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/exports/index.js",
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"types": "./dist/exports/index.d.ts"
|
||||
}
|
||||
}
|
||||
4
packages/plugin-realtime/src/exports/index.ts
Normal file
4
packages/plugin-realtime/src/exports/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { realtimePlugin } from '../plugin/index.js'
|
||||
export { usePayloadQuery } from '../react/usePayloadQuery.js'
|
||||
export { PayloadQueryClientProvider } from '../react/usePayloadQuery.js'
|
||||
export { createPayloadClient } from '../vanilla/payloadQuery.js'
|
||||
128
packages/plugin-realtime/src/plugin/endpoints.ts
Normal file
128
packages/plugin-realtime/src/plugin/endpoints.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Endpoint, Payload } from 'payload'
|
||||
|
||||
import type { ReadOperation } from '../plugin/index.js'
|
||||
import type { QuerySubscription, StringifiedQuery } from './index.js'
|
||||
|
||||
type Client = {
|
||||
clientId: string
|
||||
response: Response
|
||||
writer: WritableStreamDefaultWriter
|
||||
}
|
||||
|
||||
export const querySubscriptions = new Map<StringifiedQuery, QuerySubscription>()
|
||||
export const clients = new Map<string, Client>()
|
||||
|
||||
export const payloadQueryEndpoint: Endpoint = {
|
||||
handler: async (req) => {
|
||||
try {
|
||||
if (!req.json) {
|
||||
return new Response('req.json is not a function', { status: 500 })
|
||||
}
|
||||
const body = await req.json()
|
||||
const { type, clientId, queryParams } = body as {
|
||||
clientId: string
|
||||
queryParams: Parameters<Payload[ReadOperation]>[0]
|
||||
type: ReadOperation
|
||||
}
|
||||
|
||||
if (!type || !queryParams || !clientId) {
|
||||
throw new Error('Missing required parameters')
|
||||
}
|
||||
if (!clients.has(clientId)) {
|
||||
throw new Error('Client not found')
|
||||
}
|
||||
|
||||
// Execute the initial query
|
||||
let result
|
||||
if (type === 'count') {
|
||||
result = await req.payload.count(queryParams)
|
||||
} else if (type === 'find') {
|
||||
result = await req.payload.find(queryParams)
|
||||
} else if (type === 'findByID') {
|
||||
result = await req.payload.findByID(queryParams as Parameters<Payload['findByID']>[0])
|
||||
} else {
|
||||
throw new Error(`Unsupported query type: ${type}`)
|
||||
}
|
||||
|
||||
// Insert or update the querySubscription (depending if queryId already exists)
|
||||
const stringifiedQuery = JSON.stringify({ type, queryParams })
|
||||
if (!querySubscriptions.has(stringifiedQuery)) {
|
||||
querySubscriptions.set(stringifiedQuery, {
|
||||
type,
|
||||
clients: new Set(),
|
||||
queryParams,
|
||||
})
|
||||
}
|
||||
|
||||
// Add this client to the querySubscription
|
||||
const querySubscription = querySubscriptions.get(stringifiedQuery)!
|
||||
querySubscription.clients.add(clientId)
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
},
|
||||
method: 'post',
|
||||
path: '/payload-query',
|
||||
}
|
||||
|
||||
export const payloadSSEEndpoint: Endpoint = {
|
||||
handler: (req) => {
|
||||
try {
|
||||
const stream = new TransformStream()
|
||||
const writer = stream.writable.getWriter()
|
||||
const response = new Response(stream.readable, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Content-Type': 'text/event-stream',
|
||||
},
|
||||
})
|
||||
if (!req.url) {
|
||||
throw new Error('Missing required parameters')
|
||||
}
|
||||
const url = new URL(req.url)
|
||||
const clientId = url.searchParams.get('clientId')
|
||||
if (!clientId) {
|
||||
throw new Error('Missing required parameters')
|
||||
}
|
||||
|
||||
// Send initial heartbeat with clientId
|
||||
// await writer.write(new TextEncoder().encode(`data: ${JSON.stringify({ clientId })}\n\n`))
|
||||
|
||||
// Store client connection
|
||||
const client = { clientId, response, writer }
|
||||
clients.set(clientId, client)
|
||||
|
||||
// Clean up when client disconnects
|
||||
req.signal?.addEventListener('abort', () => {
|
||||
// Remove this client from all querySubscriptions
|
||||
for (const querySubscription of querySubscriptions.values()) {
|
||||
querySubscription.clients.delete(clientId)
|
||||
}
|
||||
clients.delete(clientId)
|
||||
writer.close().catch(console.error)
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
console.error(error)
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
},
|
||||
method: 'get',
|
||||
path: '/payload-sse',
|
||||
}
|
||||
74
packages/plugin-realtime/src/plugin/hooks.ts
Normal file
74
packages/plugin-realtime/src/plugin/hooks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, Payload } from 'payload'
|
||||
|
||||
import type { ReadOperation } from '../plugin/index.js'
|
||||
import type { QuerySubscription } from './index.js'
|
||||
|
||||
import { clients, querySubscriptions } from './endpoints.js'
|
||||
|
||||
const sendToClients = async (querySubscription: QuerySubscription, payload: Payload) => {
|
||||
const { type, queryParams } = querySubscription
|
||||
let result: Awaited<ReturnType<Payload[ReadOperation]>> | undefined
|
||||
|
||||
if (type === 'count') {
|
||||
result = await payload.count(queryParams)
|
||||
} else if (type === 'find') {
|
||||
result = await payload.find(queryParams)
|
||||
} else if (type === 'findByID') {
|
||||
result = await payload.findByID(queryParams as Parameters<Payload['findByID']>[0])
|
||||
} else {
|
||||
throw new Error('Invalid query type')
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(querySubscription.clients).map(async (clientId) => {
|
||||
const client = clients.get(clientId)
|
||||
if (!client) {
|
||||
throw new Error('Client not found')
|
||||
}
|
||||
await client.writer.write(
|
||||
new TextEncoder().encode(
|
||||
`data: ${JSON.stringify({
|
||||
queryResult: result,
|
||||
stringifiedQuery: JSON.stringify({ type, queryParams }),
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error sending to clients:', error)
|
||||
}
|
||||
console.log('sendToClients done', result)
|
||||
}
|
||||
|
||||
export const myAfterChangeHook: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
|
||||
for (const [, querySubscription] of querySubscriptions) {
|
||||
if (querySubscription.type === 'count') {
|
||||
// Always refresh count queries for the affected collection
|
||||
if (querySubscription.queryParams.collection === collection.slug) {
|
||||
await sendToClients(querySubscription, req.payload)
|
||||
}
|
||||
} else if (querySubscription.type === 'find') {
|
||||
// Refresh find queries if the collection matches
|
||||
if (querySubscription.queryParams.collection === collection.slug) {
|
||||
await sendToClients(querySubscription, req.payload)
|
||||
}
|
||||
} else if (querySubscription.type === 'findByID') {
|
||||
// Refresh findByID queries if the specific document changed
|
||||
if (
|
||||
querySubscription.queryParams.collection === collection.slug &&
|
||||
(querySubscription.queryParams as Parameters<Payload['findByID']>[0]).id === doc.id
|
||||
) {
|
||||
await sendToClients(querySubscription, req.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const myAfterDeleteHook: CollectionAfterDeleteHook = (_args) => {
|
||||
// If the collection that changed has a registered reactive count, we have to trigger all its count listeners
|
||||
// If the collection that changed has a registered find and satisfies the where of the options, we have to trigger all its find listeners
|
||||
// If the document that changed has findById registered, we have to trigger all its find listeners (set to null)
|
||||
// console.log('myAfterDeleteHook')
|
||||
}
|
||||
37
packages/plugin-realtime/src/plugin/index.ts
Normal file
37
packages/plugin-realtime/src/plugin/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type Config, type Payload } from 'payload'
|
||||
|
||||
import { payloadQueryEndpoint, payloadSSEEndpoint } from './endpoints.js'
|
||||
import { myAfterChangeHook, myAfterDeleteHook } from './hooks.js'
|
||||
|
||||
export type QuerySubscription = {
|
||||
clients: Set<string> // clientId
|
||||
queryParams: Parameters<Payload[ReadOperation]>[0]
|
||||
type: 'count' | 'find' | 'findByID'
|
||||
}
|
||||
|
||||
export type Query<T extends ReadOperation> = {
|
||||
queryParams: Parameters<Payload[T]>[0]
|
||||
type: T
|
||||
}
|
||||
export type StringifiedQuery = string
|
||||
|
||||
export type ReadOperation = 'count' | 'find' | 'findByID'
|
||||
|
||||
export const realtimePlugin =
|
||||
() =>
|
||||
(config: Config): Config => {
|
||||
return {
|
||||
...config,
|
||||
collections: config.collections?.map((collection) => {
|
||||
return {
|
||||
...collection,
|
||||
hooks: {
|
||||
...collection.hooks,
|
||||
afterChange: [...(collection.hooks?.afterChange || []), myAfterChangeHook],
|
||||
afterDelete: [...(collection.hooks?.afterDelete || []), myAfterDeleteHook],
|
||||
},
|
||||
}
|
||||
}),
|
||||
endpoints: [...(config.endpoints || []), payloadQueryEndpoint, payloadSSEEndpoint],
|
||||
}
|
||||
}
|
||||
90
packages/plugin-realtime/src/react/usePayloadQuery.tsx
Normal file
90
packages/plugin-realtime/src/react/usePayloadQuery.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { createContext, use, useEffect, useLayoutEffect, useState } from 'react'
|
||||
|
||||
import type { ReadOperation } from '../plugin/index.js'
|
||||
import type { PayloadQueryFn } from '../vanilla/payloadQuery.js'
|
||||
|
||||
import { createPayloadClient } from '../vanilla/payloadQuery.js'
|
||||
|
||||
// TODO: improve type. error, data and isLoading cannot accept all possible combinations
|
||||
type PayloadQueryResult<T extends ReadOperation> = Promise<{
|
||||
data: Awaited<ReturnType<Payload[T]>> | undefined
|
||||
error: Error | null
|
||||
isLoading: boolean
|
||||
}>
|
||||
|
||||
export function usePayloadQuery<T extends ReadOperation>(
|
||||
type: T,
|
||||
query: Parameters<Payload[T]>[0],
|
||||
): Awaited<PayloadQueryResult<T>> {
|
||||
const payloadQuery = use(PayloadQueryContext)
|
||||
const [result, setResult] = useState<Awaited<PayloadQueryResult<T>>>({
|
||||
data: undefined,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof payloadQuery !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const fetchData = async () => {
|
||||
setResult({ data: undefined, error: null, isLoading: true })
|
||||
try {
|
||||
const promise = payloadQuery(type, query, {
|
||||
onChange: (result) => {
|
||||
setResult({
|
||||
data: result.data,
|
||||
error: result.error,
|
||||
isLoading: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
const { data, error } = await promise
|
||||
if (isMounted) {
|
||||
setResult({ data, error, isLoading: false })
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
if (isMounted) {
|
||||
setResult({ data: undefined, error, isLoading: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchData().catch(console.error)
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [payloadQuery, type, JSON.stringify(query)])
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const PayloadQueryContext = createContext<PayloadQueryFn | undefined>(undefined)
|
||||
|
||||
export function PayloadQueryClientProvider({ children }: { children: React.ReactNode }) {
|
||||
const [payloadClient, setPayloadClient] = useState<PayloadQueryFn>(() => {
|
||||
return () => Promise.reject(new Error('PayloadQuery client not initialized'))
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const client = createPayloadClient()
|
||||
setPayloadClient(() => client.payloadQuery)
|
||||
}, [])
|
||||
|
||||
if (!payloadClient) {
|
||||
return null // or a loading indicator
|
||||
}
|
||||
|
||||
return (
|
||||
<PayloadQueryContext.Provider value={payloadClient}>{children}</PayloadQueryContext.Provider>
|
||||
)
|
||||
}
|
||||
111
packages/plugin-realtime/src/vanilla/payloadQuery.ts
Normal file
111
packages/plugin-realtime/src/vanilla/payloadQuery.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable no-console */
|
||||
'use client'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { ReadOperation, StringifiedQuery } from '../plugin/index.js'
|
||||
|
||||
const clientId = `client-${Date.now()}-${Math.random()}`
|
||||
const querySubscriptions = new Map<StringifiedQuery, Set<QuerySubscription>>()
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
export function createPayloadClient() {
|
||||
const connectSSE = () => {
|
||||
if (typeof window === 'undefined' || eventSource) {
|
||||
return
|
||||
}
|
||||
|
||||
eventSource = new EventSource(`/api/payload-sse?clientId=${clientId}`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// ignore initial connection message
|
||||
if (data === 'connected') {
|
||||
return
|
||||
}
|
||||
const { queryResult, stringifiedQuery } = data as {
|
||||
queryResult: Awaited<ReturnType<Payload[ReadOperation]>>
|
||||
stringifiedQuery: StringifiedQuery
|
||||
}
|
||||
|
||||
// Notify all subscribers that match this data update
|
||||
const querySubscription = querySubscriptions.get(stringifiedQuery)
|
||||
if (querySubscription) {
|
||||
querySubscription.forEach(({ options }) =>
|
||||
options?.onChange?.({
|
||||
data: queryResult,
|
||||
error: null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing server-sent event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error)
|
||||
eventSource?.close()
|
||||
eventSource = null
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(connectSSE, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
connectSSE()
|
||||
|
||||
const payloadQuery: PayloadQueryFn = async (type, queryParams, options) => {
|
||||
try {
|
||||
// We add the onChange callback to the query subscriptions for future updates
|
||||
if (options && options?.onChange) {
|
||||
const stringifiedQuery = JSON.stringify({ type, queryParams })
|
||||
let callbacks = querySubscriptions.get(stringifiedQuery)
|
||||
if (!callbacks) {
|
||||
callbacks = new Set()
|
||||
querySubscriptions.set(stringifiedQuery, callbacks)
|
||||
}
|
||||
callbacks.add({ options, stringifiedQuery })
|
||||
}
|
||||
|
||||
// The initial query request (). This request does not remain open.
|
||||
// It is immediate to obtain the initial value
|
||||
const response = await fetch(`/api/payload-query`, {
|
||||
body: JSON.stringify({ type, clientId, queryParams }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
const data = await response.json()
|
||||
return { data, error: null }
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
return { data: undefined, error }
|
||||
}
|
||||
}
|
||||
|
||||
return { payloadQuery }
|
||||
}
|
||||
|
||||
type QuerySubscription<T extends ReadOperation = ReadOperation> = {
|
||||
options?: { onChange?: (result: PayloadQueryResult<T>) => void }
|
||||
stringifiedQuery: StringifiedQuery
|
||||
}
|
||||
|
||||
type PayloadQueryResult<T extends ReadOperation> = {
|
||||
data: Awaited<ReturnType<Payload[T]>> | undefined
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export type PayloadQueryFn = <T extends ReadOperation>(
|
||||
type: T,
|
||||
queryParams: Parameters<Payload[T]>[0],
|
||||
options?: {
|
||||
onChange?: (result: PayloadQueryResult<T>) => void
|
||||
},
|
||||
) => Promise<PayloadQueryResult<T>>
|
||||
4
packages/plugin-realtime/tsconfig.json
Normal file
4
packages/plugin-realtime/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [{ "path": "../payload" }]
|
||||
}
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -1035,6 +1035,12 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
|
||||
packages/plugin-realtime:
|
||||
devDependencies:
|
||||
payload:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
|
||||
packages/plugin-redirects:
|
||||
devDependencies:
|
||||
'@payloadcms/eslint-config':
|
||||
@@ -1662,6 +1668,9 @@ importers:
|
||||
'@payloadcms/plugin-nested-docs':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugin-nested-docs
|
||||
'@payloadcms/plugin-realtime':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugin-realtime
|
||||
'@payloadcms/plugin-redirects':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugin-redirects
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@payloadcms/plugin-form-builder": "workspace:*",
|
||||
"@payloadcms/plugin-multi-tenant": "workspace:*",
|
||||
"@payloadcms/plugin-nested-docs": "workspace:*",
|
||||
"@payloadcms/plugin-realtime": "workspace:*",
|
||||
"@payloadcms/plugin-redirects": "workspace:*",
|
||||
"@payloadcms/plugin-search": "workspace:*",
|
||||
"@payloadcms/plugin-sentry": "workspace:*",
|
||||
|
||||
28
test/plugin-realtime/collections/Posts/index.ts
Normal file
28
test/plugin-realtime/collections/Posts/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const postsSlug = 'posts'
|
||||
|
||||
export const PostsCollection: CollectionConfig = {
|
||||
slug: postsSlug,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
components: {
|
||||
beforeList: ['./components/PostCount#PostCount'],
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
95
test/plugin-realtime/components/PostCount.tsx
Normal file
95
test/plugin-realtime/components/PostCount.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
'use client'
|
||||
import {
|
||||
createPayloadClient,
|
||||
PayloadQueryClientProvider,
|
||||
usePayloadQuery,
|
||||
} from '@payloadcms/plugin-realtime'
|
||||
import { usePayloadAPI } from '@payloadcms/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const { payloadQuery } = createPayloadClient()
|
||||
|
||||
export function PostCount() {
|
||||
return (
|
||||
<PayloadQueryClientProvider>
|
||||
<PostCountChild />
|
||||
</PayloadQueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostCountChild() {
|
||||
const { data, error, isLoading } = usePayloadQuery('count', { collection: 'posts' })
|
||||
|
||||
const [{ data: data2, isError, isLoading: isLoading2 }, { setParams: _ }] = usePayloadAPI(
|
||||
'/api/posts',
|
||||
{
|
||||
initialParams: { depth: 1 /** where: { title: { contains: 'Test' } } */ },
|
||||
},
|
||||
)
|
||||
const [count3, setCount3] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCount = async () => {
|
||||
const initialCount = await payloadQuery(
|
||||
'count',
|
||||
{ collection: 'posts' },
|
||||
{
|
||||
onChange: (result) => {
|
||||
if (result.data) {
|
||||
setCount3(result.data.totalDocs)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
if (initialCount.data && !count3) {
|
||||
setCount3(initialCount.data?.totalDocs)
|
||||
}
|
||||
}
|
||||
fetchCount()
|
||||
}, [])
|
||||
|
||||
if (isError) {
|
||||
return <div>Error</div>
|
||||
}
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
if (isLoading2) {
|
||||
return <div>Loading 2...</div>
|
||||
}
|
||||
if (error) {
|
||||
return <div>Error 2: {error.message}</div>
|
||||
}
|
||||
console.log(data2)
|
||||
return (
|
||||
<>
|
||||
<div>Posts count from REST API (usePayloadAPI): {data2?.totalDocs}</div>
|
||||
<div>Posts count from reactive (vanilla): {count3}</div>
|
||||
<div>Posts count from reactive (usePayloadQuery): {data?.totalDocs}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// createPost()
|
||||
// We'll use the REST API instead:
|
||||
fetch('/api/posts', {
|
||||
body: JSON.stringify({ title: 'Test Post' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
// count with fetch
|
||||
fetch('/api/posts/count')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log('COUNT', data)
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Create Post
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
test/plugin-realtime/config.ts
Normal file
40
test/plugin-realtime/config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { realtimePlugin } from '@payloadcms/plugin-realtime'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
// ...extend config here
|
||||
collections: [PostsCollection],
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
plugins: [realtimePlugin()],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: {
|
||||
title: 'example post',
|
||||
},
|
||||
})
|
||||
},
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
19
test/plugin-realtime/eslint.config.js
Normal file
19
test/plugin-realtime/eslint.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { rootParserOptions } from '../../eslint.config.js'
|
||||
import testEslintConfig from '../eslint.config.js'
|
||||
|
||||
/** @typedef {import('eslint').Linter.Config} Config */
|
||||
|
||||
/** @type {Config[]} */
|
||||
export const index = [
|
||||
...testEslintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
...rootParserOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default index
|
||||
45
test/plugin-realtime/int.spec.ts
Normal file
45
test/plugin-realtime/int.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import path from 'path'
|
||||
import { type Payload } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
||||
|
||||
import { devUser } from '../credentials.js'
|
||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||
|
||||
let payload: Payload
|
||||
let restClient: NextRESTClient
|
||||
let token: string
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
describe('@payloadcms/plugin-realtime', () => {
|
||||
beforeAll(async () => {
|
||||
;({ payload, restClient } = await initPayloadInt(dirname))
|
||||
|
||||
const data = await restClient
|
||||
.POST('/users/login', {
|
||||
body: JSON.stringify({
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
||||
token = data.token
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (typeof payload.db.destroy === 'function') {
|
||||
await payload.db.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
describe('todo', () => {
|
||||
it('todo create a tenant', async () => {
|
||||
await new Promise((resolve) => resolve(true))
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
283
test/plugin-realtime/payload-types.ts
Normal file
283
test/plugin-realtime/payload-types.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
posts: Post;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
// @ts-ignore
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
13
test/plugin-realtime/tsconfig.eslint.json
Normal file
13
test/plugin-realtime/tsconfig.eslint.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// extend your base config to share compilerOptions, etc
|
||||
//"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// ensure that nobody can accidentally use this config for a build
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
// whatever paths you intend to lint
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
]
|
||||
}
|
||||
3
test/plugin-realtime/tsconfig.json
Normal file
3
test/plugin-realtime/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user