Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9455d01
Add Jest ESM transform support for Payload/Lexical imports
busbyk Apr 1, 2026
ca73692
Refactor existing tests to use real block configs instead of syntheti…
busbyk Apr 1, 2026
c62e3be
Add file-type stub for Payload 3.81.0 compatibility
busbyk Apr 6, 2026
f23e2c9
Add extractDocumentReferences tests and fixture data
busbyk Apr 1, 2026
26583cc
Implement extractDocumentReferences for unified revalidation
busbyk Apr 2, 2026
2f78897
Fix lint errors: replace type assertions with type guards in extractD…
busbyk Apr 4, 2026
9bd8f1e
Fix events revalidation bugs: wrong block mappings and missing relati…
busbyk Apr 4, 2026
0396138
Add documentReferences field and populateDocumentReferences hook to r…
busbyk Apr 4, 2026
c250693
Add comparison logging between old and new revalidation systems
busbyk Apr 4, 2026
f4a30fe
Hide documentReferences field in admin UI unconditionally
busbyk Apr 4, 2026
7236b17
Add migration to backfill documentReferences for existing routable do…
busbyk Apr 4, 2026
e96f831
Add migration index and snapshot for backfill_document_references
busbyk Apr 4, 2026
678f78f
adding tests for hasMany polymorphic relationship fields + updating c…
busbyk Apr 6, 2026
7464772
removing unnecessary ternary
busbyk Apr 6, 2026
e071f53
removing custom field access + updating function comments
busbyk Apr 6, 2026
75fc68a
removing unnecessary comparison system
busbyk Apr 6, 2026
6b7ace8
add unified findDocumentsWithReferences query function
busbyk Apr 6, 2026
170db4e
add unified revalidateDocumentReferences orchestration function
busbyk Apr 6, 2026
6b2ee8d
replace old dual revalidation calls with unified revalidateDocumentRe…
busbyk Apr 6, 2026
7f4bbd7
remove old block tracking fields, hooks, and utilities
busbyk Apr 6, 2026
90402dd
increase ISR revalidation interval from 600s to 3600s
busbyk Apr 6, 2026
57664d8
fix formatting in migration files from prettier
busbyk Apr 6, 2026
416961f
filtering tenant relationship fields from extractDocumentReferences
busbyk Apr 6, 2026
4ea4f3a
removing unnecessary test comments
busbyk Apr 6, 2026
6fe425c
Merge branch 'revalidation' into unified-revalidation
busbyk Apr 6, 2026
ce5e8d8
Add tests for revalidateDocument to document cycle-safety invariant
busbyk Apr 6, 2026
2a290e4
Use static literal for revalidate export to fix Next.js build
busbyk Apr 6, 2026
fca1b2d
support inline blocks in extractDocumentReferences
busbyk Apr 6, 2026
b017316
Merge branch 'revalidation' into unified-revalidation
busbyk Apr 6, 2026
b762959
Fix biography revalidation query to use contains for hasMany field
busbyk Apr 9, 2026
e6992af
Add warning log for unhandled collection types in revalidateDocument
busbyk Apr 9, 2026
d350294
Update Payload CMS LLM docs reference to official llms-full.txt URL
busbyk Apr 9, 2026
2221194
Remove unused ISR_REVALIDATE_INTERVAL constant and fix stale comment
busbyk Apr 9, 2026
45642da
Update revalidation docs to reflect unified reference tracking system
busbyk Apr 9, 2026
2eb12be
Trim verbose and unnecessary comments across revalidation code
busbyk Apr 9, 2026
80f80ff
Rename DocumentReference to ReferenceQuery in findDocumentsWithRefere…
busbyk Apr 9, 2026
cfea289
Fix pagination bug in findDocumentsWithReferences
busbyk Apr 9, 2026
c436086
Merge branch 'revalidation' into unified-revalidation
busbyk Apr 14, 2026
51ffcb6
diff and explanation docs for migration
busbyk Apr 14, 2026
a173579
remove old query utilities reintroduced by merge conflict
busbyk Apr 14, 2026
ed10335
fixing naming of diff and explanation
busbyk Apr 14, 2026
645e9be
remove duplicate April 4 documentReferences migrations superseded by …
busbyk Apr 14, 2026
84cde13
add revalidateDocumentReferences to Pages and HomePages hooks
busbyk Apr 14, 2026
0ff7d36
add select clause to findDocumentsWithReferences query
busbyk Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
When working with frameworks in this project, reference the official LLM documentation:

