@payloadcms/figma 0.0.1-alpha.60 → 0.0.1-alpha.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,10 @@ export interface CreateTenantOptions {
28
28
  * Options for creating a new deployment
29
29
  */
30
30
  export interface CreateDeploymentOptions {
31
+ /** Framework adapter name (e.g., "vite", "nextjs"). Forwarded to Gatekeeper. */
32
+ adapter?: string;
33
+ /** SPA fallback path (e.g., "/index.html"). Vite-only. */
34
+ fallback?: string;
31
35
  /** Pages keyed by route, each with its associated asset keys */
32
36
  pages: Record<string, {
33
37
  assets: string[];
@@ -184,14 +184,21 @@ import * as log from '../utils/log.js';
184
184
  assets: pageData.assets
185
185
  };
186
186
  }
187
+ const body = {
188
+ pages,
189
+ static_assets: options.staticAssets
190
+ };
191
+ if (options.adapter) {
192
+ body.adapter = options.adapter;
193
+ }
194
+ if (options.fallback) {
195
+ body.fallback = options.fallback;
196
+ }
187
197
  // REAL API IMPLEMENTATION
188
198
  const response = await controlPlaneFetch({
189
199
  context: 'create deployment',
190
200
  options: {
191
- body: JSON.stringify({
192
- pages,
193
- static_assets: options.staticAssets
194
- }),
201
+ body: JSON.stringify(body),
195
202
  headers: {
196
203
  ...getAuthHeaders(credential),
197
204
  'Content-Type': 'application/json'
package/dist/cli.js CHANGED
@@ -68,9 +68,11 @@ class Main {
68
68
  setInfraEnvironment('production');
69
69
  } else if (infraEnvArg === 'staging') {
70
70
  setInfraEnvironment('staging');
71
+ } else if (infraEnvArg === 'devbox') {
72
+ setInfraEnvironment('devbox');
71
73
  } else {
72
74
  // eslint-disable-next-line no-console
73
- console.error(`Invalid --infra-env value: ${this.args['--infra-env']}. Use 'production' or 'staging'.`);
75
+ console.error(`Invalid --infra-env value: ${this.args['--infra-env']}. Use 'production', 'staging', or 'devbox'.`);
74
76
  process.exit(1);
75
77
  }
76
78
  }
@@ -241,6 +241,8 @@ import { loginCommand } from './login.js';
241
241
  };
242
242
  }
243
243
  const createResponse = await createDeployment(credential, tenantInstanceId, {
244
+ adapter: adapter.name,
245
+ fallback: adapter.fallback,
244
246
  pages: pagesPayload,
245
247
  staticAssets: assets.uploadKeys
246
248
  });
@@ -67,7 +67,7 @@ import { cacheAllEnvironments } from '../utils/cache-bootstrap.js';
67
67
  *
68
68
  * @param options - Command options
69
69
  */ export async function initCommand(options) {
70
- // Check for outdated version (non-blocking)
70
+ // Check for mismatched version (non-blocking)
71
71
  const currentVersion = await getOwnVersion();
72
72
  await checkForUpdates(currentVersion);
73
73
  // Check authentication
@@ -1,8 +1,16 @@
1
1
  import type { OAuthConfig } from '../auth/types.js';
2
2
  export declare const DEFAULT_CALLBACK_PORT = 34462;
3
3
  /**
4
- * Get OAuth configuration for current environment
5
- * Uses getEnvConfig() to determine staging vs production
4
+ * Get OAuth configuration for current environment.
5
+ *
6
+ * Honors env overrides:
7
+ * FIGMA_API_BASE_URL → tokenUrl, refreshUrl
8
+ * FIGMA_WEB_BASE_URL → authorizationUrl (appends /oauth)
9
+ * FIGMA_CLIENT_ID → clientId
10
+ * FIGMA_REDIRECT_URI → redirectUri
11
+ *
12
+ * When FIGMA_INFRA_ENV=devbox, FIGMA_API_BASE_URL and FIGMA_WEB_BASE_URL are
13
+ * required (ENV_CONFIG.devbox has empty URL defaults to fail-closed).
6
14
  */
7
15
  export declare function getOAuthConfig(): OAuthConfig;
8
16
  export declare const TOKEN_EXPIRY_BUFFER_SECONDS = 300;
@@ -1,10 +1,24 @@
1
- import { getEnvConfig } from '../constants.js';
1
+ import { getEnvConfig, getInfraEnvironment } from '../constants.js';
2
2
  export const DEFAULT_CALLBACK_PORT = 34462;
3
3
  /**
4
- * Get OAuth configuration for current environment
5
- * Uses getEnvConfig() to determine staging vs production
4
+ * Get OAuth configuration for current environment.
5
+ *
6
+ * Honors env overrides:
7
+ * FIGMA_API_BASE_URL → tokenUrl, refreshUrl
8
+ * FIGMA_WEB_BASE_URL → authorizationUrl (appends /oauth)
9
+ * FIGMA_CLIENT_ID → clientId
10
+ * FIGMA_REDIRECT_URI → redirectUri
11
+ *
12
+ * When FIGMA_INFRA_ENV=devbox, FIGMA_API_BASE_URL and FIGMA_WEB_BASE_URL are
13
+ * required (ENV_CONFIG.devbox has empty URL defaults to fail-closed).
6
14
  */ export function getOAuthConfig() {
7
- const { apiBaseUrl, authorizationUrl, clientId } = getEnvConfig();
15
+ const { apiBaseUrl: defaultApi, authorizationUrl: defaultAuth, clientId } = getEnvConfig();
16
+ const apiBaseUrl = process.env.FIGMA_API_BASE_URL || defaultApi;
17
+ const webBase = process.env.FIGMA_WEB_BASE_URL;
18
+ const authorizationUrl = webBase ? `${webBase}/oauth` : defaultAuth;
19
+ if (getInfraEnvironment() === 'devbox' && (!apiBaseUrl || !authorizationUrl)) {
20
+ throw new Error('FIGMA_API_BASE_URL and FIGMA_WEB_BASE_URL must be set when FIGMA_INFRA_ENV=devbox');
21
+ }
8
22
  return {
9
23
  authorizationUrl,
10
24
  clientId: process.env.FIGMA_CLIENT_ID || clientId,
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Figma infrastructure environment (production or staging)
2
+ * Figma infrastructure environment (production, staging, or devbox).
3
+ * devbox is a local-dev mode that requires FIGMA_API_BASE_URL and
4
+ * FIGMA_WEB_BASE_URL env overrides to point at a Coder devbox.
3
5
  */
4
- export type Environment = 'production' | 'staging';
6
+ export type Environment = 'devbox' | 'production' | 'staging';
5
7
  type EnvironmentConfig = {
6
8
  /** Figma API base URL. Override: FIGMA_API_BASE_URL */
7
9
  apiBaseUrl: string;
package/dist/constants.js CHANGED
@@ -18,6 +18,17 @@ import { getEnvVarSync } from './utils/env-management.js';
18
18
  contentApiUrl: 'https://us-east-1.cms-tenants-001-staging.figmacontentstaging.com',
19
19
  identityMetadata: 'https://staging.figma.com/.well-known/openid-configuration',
20
20
  jwksUri: 'https://static.figmacontentstaging.com/.well_known/jwks.json'
21
+ },
22
+ // Empty URLs are intentional: devbox mode requires FIGMA_API_BASE_URL and
23
+ // FIGMA_WEB_BASE_URL to be set, validated at getOAuthConfig() time.
24
+ // clientId defaults to the dev seed value in sinatra/db/seeds.rb.
25
+ devbox: {
26
+ apiBaseUrl: '',
27
+ authorizationUrl: '',
28
+ clientId: 'rNZBcf3xBDmI76mQ9603su',
29
+ contentApiUrl: '',
30
+ identityMetadata: '',
31
+ jwksUri: ''
21
32
  }
22
33
  };
23
34
  /**
@@ -58,9 +69,13 @@ export function getInfraEnvironment() {
58
69
  return envOverride;
59
70
  }
60
71
  // Check process.env first (case-insensitive)
61
- if (process.env.FIGMA_INFRA_ENV?.toLowerCase() === 'staging') {
72
+ const processEnv = process.env.FIGMA_INFRA_ENV?.toLowerCase();
73
+ if (processEnv === 'staging') {
62
74
  return 'staging';
63
75
  }
76
+ if (processEnv === 'devbox') {
77
+ return 'devbox';
78
+ }
64
79
  // Fall back to .env file in cwd (check both new and old env var names)
65
80
  const envFileValue = getEnvVarSync(process.cwd(), 'FIGMA_INFRA_ENV') ?? getEnvVarSync(process.cwd(), 'FIGMA_ENV');
66
81
  if (envFileValue) {
@@ -68,6 +83,9 @@ export function getInfraEnvironment() {
68
83
  if (env === 'staging') {
69
84
  return 'staging';
70
85
  }
86
+ if (env === 'devbox') {
87
+ return 'devbox';
88
+ }
71
89
  if (env !== 'production') {
72
90
  // eslint-disable-next-line no-console
73
91
  console.warn(`Warning: Invalid FIGMA_INFRA_ENV value "${envFileValue}" in .env file. Using production.`);
@@ -8,6 +8,7 @@ import { addFallbackSort } from './temp-utilities/sorting.js';
8
8
  import { unwrapDocument, unwrapFindResponse } from './temp-utilities/unwrapDocument.js';
9
9
  import { createAuthMiddleware, createErrorMiddleware } from './utilities/auth.js';
10
10
  import { dataToContentAPI, resolveVersionContent } from './utilities/data/index.js';
11
+ import { validateRelationshipIds } from './utilities/data/validateRelationships.js';
11
12
  import { convertPayloadJoinsToContentAPI } from './utilities/joins.js';
12
13
  import { addFallbackLocale } from './utilities/locale/index.js';
13
14
  import { buildMeta } from './utilities/meta/buildMeta.js';
@@ -56,7 +57,7 @@ async function syncCollections() {
56
57
  }
57
58
  }
58
59
  }
59
- async function init() {
60
+ function init() {
60
61
  if (this.auth.mode === 'apiKey') {
61
62
  this.payload.logger.info('Using API Key authentication');
62
63
  } else if (this.auth.mode === 'tokenStore') {
@@ -64,8 +65,15 @@ async function init() {
64
65
  } else {
65
66
  this.payload.logger.info('Using Dev JWT authentication (testing)');
66
67
  }
67
- if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
68
+ }
69
+ // connect is called after init — once on first startup (hotReload=false) and again on HMR
70
+ // (hotReload=true). Only clear the database on the first startup, never on hot reload.
71
+ async function connect(args) {
72
+ const hotReload = args?.hotReload ?? false;
73
+ if (!hotReload && process.env.PAYLOAD_DROP_DATABASE === 'true') {
74
+ // clearDatabase already calls syncCollections internally after clearing
68
75
  await this.clearDatabase();
76
+ return;
69
77
  }
70
78
  await syncCollections.call(this);
71
79
  }
@@ -99,7 +107,7 @@ async function findMany({ collection, joins, limit, locale: localeArg, page, pag
99
107
  body: {
100
108
  collection,
101
109
  contentSystemId: this.contentSystemId,
102
- join: convertPayloadJoinsToContentAPI(this.payload, collection, joins),
110
+ join: convertPayloadJoinsToContentAPI(this.payload, collection, joins, locale),
103
111
  limit,
104
112
  locale,
105
113
  page: page ?? 1,
@@ -346,6 +354,11 @@ async function findDistinct(args) {
346
354
  }
347
355
  async function updateMany(args) {
348
356
  const locale = addFallbackLocale(args.locale, this.payload);
357
+ validateRelationshipIds({
358
+ collectionSlug: args.collection,
359
+ data: args.data,
360
+ payload: this.payload
361
+ });
349
362
  const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
350
363
  body: {
351
364
  collection: args.collection,
@@ -387,6 +400,11 @@ async function updateOne(args) {
387
400
  equals: args.id
388
401
  }
389
402
  };
403
+ validateRelationshipIds({
404
+ collectionSlug: args.collection,
405
+ data: args.data,
406
+ payload: this.payload
407
+ });
390
408
  const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
391
409
  body: {
392
410
  collection: args.collection,
@@ -491,6 +509,11 @@ async function create(args) {
491
509
  id = uuid();
492
510
  }
493
511
  const locale = addFallbackLocale(args.locale, this.payload);
512
+ validateRelationshipIds({
513
+ collectionSlug: args.collection,
514
+ data: args.data,
515
+ payload: this.payload
516
+ });
494
517
  const { data: response, error } = await this.client.POST('/api/v0/documents:create', {
495
518
  body: {
496
519
  collection: args.collection,
@@ -604,6 +627,11 @@ async function upsert(args) {
604
627
  documentId = uuid();
605
628
  }
606
629
  const locale = addFallbackLocale(args.locale, this.payload);
630
+ validateRelationshipIds({
631
+ collectionSlug: args.collection,
632
+ data: args.data,
633
+ payload: this.payload
634
+ });
607
635
  const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
608
636
  body: {
609
637
  collection: args.collection,
@@ -645,13 +673,131 @@ async function upsert(args) {
645
673
  payload: this.payload
646
674
  });
647
675
  }
676
+ // Mirrors MAX_DOCUMENT_UPDATES in the content API's updateDocuments.ts.
677
+ // updateJobs may be called with larger limits (e.g. 150), so we batch sequentially.
678
+ const CONTENT_API_MAX_UPDATES = 20;
679
+ // Issues a single update request against payload-jobs and returns the unwrapped docs.
680
+ async function updateJobsBatch({ batchSize, docData, meta, sortClause, whereQuery }) {
681
+ const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
682
+ body: {
683
+ collection: 'payload-jobs',
684
+ contentSystemId: this.contentSystemId,
685
+ createOnMissing: false,
686
+ doc: docData,
687
+ ...batchSize != null && {
688
+ limit: batchSize
689
+ },
690
+ returning: {},
691
+ sort: sortClause,
692
+ where: whereQuery,
693
+ ...meta
694
+ }
695
+ });
696
+ if (error) {
697
+ throw new Error(`Content API updateJobs error: ${JSON.stringify(error)}`);
698
+ }
699
+ if (!response) {
700
+ throw new Error('No response from updateJobs');
701
+ }
702
+ if (!response.result || !('data' in response.result)) {
703
+ return [];
704
+ }
705
+ return response.result.data.map((doc)=>unwrapDocument({
706
+ collectionSlug: 'payload-jobs',
707
+ doc,
708
+ payload: this.payload
709
+ }));
710
+ }
711
+ async function updateJobs(args) {
712
+ const { id, limit, returning, sort, where } = args;
713
+ if (id == null && where == null) {
714
+ throw new Error('updateJobs requires either id or a where clause');
715
+ }
716
+ // Copy data to avoid mutating the caller's object.
717
+ const data = {
718
+ ...args.data
719
+ };
720
+ // Strip log if empty/absent. The content API's deep merge replaces arrays entirely
721
+ // (it does not concatenate), so sending log: [] would wipe existing log entries.
722
+ // Only include log when it carries new content: a non-empty array or a $push operation.
723
+ const log = data.log;
724
+ if (!(Array.isArray(log) && log.length > 0) && !(log && typeof log === 'object' && '$push' in log)) {
725
+ delete data.log;
726
+ }
727
+ const whereClause = id != null ? {
728
+ id: {
729
+ equals: String(id)
730
+ }
731
+ } : where;
732
+ const docData = dataToContentAPI(this.payload, 'payload-jobs', data);
733
+ const sortClause = addFallbackSort(sort || '-updatedAt', this.payload, 'payload-jobs');
734
+ const whereQuery = convertPayloadWhereToContentAPI(whereClause);
735
+ const meta = buildMeta(this.payload, {
736
+ collection: 'payload-jobs',
737
+ locale: undefined,
738
+ where: whereClause
739
+ });
740
+ // When limit exceeds the content API's per-request cap, batch sequentially.
741
+ const needsBatching = limit != null && limit > CONTENT_API_MAX_UPDATES;
742
+ if (!needsBatching) {
743
+ const docs = await updateJobsBatch.call(this, {
744
+ batchSize: limit,
745
+ docData,
746
+ meta,
747
+ sortClause,
748
+ whereQuery
749
+ });
750
+ if (returning === false) {
751
+ return null;
752
+ }
753
+ return docs;
754
+ }
755
+ // Batched path: issue sequential requests of ≤ CONTENT_API_MAX_UPDATES each.
756
+ // This relies on the update itself causing matched jobs to no longer satisfy the where
757
+ // clause on subsequent batches (e.g. setting processing: true removes them from a
758
+ // "processing: false" query). If a future updateJobs call in Payload core updates jobs
759
+ // in a way that does NOT change their match status, the same jobs could be updated
760
+ // multiple times across batches.
761
+ const allDocs = [];
762
+ let remaining = limit;
763
+ while(remaining > 0){
764
+ const batchSize = Math.min(remaining, CONTENT_API_MAX_UPDATES);
765
+ const batchDocs = await updateJobsBatch.call(this, {
766
+ batchSize,
767
+ docData,
768
+ meta,
769
+ sortClause,
770
+ whereQuery
771
+ });
772
+ allDocs.push(...batchDocs);
773
+ remaining -= batchSize;
774
+ // Stop early when this batch returned fewer docs than requested — no more matching docs.
775
+ if (batchDocs.length < batchSize) {
776
+ break;
777
+ }
778
+ }
779
+ if (returning === false) {
780
+ return null;
781
+ }
782
+ return allDocs;
783
+ }
648
784
  function createGlobal(args) {
649
- return this.create({
785
+ // Globals are singletons identified by their slug. Use upsert so concurrent calls
786
+ // (e.g. handleSchedules processing multiple queueables with the same null jobStats)
787
+ // don't fail with "A record with this field already exists". The deep-merge semantics
788
+ // of the update path are safe here: callers that want a fresh create (first run) get
789
+ // an effective create, and callers that race a prior create get a merge instead.
790
+ return this.upsert({
650
791
  collection: getGlobalSlug(args.slug),
651
792
  data: {
652
793
  ...args.data,
653
794
  id: args.slug,
654
795
  globalType: args.slug
796
+ },
797
+ where: {
798
+ id: {
799
+ equals: args.slug
800
+ }
655
801
  }
656
802
  });
657
803
  }
@@ -749,6 +895,7 @@ export const contentAPIAdapter = (opts)=>({
749
895
  clearDatabase: clearDatabase,
750
896
  client,
751
897
  commitTransaction: async ()=>{},
898
+ connect: connect,
752
899
  contentSystemId: opts.contentSystemId,
753
900
  count: count,
754
901
  countGlobalVersions: countGlobalVersions,
@@ -775,6 +922,7 @@ export const contentAPIAdapter = (opts)=>({
775
922
  rollbackTransaction: async ()=>{},
776
923
  updateGlobal: updateGlobal,
777
924
  updateGlobalVersion: updateGlobalVersion,
925
+ updateJobs: updateJobs,
778
926
  updateMany: updateMany,
779
927
  updateOne: updateOne,
780
928
  updateVersion: updateVersion,
@@ -3,6 +3,22 @@
3
3
  * This handles defaults for nested structures (arrays, groups) which traverseFields doesn't handle
4
4
  */ export function applyDefaults(data, fields) {
5
5
  for (const field of fields){
6
+ // Tabs fields: unnamed tabs are transparent (fields at the same level); named tabs nest
7
+ // their fields under the tab's name key. Recurse before the name-based guard so
8
+ // tab-nested fields (e.g. hasError in payload-jobs) get their defaults applied.
9
+ if (field.type === 'tabs') {
10
+ for (const tab of field.tabs){
11
+ if ('name' in tab && tab.name) {
12
+ if (!data[tab.name]) {
13
+ data[tab.name] = {};
14
+ }
15
+ applyDefaults(data[tab.name], tab.fields);
16
+ } else {
17
+ applyDefaults(data, tab.fields);
18
+ }
19
+ }
20
+ continue;
21
+ }
6
22
  // Only process fields that affect data (have a name)
7
23
  if (!('name' in field) || !field.name) {
8
24
  continue;
@@ -37,11 +53,11 @@
37
53
  applyDefaults(element, field.fields);
38
54
  }
39
55
  }
40
- } else if (field.type === 'group' && currentValue && typeof currentValue === 'object') {
41
- // Apply defaults to group fields
42
- if ('fields' in field) {
43
- applyDefaults(currentValue, field.fields);
56
+ } else if (field.type === 'group' && 'fields' in field) {
57
+ if (currentValue == null || typeof currentValue !== 'object') {
58
+ data[field.name] = {};
44
59
  }
60
+ applyDefaults(data[field.name], field.fields);
45
61
  } else if (field.type === 'blocks' && Array.isArray(currentValue) && 'blocks' in field) {
46
62
  // Apply defaults to each block
47
63
  for (const element of currentValue){
@@ -179,6 +179,23 @@ export function dataFromContentAPI(payload, collectionSlug, data, locale) {
179
179
  const current = ref;
180
180
  const value = current[field.name];
181
181
  const isLocalized = 'localized' in field && field.localized;
182
+ // Group fields must always be an object so Payload can traverse sub-fields
183
+ // (e.g. join fields inside a group). A null/undefined group crashes afterRead.
184
+ if (field.type === 'group') {
185
+ if (value == null || typeof value !== 'object') {
186
+ current[field.name] = {};
187
+ }
188
+ return;
189
+ }
190
+ // Localized join fields: wrap in locale map so afterRead can hoist the value.
191
+ // Content API returns join data directly at the field path (e.g. { docs: [...] }),
192
+ // but Payload's afterRead expects localized fields as { [locale]: value }.
193
+ if (field.type === 'join' && isLocalized && value !== undefined && !isAllLocales && locale) {
194
+ current[field.name] = {
195
+ [locale]: value
196
+ };
197
+ return;
198
+ }
182
199
  // Content API may omit fields; Payload expects null for optional fields.
183
200
  // In all-locales mode, localized fields use {} so afterRead can safely iterate locale keys.
184
201
  if (value === undefined) {
@@ -0,0 +1,7 @@
1
+ import type { Payload } from 'payload';
2
+ export declare function validateRelationshipIds({ collectionSlug, data, payload, }: {
3
+ collectionSlug: string;
4
+ data: Record<string, unknown>;
5
+ payload: Payload;
6
+ }): void;
7
+ //# sourceMappingURL=validateRelationships.d.ts.map
@@ -0,0 +1,102 @@
1
+ import { traverseFields } from 'payload';
2
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ function collectRelationshipValues({ data, fields }) {
4
+ const relationships = [];
5
+ const callback = ({ field, ref })=>{
6
+ if (!('name' in field) || !field.name) {
7
+ return;
8
+ }
9
+ if (field.type !== 'relationship' && field.type !== 'upload') {
10
+ return;
11
+ }
12
+ if (!ref || typeof ref !== 'object') {
13
+ return;
14
+ }
15
+ const rawValue = ref[field.name];
16
+ if (rawValue == null || rawValue === '') {
17
+ return;
18
+ }
19
+ // For localized fields, the value is a locale map (e.g. { en: "uuid", fr: "uuid" }).
20
+ // Iterate locale values instead of treating the map as an ID.
21
+ if ('localized' in field && field.localized) {
22
+ if (typeof rawValue === 'object' && !Array.isArray(rawValue)) {
23
+ for (const localeValue of Object.values(rawValue)){
24
+ if (localeValue != null && localeValue !== '') {
25
+ parseRelationshipValues(localeValue, field.relationTo, relationships);
26
+ }
27
+ }
28
+ }
29
+ return;
30
+ }
31
+ parseRelationshipValues(rawValue, field.relationTo, relationships);
32
+ };
33
+ traverseFields({
34
+ callback,
35
+ fields,
36
+ fillEmpty: false,
37
+ ref: data
38
+ });
39
+ return relationships;
40
+ }
41
+ function parseRelationshipValues(value, relationTo, relationships) {
42
+ if (Array.isArray(relationTo)) {
43
+ // Polymorphic (single or hasMany): value is { relationTo, value } or array of them
44
+ const values = Array.isArray(value) ? value : [
45
+ value
46
+ ];
47
+ for (const v of values){
48
+ if (v && typeof v === 'object' && 'relationTo' in v && 'value' in v) {
49
+ const obj = v;
50
+ if (obj.value != null && obj.value !== '') {
51
+ relationships.push({
52
+ collection: obj.relationTo,
53
+ value: obj.value
54
+ });
55
+ }
56
+ }
57
+ }
58
+ } else {
59
+ // Non-polymorphic (single or hasMany): value is a bare ID or array of IDs
60
+ const values = Array.isArray(value) ? value : [
61
+ value
62
+ ];
63
+ for (const v of values){
64
+ if (v != null && v !== '') {
65
+ relationships.push({
66
+ collection: relationTo,
67
+ value: v
68
+ });
69
+ }
70
+ }
71
+ }
72
+ }
73
+ function isValidId(value, customIDType) {
74
+ if (customIDType === 'number') {
75
+ const num = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
76
+ return Number.isFinite(num);
77
+ }
78
+ if (customIDType === 'text') {
79
+ return typeof value === 'string' && value.length > 0;
80
+ }
81
+ return typeof value === 'string' && UUID_REGEX.test(value);
82
+ }
83
+ export function validateRelationshipIds({ collectionSlug, data, payload }) {
84
+ const isGlobal = collectionSlug.startsWith('_global-');
85
+ const actualSlug = isGlobal ? collectionSlug.substring(8) : collectionSlug;
86
+ const config = isGlobal ? payload.config.globals?.find((g)=>g.slug === actualSlug) : payload.config.collections.find((c)=>c.slug === actualSlug);
87
+ if (!config?.fields) {
88
+ return;
89
+ }
90
+ const relationships = collectRelationshipValues({
91
+ data,
92
+ fields: config.fields
93
+ });
94
+ for (const { collection, value } of relationships){
95
+ const customIDType = payload.collections?.[collection]?.customIDType;
96
+ if (!isValidId(value, customIDType)) {
97
+ throw new Error(`Invalid relationship ID "${String(value)}" for collection "${collection}". Expected ${customIDType === 'number' ? 'a number' : customIDType === 'text' ? 'a non-empty string' : 'a valid UUID'}.`);
98
+ }
99
+ }
100
+ }
101
+
102
+ //# sourceMappingURL=validateRelationships.js.map
@@ -17,8 +17,6 @@ type ContentAPIJoin = components['schemas']['JoinClause'][number];
17
17
  * 3. **Polymorphic relationships** - Stored as `{ relationTo, value }` objects, but
18
18
  * Content API expects a simple ID string.
19
19
  *
20
- * 4. **Localized relationship fields** - Locale-aware joins not implemented.
21
- *
22
20
  * Note: "Where querying through joins" (e.g., `where: { 'relatedPosts.title': { equals: 'x' } }`)
23
21
  * is a separate Payload feature that filters documents based on joined data. This is NOT
24
22
  * a join feature but a where clause feature, and would need separate Content API support.
@@ -50,6 +48,6 @@ type ContentAPIJoin = components['schemas']['JoinClause'][number];
50
48
  * - Which collection each join field refers to
51
49
  * - What the 'on' field is for each join
52
50
  */
53
- export declare function convertPayloadJoinsToContentAPI(payload: Payload, collectionSlug: string, joins: false | JoinQuery | undefined): ContentAPIJoin[] | undefined;
51
+ export declare function convertPayloadJoinsToContentAPI(payload: Payload, collectionSlug: string, joins: false | JoinQuery | undefined, locale?: string): ContentAPIJoin[] | undefined;
54
52
  export {};
55
53
  //# sourceMappingURL=joins.d.ts.map
@@ -16,8 +16,6 @@ import { convertPayloadWhereToContentAPI } from './where.js';
16
16
  * 3. **Polymorphic relationships** - Stored as `{ relationTo, value }` objects, but
17
17
  * Content API expects a simple ID string.
18
18
  *
19
- * 4. **Localized relationship fields** - Locale-aware joins not implemented.
20
- *
21
19
  * Note: "Where querying through joins" (e.g., `where: { 'relatedPosts.title': { equals: 'x' } }`)
22
20
  * is a separate Payload feature that filters documents based on joined data. This is NOT
23
21
  * a join feature but a where clause feature, and would need separate Content API support.
@@ -48,7 +46,7 @@ import { convertPayloadWhereToContentAPI } from './where.js';
48
46
  * The mapping requires the collection config to resolve:
49
47
  * - Which collection each join field refers to
50
48
  * - What the 'on' field is for each join
51
- */ export function convertPayloadJoinsToContentAPI(payload, collectionSlug, joins) {
49
+ */ export function convertPayloadJoinsToContentAPI(payload, collectionSlug, joins, locale) {
52
50
  const collectionConfig = payload.config.collections.find((c)=>c.slug === collectionSlug);
53
51
  if (!collectionConfig) {
54
52
  return undefined;
@@ -74,11 +72,14 @@ import { convertPayloadWhereToContentAPI } from './where.js';
74
72
  for (const [collectionSlug, sanitizedJoins] of Object.entries(collectionConfig.joins)){
75
73
  for (const sanitizedJoin of sanitizedJoins){
76
74
  if (sanitizedJoin.joinPath === joinPath) {
75
+ const on = locale && sanitizedJoin.getForeignPath ? sanitizedJoin.getForeignPath({
76
+ locale
77
+ }) : sanitizedJoin.field.on;
77
78
  foundJoin = {
78
79
  collectionSlug,
79
80
  defaultLimit: sanitizedJoin.field.defaultLimit,
80
81
  defaultSort: sanitizedJoin.field.defaultSort,
81
- on: sanitizedJoin.field.on
82
+ on
82
83
  };
83
84
  break;
84
85
  }
@@ -92,10 +93,13 @@ import { convertPayloadWhereToContentAPI } from './where.js';
92
93
  for (const sanitizedJoin of collectionConfig.polymorphicJoins){
93
94
  if (sanitizedJoin.joinPath === joinPath) {
94
95
  const collections = sanitizedJoin.field.collection;
96
+ const on = locale && sanitizedJoin.getForeignPath ? sanitizedJoin.getForeignPath({
97
+ locale
98
+ }) : sanitizedJoin.field.on;
95
99
  foundJoin = {
96
100
  collectionSlug: collections,
97
101
  defaultSort: sanitizedJoin.field.defaultSort,
98
- on: sanitizedJoin.field.on
102
+ on
99
103
  };
100
104
  break;
101
105
  }
@@ -74,6 +74,10 @@ export function convertPayloadWhereToContentAPI(where, options = {}) {
74
74
  if (op === 'exists' && typeof operatorValue === 'string') {
75
75
  finalValue = operatorValue === 'true';
76
76
  }
77
+ // REST API sends "null" as a string; convert to actual null
78
+ if (finalValue === 'null') {
79
+ finalValue = null;
80
+ }
77
81
  // Content API stores all document IDs as strings.
78
82
  // Payload sends numeric values for collections with custom numeric ID fields,
79
83
  // so we must stringify to match.
@@ -23,24 +23,7 @@ export const getHandler = ({ client, collection })=>{
23
23
  status: 500
24
24
  });
25
25
  }
26
- // Fetch file bytes from S3 (required for Payload's image processing)
27
- const fileResponse = await fetch(data.url);
28
- if (!fileResponse.ok) {
29
- req.payload.logger.error(`S3 fetch failed: ${fileResponse.status} ${fileResponse.statusText}`);
30
- return new Response('Failed to fetch file from storage', {
31
- status: 502
32
- });
33
- }
34
- const contentType = fileResponse.headers.get('content-type') || 'application/octet-stream';
35
- const contentLength = fileResponse.headers.get('content-length');
36
- return new Response(fileResponse.body, {
37
- headers: {
38
- 'Content-Type': contentType,
39
- ...contentLength && {
40
- 'Content-Length': contentLength
41
- }
42
- }
43
- });
26
+ return Response.redirect(data.url, 302);
44
27
  } catch (err) {
45
28
  req.payload.logger.error({
46
29
  err,
@@ -4,8 +4,10 @@
4
4
  * Cross-platform replacement for build_for_lambda.sh:
5
5
  * 1. Copies .next/static to .next/standalone/.next/static
6
6
  * 2. Copies run.sh to .next/standalone/run.sh
7
- * 3. Copies public/ to .next/standalone/public/ (if exists)
8
- * 4. Creates lambda.zip from .next/standalone/
7
+ * 3. Creates lambda.zip from .next/standalone/
8
+ *
9
+ * Note: public/ is NOT included in the zip — those files are uploaded
10
+ * as static assets and served via CDN, not from the Lambda function.
9
11
  *
10
12
  * @param projectPath - Path to project root
11
13
  * @throws Error if standalone directory missing or zip creation fails
@@ -8,15 +8,16 @@ import path from 'path';
8
8
  * Cross-platform replacement for build_for_lambda.sh:
9
9
  * 1. Copies .next/static to .next/standalone/.next/static
10
10
  * 2. Copies run.sh to .next/standalone/run.sh
11
- * 3. Copies public/ to .next/standalone/public/ (if exists)
12
- * 4. Creates lambda.zip from .next/standalone/
11
+ * 3. Creates lambda.zip from .next/standalone/
12
+ *
13
+ * Note: public/ is NOT included in the zip — those files are uploaded
14
+ * as static assets and served via CDN, not from the Lambda function.
13
15
  *
14
16
  * @param projectPath - Path to project root
15
17
  * @throws Error if standalone directory missing or zip creation fails
16
18
  */ export async function buildLambdaZip(projectPath) {
17
19
  const standalonePath = path.join(projectPath, '.next', 'standalone');
18
20
  const staticPath = path.join(projectPath, '.next', 'static');
19
- const publicPath = path.join(projectPath, 'public');
20
21
  const runShPath = path.join(projectPath, 'run.sh');
21
22
  const zipPath = path.join(projectPath, 'lambda.zip');
22
23
  if (!await isDirectory(standalonePath)) {
@@ -31,10 +32,6 @@ import path from 'path';
31
32
  } catch {
32
33
  // run.sh may not exist in all setups
33
34
  }
34
- if (await isDirectory(publicPath)) {
35
- const destPublic = path.join(standalonePath, 'public');
36
- await copyDirectory(publicPath, destPublic);
37
- }
38
35
  await createZip(standalonePath, zipPath);
39
36
  }
40
37
  async function isDirectory(dirPath) {
@@ -17,7 +17,7 @@ NODE_ENV=production exec node server.js
17
17
  const runShPath = path.join(projectPath, 'run.sh');
18
18
  await fs.writeFile(runShPath, RUN_SH_CONTENT, 'utf-8');
19
19
  await fs.chmod(runShPath, 0o755);
20
- // 2. Modify next config to add standalone output and eslint ignore
20
+ // 2. Modify next config to add standalone output
21
21
  await addNextConfigProperties(projectPath);
22
22
  // 3. Create/update .npmrc
23
23
  const npmrcPath = path.join(projectPath, '.npmrc');
@@ -58,7 +58,6 @@ NODE_ENV=production exec node server.js
58
58
  /**
59
59
  * Add required Next.js config properties using ts-morph AST parsing
60
60
  * - output: 'standalone' (for Lambda deployment)
61
- * - eslint: { ignoreDuringBuilds: true } (allow builds with lint errors)
62
61
  */ async function addNextConfigProperties(projectPath) {
63
62
  const nextConfigPath = await findNextConfigPath(projectPath);
64
63
  const project = new Project({
@@ -76,24 +75,14 @@ NODE_ENV=production exec node server.js
76
75
  if (!configObject) {
77
76
  throw new Error(`Could not find Next.js config object in ${path.basename(nextConfigPath)}. ` + `Expected either 'export default { ... }' or 'export default wrapper(configVar, ...)'`);
78
77
  }
79
- // Check which properties already exist using AST (avoids false positives from comments)
80
78
  const hasOutput = configObject.getProperty('output') !== undefined;
81
- const hasEslint = configObject.getProperty('eslint') !== undefined;
82
- if (hasOutput && hasEslint) {
79
+ if (hasOutput) {
83
80
  return;
84
81
  }
85
- if (!hasOutput) {
86
- configObject.addPropertyAssignment({
87
- name: 'output',
88
- initializer: "'standalone'"
89
- });
90
- }
91
- if (!hasEslint) {
92
- configObject.addPropertyAssignment({
93
- name: 'eslint',
94
- initializer: `{ ignoreDuringBuilds: true }`
95
- });
96
- }
82
+ configObject.addPropertyAssignment({
83
+ name: 'output',
84
+ initializer: "'standalone'"
85
+ });
97
86
  await sourceFile.save();
98
87
  }
99
88
  /**
@@ -59,7 +59,7 @@ export function helpMessage() {
59
59
 
60
60
  ${pc.bold('GLOBAL OPTIONS')}
61
61
 
62
- ${pc.dim('--infra-env <env>')} Target infrastructure (production or staging)
62
+ ${pc.dim('--infra-env <env>')} Target infrastructure (production, staging, or devbox)
63
63
 
64
64
  ${pc.bold('DOCUMENTATION')}
65
65
 
@@ -9,9 +9,9 @@ import * as log from './log.js';
9
9
  hasOtherPayloadImports: false,
10
10
  needsImportChange: false
11
11
  };
12
- // Find buildConfig import
12
+ // Find buildConfig import — prefer @payloadcms/figma (already migrated) over payload
13
13
  const imports = sourceFile.getImportDeclarations();
14
- const payloadImport = imports.find((imp)=>imp.getModuleSpecifierValue() === 'payload' || imp.getModuleSpecifierValue() === '@payloadcms/figma');
14
+ const payloadImport = imports.find((imp)=>imp.getModuleSpecifierValue() === '@payloadcms/figma') || imports.find((imp)=>imp.getModuleSpecifierValue() === 'payload');
15
15
  if (!payloadImport) {
16
16
  log.debug('No payload or @payloadcms/figma import found');
17
17
  result.hasBuildConfig = false;
@@ -157,9 +157,27 @@ import * as log from './log.js';
157
157
  // Remove duplicates and sort in reverse order to avoid position shifts
158
158
  const uniqueRanges = Array.from(new Set(ranges.map((r)=>JSON.stringify(r)))).map((r)=>JSON.parse(r));
159
159
  uniqueRanges.sort((a, b)=>b[0] - a[0]);
160
- // Remove each comment range
160
+ // Remove each comment range, extending to the full line when the comment is the only content
161
161
  for (const [pos, end] of uniqueRanges){
162
- sourceFile.removeText(pos, end);
162
+ const fullText = sourceFile.getFullText();
163
+ let removeStart = pos;
164
+ let removeEnd = end;
165
+ // Find the start of the line containing this comment
166
+ let lineStart = pos;
167
+ while(lineStart > 0 && fullText[lineStart - 1] !== '\n'){
168
+ lineStart--;
169
+ }
170
+ const beforeComment = fullText.substring(lineStart, pos);
171
+ const nextNewline = fullText.indexOf('\n', end);
172
+ const afterComment = fullText.substring(end, nextNewline === -1 ? fullText.length : nextNewline);
173
+ // If the comment is the only content on this line, remove the entire line
174
+ if (beforeComment.trim() === '' && afterComment.trim() === '') {
175
+ removeStart = lineStart > 0 ? lineStart - 1 : lineStart;
176
+ if (nextNewline !== -1) {
177
+ removeEnd = nextNewline + 1;
178
+ }
179
+ }
180
+ sourceFile.removeText(removeStart, removeEnd);
163
181
  }
164
182
  return true;
165
183
  }
@@ -21,7 +21,7 @@ export async function checkForUpdates(currentVersion) {
21
21
  if (!latestVersion || latestVersion === currentVersion) {
22
22
  return;
23
23
  }
24
- p.log.warn(pc.yellow(`@payloadcms/figma ${currentVersion} is outdated. Latest: ${latestVersion}`));
24
+ p.log.warn(pc.yellow(`@payloadcms/figma ${currentVersion} differs from latest. Latest: ${latestVersion}`));
25
25
  p.log.message(pc.dim(` Run: npx @payloadcms/figma@${latestVersion} init --id <your-id>`));
26
26
  } catch {
27
27
  // Silent catch - network errors, timeouts, parse errors should not interrupt CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payloadcms/figma",
3
- "version": "0.0.1-alpha.60",
3
+ "version": "0.0.1-alpha.62",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,26 +32,26 @@
32
32
  "@sindresorhus/slugify": "^3.0.0",
33
33
  "archiver": "7.0.1",
34
34
  "arg": "^5.0.2",
35
- "conf": "^13.0.1",
35
+ "conf": "^13.1.0",
36
36
  "cross-spawn": "7.0.6",
37
37
  "figures": "^6.1.0",
38
38
  "jose": "6.0.12",
39
39
  "jsonwebtoken": "9.0.3",
40
- "open": "^10.1.0",
40
+ "open": "^10.2.0",
41
41
  "openapi-fetch": "0.15.0",
42
42
  "picocolors": "1.1.1",
43
- "tar": "^7.4.3",
43
+ "tar": "^7.5.13",
44
44
  "terminal-link": "^5.0.0",
45
- "ts-morph": "^21.0.0",
45
+ "ts-morph": "^21.0.1",
46
46
  "uuid": "^10.0.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/archiver": "7.0.0",
50
50
  "@types/cross-spawn": "6.0.6",
51
51
  "@types/node": "22.12.0",
52
- "openapi-typescript": "^7.10.1",
52
+ "openapi-typescript": "^7.13.0",
53
53
  "tsx": "4.20.6",
54
- "typescript": "5.9.3",
54
+ "typescript": "5.7.3",
55
55
  "vitest": "4.0.15"
56
56
  },
57
57
  "peerDependencies": {