Compare commits

...

17 Commits

Author SHA1 Message Date
Germán Jabloñski
ad04c6e16e fix imports 2025-02-21 13:08:20 -03:00
Germán Jabloñski
7083d0e947 add readme 2025-02-21 12:47:51 -03:00
Germán Jabloñski
9145e9e3da nit, rename state 2025-02-21 10:40:43 -03:00
Germán Jabloñski
784f155962 react hook working 2025-02-21 10:25:43 -03:00
Germán Jabloñski
680131616c fix errors 2025-02-20 22:35:29 -03:00
Germán Jabloñski
d773f1ca67 simplified types 2025-02-20 17:28:57 -03:00
Germán Jabloñski
0e96050122 create 2025-02-20 16:18:28 -03:00
Germán Jabloñski
b96cebeed4 refactor endpoints and hooks to separate files, fix abort event, remove console.logs, etc 2025-02-20 15:24:19 -03:00
Germán Jabloñski
77c6cce862 IT WORKS!!!!!!!!!! 2025-02-20 14:29:17 -03:00
Germán Jabloñski
180e897655 save WIP 2025-02-20 14:11:27 -03:00
Germán Jabloñski
6b610bb804 save WIP 2025-02-20 11:54:51 -03:00
Germán Jabloñski
871785b347 save WIP 2025-02-20 11:03:25 -03:00
Germán Jabloñski
37f8c1861e save WIP 2025-02-20 10:19:39 -03:00
Germán Jabloñski
8b819d03de add payloadQuery and usePayloadQuery. Not reactive yet (hooks + SSE missing) 2025-02-18 18:24:41 -03:00
Germán Jabloñski
82663a265a improvements 2025-02-17 15:14:44 -03:00
Germán Jabloñski
d1e47bdcd5 add afterChange and afterDelete hooks 2025-02-17 14:38:10 -03:00
Germán Jabloñski
7a55413950 first commit. Set up package and test folder 2025-02-17 13:20:29 -03:00
23 changed files with 1244 additions and 0 deletions

View File

@@ -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
View 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

View 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"
}
}

View 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.

View 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

View 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"
}
}

View 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'

View 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',
}

View 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')
}

View 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],
}
}

View 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>
)
}

View 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>>

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../payload" }]
}

9
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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:*",

View 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],
}),
},
],
}

View 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>
</>
)
}

View 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'),
},
})

View 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

View 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)
})
})
})

View 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 {}
}

View 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"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}