- **Next.js**: https://nextjs.org/docs/llms-full.txt
- **Payload CMS**: No official llms.txt yet (requested in [issue #13362](https://github.com/payloadcms/payload/issues/13362))
- **Payload CMS**: https://payloadcms.com/llms-full.txt

### Payload CMS Source Reference

Expand Down
190 changes: 190 additions & 0 deletions __tests__/server/findDocumentsWithReferences.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
jest.mock('../../src/payload.config', () => ({}))

jest.mock('@payloadcms/richtext-lexical', () => ({
lexicalEditor: ({ features }: { features?: unknown }) => {
const resolvedFeatures =
typeof features === 'function' ? features({ rootFeatures: [] }) : (features ?? [])
return { features: resolvedFeatures }
},
BlocksFeature: ({ blocks }: { blocks?: unknown[] }) => ({
key: 'blocks',
serverFeatureProps: { blocks: blocks ?? [] },
}),
FixedToolbarFeature: () => ({ key: 'fixedToolbar' }),
HeadingFeature: () => ({ key: 'heading' }),
HorizontalRuleFeature: () => ({ key: 'horizontalRule' }),
InlineToolbarFeature: () => ({ key: 'inlineToolbar' }),
AlignFeature: () => ({ key: 'align' }),
ParagraphFeature: () => ({ key: 'paragraph' }),
UnderlineFeature: () => ({ key: 'underline' }),
BoldFeature: () => ({ key: 'bold' }),
ItalicFeature: () => ({ key: 'italic' }),
LinkFeature: () => ({ key: 'link' }),
}))

const mockFind = jest.fn()
const mockLogger = { warn: jest.fn() }

import { Events } from '@/collections/Events'
import { HomePages } from '@/collections/HomePages'
import { Media } from '@/collections/Media'
import { Pages } from '@/collections/Pages'
import { Posts } from '@/collections/Posts'
import { Teams } from '@/collections/Teams'

// Include a combination of routable collections with documentReferences fields and those without
const collectionsToTest = [Pages, Posts, HomePages, Events, Media, Teams]

jest.mock('payload', () => ({
getPayload: jest.fn(() =>
Promise.resolve({
find: (...args: unknown[]) => mockFind(...args),
logger: mockLogger,
config: { collections: collectionsToTest },
}),
),
}))

import { findDocumentsWithReferences } from '@/utilities/findDocumentsWithReferences'

beforeEach(() => {
mockFind.mockReset()
mockLogger.warn.mockReset()
mockFind.mockResolvedValue({ docs: [] })
})

describe('findDocumentsWithReferences', () => {
it('queries only collections that have a documentReferences field', async () => {
await findDocumentsWithReferences({ collection: 'sponsors', id: 1 })

expect(mockFind).toHaveBeenCalledTimes(4)

const queriedCollections = mockFind.mock.calls.map(
(call: [{ collection: string }]) => call[0].collection,
)
expect(queriedCollections).toEqual(
expect.arrayContaining(['pages', 'posts', 'homePages', 'events']),
)
expect(queriedCollections).toHaveLength(4)
})

it('does not query collections without documentReferences field', async () => {
await findDocumentsWithReferences({ collection: 'media', id: 1 })

const queriedCollections = mockFind.mock.calls.map(
(call: [{ collection: string }]) => call[0].collection,
)
expect(queriedCollections).not.toContain('media')
expect(queriedCollections).not.toContain('teams')
})

it('uses correct where clause with _status, collection, and docId', async () => {
await findDocumentsWithReferences({ collection: 'media', id: 42 })

for (const call of mockFind.mock.calls) {
const { where, depth } = call[0]
expect(where).toEqual({
and: [
{ _status: { equals: 'published' } },
{ 'documentReferences.collection': { equals: 'media' } },
{ 'documentReferences.docId': { equals: 42 } },
],
})
expect(depth).toBe(1)
}
})

it('returns single match in one collection', async () => {
mockFind.mockImplementation(({ collection }: { collection: string }) => {
if (collection === 'posts') {
return { docs: [{ id: 10, slug: 'my-post', tenant: 1 }] }
}
return { docs: [] }
})

const results = await findDocumentsWithReferences({ collection: 'sponsors', id: 5 })

expect(results).toEqual([{ collection: 'posts', id: 10, slug: 'my-post', tenant: 1 }])
})

it('returns multiple matches across collections', async () => {
mockFind.mockImplementation(({ collection }: { collection: string }) => {
if (collection === 'pages') {
return {
docs: [
{ id: 1, slug: 'about', tenant: 1 },
{ id: 2, slug: 'supporters', tenant: 2 },
],
}
}
if (collection === 'events') {
return { docs: [{ id: 20, slug: 'winter-event', tenant: 1 }] }
}
return { docs: [] }
})

const results = await findDocumentsWithReferences({ collection: 'teams', id: 3 })

expect(results).toContainEqual({ collection: 'pages', id: 1, slug: 'about', tenant: 1 })
expect(results).toContainEqual({ collection: 'pages', id: 2, slug: 'supporters', tenant: 2 })
expect(results).toContainEqual({
collection: 'events',
id: 20,
slug: 'winter-event',
tenant: 1,
})
expect(results).toHaveLength(3)
})

it('returns empty array when no matches', async () => {
const results = await findDocumentsWithReferences({ collection: 'tags', id: 99 })
expect(results).toEqual([])
})

it('uses empty slug for collections without a slug field (e.g. homePages)', async () => {
mockFind.mockImplementation(({ collection }: { collection: string }) => {
if (collection === 'homePages') {
return { docs: [{ id: 5, tenant: 1 }] }
}
return { docs: [] }
})

const results = await findDocumentsWithReferences({ collection: 'media', id: 7 })

expect(results).toEqual([{ collection: 'homePages', id: 5, slug: '', tenant: 1 }])
})

it('preserves tenant object when populated', async () => {
mockFind.mockImplementation(({ collection }: { collection: string }) => {
if (collection === 'pages') {
return { docs: [{ id: 1, slug: 'about', tenant: { id: 1, slug: 'nwac' } }] }
}
return { docs: [] }
})

const results = await findDocumentsWithReferences({ collection: 'media', id: 10 })

expect(results).toEqual([
{ collection: 'pages', id: 1, slug: 'about', tenant: { id: 1, slug: 'nwac' } },
])
})

it('logs warning and continues on query error', async () => {
mockFind.mockImplementation(({ collection }: { collection: string }) => {
if (collection === 'pages') {
throw new Error('DB connection failed')
}
if (collection === 'posts') {
return { docs: [{ id: 10, slug: 'my-post', tenant: 1 }] }
}
return { docs: [] }
})

const results = await findDocumentsWithReferences({ collection: 'sponsors', id: 1 })

expect(mockLogger.warn).toHaveBeenCalledTimes(1)
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Error querying pages'))
// Still returns results from other collections
expect(results).toContainEqual({ collection: 'posts', id: 10, slug: 'my-post', tenant: 1 })
})
})
113 changes: 113 additions & 0 deletions __tests__/server/revalidateDocument.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
jest.mock('../../src/payload.config', () => ({}))

const mockLogger = { info: jest.fn(), warn: jest.fn() }
const mockPayload = { logger: mockLogger }

jest.mock('payload', () => ({
getPayload: jest.fn(() => Promise.resolve(mockPayload)),
}))

const mockRevalidatePath = jest.fn()
jest.mock('next/cache', () => ({
revalidatePath: (...args: unknown[]) => mockRevalidatePath(...args),
}))

const mockResolveTenant = jest.fn()
jest.mock('../../src/utilities/tenancy/resolveTenant', () => ({
resolveTenant: (...args: unknown[]) => mockResolveTenant(...args),
}))

const mockGetCachedTopLevelNavItems = jest.fn()
jest.mock('../../src/components/Header/utils', () => ({
getCachedTopLevelNavItems: (...args: unknown[]) => mockGetCachedTopLevelNavItems(...args),
getNavigationPathForSlug: () => [],
}))

import { revalidateDocument } from '@/utilities/revalidateDocument'

beforeEach(() => {
mockRevalidatePath.mockReset()
mockResolveTenant.mockReset()
mockGetCachedTopLevelNavItems.mockReset()
mockLogger.info.mockReset()
mockLogger.warn.mockReset()

mockResolveTenant.mockResolvedValue({ id: 1, slug: 'nwac' })
mockGetCachedTopLevelNavItems.mockReturnValue(() => Promise.resolve({ topLevelNavItems: [] }))
})

describe('revalidateDocument', () => {
// Cycle safety: revalidateDocument must only call revalidatePath(), never
// payload.update/create/delete. Otherwise circular references (A → B → A)
// would cause infinite loops through afterChange hooks.
it('only invalidates Next.js cache — never triggers Payload write operations', async () => {
const postB = { collection: 'posts', id: 10, slug: 'post-b', tenant: 1 }

await revalidateDocument(postB)

expect(mockRevalidatePath).toHaveBeenCalled()
expect(mockPayload).not.toHaveProperty('update')
expect(mockPayload).not.toHaveProperty('create')
expect(mockPayload).not.toHaveProperty('delete')
})

it('revalidates correct paths for pages', async () => {
const page = { collection: 'pages', id: 1, slug: 'about', tenant: 1 }

await revalidateDocument(page)

expect(mockRevalidatePath).toHaveBeenCalledWith('/about')
expect(mockRevalidatePath).toHaveBeenCalledWith('/nwac/about')
})

it('revalidates correct paths for posts', async () => {
const post = { collection: 'posts', id: 10, slug: 'my-post', tenant: 1 }

await revalidateDocument(post)

expect(mockRevalidatePath).toHaveBeenCalledWith('/blog/my-post')
expect(mockRevalidatePath).toHaveBeenCalledWith('/nwac/blog/my-post')
})

it('revalidates correct paths for homePages', async () => {
const homePage = { collection: 'homePages', id: 1, slug: '', tenant: 1 }

await revalidateDocument(homePage)

expect(mockRevalidatePath).toHaveBeenCalledWith('/')
expect(mockRevalidatePath).toHaveBeenCalledWith('/nwac')
})

it('revalidates correct paths for events', async () => {
const event = { collection: 'events', id: 5, slug: 'winter-summit', tenant: 1 }

await revalidateDocument(event)

expect(mockRevalidatePath).toHaveBeenCalledWith('/events/winter-summit')
expect(mockRevalidatePath).toHaveBeenCalledWith('/nwac/events/winter-summit')
})

it('logs a warning for unrecognized collection types', async () => {
const doc = { collection: 'unknownCollection', id: 1, slug: 'test', tenant: 1 }

await revalidateDocument(doc)

expect(mockRevalidatePath).not.toHaveBeenCalled()
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("no path mapping for collection 'unknownCollection'"),
)
})

it('handles tenant resolution failure gracefully', async () => {
mockResolveTenant.mockRejectedValue(new Error('Tenant not found'))

const page = { collection: 'pages', id: 1, slug: 'about', tenant: 999 }

await revalidateDocument(page)

expect(mockRevalidatePath).not.toHaveBeenCalled()
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to resolve tenant'),
)
})
})
Loading
Loading