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

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.
@@ -56,7 +56,7 @@ async function syncCollections() {
56
56
  }
57
57
  }
58
58
  }
59
- async function init() {
59
+ function init() {
60
60
  if (this.auth.mode === 'apiKey') {
61
61
  this.payload.logger.info('Using API Key authentication');
62
62
  } else if (this.auth.mode === 'tokenStore') {
@@ -64,8 +64,15 @@ async function init() {
64
64
  } else {
65
65
  this.payload.logger.info('Using Dev JWT authentication (testing)');
66
66
  }
67
- if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
67
+ }
68
+ // connect is called after init — once on first startup (hotReload=false) and again on HMR
69
+ // (hotReload=true). Only clear the database on the first startup, never on hot reload.
70
+ async function connect(args) {
71
+ const hotReload = args?.hotReload ?? false;
72
+ if (!hotReload && process.env.PAYLOAD_DROP_DATABASE === 'true') {
73
+ // clearDatabase already calls syncCollections internally after clearing
68
74
  await this.clearDatabase();
75
+ return;
69
76
  }
70
77
  await syncCollections.call(this);
71
78
  }
@@ -99,7 +106,7 @@ async function findMany({ collection, joins, limit, locale: localeArg, page, pag
99
106
  body: {
100
107
  collection,
101
108
  contentSystemId: this.contentSystemId,
102
- join: convertPayloadJoinsToContentAPI(this.payload, collection, joins),
109
+ join: convertPayloadJoinsToContentAPI(this.payload, collection, joins, locale),
103
110
  limit,
104
111
  locale,
105
112
  page: page ?? 1,
@@ -645,13 +652,131 @@ async function upsert(args) {
645
652
  payload: this.payload
646
653
  });
647
654
  }
655
+ // Mirrors MAX_DOCUMENT_UPDATES in the content API's updateDocuments.ts.
656
+ // updateJobs may be called with larger limits (e.g. 150), so we batch sequentially.
657
+ const CONTENT_API_MAX_UPDATES = 20;
658
+ // Issues a single update request against payload-jobs and returns the unwrapped docs.
659
+ async function updateJobsBatch({ batchSize, docData, meta, sortClause, whereQuery }) {
660
+ const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
661
+ body: {
662
+ collection: 'payload-jobs',
663
+ contentSystemId: this.contentSystemId,
664
+ createOnMissing: false,
665
+ doc: docData,
666
+ ...batchSize != null && {
667
+ limit: batchSize
668
+ },
669
+ returning: {},
670
+ sort: sortClause,
671
+ where: whereQuery,
672
+ ...meta
673
+ }
674
+ });
675
+ if (error) {
676
+ throw new Error(`Content API updateJobs error: ${JSON.stringify(error)}`);
677
+ }
678
+ if (!response) {
679
+ throw new Error('No response from updateJobs');
680
+ }
681
+ if (!response.result || !('data' in response.result)) {
682
+ return [];
683
+ }
684
+ return response.result.data.map((doc)=>unwrapDocument({
685
+ collectionSlug: 'payload-jobs',
686
+ doc,
687
+ payload: this.payload
688
+ }));
689
+ }
690
+ async function updateJobs(args) {
691
+ const { id, limit, returning, sort, where } = args;
692
+ if (id == null && where == null) {
693
+ throw new Error('updateJobs requires either id or a where clause');
694
+ }
695
+ // Copy data to avoid mutating the caller's object.
696
+ const data = {
697
+ ...args.data
698
+ };
699
+ // Strip log if empty/absent. The content API's deep merge replaces arrays entirely
700
+ // (it does not concatenate), so sending log: [] would wipe existing log entries.
701
+ // Only include log when it carries new content: a non-empty array or a $push operation.
702
+ const log = data.log;
703
+ if (!(Array.isArray(log) && log.length > 0) && !(log && typeof log === 'object' && '$push' in log)) {
704
+ delete data.log;
705
+ }
706
+ const whereClause = id != null ? {
707
+ id: {
708
+ equals: String(id)
709
+ }
710
+ } : where;
711
+ const docData = dataToContentAPI(this.payload, 'payload-jobs', data);
712
+ const sortClause = addFallbackSort(sort || '-updatedAt', this.payload, 'payload-jobs');
713
+ const whereQuery = convertPayloadWhereToContentAPI(whereClause);
714
+ const meta = buildMeta(this.payload, {
715
+ collection: 'payload-jobs',
716
+ locale: undefined,
717
+ where: whereClause
718
+ });
719
+ // When limit exceeds the content API's per-request cap, batch sequentially.
720
+ const needsBatching = limit != null && limit > CONTENT_API_MAX_UPDATES;
721
+ if (!needsBatching) {
722
+ const docs = await updateJobsBatch.call(this, {
723
+ batchSize: limit,
724
+ docData,
725
+ meta,
726
+ sortClause,
727
+ whereQuery
728
+ });
729
+ if (returning === false) {
730
+ return null;
731
+ }
732
+ return docs;
733
+ }
734
+ // Batched path: issue sequential requests of ≤ CONTENT_API_MAX_UPDATES each.
735
+ // This relies on the update itself causing matched jobs to no longer satisfy the where
736
+ // clause on subsequent batches (e.g. setting processing: true removes them from a
737
+ // "processing: false" query). If a future updateJobs call in Payload core updates jobs
738
+ // in a way that does NOT change their match status, the same jobs could be updated
739
+ // multiple times across batches.
740
+ const allDocs = [];
741
+ let remaining = limit;
742
+ while(remaining > 0){
743
+ const batchSize = Math.min(remaining, CONTENT_API_MAX_UPDATES);
744
+ const batchDocs = await updateJobsBatch.call(this, {
745
+ batchSize,
746
+ docData,
747
+ meta,
748
+ sortClause,
749
+ whereQuery
750
+ });
751
+ allDocs.push(...batchDocs);
752
+ remaining -= batchSize;
753
+ // Stop early when this batch returned fewer docs than requested — no more matching docs.
754
+ if (batchDocs.length < batchSize) {
755
+ break;
756
+ }
757
+ }
758
+ if (returning === false) {
759
+ return null;
760
+ }
761
+ return allDocs;
762
+ }
648
763
  function createGlobal(args) {
649
- return this.create({
764
+ // Globals are singletons identified by their slug. Use upsert so concurrent calls
765
+ // (e.g. handleSchedules processing multiple queueables with the same null jobStats)
766
+ // don't fail with "A record with this field already exists". The deep-merge semantics
767
+ // of the update path are safe here: callers that want a fresh create (first run) get
768
+ // an effective create, and callers that race a prior create get a merge instead.
769
+ return this.upsert({
650
770
  collection: getGlobalSlug(args.slug),
651
771
  data: {
652
772
  ...args.data,
653
773
  id: args.slug,
654
774
  globalType: args.slug
775
+ },
776
+ where: {
777
+ id: {
778
+ equals: args.slug
779
+ }
655
780
  }
656
781
  });
657
782
  }
@@ -749,6 +874,7 @@ export const contentAPIAdapter = (opts)=>({
749
874
  clearDatabase: clearDatabase,
750
875
  client,
751
876
  commitTransaction: async ()=>{},
877
+ connect: connect,
752
878
  contentSystemId: opts.contentSystemId,
753
879
  count: count,
754
880
  countGlobalVersions: countGlobalVersions,
@@ -775,6 +901,7 @@ export const contentAPIAdapter = (opts)=>({
775
901
  rollbackTransaction: async ()=>{},
776
902
  updateGlobal: updateGlobal,
777
903
  updateGlobalVersion: updateGlobalVersion,
904
+ updateJobs: updateJobs,
778
905
  updateMany: updateMany,
779
906
  updateOne: updateOne,
780
907
  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) {
@@ -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.
@@ -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
  /**
@@ -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
  }
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.61",
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": {