Compare commits

..

45 Commits

Author SHA1 Message Date
Elliot DeNolf
4cda7d2363 chore(release): v3.0.0-beta.92 [skip ci] 2024-08-27 09:44:02 -04:00
Elliot DeNolf
ea48cfbfe9 feat: implement info command (#7882)
Implements `info` command similar to Next.js.

`pnpm payload info` will output info in this format:

```
Binaries:
  Node: 18.20.2
  npm: 10.5.0
  Yarn: 1.22.19
  pnpm: 9.7.0
Relevant Packages:
  payload: 3.0.0-beta.91
  next: 15.0.0-canary.104
  @payloadcms/db-mongodb: 3.0.0-beta.91
  @payloadcms/db-postgres: 3.0.0-beta.91
  @payloadcms/email-nodemailer: 3.0.0-beta.91
  @payloadcms/graphql: 3.0.0-beta.91
  @payloadcms/next/utilities: 3.0.0-beta.91
  @payloadcms/plugin-cloud: 3.0.0-beta.91
  @payloadcms/richtext-lexical: 3.0.0-beta.91
  @payloadcms/richtext-slate: 3.0.0-beta.91
  @payloadcms/translations: 3.0.0-beta.91
  @payloadcms/ui/shared: 3.0.0-beta.91
  react: 19.0.0-rc-06d0b89e-20240801
  react-dom: 19.0.0-rc-06d0b89e-20240801
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 23.6.0: Mon Jul 29 21:13:04 PDT 2024; root:xnu-10063.141.2~1/RELEASE_ARM64_T6020
  Available memory (MB): 32768
  Available CPU cores: 12
 ```
2024-08-27 01:41:39 +00:00
Paul
1aeb912762 fix(templates): website live preview and code block (#7881) 2024-08-26 23:42:45 +00:00
Paul
ce2cb35d71 fix(plugin-seo): remove dependency on import from payload/next package (#7879) 2024-08-26 22:40:38 +00:00
Paul
d3ec68ac2f fix: error when closing the live preview popup window (#7878) 2024-08-26 22:33:56 +00:00
Paul
05bf52aac3 fix(templates): website bug fixes for slug generation and form builder and adds support for strictNullChecks: true (#7877) 2024-08-26 22:10:09 +00:00
Jacob Fletcher
fed7f2fa5b fix: sanitizes modifyResponseHeaders from client config (#7876) 2024-08-26 21:50:29 +00:00
James Mikrut
686b0865b2 fix: exports richtext-slate useSlatePlugin (#7875)
## Description

exports `useSlatePlugin` for `richtext-slate`
2024-08-26 17:21:25 -04:00
Alessio Gravili
dfb4c8eb4c feat: add nextjs and react version checks to dependency checker (#7868) 2024-08-26 17:19:14 -04:00
Alessio Gravili
ad7a387e19 feat(richtext-lexical): more lenient url validation, URL-encode invalid urls on save (#7870)
Fixes https://github.com/payloadcms/payload/issues/7477

This simplifies validation to the point where it only errors on spaces.
Actual validation is then used in beforeChange, which then automatically
url encodes the input if it doesn't pass
2024-08-26 15:33:29 -04:00
Elliot DeNolf
d05be016ce ci(scripts): emoji release notes 2024-08-23 16:25:50 -04:00
Elliot DeNolf
ec3bb71e7c chore(release): v3.0.0-beta.91 [skip ci] 2024-08-23 16:13:30 -04:00
Elliot DeNolf
825d8b83d1 feat(templates): add vercel postgres template (#7841)
- docs: add db-vercel-postgres info
- feat: update template variations
2024-08-23 15:17:26 -04:00
Alessio Gravili
83022f6d55 feat: enable react compiler for @payloadcms/next package (#7839)
also upgrades esbuild and react compiler packages
2024-08-23 18:01:21 +00:00
Alessio Gravili
4bbc593dc5 chore: hide node deprecation warnings in monorepo (#7837) 2024-08-23 12:33:21 -04:00
Jacob Fletcher
03440f5eca fix(next): properly 404s not found documents (#7833) 2024-08-23 14:59:03 +00:00
Elliot DeNolf
0fa6611260 fix: trim down accepted args of getPayloadHMR (#7834)
`getPayloadHMR`'s arg type was accepting unnecessary args that did not
do anything. This was leading to confusion.

This PR trims down the accepted type.

Fixes #7832
2024-08-23 14:54:20 +00:00
Tylan Davis
a2d68f84e1 chore(richtext-*): improved rich text editor styles and interaction (#7817)
## Description

- Improves the standard typography styles of the rich text editors.
- Improve styles of Lexical relationship, inline-relationship, upload,
and blocks features.
- Improves drag and drop interaction for Lexical.
- Adds a dark mode style for Lexical inline toolbar, floating link editor,
and slash menu.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Chore (non-breaking change which does not add functionality)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-08-23 14:50:53 +00:00
Jarrod Flesch
49c0709fed fix: collapsible toggle hover stacking issue (#7812) 2024-08-23 08:51:10 -04:00
Elliot DeNolf
350a4a0718 build(deps): update turborepo (#7827)
Updates turbo to v2.
2024-08-23 03:00:21 +00:00
Alessio Gravili
6349cd42e9 feat(richtext-lexical): improve upload and relationship node types (#7822)
Fixes https://github.com/payloadcms/payload/issues/7808. The types are
now accurate. Previously, they would assume that upload and relationship
nodes are never populated
2024-08-22 22:05:45 +00:00
Alessio Gravili
c2b2f10676 fix: weaken JsonObject type to allow types from payload-types being assigned to it (#7815)
This fixes that type in the website template:
3d86bf1974/templates/website/src/app/components/RichText/serialize.tsx (L24)

Now, JsonObject still ensures that only objects can be passed, but it's
weak enough to allow non-dynamic types like the ones we generate in
payload-types.

The "JSON" part of this type has no meaning anymore, as it does allow
objects with functions now. However, we can still use it to signal to
the user that this should be JSON-serializable. It's more clear than
just using Record<string, unknown>
2024-08-22 20:24:58 +00:00
Paul
95ebead464 feat(ui): add styling for no docs and clear all on hasmany upload (#7816) 2024-08-22 18:54:35 +00:00
Jacob Fletcher
3eed8b11cb fix(ui): relationship field "add new" button styling (#7814) 2024-08-22 14:55:36 +00:00
Dan Ribbens
404008dc4e chore: fix dev importmap for windows (#7811) 2024-08-22 09:55:55 -04:00
Elliot DeNolf
c7c6fca537 chore(eslint): remove fixable from no-imports-from-self [skip ci] 2024-08-22 09:36:17 -04:00
Ritsu
9de3ffdcfe chore(ui): expose useCollapsible hook (#7807)
Exposes `useCollapsible` hook from `@payloadcms/ui`
2024-08-22 04:11:18 +00:00
Alessio Gravili
1eefb12070 fix(richtext-lexical): slate => lexical migrator improvements (#7802) 2024-08-22 00:09:43 -04:00
Elliot DeNolf
2d8b752ef2 chore(release): v3.0.0-beta.90 [skip ci] 2024-08-21 22:58:28 -04:00
Elliot DeNolf
3e5c31a024 chore(scripts): add db-vercel-postgres to publish list 2024-08-21 22:55:29 -04:00
Elliot DeNolf
631431e006 feat: @payloadcms/db-vercel-postgres adapter (#7806)
Dedicated adapter for Vercel Postgres

- Uses the `@vercel/postgres` package under the hood.
- No `pg` dependency, speeds up invocation
- Includes refactoring all base postgres functionality into a
`BasePostgresAdapter` type, which will ease implementation of [other
adapters supported by
drizzle-orm](https://orm.drizzle.team/docs/get-started-postgresql)

## Usage

```ts
import { buildConfig } from 'payload'
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'

export default buildConfig({
  db: vercelPostgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URI,
    },
  }),
  // ...rest of config
})
```

### Automatic Connection String Detection

Have Vercel automatically detect from environment variable (typically
`process.env.POSTGRES_URL`)

```ts
export default buildConfig({
  db: postgresAdapter(),
  // ...rest of config
})
```
2024-08-21 22:54:47 -04:00
Paul
492d920133 fix(ui): upload has many no rows error (#7804) 2024-08-21 22:52:20 -04:00
Elliot DeNolf
f754edc375 chore(release): v3.0.0-beta.89 [skip ci] 2024-08-21 20:54:18 -04:00
Paul
d2571e10d6 feat: upload hasmany (#7796)
Supports `hasMany` upload fields, similar to how `hasMany` works in
other fields, i.e.:

```ts
{
  type: 'upload',
  relationTo: 'media',
  hasMany: true
}
```

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
2024-08-21 20:44:04 -04:00
Alessio Gravili
a687cb9c5b Merge PR: Lexical migration and validation improvements 2024-08-21 18:31:53 -04:00
Alessio Gravili
cf6634111f fix(richtext-lexical): ensure errors during slate => lexical migration are caught and do not halt migration progress 2024-08-21 17:52:45 -04:00
Jarrod Flesch
1ee19d3016 feat: bulk upload (#7800)
## Description

Adds bulk upload functionality to upload enabled configs.

You can disable the ability by defining `upload.bulkUpload: false` in
your upload enabled config.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-08-21 17:44:34 -04:00
Alessio Gravili
9beaa281dc feat: log document id in ValidationError cause, if present 2024-08-21 17:31:31 -04:00
Alessio Gravili
5174c7092f fix(richtext-lexical): inaccurate detection of whether the editor is empty or not 2024-08-21 17:26:02 -04:00
Alessio Gravili
d894ac75f0 fix(richtext-lexical): migrate scripts not working due to migration hooks running during migrate script 2024-08-21 16:43:56 -04:00
Alessio Gravili
af0105ced5 fix: no longer handle disabling node deprecation warnings within bin script shebangs, as it errored on some systems (#7797)
Fixes https://github.com/payloadcms/payload/issues/7741

I have no idea why it broke and was not able to reproduce this at all.
But given the amount of people reporting this issue, it's not worth
keeping this around for the small benefit this brings
2024-08-21 17:01:57 +00:00
Tylan Davis
93e81314df fix(ui, richtext-lexical): corrects clickable areas on block headers (#7791)
## Description

Fixes an issue where Block component section titles were taking up the
entire clickable area of block headers.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-08-21 10:45:05 -04:00
Jarrod Flesch
163d1c85da chore: corrects icon color styles (#7792) 2024-08-21 10:28:01 -04:00
Jacob Fletcher
cb9b80aaf9 fix!: handles custom collection description components (#7789)
## Description

Closes #7784 by properly handling custom collection description
components via `admin.components.Description`. This component was
incorrectly added to the `admin.components.edit` key, and also was never
handled on the front-end. This was especially misleading because the
client-side config had a duplicative key in the proper position.

## Breaking Changes

This PR is only labeled as a breaking change because the key has changed
position within the config. If you were previously defining a custom
description component on a collection, simply move it into the correct
position:

Old:
```ts
{
  admin: {
    components: {
      edit: {
        Description: ''
      }
    }
  }
}
```

New:

```ts
{
  admin: {
    components: {
      Description: ''
    }
  }
}
```

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] This change requires a documentation update

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [x] I have made corresponding changes to the documentation
2024-08-21 10:20:22 -04:00
Jarrod Flesch
cad1906725 feat: extends Button and extracts ListHeader components (#7777) 2024-08-21 09:37:11 -04:00
374 changed files with 18675 additions and 1609 deletions

1
.gitignore vendored
View File

@@ -154,6 +154,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
dist_optimized
# Gatsby files
.cache/

View File

@@ -31,7 +31,7 @@ The following options are available:
| **`hidden`** | Set to true or a function, called with the current user, returning true to exclude this Collection from navigation and admin routing. |
| **`hooks`** | Admin-specific hooks for this Collection. [More details](../hooks/collections). |
| **`useAsTitle`** | Specify a top-level field to use for a document title throughout the Admin Panel. If no field is defined, the ID of the document is used as the title. |
| **`description`** | Text or React component to display below the Collection label in the List View to give editors more information. |
| **`description`** | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#components). |
| **`defaultColumns`** | Array of field names that correspond to which columns to show by default in this Collection's List View. |
| **`hideAPIURL`** | Hides the "API URL" meta field while editing documents within this Collection. |
| **`enableRichTextLink`** | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
@@ -69,7 +69,8 @@ The following options are available:
| **`beforeList`** | An array of components to inject _before_ the built-in List View |
| **`beforeListTable`** | An array of components to inject _before_ the built-in List View's table |
| **`afterList`** | An array of components to inject _after_ the built-in List View |
| **`afterListTable`** | An array of components to inject _after_ the built-in List View's table |
| **`afterListTable`** | An array of components to inject _after_ the built-in List View's table
| **`Description`** | A component to render below the Collection label in the List View. An alternative to the `admin.description` property. |
| **`edit.SaveButton`** | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. |
| **`edit.SaveDraftButton`** | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. |
| **`edit.PublishButton`** | Replace the default Publish Button with a Custom Component. [Drafts](../versions/drafts) must be enabled. |

View File

@@ -36,7 +36,6 @@ Here is one of the simplest possible Payload configs:
```ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
// import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
secret: process.env.PAYLOAD_SECRET,

View File

@@ -8,18 +8,20 @@ keywords: Postgres, documentation, typescript, Content Management System, cms, h
To use Payload with Postgres, install the package `@payloadcms/db-postgres`. It leverages Drizzle ORM and `node-postgres` to interact with a Postgres database that you provide.
Alternatively, the `@payloadcms/db-vercel-postgres` package is also available and is optimized for use with Vercel.
It automatically manages changes to your database for you in development mode, and exposes a full suite of migration controls for you to leverage in order to keep other database environments in sync with your schema. DDL transformations are automatically generated.
To configure Payload to use Postgres, pass the `postgresAdapter` to your Payload Config as follows:
### Usage
`@payloadcms/db-postgres`:
```ts
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
// Your config goes here
collections: [
// Collections go here
],
// Configure the Postgres adapter here
db: postgresAdapter({
// Postgres-specific arguments go here.
@@ -31,11 +33,28 @@ export default buildConfig({
})
```
`@payloadcms/db-vercel-postgres`:
```ts
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
export default buildConfig({
// Automatically uses proces.env.POSTGRES_URL if no options are provided.
db: vercelPostgresAdapter(),
// Optionally, can accept the same options as the @vercel/postgres package.
db: vercelPostgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL
},
}),
})
```
## Options
| Option | Description |
|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `pool` \* | [Pool connection options](https://orm.drizzle.team/docs/quick-postgresql/node-postgres) that will be passed to Drizzle and `node-postgres`. |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pool` \* | [Pool connection options](https://orm.drizzle.team/docs/quick-postgresql/node-postgres) that will be passed to Drizzle and `node-postgres` or to `@vercel/postgres` |
| `push` | Disable Drizzle's [`db push`](https://orm.drizzle.team/kit-docs/overview#prototyping-with-db-push) in development mode. By default, `push` is enabled for development mode only. |
| `migrationDir` | Customize the directory that migrations are stored. |
| `logger` | The instance of the logger to be passed to drizzle. By default Payload's will be used. |

View File

@@ -162,7 +162,7 @@ All of Payload's GraphQL functionality is abstracted into a separate package. Pa
This is the UI library that Payload's Admin Panel uses. All components are exported from this package and can be re-used as you build extensions to the Payload admin UI, or want to use Payload components in your own React apps. Some exports are server components and some are client components.
`@payloadcms/db-postgres`, `@payloadcms/db-mongodb`
`@payloadcms/db-postgres`, `@payloadcms/db-vercel-postgres`, `@payloadcms/db-mongodb`
You can choose which Database Adapter you'd like to use for your project, and no matter which you choose, the entire data layer for Payload is contained within these packages. You can only use one at a time for any given project.

View File

@@ -18,6 +18,7 @@ IMPORTANT: This will overwrite all slate data. We recommend doing the following
1. Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.
2. Make every richText field a lexical editor. This script will only convert lexical richText fields with old Slate data
3. Add the SlateToLexicalFeature (as seen below) first, and test it out by loading up the Admin Panel, to see if the migrator works as expected. You might have to build some custom converters for some fields first in order to convert custom Slate nodes. The SlateToLexicalFeature is where the converters are stored. Only fields with this feature added will be migrated.
4. If this works as expected, add the `disableHooks: true` prop everywhere you're initializing `SlateToLexicalFeature`. Example: `SlateToLexicalFeature({ disableHooks: true })`. Once you did that, you're ready to run the migration script.
```ts
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical/migrate'

View File

@@ -92,6 +92,7 @@ _An asterisk denotes that an option is required._
| Option | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |

View File

@@ -23,7 +23,7 @@ export default withBundleAnalyzer(
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),
PAYLOAD_DISABLE_DEPENDENCY_CHECKER: 'true',
PAYLOAD_CI_DEPENDENCY_CHECKER: 'true',
},
async redirects() {
return [

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"private": true,
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"build:db-mongodb": "turbo build --filter db-mongodb",
"build:db-postgres": "turbo build --filter db-postgres",
"build:db-sqlite": "turbo build --filter db-sqlite",
"build:db-vercel-postgres": "turbo build --filter db-vercel-postgres",
"build:drizzle": "turbo build --filter drizzle",
"build:email-nodemailer": "turbo build --filter email-nodemailer",
"build:email-resend": "turbo build --filter email-resend",
@@ -26,7 +27,7 @@
"build:live-preview": "turbo build --filter live-preview",
"build:live-preview-react": "turbo build --filter live-preview-react",
"build:live-preview-vue": "turbo build --filter live-preview-vue",
"build:next": "turbo build --filter next",
"build:next": "turbo build --filter \"@payloadcms/next\"",
"build:payload": "turbo build --filter payload",
"build:plugin-cloud": "turbo build --filter plugin-cloud",
"build:plugin-cloud-storage": "turbo build --filter plugin-cloud-storage",
@@ -53,11 +54,12 @@
"clean:all": "node ./scripts/delete-recursively.js '@node_modules' 'media/*' '**/dist/' '**/.cache/*' '**/.next/*' '**/.turbo/*' '**/tsconfig.tsbuildinfo' '**/payload*.tgz' '**/meta_*.json'",
"clean:build": "node ./scripts/delete-recursively.js 'media/' '**/dist/' '**/.cache/' '**/.next/' '**/.turbo/' '**/tsconfig.tsbuildinfo' '**/payload*.tgz' '**/meta_*.json'",
"clean:cache": "node ./scripts/delete-recursively.js node_modules/.cache! packages/payload/node_modules/.cache! .next/*",
"dev": "tsx ./test/dev.ts",
"dev": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts",
"dev:generate-graphql-schema": "pnpm runts ./test/generateGraphQLSchema.ts",
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
"dev:generate-types": "pnpm runts ./test/generateTypes.ts",
"dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts",
"dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts",
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d",
@@ -71,7 +73,7 @@
"reinstall": "pnpm clean:all && pnpm install",
"release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
"release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",
"runts": "node --no-deprecation --import @swc-node/register/esm-register",
"runts": "cross-env NODE_OPTIONS=--no-deprecation node --no-deprecation --import @swc-node/register/esm-register",
"script:gen-templates": "pnpm runts ./scripts/generate-template-variations.ts",
"script:list-published": "pnpm runts scripts/lib/getPackageRegistryVersions.ts",
"script:pack": "pnpm runts scripts/pack-all-to-dest.ts",
@@ -157,13 +159,14 @@
"swc-plugin-transform-remove-imports": "1.15.0",
"tempy": "1.0.1",
"tsx": "4.17.0",
"turbo": "^1.13.3",
"turbo": "^2.0.14",
"typescript": "5.5.4"
},
"peerDependencies": {
"react": "^19.0.0 || ^19.0.0-rc-06d0b89e-20240801",
"react-dom": "^19.0.0 || ^19.0.0-rc-06d0b89e-20240801"
},
"packageManager": "pnpm@9.7.1",
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9.7.0"

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -29,6 +29,18 @@ const postgresReplacement: DbAdapterReplacement = {
packageName: '@payloadcms/db-postgres',
}
const vercelPostgresReplacement: DbAdapterReplacement = {
configReplacement: (envName = 'POSTGRES_URL') => [
' db: vercelPostgresAdapter({',
' pool: {',
` connectionString: process.env.${envName} || '',`,
' },',
' }),',
],
importReplacement: "import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'",
packageName: '@payloadcms/db-vercel-postgres',
}
const sqliteReplacement: DbAdapterReplacement = {
configReplacement: (envName = 'DATABASE_URI') => [
' db: sqliteAdapter({',
@@ -45,6 +57,7 @@ export const dbReplacements: Record<DbType, DbAdapterReplacement> = {
mongodb: mongodbReplacement,
postgres: postgresReplacement,
sqlite: sqliteReplacement,
vercelPostgres: vercelPostgresReplacement,
}
type StorageAdapterReplacement = {

View File

@@ -27,6 +27,11 @@ const dbChoiceRecord: Record<DbType, DbChoice> = {
title: 'SQLite (beta)',
value: 'sqlite',
},
vercelPostgres: {
dbConnectionPrefix: 'postgres://postgres:<password>@127.0.0.1:5432/',
title: 'Vercel Postgres (beta)',
value: 'vercelPostgres',
},
}
export async function selectDb(args: CliArgs, projectName: string): Promise<DbDetails> {

View File

@@ -42,7 +42,12 @@ export async function writeEnvFile(args: {
const key = split[0]
let value = split[1]
if (key === 'MONGODB_URI' || key === 'MONGO_URL' || key === 'DATABASE_URI') {
if (
key === 'MONGODB_URI' ||
key === 'MONGO_URL' ||
key === 'DATABASE_URI' ||
key === 'POSTGRES_URL'
) {
value = databaseUri
}
if (key === 'PAYLOAD_SECRET' || key === 'PAYLOAD_SECRET_KEY') {

View File

@@ -57,7 +57,7 @@ interface Template {
export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn'
export type DbType = 'mongodb' | 'postgres' | 'sqlite'
export type DbType = 'mongodb' | 'postgres' | 'sqlite' | 'vercelPostgres'
export type DbDetails = {
dbUri: string

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -182,6 +182,7 @@ export function mongooseAdapter({
init,
migrateFresh,
migrationDir,
packageName: '@payloadcms/db-mongodb',
payload,
prodMigrations,
queryDrafts,

View File

@@ -595,14 +595,77 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
config: SanitizedConfig,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
const hasManyRelations = Array.isArray(field.relationTo)
let schemaToReturn: { [key: string]: any } = {}
if (field.localized && config.localization) {
schemaToReturn = {
type: config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}
if (hasManyRelations) {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
_id: false,
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
refPath: `${field.name}.${locale}.relationTo`,
},
}
} else {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
}
}
return {
...locales,
[locale]: field.hasMany
? { type: [localeSchema], default: formatDefaultValue(field) }
: localeSchema,
}
}, {}),
localized: true,
}
} else if (hasManyRelations) {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
_id: false,
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
refPath: `${field.name}.relationTo`,
},
}
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: formatDefaultValue(field),
}
}
} else {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
}
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: formatDefaultValue(field),
}
}
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: schemaToReturn,
})
},
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -59,7 +59,7 @@
"@payloadcms/eslint-config": "workspace:*",
"@types/pg": "8.10.2",
"@types/to-snake-case": "1.0.0",
"esbuild": "0.23.0",
"esbuild": "0.23.1",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -139,6 +139,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
migrateReset,
migrateStatus,
migrationDir,
packageName: '@payloadcms/db-postgres',
payload,
queryDrafts,
rejectInitializing,

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -140,6 +140,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
migrateReset,
migrateStatus,
migrationDir,
packageName: '@payloadcms/db-sqlite',
payload,
queryDrafts,
rejectInitializing,

View File

@@ -717,7 +717,7 @@ export const traverseFields = ({
case 'upload':
if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => relationships.add(relation))
} else if (field.type === 'relationship' && field.hasMany) {
} else if (field.hasMany) {
relationships.add(field.relationTo)
} else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table

View File

@@ -0,0 +1 @@
/migrations

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,43 @@
# Payload Postgres Adapter
[Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) adapter for [Payload](https://payloadcms.com).
- [Main Repository](https://github.com/payloadcms/payload)
- [Payload Docs](https://payloadcms.com/docs)
## Installation
```bash
npm install @payloadcms/db-vercel-postgres
```
## Usage
### Explicit Connection String
```ts
import { buildConfig } from 'payload'
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
export default buildConfig({
db: vercelPostgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI,
},
}),
// ...rest of config
})
```
### Automatic Connection String Detection
Have Vercel automatically detect from environment variable (typically `process.env.POSTGRES_URL`)
```ts
export default buildConfig({
db: postgresAdapter(),
// ...rest of config
})
```
More detailed usage can be found in the [Payload Docs](https://payloadcms.com/docs/configuration/overview).

View File

@@ -0,0 +1,20 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig
/** @type {FlatConfig[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigDirName: import.meta.dirname,
...rootParserOptions,
},
},
},
]
export default index

View File

@@ -0,0 +1,89 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.0.0-beta.92",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/db-vercel-postgres"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types": {
"import": "./src/types.ts",
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./migration-utils": {
"import": "./src/exports/migration-utils.ts",
"types": "./src/exports/migration-utils.ts",
"default": "./src/exports/migration-utils.ts"
}
},
"main": "./src/index.ts",
"types": "./src/types.ts",
"files": [
"dist",
"mock.js"
],
"scripts": {
"build": "rimraf .dist && rimraf tsconfig.tsbuildinfo && pnpm build:types && pnpm build:swc && pnpm build:esbuild && pnpm renamePredefinedMigrations",
"build:esbuild": "echo skipping esbuild",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"prepack": "pnpm clean && pnpm turbo build",
"prepublishOnly": "pnpm clean && pnpm turbo build",
"renamePredefinedMigrations": "node --no-deprecation --import @swc-node/register/esm-register ./scripts/renamePredefinedMigrations.ts"
},
"dependencies": {
"@payloadcms/drizzle": "workspace:*",
"@vercel/postgres": "^0.9.0",
"console-table-printer": "2.11.2",
"drizzle-kit": "0.23.2-df9e596",
"drizzle-orm": "0.32.1",
"prompts": "2.4.2",
"to-snake-case": "1.0.0",
"uuid": "10.0.0"
},
"devDependencies": {
"@hyrious/esbuild-plugin-commonjs": "^0.2.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/pg": "8.10.2",
"@types/to-snake-case": "1.0.0",
"esbuild": "0.23.1",
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"import": "./dist/types.js",
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
},
"./migration-utils": {
"import": "./dist/exports/migration-utils.js",
"types": "./dist/exports/migration-utils.d.ts",
"default": "./dist/exports/migration-utils.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}

View File

@@ -0,0 +1,13 @@
const imports = `import { migratePostgresV2toV3 } from '@payloadcms/migratePostgresV2toV3'`
const up = ` await migratePostgresV2toV3({
// enables logging of changes that will be made to the database
debug: false,
// skips calls that modify schema or data
dryRun: false,
payload,
req,
})
`
export { imports, up }
//# sourceMappingURL=relationships-v2-v3.js.map

View File

@@ -0,0 +1,19 @@
import fs from 'fs'
import path from 'path'
/**
* Changes built .js files to .mjs to for ESM imports
*/
const rename = () => {
fs.readdirSync(path.resolve('./dist/predefinedMigrations'))
.filter((f) => {
return f.endsWith('.js')
})
.forEach((file) => {
const newPath = path.join('./dist/predefinedMigrations', file)
fs.renameSync(newPath, newPath.replace('.js', '.mjs'))
})
console.log('done')
}
rename()

View File

@@ -0,0 +1,61 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle'
import { VercelPool, sql } from '@vercel/postgres'
import { drizzle } from 'drizzle-orm/node-postgres'
import type { VercelPostgresAdapter } from './types.js'
export const connect: Connect = async function connect(
this: VercelPostgresAdapter,
options = {
hotReload: false,
},
) {
const { hotReload } = options
this.schema = {
pgSchema: this.pgSchema,
...this.tables,
...this.relations,
...this.enums,
}
try {
const logger = this.logger || false
// Passed the poolOptions if provided,
// else have vercel/postgres detect the connection string from the environment
this.drizzle = drizzle(this.poolOptions ? new VercelPool(this.poolOptions) : sql, {
logger,
schema: this.schema,
})
if (!hotReload) {
if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
this.payload.logger.info(`---- DROPPING TABLES SCHEMA(${this.schemaName || 'public'}) ----`)
await this.dropDatabase({ adapter: this })
this.payload.logger.info('---- DROPPED TABLES ----')
}
}
} catch (err) {
this.payload.logger.error(`Error: cannot connect to Postgres. Details: ${err.message}`, err)
if (typeof this.rejectInitializing === 'function') this.rejectInitializing()
process.exit(1)
}
// Only push schema if not in production
if (
process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_MIGRATING !== 'true' &&
this.push !== false
) {
await pushDevSchema(this as unknown as DrizzleAdapter)
}
if (typeof this.resolveInitializing === 'function') this.resolveInitializing()
if (process.env.NODE_ENV === 'production' && this.prodMigrations) {
await this.migrate({ migrations: this.prodMigrations })
}
}

View File

@@ -0,0 +1 @@
export { migratePostgresV2toV3 } from '../predefinedMigrations/v2-v3/index.js'

View File

@@ -0,0 +1,163 @@
import type { DatabaseAdapterObj, Payload } from 'payload'
import {
beginTransaction,
commitTransaction,
count,
create,
createGlobal,
createGlobalVersion,
createVersion,
deleteMany,
deleteOne,
deleteVersions,
destroy,
find,
findGlobal,
findGlobalVersions,
findMigrationDir,
findOne,
findVersions,
migrate,
migrateDown,
migrateFresh,
migrateRefresh,
migrateReset,
migrateStatus,
operatorMap,
queryDrafts,
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
import {
convertPathToJSONTraversal,
countDistinct,
createJSONQuery,
createMigration,
defaultDrizzleSnapshot,
deleteWhere,
dropDatabase,
execute,
getMigrationTemplate,
init,
insert,
requireDrizzleKit,
} from '@payloadcms/drizzle/postgres'
import { pgEnum, pgSchema, pgTable } from 'drizzle-orm/pg-core'
import { createDatabaseAdapter } from 'payload'
import type { Args, VercelPostgresAdapter } from './types.js'
import { connect } from './connect.js'
export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<VercelPostgresAdapter> {
const postgresIDType = args.idType || 'serial'
const payloadIDType = postgresIDType === 'serial' ? 'number' : 'text'
function adapter({ payload }: { payload: Payload }) {
const migrationDir = findMigrationDir(args.migrationDir)
let resolveInitializing
let rejectInitializing
let adapterSchema: VercelPostgresAdapter['pgSchema']
const initializing = new Promise<void>((res, rej) => {
resolveInitializing = res
rejectInitializing = rej
})
if (args.schemaName) {
adapterSchema = pgSchema(args.schemaName)
} else {
adapterSchema = { enum: pgEnum, table: pgTable }
}
return createDatabaseAdapter<VercelPostgresAdapter>({
name: 'postgres',
defaultDrizzleSnapshot,
drizzle: undefined,
enums: {},
features: {
json: true,
},
fieldConstraints: {},
getMigrationTemplate,
idType: postgresIDType,
initializing,
localesSuffix: args.localesSuffix || '_locales',
logger: args.logger,
operators: operatorMap,
pgSchema: adapterSchema,
pool: undefined,
poolOptions: args.pool,
prodMigrations: args.prodMigrations,
push: args.push,
relations: {},
relationshipsSuffix: args.relationshipsSuffix || '_rels',
schema: {},
schemaName: args.schemaName,
sessions: {},
tableNameMap: new Map<string, string>(),
tables: {},
transactionOptions: args.transactionOptions || undefined,
versionsSuffix: args.versionsSuffix || '_v',
// DatabaseAdapter
beginTransaction: args.transactionOptions === false ? undefined : beginTransaction,
commitTransaction,
connect,
convertPathToJSONTraversal,
count,
countDistinct,
create,
createGlobal,
createGlobalVersion,
createJSONQuery,
createMigration,
createVersion,
defaultIDType: payloadIDType,
deleteMany,
deleteOne,
deleteVersions,
deleteWhere,
destroy,
dropDatabase,
execute,
find,
findGlobal,
findGlobalVersions,
findOne,
findVersions,
init,
insert,
migrate,
migrateDown,
migrateFresh,
migrateRefresh,
migrateReset,
migrateStatus,
migrationDir,
packageName: '@payloadcms/db-vercel-postgres',
payload,
queryDrafts,
rejectInitializing,
requireDrizzleKit,
resolveInitializing,
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateOne,
updateVersion,
})
}
return {
defaultIDType: payloadIDType,
init: adapter,
}
}
export type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/drizzle/postgres'
export { sql } from 'drizzle-orm'

View File

@@ -0,0 +1,10 @@
const imports = `import { migratePostgresV2toV3 } from '@payloadcms/db-postgres/migration-utils'`
const upSQL = ` await migratePostgresV2toV3({
// enables logging of changes that will be made to the database
debug: false,
payload,
req,
})
`
export { imports, upSQL }

View File

@@ -0,0 +1,237 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { Field, Payload, PayloadRequest } from 'payload'
import { upsertRow } from '@payloadcms/drizzle'
import type { VercelPostgresAdapter } from '../../../types.js'
import type { DocsToResave } from '../types.js'
import { traverseFields } from './traverseFields.js'
type Args = {
adapter: VercelPostgresAdapter
collectionSlug?: string
db: TransactionPg
debug: boolean
docsToResave: DocsToResave
fields: Field[]
globalSlug?: string
isVersions: boolean
payload: Payload
req: PayloadRequest
tableName: string
}
export const fetchAndResave = async ({
adapter,
collectionSlug,
db,
debug,
docsToResave,
fields,
globalSlug,
isVersions,
payload,
req,
tableName,
}: Args) => {
for (const [id, rows] of Object.entries(docsToResave)) {
if (collectionSlug) {
const collectionConfig = payload.collections[collectionSlug].config
if (collectionConfig) {
if (isVersions) {
const doc = await payload.findVersionByID({
id,
collection: collectionSlug,
depth: 0,
fallbackLocale: null,
locale: 'all',
req,
showHiddenFields: true,
})
if (debug) {
payload.logger.info(
`The collection "${collectionConfig.slug}" version with ID ${id} will be migrated`,
)
}
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
id: doc.id,
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(
`"${collectionConfig.slug}" version with ID ${doc.id} FAILED TO MIGRATE`,
)
throw err
}
if (debug) {
payload.logger.info(
`"${collectionConfig.slug}" version with ID ${doc.id} migrated successfully!`,
)
}
} else {
const doc = await payload.findByID({
id,
collection: collectionSlug,
depth: 0,
fallbackLocale: null,
locale: 'all',
req,
showHiddenFields: true,
})
if (debug) {
payload.logger.info(
`The collection "${collectionConfig.slug}" with ID ${doc.id} will be migrated`,
)
}
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
id: doc.id,
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(
`The collection "${collectionConfig.slug}" with ID ${doc.id} has FAILED TO MIGRATE`,
)
throw err
}
if (debug) {
payload.logger.info(
`The collection "${collectionConfig.slug}" with ID ${doc.id} has migrated successfully!`,
)
}
}
}
}
if (globalSlug) {
const globalConfig = payload.config.globals?.find((global) => global.slug === globalSlug)
if (globalConfig) {
if (isVersions) {
const { docs } = await payload.findGlobalVersions({
slug: globalSlug,
depth: 0,
fallbackLocale: null,
limit: 0,
locale: 'all',
req,
showHiddenFields: true,
})
if (debug) {
payload.logger.info(`${docs.length} global "${globalSlug}" versions will be migrated`)
}
for (const doc of docs) {
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
id: doc.id,
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(`"${globalSlug}" version with ID ${doc.id} FAILED TO MIGRATE`)
throw err
}
if (debug) {
payload.logger.info(
`"${globalSlug}" version with ID ${doc.id} migrated successfully!`,
)
}
}
} else {
const doc = await payload.findGlobal({
slug: globalSlug,
depth: 0,
fallbackLocale: null,
locale: 'all',
req,
showHiddenFields: true,
})
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(`The global "${globalSlug}" has FAILED TO MIGRATE`)
throw err
}
if (debug) {
payload.logger.info(`The global "${globalSlug}" has migrated successfully!`)
}
}
}
}
}
}

View File

@@ -0,0 +1,215 @@
import type { Field } from 'payload'
import { tabHasName } from 'payload/shared'
type Args = {
doc: Record<string, unknown>
fields: Field[]
locale?: string
path: string
rows: Record<string, unknown>[]
}
export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
fields.forEach((field) => {
switch (field.type) {
case 'group': {
const newPath = `${path ? `${path}.` : ''}${field.name}`
const newDoc = doc?.[field.name]
if (typeof newDoc === 'object' && newDoc !== null) {
if (field.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: field.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: field.fields,
path: newPath,
rows,
})
}
}
break
}
case 'row':
case 'collapsible': {
return traverseFields({
doc,
fields: field.fields,
path,
rows,
})
}
case 'array': {
const rowData = doc?.[field.name]
if (field.localized && typeof rowData === 'object' && rowData !== null) {
Object.entries(rowData).forEach(([locale, localeRows]) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
return traverseFields({
doc: row as Record<string, unknown>,
fields: field.fields,
locale,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
})
}
})
}
if (Array.isArray(rowData)) {
rowData.forEach((row, i) => {
return traverseFields({
doc: row as Record<string, unknown>,
fields: field.fields,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
})
}
break
}
case 'blocks': {
const rowData = doc?.[field.name]
if (field.localized && typeof rowData === 'object' && rowData !== null) {
Object.entries(rowData).forEach(([locale, localeRows]) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
if (matchedBlock) {
return traverseFields({
doc: row as Record<string, unknown>,
fields: matchedBlock.fields,
locale,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
}
})
}
})
}
if (Array.isArray(rowData)) {
rowData.forEach((row, i) => {
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
if (matchedBlock) {
return traverseFields({
doc: row as Record<string, unknown>,
fields: matchedBlock.fields,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
}
})
}
break
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
const newDoc = doc?.[tab.name]
const newPath = `${path ? `${path}.` : ''}${tab.name}`
if (typeof newDoc === 'object' && newDoc !== null) {
if (tab.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: tab.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: tab.fields,
path: newPath,
rows,
})
}
}
} else {
traverseFields({
doc,
fields: tab.fields,
path,
rows,
})
}
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
const relationshipPath = `${path ? `${path}.` : ''}${field.name}`
if (field.localized) {
const matchedRelationshipsWithLocales = rows.filter(
(row) => row.path === relationshipPath,
)
if (matchedRelationshipsWithLocales.length && !doc[field.name]) {
doc[field.name] = {}
}
const newDoc = doc[field.name] as Record<string, unknown>
matchedRelationshipsWithLocales.forEach((localeRow) => {
if (typeof localeRow.locale === 'string') {
const [, id] = Object.entries(localeRow).find(
([key, val]) =>
val !== null && !['id', 'locale', 'order', 'parent_id', 'path'].includes(key),
)
newDoc[localeRow.locale] = id
}
})
} else {
const matchedRelationship = rows.find((row) => {
const matchesPath = row.path === relationshipPath
if (locale) return matchesPath && locale === row.locale
return row.path === relationshipPath
})
if (matchedRelationship) {
const [, id] = Object.entries(matchedRelationship).find(
([key, val]) =>
val !== null && !['id', 'locale', 'order', 'parent_id', 'path'].includes(key),
)
doc[field.name] = id
}
}
}
}
}
}
})
}

View File

@@ -0,0 +1,74 @@
export type Groups =
| 'addColumn'
| 'addConstraint'
| 'dropColumn'
| 'dropConstraint'
| 'dropTable'
| 'notNull'
/**
* Convert an "ADD COLUMN" statement to an "ALTER COLUMN" statement
* example: ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;
* to: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
* @param sql
*/
function convertAddColumnToAlterColumn(sql) {
// Regular expression to match the ADD COLUMN statement with its constraints
const regex = /ALTER TABLE ("[^"]+") ADD COLUMN ("[^"]+") [\w\s]+ NOT NULL;/
// Replace the matched part with "ALTER COLUMN ... SET NOT NULL;"
return sql.replace(regex, 'ALTER TABLE $1 ALTER COLUMN $2 SET NOT NULL;')
}
export const groupUpSQLStatements = (list: string[]): Record<Groups, string[]> => {
const groups = {
addColumn: 'ADD COLUMN',
// example: ALTER TABLE "posts" ADD COLUMN "category_id" integer
addConstraint: 'ADD CONSTRAINT',
//example:
// DO $$ BEGIN
// ALTER TABLE "pages_blocks_my_block" ADD CONSTRAINT "pages_blocks_my_block_person_id_users_id_fk" FOREIGN KEY ("person_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
// EXCEPTION
// WHEN duplicate_object THEN null;
// END $$;
dropColumn: 'DROP COLUMN',
// example: ALTER TABLE "_posts_v_rels" DROP COLUMN IF EXISTS "posts_id";
dropConstraint: 'DROP CONSTRAINT',
// example: ALTER TABLE "_posts_v_rels" DROP CONSTRAINT "_posts_v_rels_posts_fk";
dropTable: 'DROP TABLE',
// example: DROP TABLE "pages_rels";
notNull: 'NOT NULL',
// example: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
}
const result = Object.keys(groups).reduce((result, group: Groups) => {
result[group] = []
return result
}, {}) as Record<Groups, string[]>
for (const line of list) {
Object.entries(groups).some(([key, value]) => {
if (line.endsWith('NOT NULL;')) {
// split up the ADD COLUMN and ALTER COLUMN NOT NULL statements
// example: ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;
// becomes two separate statements:
// 1. ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer;
// 2. ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
result.addColumn.push(line.replace(' NOT NULL;', ';'))
result.notNull.push(convertAddColumnToAlterColumn(line))
return true
}
if (line.includes(value)) {
result[key].push(line)
return true
}
})
}
return result
}

View File

@@ -0,0 +1,278 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { DrizzleSnapshotJSON } from 'drizzle-kit/api'
import type { Payload, PayloadRequest } from 'payload'
import { sql } from 'drizzle-orm'
import fs from 'fs'
import { createRequire } from 'module'
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { VercelPostgresAdapter } from '../../types.js'
import type { PathsToQuery } from './types.js'
import { groupUpSQLStatements } from './groupUpSQLStatements.js'
import { migrateRelationships } from './migrateRelationships.js'
import { traverseFields } from './traverseFields.js'
const require = createRequire(import.meta.url)
type Args = {
debug?: boolean
payload: Payload
req?: Partial<PayloadRequest>
}
/**
* Moves upload and relationship columns from the join table and into the tables while moving data
* This is done in the following order:
* ADD COLUMNs
* -- manipulate data to move relationships to new columns
* ADD CONSTRAINTs
* NOT NULLs
* DROP TABLEs
* DROP CONSTRAINTs
* DROP COLUMNs
* @param debug
* @param payload
* @param req
*/
export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
const adapter = payload.db as unknown as VercelPostgresAdapter
const db = adapter.sessions[await req.transactionID].db as TransactionPg
const dir = payload.db.migrationDir
// get the drizzle migrateUpSQL from drizzle using the last schema
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/api')
const drizzleJsonAfter = generateDrizzleJson(adapter.schema)
// Get the previous migration snapshot
const previousSnapshot = fs
.readdirSync(dir)
.filter((file) => file.endsWith('.json') && !file.endsWith('relationships_v2_v3.json'))
.sort()
.reverse()?.[0]
if (!previousSnapshot) {
throw new Error(
`No previous migration schema file found! A prior migration from v2 is required to migrate to v3.`,
)
}
const drizzleJsonBefore = JSON.parse(
fs.readFileSync(`${dir}/${previousSnapshot}`, 'utf8'),
) as DrizzleSnapshotJSON
const generatedSQL = await generateMigration(drizzleJsonBefore, drizzleJsonAfter)
if (!generatedSQL.length) {
payload.logger.info(`No schema changes needed.`)
process.exit(0)
}
const sqlUpStatements = groupUpSQLStatements(generatedSQL)
const addColumnsStatement = sqlUpStatements.addColumn.join('\n')
if (debug) {
payload.logger.info('CREATING NEW RELATIONSHIP COLUMNS')
payload.logger.info(addColumnsStatement)
}
await db.execute(sql.raw(addColumnsStatement))
for (const collection of payload.config.collections) {
const tableName = adapter.tableNameMap.get(toSnakeCase(collection.slug))
const pathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
collectionSlug: collection.slug,
columnPrefix: '',
db,
disableNotNull: false,
fields: collection.fields,
isVersions: false,
newTableName: tableName,
parentTableName: tableName,
path: '',
pathsToQuery,
payload,
rootTableName: tableName,
})
await migrateRelationships({
adapter,
collectionSlug: collection.slug,
db,
debug,
fields: collection.fields,
isVersions: false,
pathsToQuery,
payload,
req,
tableName,
})
if (collection.versions) {
const versionsTableName = adapter.tableNameMap.get(
`_${toSnakeCase(collection.slug)}${adapter.versionsSuffix}`,
)
const versionFields = buildVersionCollectionFields(collection)
const versionPathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
collectionSlug: collection.slug,
columnPrefix: '',
db,
disableNotNull: true,
fields: versionFields,
isVersions: true,
newTableName: versionsTableName,
parentTableName: versionsTableName,
path: '',
pathsToQuery: versionPathsToQuery,
payload,
rootTableName: versionsTableName,
})
await migrateRelationships({
adapter,
collectionSlug: collection.slug,
db,
debug,
fields: versionFields,
isVersions: true,
pathsToQuery: versionPathsToQuery,
payload,
req,
tableName: versionsTableName,
})
}
}
for (const global of payload.config.globals) {
const tableName = adapter.tableNameMap.get(toSnakeCase(global.slug))
const pathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
columnPrefix: '',
db,
disableNotNull: false,
fields: global.fields,
globalSlug: global.slug,
isVersions: false,
newTableName: tableName,
parentTableName: tableName,
path: '',
pathsToQuery,
payload,
rootTableName: tableName,
})
await migrateRelationships({
adapter,
db,
debug,
fields: global.fields,
globalSlug: global.slug,
isVersions: false,
pathsToQuery,
payload,
req,
tableName,
})
if (global.versions) {
const versionsTableName = adapter.tableNameMap.get(
`_${toSnakeCase(global.slug)}${adapter.versionsSuffix}`,
)
const versionFields = buildVersionGlobalFields(global)
const versionPathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
columnPrefix: '',
db,
disableNotNull: true,
fields: versionFields,
globalSlug: global.slug,
isVersions: true,
newTableName: versionsTableName,
parentTableName: versionsTableName,
path: '',
pathsToQuery: versionPathsToQuery,
payload,
rootTableName: versionsTableName,
})
await migrateRelationships({
adapter,
db,
debug,
fields: versionFields,
globalSlug: global.slug,
isVersions: true,
pathsToQuery: versionPathsToQuery,
payload,
req,
tableName: versionsTableName,
})
}
}
// ADD CONSTRAINT
const addConstraintsStatement = sqlUpStatements.addConstraint.join('\n')
if (debug) {
payload.logger.info('ADDING CONSTRAINTS')
payload.logger.info(addConstraintsStatement)
}
await db.execute(sql.raw(addConstraintsStatement))
// NOT NULL
const notNullStatements = sqlUpStatements.notNull.join('\n')
if (debug) {
payload.logger.info('NOT NULL CONSTRAINTS')
payload.logger.info(notNullStatements)
}
await db.execute(sql.raw(notNullStatements))
// DROP TABLE
const dropTablesStatement = sqlUpStatements.dropTable.join('\n')
if (debug) {
payload.logger.info('DROPPING TABLES')
payload.logger.info(dropTablesStatement)
}
await db.execute(sql.raw(dropTablesStatement))
// DROP CONSTRAINT
const dropConstraintsStatement = sqlUpStatements.dropConstraint.join('\n')
if (debug) {
payload.logger.info('DROPPING CONSTRAINTS')
payload.logger.info(dropConstraintsStatement)
}
await db.execute(sql.raw(dropConstraintsStatement))
// DROP COLUMN
const dropColumnsStatement = sqlUpStatements.dropColumn.join('\n')
if (debug) {
payload.logger.info('DROPPING COLUMNS')
payload.logger.info(dropColumnsStatement)
}
await db.execute(sql.raw(dropColumnsStatement))
}

View File

@@ -0,0 +1,103 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { Field, Payload, PayloadRequest } from 'payload'
import { sql } from 'drizzle-orm'
import type { VercelPostgresAdapter } from '../../types.js'
import type { DocsToResave, PathsToQuery } from './types.js'
import { fetchAndResave } from './fetchAndResave/index.js'
type Args = {
adapter: VercelPostgresAdapter
collectionSlug?: string
db: TransactionPg
debug: boolean
fields: Field[]
globalSlug?: string
isVersions: boolean
pathsToQuery: PathsToQuery
payload: Payload
req?: Partial<PayloadRequest>
tableName: string
}
export const migrateRelationships = async ({
adapter,
collectionSlug,
db,
debug,
fields,
globalSlug,
isVersions,
pathsToQuery,
payload,
req,
tableName,
}: Args) => {
if (pathsToQuery.size === 0) return
let offset = 0
let paginationResult
const where = Array.from(pathsToQuery).reduce((statement, path, i) => {
return (statement += `
"${tableName}${adapter.relationshipsSuffix}"."path" LIKE '${path}'${pathsToQuery.size !== i + 1 ? ' OR' : ''}
`)
}, '')
while (typeof paginationResult === 'undefined' || paginationResult.rows.length > 0) {
const paginationStatement = `SELECT DISTINCT parent_id FROM ${tableName}${adapter.relationshipsSuffix} WHERE
${where} ORDER BY parent_id LIMIT 500 OFFSET ${offset * 500};
`
paginationResult = await adapter.drizzle.execute(sql.raw(`${paginationStatement}`))
if (paginationResult.rows.length === 0) return
offset += 1
const statement = `SELECT * FROM ${tableName}${adapter.relationshipsSuffix} WHERE
(${where}) AND parent_id IN (${paginationResult.rows.map((row) => row.parent_id).join(', ')});
`
if (debug) {
payload.logger.info('FINDING ROWS TO MIGRATE')
payload.logger.info(statement)
}
const result = await adapter.drizzle.execute(sql.raw(`${statement}`))
const docsToResave: DocsToResave = {}
result.rows.forEach((row) => {
const parentID = row.parent_id
if (typeof parentID === 'string' || typeof parentID === 'number') {
if (!docsToResave[parentID]) docsToResave[parentID] = []
docsToResave[parentID].push(row)
}
})
await fetchAndResave({
adapter,
collectionSlug,
db,
debug,
docsToResave,
fields,
globalSlug,
isVersions,
payload,
req: req as unknown as PayloadRequest,
tableName,
})
}
const deleteStatement = `DELETE FROM ${tableName}${adapter.relationshipsSuffix} WHERE ${where}`
if (debug) {
payload.logger.info('DELETING ROWS')
payload.logger.info(deleteStatement)
}
await db.execute(sql.raw(`${deleteStatement}`))
}

View File

@@ -0,0 +1,117 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { Field, Payload } from 'payload'
import { tabHasName } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type { VercelPostgresAdapter } from '../../types.js'
import type { PathsToQuery } from './types.js'
type Args = {
adapter: VercelPostgresAdapter
collectionSlug?: string
columnPrefix: string
db: TransactionPg
disableNotNull: boolean
fields: Field[]
globalSlug?: string
isVersions: boolean
newTableName: string
parentTableName: string
path: string
pathsToQuery: PathsToQuery
payload: Payload
rootTableName: string
}
export const traverseFields = (args: Args) => {
args.fields.forEach((field) => {
switch (field.type) {
case 'group': {
let newTableName = `${args.newTableName}_${toSnakeCase(field.name)}`
if (field.localized && args.payload.config.localization) {
newTableName += args.adapter.localesSuffix
}
return traverseFields({
...args,
columnPrefix: `${args.columnPrefix}${toSnakeCase(field.name)}_`,
fields: field.fields,
newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}`,
})
}
case 'row':
case 'collapsible': {
return traverseFields({
...args,
fields: field.fields,
})
}
case 'array': {
const newTableName = args.adapter.tableNameMap.get(
`${args.newTableName}_${toSnakeCase(field.name)}`,
)
return traverseFields({
...args,
columnPrefix: '',
fields: field.fields,
newTableName,
parentTableName: newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}.%`,
})
}
case 'blocks': {
return field.blocks.forEach((block) => {
const newTableName = args.adapter.tableNameMap.get(
`${args.rootTableName}_blocks_${toSnakeCase(block.slug)}`,
)
traverseFields({
...args,
columnPrefix: '',
fields: block.fields,
newTableName,
parentTableName: newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}.%`,
})
})
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
args.columnPrefix = `${args.columnPrefix}_${toSnakeCase(tab.name)}_`
args.path = `${args.path ? `${args.path}.` : ''}${tab.name}`
args.newTableName = `${args.newTableName}_${toSnakeCase(tab.name)}`
if (tab.localized && args.payload.config.localization) {
args.newTableName += args.adapter.localesSuffix
}
}
traverseFields({
...args,
fields: tab.fields,
})
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
args.pathsToQuery.add(`${args.path ? `${args.path}.` : ''}${field.name}`)
}
}
return null
}
}
})
}

View File

@@ -0,0 +1,9 @@
/**
* Set of all paths which should be moved
* This will be built up into one WHERE query
*/
export type PathsToQuery = Set<string>
export type DocsToResave = {
[id: number | string]: Record<string, unknown>[]
}

View File

@@ -0,0 +1,78 @@
import type {
BasePostgresAdapter,
GenericEnum,
MigrateDownArgs,
MigrateUpArgs,
PostgresDB,
} from '@payloadcms/drizzle/postgres'
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { VercelPool, VercelPostgresPoolConfig } from '@vercel/postgres'
import type { DrizzleConfig } from 'drizzle-orm'
import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-core'
export type Args = {
connectionString?: string
idType?: 'serial' | 'uuid'
localesSuffix?: string
logger?: DrizzleConfig['logger']
migrationDir?: string
/**
* Optional pool configuration for Vercel Postgres
* If not provided, vercel/postgres will attempt to use the Vercel environment variables
*/
pool?: VercelPostgresPoolConfig
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise<void>
name: string
up: (args: MigrateUpArgs) => Promise<void>
}[]
push?: boolean
relationshipsSuffix?: string
/**
* The schema name to use for the database
* @experimental This only works when there are not other tables or enums of the same name in the database under a different schema. Awaiting fix from Drizzle.
*/
schemaName?: string
transactionOptions?: PgTransactionConfig | false
versionsSuffix?: string
}
export type VercelPostgresAdapter = {
pool?: VercelPool
poolOptions?: Args['pool']
} & BasePostgresAdapter
declare module 'payload' {
export interface DatabaseAdapter
extends Omit<Args, 'idType' | 'logger' | 'migrationDir' | 'pool'>,
DrizzleAdapter {
beginTransaction: (options?: PgTransactionConfig) => Promise<null | number | string>
drizzle: PostgresDB
enums: Record<string, GenericEnum>
/**
* An object keyed on each table, with a key value pair where the constraint name is the key, followed by the dot-notation field name
* Used for returning properly formed errors from unique fields
*/
fieldConstraints: Record<string, Record<string, string>>
idType: Args['idType']
initializing: Promise<void>
localesSuffix?: string
logger: DrizzleConfig['logger']
pgSchema?: { table: PgTableFn } | PgSchema
pool: VercelPool
poolOptions: Args['pool']
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise<void>
name: string
up: (args: MigrateUpArgs) => Promise<void>
}[]
push: boolean
rejectInitializing: () => void
relationshipsSuffix?: string
resolveInitializing: () => void
schema: Record<string, unknown>
schemaName?: Args['schemaName']
tableNameMap: Map<string, string>
versionsSuffix?: string
}
}

View File

@@ -0,0 +1,38 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
},
"exclude": [
"dist",
"build",
"tests",
"test",
"node_modules",
"eslint.config.js",
"src/**/*.spec.js",
"src/**/*.spec.jsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"include": [
"src",
"src/**/*.ts",
],
"references": [
{
"path": "../payload"
},
{
"path": "../translations"
},
{
"path": "../drizzle"
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-param-reassign */
import type { Field } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared'
@@ -34,8 +33,9 @@ export const traverseFields = ({
// handle simple relationship
if (
depth > 0 &&
(field.type === 'upload' ||
(field.type === 'relationship' && !field.hasMany && typeof field.relationTo === 'string'))
(field.type === 'upload' || field.type === 'relationship') &&
!field.hasMany &&
typeof field.relationTo === 'string'
) {
if (field.localized) {
_locales.with[`${path}${field.name}`] = true

View File

@@ -1,4 +1,3 @@
import type { DrizzleSnapshotJSON } from 'drizzle-kit/api'
import type { CreateMigration } from 'payload'
import fs from 'fs'
@@ -112,6 +111,7 @@ export const createMigration: CreateMigration = async function createMigration(
getMigrationTemplate({
downSQL: downSQL || ` // Migration code`,
imports,
packageName: payload.db.packageName,
upSQL: upSQL || ` // Migration code`,
}),
)

View File

@@ -9,8 +9,9 @@ export const indent = (text: string) =>
export const getMigrationTemplate = ({
downSQL,
imports,
packageName,
upSQL,
}: MigrationTemplateArgs): string => `import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
}: MigrationTemplateArgs): string => `import { MigrateUpArgs, MigrateDownArgs, sql } from '${packageName}'
${imports ? `${imports}\n` : ''}
export async function up({ payload, req }: MigrateUpArgs): Promise<void> {
${indent(upSQL)}

View File

@@ -726,7 +726,7 @@ export const traverseFields = ({
case 'upload':
if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => relationships.add(relation))
} else if (field.type === 'relationship' && field.hasMany) {
} else if (field.hasMany) {
relationships.add(field.relationTo)
} else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table

View File

@@ -111,8 +111,6 @@ export type BasePostgresAdapter = {
logger: DrizzleConfig['logger']
operators: Operators
pgSchema?: Schema
// pool: Pool
// poolOptions: Args['pool']
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise<void>
name: string

View File

@@ -445,7 +445,7 @@ export const getTableColumnFromPath = ({
case 'relationship':
case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.')
if (Array.isArray(field.relationTo) || (field.type === 'relationship' && field.hasMany)) {
if (Array.isArray(field.relationTo) || field.hasMany) {
let relationshipFields
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
const {

View File

@@ -351,6 +351,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
throw error.code === '23505'
? new ValidationError(
{
id,
errors: [
{
field: adapter.fieldConstraints[tableName][error.constraint],

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -9,7 +9,6 @@ export const rule = {
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
},

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env node --no-deprecation
#!/usr/bin/env node
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -307,10 +307,51 @@ export function buildMutationInputType({
...inputObjectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) },
}),
upload: (inputObjectTypeConfig: InputObjectTypeConfig, field: UploadField) => ({
...inputObjectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) },
}),
upload: (inputObjectTypeConfig: InputObjectTypeConfig, field: UploadField) => {
const { relationTo } = field
type PayloadGraphQLRelationshipType =
| GraphQLInputObjectType
| GraphQLList<GraphQLScalarType>
| GraphQLScalarType
let type: PayloadGraphQLRelationshipType
if (Array.isArray(relationTo)) {
const fullName = `${combineParentName(
parentName,
toWords(field.name, true),
)}RelationshipInput`
type = new GraphQLInputObjectType({
name: fullName,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${fullName}RelationTo`,
values: relationTo.reduce(
(values, option) => ({
...values,
[formatName(option)]: {
value: option,
},
}),
{},
),
}),
},
value: { type: GraphQLJSON },
},
})
} else {
type = getCollectionIDType(
config.db.defaultIDType,
graphqlResult.collections[relationTo].config,
)
}
return {
...inputObjectTypeConfig,
[field.name]: { type: field.hasMany ? new GraphQLList(type) : type },
}
},
}
const fieldName = formatName(name)

View File

@@ -594,49 +594,164 @@ export function buildObjectType({
}),
upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => {
const { relationTo } = field
const isRelatedToManyCollections = Array.isArray(relationTo)
const hasManyValues = field.hasMany
const relationshipName = combineParentName(parentName, toWords(field.name, true))
const uploadName = combineParentName(parentName, toWords(field.name, true))
let type
let relationToType = null
if (Array.isArray(relationTo)) {
relationToType = new GraphQLEnumType({
name: `${relationshipName}_RelationTo`,
values: relationTo.reduce(
(relations, relation) => ({
...relations,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
})
const types = relationTo.map((relation) => graphqlResult.collections[relation].graphQL.type)
type = new GraphQLObjectType({
name: `${relationshipName}_Relationship`,
fields: {
relationTo: {
type: relationToType,
},
value: {
type: new GraphQLUnionType({
name: relationshipName,
resolveType(data, { req }) {
return graphqlResult.collections[data.collection].graphQL.type.name
},
types,
}),
},
},
})
} else {
;({ type } = graphqlResult.collections[relationTo].graphQL)
}
// If the relationshipType is undefined at this point,
// it can be assumed that this blockType can have a relationship
// to itself. Therefore, we set the relationshipType equal to the blockType
// that is currently being created.
const type = withNullableType(
field,
graphqlResult.collections[relationTo].graphQL.type || newlyCreatedBlockType,
forceNullable,
type = type || newlyCreatedBlockType
const relationshipArgs: {
draft?: unknown
fallbackLocale?: unknown
limit?: unknown
locale?: unknown
page?: unknown
where?: unknown
} = {}
const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some(
(relation) => graphqlResult.collections[relation].config.versions?.drafts,
)
const uploadArgs = {} as LocaleInputType
if (relationsUseDrafts) {
relationshipArgs.draft = {
type: GraphQLBoolean,
}
}
if (config.localization) {
uploadArgs.locale = {
relationshipArgs.locale = {
type: graphqlResult.types.localeInputType,
}
uploadArgs.fallbackLocale = {
relationshipArgs.fallbackLocale = {
type: graphqlResult.types.fallbackLocaleInputType,
}
}
const relatedCollectionSlug = field.relationTo
const upload = {
type,
args: uploadArgs,
extensions: { complexity: 20 },
const relationship = {
type: withNullableType(
field,
hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type,
forceNullable,
),
args: relationshipArgs,
extensions: { complexity: 10 },
async resolve(parent, args, context: Context) {
const value = parent[field.name]
const locale = args.locale || context.req.locale
const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale
const id = value
let relatedCollectionSlug = field.relationTo
const draft = Boolean(args.draft ?? context.req.query?.draft)
if (hasManyValues) {
const results = []
const resultPromises = []
const createPopulationPromise = async (relatedDoc, i) => {
let id = relatedDoc
let collectionSlug = field.relationTo
if (isRelatedToManyCollections) {
collectionSlug = relatedDoc.relationTo
id = relatedDoc.value
}
const result = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: collectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (result) {
if (isRelatedToManyCollections) {
results[i] = {
relationTo: collectionSlug,
value: {
...result,
collection: collectionSlug,
},
}
} else {
results[i] = result
}
}
}
if (value) {
value.forEach((relatedDoc, i) => {
resultPromises.push(createPopulationPromise(relatedDoc, i))
})
}
await Promise.all(resultPromises)
return results
}
let id = value
if (isRelatedToManyCollections && value) {
id = value.value
relatedCollectionSlug = value.relationTo
}
if (id) {
const relatedDocument = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: relatedCollectionSlug,
collectionSlug: relatedCollectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
@@ -649,26 +764,30 @@ export function buildObjectType({
}),
)
return relatedDocument || null
if (relatedDocument) {
if (isRelatedToManyCollections) {
return {
relationTo: relatedCollectionSlug,
value: {
...relatedDocument,
collection: relatedCollectionSlug,
},
}
}
return relatedDocument
}
return null
}
return null
},
}
const whereFields = graphqlResult.collections[relationTo].config.fields
upload.args.where = {
type: buildWhereInputType({
name: uploadName,
fields: whereFields,
parentName: uploadName,
}),
}
return {
...objectTypeConfig,
[field.name]: upload,
[field.name]: relationship,
}
},
}

View File

@@ -130,9 +130,36 @@ const fieldToSchemaMap = ({ nestedFieldName, parentName }: Args): any => ({
textarea: (field: TextareaField) => ({
type: withOperators(field, parentName),
}),
upload: (field: UploadField) => ({
type: withOperators(field, parentName),
}),
upload: (field: UploadField) => {
if (Array.isArray(field.relationTo)) {
return {
type: new GraphQLInputObjectType({
name: `${combineParentName(parentName, field.name)}_Relation`,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`,
values: field.relationTo.reduce(
(values, relation) => ({
...values,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
}),
},
value: { type: GraphQLJSON },
},
}),
}
}
return {
type: withOperators(field, parentName),
}
},
})
export default fieldToSchemaMap

View File

@@ -230,9 +230,9 @@ const defaults: DefaultsType = {
},
upload: {
operators: [
...operators.equality.map((operator) => ({
...[...operators.equality, ...operators.contains].map((operator) => ({
name: operator,
type: GraphQLString,
type: GraphQLJSON,
})),
],
},

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -0,0 +1,36 @@
const fs = require('fs')
// Plugin options can be found here: https://github.com/facebook/react/blob/main/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts#L38
const ReactCompilerConfig = {
sources: (filename) => {
const isInNodeModules = filename.includes('node_modules')
if (isInNodeModules || ( !filename.endsWith('.tsx') && !filename.endsWith('.jsx') && !filename.endsWith('.js'))) {
return false
}
// Only compile files with 'use client' directives. We do not want to
// accidentally compile React Server Components
const file = fs.readFileSync(filename, 'utf8')
if (file.includes("'use client'")) {
return true
}
console.log('React compiler - skipping file: ' + filename)
return false
},
}
module.exports = function (api) {
api.cache(false)
return {
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig], // must run first!
/* [
'babel-plugin-transform-remove-imports',
{
test: '\\.(scss|css)$',
},
],*/
],
}
}

View File

@@ -1,4 +1,6 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
import reactCompiler from 'eslint-plugin-react-compiler'
const { rules } = reactCompiler
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig
@@ -20,6 +22,16 @@ export const index = [
},
},
},
{
plugins: {
'react-compiler': {
rules,
},
},
rules: {
'react-compiler/react-compiler': 'error',
},
},
]
export default index

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -50,13 +50,16 @@
"dist"
],
"scripts": {
"build": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm copyfiles && pnpm build:types && pnpm build:swc && pnpm build:cjs && pnpm build:esbuild",
"build": "pnpm build:reactcompiler",
"build:babel": "rm -rf dist_optimized && babel dist --out-dir dist_optimized --source-maps --extensions .ts,.js,.tsx,.jsx,.cjs,.mjs && rm -rf dist && mv dist_optimized dist",
"build:cjs": "swc ./src/withPayload.js -o ./dist/cjs/withPayload.cjs --config-file .swcrc-cjs --strip-leading-paths",
"build:esbuild": "node bundleScss.js",
"build:reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm build:swc && pnpm build:cjs && pnpm build:babel && pnpm copyfiles && pnpm build:types && pnpm build:esbuild",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"build:without_reactcompiler": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && pnpm copyfiles && pnpm build:types && pnpm build:swc && pnpm build:cjs && pnpm build:esbuild",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" \"src/app/api/**\" dist/",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"prepublishOnly": "pnpm clean && pnpm turbo build"
@@ -80,15 +83,22 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.1.0",
"@babel/cli": "^7.24.5",
"@babel/core": "^7.24.5",
"@babel/preset-env": "^7.24.5",
"@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1",
"@next/eslint-plugin-next": "15.0.0-canary.104",
"@payloadcms/eslint-config": "workspace:*",
"@types/busboy": "^1.5.3",
"@types/react": "npm:types-react@19.0.0-rc.0",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.0",
"@types/uuid": "10.0.0",
"@types/ws": "^8.5.10",
"esbuild": "0.23.0",
"babel-plugin-react-compiler": "0.0.0-experimental-48eb8f4-20240822",
"esbuild": "0.23.1",
"esbuild-sass-plugin": "3.3.1",
"eslint-plugin-react-compiler": "0.0.0-experimental-72f06b2-20240822",
"payload": "workspace:*",
"swc-plugin-transform-remove-imports": "1.15.0"
},

View File

@@ -1,3 +1,4 @@
'use client'
// Credit: @Taiki92777
// - Source: https://github.com/vercel/next.js/discussions/32231#discussioncomment-7284386
// Credit: `react-use` maintainers

View File

@@ -21,8 +21,8 @@
--theme-baseline-body-size: #{$baseline-body-size};
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
--font-serif: Georgia, 'Bitstream Charter', 'Charis SIL', Utopia, 'URW Bookman L', serif;
--font-mono: monospace;
--font-serif: 'Georgia', 'Bitstream Charter', 'Charis SIL', Utopia, 'URW Bookman L', serif;
--font-mono: 'SF Mono', Menlo, Consolas, Monaco, monospace;
--style-radius-s: #{$style-radius-s};
--style-radius-m: #{$style-radius-m};
@@ -146,13 +146,13 @@ h6 {
}
p {
margin: 0 0 $baseline;
margin: 0;
}
ul,
ol {
padding-left: $baseline;
margin: 0 0 $baseline;
margin: 0;
}
:focus-visible {

View File

@@ -16,7 +16,7 @@
}
%h1 {
margin: 0 0 base(1);
margin: 0;
font-size: base(1.6);
line-height: base(1.8);
@@ -27,7 +27,7 @@
}
%h2 {
margin: 0 0 base(1);
margin: 0;
font-size: base(1.3);
line-height: base(1.6);
@@ -37,7 +37,7 @@
}
%h3 {
margin: 0 0 base(1);
margin: 0;
font-size: base(1);
line-height: base(1.2);
@@ -48,7 +48,7 @@
}
%h4 {
margin: 0 0 $baseline;
margin: 0;
font-size: base(0.8);
line-height: base(1);
letter-spacing: -0.375px;

View File

@@ -62,7 +62,9 @@ export const reload = async (
}
}
export const getPayloadHMR = async (options: InitOptions): Promise<Payload> => {
export const getPayloadHMR = async (
options: Pick<InitOptions, 'config' | 'importMap'>,
): Promise<Payload> => {
if (!options?.config) {
throw new Error('Error: the payload config is required for getPayloadHMR to work.')
}

View File

@@ -1,3 +1,4 @@
'use client'
import { SelectField, useTranslation } from '@payloadcms/ui'
import React from 'react'

View File

@@ -75,6 +75,10 @@ export const Document: React.FC<AdminViewProps> = async ({
req,
})
if (!data) {
notFound()
}
const { docPermissions, hasPublishPermission, hasSavePermission } = await getDocumentPermissions({
id,
collectionConfig,

View File

@@ -11,21 +11,13 @@
}
}
&__header {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: base(0.8);
h1 {
margin: 0;
}
.list-header {
a {
text-decoration: none;
}
.pill {
.btn--withoutPopup,
.btn--withPopup {
position: relative;
margin: 0 0 base(0.2);
}

View File

@@ -4,15 +4,17 @@ import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
BulkUploadDrawer,
Button,
DeleteMany,
EditMany,
Gutter,
ListControls,
ListHeader,
ListSelection,
Pagination,
PerPage,
Pill,
PopupList,
PublishMany,
RelationshipProvider,
RenderComponent,
@@ -22,10 +24,13 @@ import {
Table,
UnpublishMany,
ViewDescription,
bulkUploadDrawerSlug,
useConfig,
useEditDepth,
useListInfo,
useListQuery,
useModal,
useRouteCache,
useSearchParams,
useStepNav,
useTranslation,
@@ -41,9 +46,20 @@ const baseClass = 'collection-list'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const DefaultListView: React.FC = () => {
const { Header, collectionSlug, hasCreatePermission, newDocumentURL } = useListInfo()
const {
Header,
beforeActions,
collectionSlug,
disableBulkDelete,
disableBulkEdit,
hasCreatePermission,
newDocumentURL,
} = useListInfo()
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery()
const { searchParams } = useSearchParams()
const { openModal } = useModal()
const { clearRouteCache } = useRouteCache()
const { getEntityConfig } = useConfig()
@@ -67,7 +83,7 @@ export const DefaultListView: React.FC = () => {
labels,
} = collectionConfig
const { i18n } = useTranslation()
const { i18n, t } = useTranslation()
const drawerDepth = useEditDepth()
@@ -79,7 +95,9 @@ export const DefaultListView: React.FC = () => {
let docs = data.docs || []
if (collectionConfig.upload) {
const isUploadCollection = Boolean(collectionConfig.upload)
if (isUploadCollection) {
docs = docs?.map((doc) => {
return {
...doc,
@@ -104,35 +122,47 @@ export const DefaultListView: React.FC = () => {
<RenderComponent mappedComponent={beforeList} />
<SelectionProvider docs={data.docs} totalDocs={data.totalDocs}>
<Gutter className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
{Header || (
<Fragment>
<h1>{getTranslation(labels?.plural, i18n)}</h1>
{hasCreatePermission && (
<Pill
aria-label={i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n),
})}
to={newDocumentURL}
>
{i18n.t('general:createNew')}
</Pill>
)}
{!smallBreak && (
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
)}
{description && (
<div className={`${baseClass}__sub-header`}>
<RenderComponent
Component={ViewDescription}
clientProps={{ description }}
mappedComponent={Description}
/>
</div>
)}
</Fragment>
)}
</header>
{Header || (
<ListHeader heading={getTranslation(labels?.plural, i18n)}>
{hasCreatePermission && (
<Button
Link={Link}
SubMenuPopupContent={
isUploadCollection && collectionConfig.upload.bulkUpload ? (
<PopupList.ButtonGroup>
<PopupList.Button onClick={() => openModal(bulkUploadDrawerSlug)}>
{t('upload:bulkUpload')}
</PopupList.Button>
</PopupList.ButtonGroup>
) : null
}
aria-label={i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n),
})}
buttonStyle="pill"
el="link"
size="small"
to={newDocumentURL}
>
{i18n.t('general:createNew')}
</Button>
)}
{!smallBreak && (
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
)}
{(description || Description) && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription Description={Description} description={description} />
</div>
)}
{isUploadCollection && collectionConfig.upload.bulkUpload ? (
<BulkUploadDrawer
collectionSlug={collectionSlug}
onSuccess={() => clearRouteCache()}
/>
) : null}
</ListHeader>
)}
<ListControls collectionConfig={collectionConfig} fields={fields} />
<RenderComponent mappedComponent={beforeListTable} />
{!data.docs && (
@@ -174,7 +204,7 @@ export const DefaultListView: React.FC = () => {
limit={data.limit}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={handlePageChange}
onChange={(page) => void handlePageChange(page)}
page={data.page}
prevPage={data.prevPage}
totalPages={data.totalPages}
@@ -189,7 +219,7 @@ export const DefaultListView: React.FC = () => {
{i18n.t('general:of')} {data.totalDocs}
</div>
<PerPage
handleChange={handlePerPageChange}
handleChange={(limit) => void handlePerPageChange(limit)}
limit={
isNumber(searchParams?.limit) ? Number(searchParams.limit) : defaultLimit
}
@@ -200,10 +230,15 @@ export const DefaultListView: React.FC = () => {
<div className={`${baseClass}__list-selection`}>
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
<div className={`${baseClass}__list-selection-actions`}>
<EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
<DeleteMany collection={collectionConfig} />
{beforeActions && beforeActions}
{!disableBulkEdit && (
<Fragment>
<EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
</Fragment>
)}
{!disableBulkDelete && <DeleteMany collection={collectionConfig} />}
</div>
</div>
)}

View File

@@ -1,6 +1,8 @@
'use client'
import type { LivePreviewConfig } from 'payload'
import type { fieldSchemaToJSON } from 'payload/shared'
import type { Dispatch } from 'react'
import type React from 'react'
import { createContext, useContext } from 'react'

View File

@@ -37,6 +37,8 @@ export const DeviceContainer: React.FC<{
x = '-50%'
if (
desiredSize &&
measuredDeviceSize &&
typeof zoom === 'number' &&
typeof desiredSize.width === 'number' &&
typeof desiredSize.height === 'number' &&

View File

@@ -1,3 +1,4 @@
'use client'
import type { StepNavItem } from '@payloadcms/ui'
import type { ClientCollectionConfig, ClientField, ClientGlobalConfig } from 'payload'
import type React from 'react'
@@ -30,7 +31,7 @@ export const SetStepNav: React.FC<{
routes: { admin: adminRoute },
} = config
if (collectionSlug) {
if (collectionSlug && collectionConfig) {
let docLabel = ''
const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id'
@@ -82,7 +83,7 @@ export const SetStepNav: React.FC<{
]
}
if (globalSlug) {
if (globalSlug && globalConfig) {
nav = [
{
label: globalConfig.label,

View File

@@ -1,3 +1,4 @@
'use client'
import type { ClientField } from 'payload'
import { getTranslation } from '@payloadcms/translations'

View File

@@ -1,3 +1,4 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'

View File

@@ -1,11 +1,6 @@
'use client'
import type { I18nClient } from '@payloadcms/translations'
import type {
ClientField,
OptionObject,
SelectField,
SelectFieldClient,
SelectFieldProps,
} from 'payload'
import type { OptionObject, SelectField, SelectFieldClient } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'

View File

@@ -1,3 +1,4 @@
'use client'
import type { TabsFieldClient } from 'payload'
import React from 'react'

View File

@@ -1,3 +1,4 @@
'use client'
import type { TextFieldClient } from 'payload'
import { getTranslation } from '@payloadcms/translations'

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-deprecation
#!/usr/bin/env node
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.92",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
@@ -124,7 +124,7 @@
"@types/uuid": "10.0.0",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"esbuild": "0.23.0",
"esbuild": "0.23.1",
"graphql-http": "^1.22.0",
"rimraf": "3.0.2",
"sharp": "0.32.6"

View File

@@ -85,6 +85,7 @@ export async function generateImportMap(
if (shouldLog) {
console.log('Generating import map')
}
const importMap: InternalImportMap = {}
const imports: Imports = {}

View File

@@ -33,8 +33,8 @@ export function iterateCollections({
addToImportMap(collection.admin?.components?.afterListTable)
addToImportMap(collection.admin?.components?.beforeList)
addToImportMap(collection.admin?.components?.beforeListTable)
addToImportMap(collection.admin?.components?.Description)
addToImportMap(collection.admin?.components?.edit?.Description)
addToImportMap(collection.admin?.components?.edit?.PreviewButton)
addToImportMap(collection.admin?.components?.edit?.PublishButton)
addToImportMap(collection.admin?.components?.edit?.SaveButton)

View File

@@ -7,6 +7,7 @@ import type { BinScript } from '../config/types.js'
import { findConfig } from '../config/find.js'
import { generateImportMap } from './generateImportMap/index.js'
import { generateTypes } from './generateTypes.js'
import { info } from './info.js'
import { loadEnv } from './loadEnv.js'
import { migrate } from './migrate.js'
@@ -16,6 +17,11 @@ export const bin = async () => {
const args = minimist(process.argv.slice(2))
const script = (typeof args._[0] === 'string' ? args._[0] : '').toLowerCase()
if (script === 'info') {
await info()
return
}
if (script === 'run') {
const scriptPath = args._[1]
if (!scriptPath) {

View File

@@ -0,0 +1,63 @@
import { execFileSync } from 'child_process'
import os from 'os'
import { getDependencies } from '../index.js'
import { PAYLOAD_PACKAGE_LIST } from '../versions/payloadPackageList.js'
export const info = async () => {
const deps = await getDependencies(process.cwd(), [
...PAYLOAD_PACKAGE_LIST,
'next',
'react',
'react-dom',
])
const formattedDeps = Array.from(deps.resolved.entries()).map(([name, { version }]) => ({
name,
version,
}))
console.log(generateOutput(formattedDeps))
}
function generateOutput(packages: Array<{ name: string; version: string }>) {
const cpuCores = os.cpus().length
const primaryDeps = packages.filter(({ name }) => name === 'payload' || name === 'next')
const otherDeps = packages
.filter(({ name }) => name !== 'payload' && name !== 'next')
.sort((a, b) => a.name.localeCompare(b.name))
const formattedDeps = [...primaryDeps, ...otherDeps]
.map(({ name, version }) => ` ${name}: ${version}`)
.join('\n')
return `
Binaries:
Node: ${process.versions.node}
npm: ${getBinaryVersion('npm')}
Yarn: ${getBinaryVersion('yarn')}
pnpm: ${getBinaryVersion('pnpm')}
Relevant Packages:
${formattedDeps}
Operating System:
Platform: ${os.platform()}
Arch: ${os.arch()}
Version: ${os.version()}
Available memory (MB): ${Math.ceil(os.totalmem() / 1024 / 1024)}
Available CPU cores: ${cpuCores > 0 ? cpuCores : 'N/A'}
`
}
function getBinaryVersion(binaryName: string) {
try {
return execFileSync(binaryName, ['--version']).toString().trim()
} catch {
return 'N/A'
}
}
// Direct execution
if (import.meta.url === `file://${process.argv[1]}`) {
void info()
}

View File

@@ -0,0 +1,59 @@
import type { CustomVersionParser } from './utilities/dependencies/dependencyChecker.js'
import { checkDependencies } from './utilities/dependencies/dependencyChecker.js'
import { PAYLOAD_PACKAGE_LIST } from './versions/payloadPackageList.js'
const customReactVersionParser: CustomVersionParser = (version) => {
const [mainVersion, ...preReleases] = version.split('-')
if (preReleases?.length === 3) {
// Needs different handling, as it's in a format like 19.0.0-rc-06d0b89e-20240801 format
const date = preReleases[2]
const parts = mainVersion.split('.').map(Number)
return { parts, preReleases: [date] }
}
const parts = mainVersion.split('.').map(Number)
return { parts, preReleases }
}
export async function checkPayloadDependencies() {
const dependencies = [...PAYLOAD_PACKAGE_LIST]
if (process.env.PAYLOAD_CI_DEPENDENCY_CHECKER !== 'true') {
dependencies.push('@payloadcms/plugin-sentry')
}
// First load. First check if there are mismatching dependency versions of payload packages
await checkDependencies({
dependencyGroups: [
{
name: 'payload',
dependencies,
targetVersionDependency: 'payload',
},
{
name: 'react',
dependencies: ['react', 'react-dom'],
targetVersionDependency: 'react',
},
],
dependencyVersions: {
next: {
required: false,
version: '>=15.0.0-canary.104',
},
react: {
customVersionParser: customReactVersionParser,
required: false,
version: '>=19.0.0-rc-06d0b89e-20240801',
},
'react-dom': {
customVersionParser: customReactVersionParser,
required: false,
version: '>=19.0.0-rc-06d0b89e-20240801',
},
},
})
}

View File

@@ -14,6 +14,15 @@ export type ServerOnlyCollectionAdminProperties = keyof Pick<
'hidden' | 'preview'
>
export type ServerOnlyUploadProperties = keyof Pick<
SanitizedCollectionConfig['upload'],
| 'adminThumbnail'
| 'externalFileHeaderFilter'
| 'handlers'
| 'modifyResponseHeaders'
| 'withMetadata'
>
export type ClientCollectionConfig = {
_isPreviewEnabled?: true
admin: {
@@ -24,7 +33,6 @@ export type ClientCollectionConfig = {
beforeList: MappedComponent[]
beforeListTable: MappedComponent[]
edit: {
Description: MappedComponent
PreviewButton: MappedComponent
PublishButton: MappedComponent
SaveButton: MappedComponent

View File

@@ -120,6 +120,7 @@ export const sanitizeCollection = async (
// disable duplicate for uploads by default
sanitized.disableDuplicate = sanitized.disableDuplicate || true
sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
sanitized.admin.useAsTitle =
sanitized.admin.useAsTitle && sanitized.admin.useAsTitle !== 'id'

View File

@@ -250,6 +250,7 @@ export type CollectionAdminOptions = {
* Custom admin components
*/
components?: {
Description?: EntityDescriptionComponent
afterList?: CustomComponent[]
afterListTable?: CustomComponent[]
beforeList?: CustomComponent[]
@@ -258,8 +259,6 @@ export type CollectionAdminOptions = {
* Components within the edit view
*/
edit?: {
Description?: EntityDescriptionComponent
/**
* Replaces the "Preview" button
*/

View File

@@ -105,7 +105,10 @@ export async function validateSearchParam({
fieldPath = path.slice(0, -(req.locale.length + 1))
}
// remove ".value" from ends of polymorphic relationship paths
if (field.type === 'relationship' && Array.isArray(field.relationTo)) {
if (
(field.type === 'relationship' || field.type === 'upload') &&
Array.isArray(field.relationTo)
) {
fieldPath = fieldPath.replace('.value', '')
}
const entityType: 'collections' | 'globals' = globalConfig ? 'globals' : 'collections'

View File

@@ -100,6 +100,13 @@ export interface BaseDatabaseAdapter {
* The name of the database adapter
*/
name: string
/**
* Full package name of the database adapter
*
* @example @payloadcms/db-postgres
*/
packageName: string
/**
* reference to the instance of payload
*/
@@ -434,5 +441,6 @@ export type DBIdentifierName =
export type MigrationTemplateArgs = {
downSQL?: string
imports?: string
packageName?: string
upSQL?: string
}

View File

@@ -21,7 +21,12 @@ export class ValidationError extends APIError<{
global?: string
}> {
constructor(
results: { collection?: string; errors: ValidationFieldError[]; global?: string },
results: {
collection?: string
errors: ValidationFieldError[]
global?: string
id?: number | string
},
t?: TFunction,
) {
const message = t

View File

@@ -99,19 +99,26 @@ export const sanitizeFields = async ({
})
}
if (field.type === 'relationship') {
if (field.min && !field.minRows) {
console.warn(
`(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`,
)
if (field.min && !field.minRows) {
console.warn(
`(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`,
)
}
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
)
}
field.minRows = field.minRows || field.min
field.maxRows = field.maxRows || field.max
}
if (field.type === 'upload') {
if (!field.admin || !('isSortable' in field.admin)) {
field.admin = {
isSortable: true,
...field.admin,
}
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
)
}
field.minRows = field.minRows || field.min
field.maxRows = field.maxRows || field.max
}
}

View File

@@ -824,35 +824,106 @@ export type UIFieldClient = {
} & Omit<DeepUndefinable<FieldBaseClient>, '_isPresentational' | 'admin'> & // still include FieldBaseClient (even if it's undefinable) so that we don't need constant type checks (e.g. if('xy' in field))
Pick<UIField, 'label' | 'name' | 'type'>
export type UploadField = {
admin?: {
components?: {
Error?: CustomComponent<UploadFieldErrorClientComponent | UploadFieldErrorServerComponent>
Label?: CustomComponent<UploadFieldLabelClientComponent | UploadFieldLabelServerComponent>
} & Admin['components']
}
type SharedUploadProperties = {
/**
* Toggle the preview in the admin interface.
*/
displayPreview?: boolean
filterOptions?: FilterOptions
hasMany?: boolean
/**
* Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached.
*
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
*/
maxDepth?: number
relationTo: CollectionSlug
type: 'upload'
validate?: Validate<unknown, unknown, unknown, UploadField>
} & FieldBase
validate?: Validate<unknown, unknown, unknown, SharedUploadProperties>
} & (
| {
hasMany: true
/**
* @deprecated Use 'maxRows' instead
*/
max?: number
maxRows?: number
/**
* @deprecated Use 'minRows' instead
*/
min?: number
minRows?: number
}
| {
hasMany?: false | undefined
/**
* @deprecated Use 'maxRows' instead
*/
max?: undefined
maxRows?: undefined
/**
* @deprecated Use 'minRows' instead
*/
min?: undefined
minRows?: undefined
}
) &
FieldBase
export type UploadFieldClient = {
type SharedUploadPropertiesClient = FieldBaseClient &
Pick<
SharedUploadProperties,
'hasMany' | 'max' | 'maxDepth' | 'maxRows' | 'min' | 'minRows' | 'type'
>
type UploadAdmin = {
allowCreate?: boolean
components?: {
Error?: CustomComponent<
RelationshipFieldErrorClientComponent | RelationshipFieldErrorServerComponent
>
Label?: CustomComponent<
RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent
>
} & Admin['components']
isSortable?: boolean
} & Admin
type UploadAdminClient = {
components?: {
Error?: MappedComponent
Label?: MappedComponent
} & AdminClient['components']
} & AdminClient &
Pick<UploadAdmin, 'allowCreate' | 'isSortable'>
export type PolymorphicUploadField = {
admin?: {
components?: {
Error?: MappedComponent
Label?: MappedComponent
} & AdminClient['components']
}
} & FieldBaseClient &
Pick<UploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'>
sortOptions?: { [collectionSlug: CollectionSlug]: string }
} & UploadAdmin
relationTo: CollectionSlug[]
} & SharedUploadProperties
export type PolymorphicUploadFieldClient = {
admin?: {
sortOptions?: Pick<PolymorphicUploadField['admin'], 'sortOptions'>
} & UploadAdminClient
} & Pick<PolymorphicUploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> &
SharedUploadPropertiesClient
export type SingleUploadField = {
admin?: {
sortOptions?: string
} & UploadAdmin
relationTo: CollectionSlug
} & SharedUploadProperties
export type SingleUploadFieldClient = {
admin?: Pick<SingleUploadField['admin'], 'sortOptions'> & UploadAdminClient
} & Pick<SingleUploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> &
SharedUploadPropertiesClient
export type UploadField = /* PolymorphicUploadField | */ SingleUploadField
export type UploadFieldClient = /* PolymorphicUploadFieldClient | */ SingleUploadFieldClient
export type CodeField = {
admin?: {

View File

@@ -71,6 +71,7 @@ export const beforeChange = async <T extends JsonObject>({
if (errors.length > 0) {
throw new ValidationError(
{
id,
collection: collection?.slug,
errors,
global: global?.slug,

View File

@@ -138,7 +138,7 @@ export const promise = async <T>({
siblingData[field.name] === 'null' ||
siblingData[field.name] === null
) {
if (field.type === 'relationship' && field.hasMany === true) {
if (field.hasMany === true) {
siblingData[field.name] = []
} else {
siblingData[field.name] = null
@@ -153,32 +153,32 @@ export const promise = async <T>({
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === relatedDoc.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = {
...relatedDoc,
value: parseFloat(relatedDoc.value as string),
}
}
}
})
}
if (field.hasMany !== true && valueIsValueWithRelation(value)) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = {
...relatedDoc,
value: parseFloat(relatedDoc.value as string),
}
siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }
}
})
}
if (
field.type === 'relationship' &&
field.hasMany !== true &&
valueIsValueWithRelation(value)
) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }
}
}
} else {
@@ -187,25 +187,31 @@ export const promise = async <T>({
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string)
}
}
})
}
if (field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string)
siblingData[field.name] = parseFloat(value as string)
}
})
}
if (field.type === 'relationship' && field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = parseFloat(value as string)
}
}
}

View File

@@ -571,18 +571,76 @@ const validateFilterOptions: Validate<
}
export type UploadFieldValidation = Validate<unknown, unknown, unknown, UploadField>
export const upload: UploadFieldValidation = (value: string, options) => {
if (!value && options.required) {
return options?.req?.t('validation:required')
export const upload: UploadFieldValidation = async (value, options) => {
const {
maxRows,
minRows,
relationTo,
req: { payload, t },
required,
} = options
if (
((!value && typeof value !== 'number') || (Array.isArray(value) && value.length === 0)) &&
required
) {
return t('validation:required')
}
if (Array.isArray(value) && value.length > 0) {
if (minRows && value.length < minRows) {
return t('validation:lessThanMin', {
label: t('general:rows'),
min: minRows,
value: value.length,
})
}
if (maxRows && value.length > maxRows) {
return t('validation:greaterThanMax', {
label: t('general:rows'),
max: maxRows,
value: value.length,
})
}
}
if (typeof value !== 'undefined' && value !== null) {
const idType =
options?.req?.payload?.collections[options.relationTo]?.customIDType ||
options?.req?.payload?.db?.defaultIDType
const values = Array.isArray(value) ? value : [value]
if (!isValidID(value, idType)) {
return options.req?.t('validation:validUploadID')
const invalidRelationships = values.filter((val) => {
let collectionSlug: string
let requestedID
if (typeof relationTo === 'string') {
collectionSlug = relationTo
// custom id
if (val || typeof val === 'number') {
requestedID = val
}
}
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {
collectionSlug = val.relationTo
requestedID = val.value
}
if (requestedID === null) return false
const idType =
payload.collections[collectionSlug]?.customIDType || payload?.db?.defaultIDType || 'text'
return !isValidID(requestedID, idType)
})
if (invalidRelationships.length > 0) {
return `This relationship field has the following invalid relationships: ${invalidRelationships
.map((err, invalid) => {
return `${err} ${JSON.stringify(invalid)}`
})
.join(', ')}`
}
}

Some files were not shown because too many files have changed in this diff Show More