@directus/api 33.1.0 → 33.2.0

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.
@@ -25,6 +25,7 @@ function formatVisualElement(att) {
25
25
  const display = escapeAngleBrackets(att.display);
26
26
  return `### ${collection}/${item} — "${display}"
27
27
  Editable fields: ${fields}
28
+ To update: items tool with collection="${collection}", keys=["${item}"], action="update"
28
29
  \`\`\`json
29
30
  ${escapeAngleBrackets(JSON.stringify(att.snapshot, null, 2))}
30
31
  \`\`\``;
@@ -106,6 +107,10 @@ ${itemLines}`);
106
107
  ## Selected Elements
107
108
  The user selected these elements for editing in the visual editor.
108
109
 
110
+ IMPORTANT: For visual editor elements, ALWAYS use the items tool:
111
+ - To UPDATE: items tool with action: 'update', collection, keys, and data
112
+ - NEVER use form-values tools for visual editor elements
113
+
109
114
  ${elementLines}
110
115
  </visual_editing>`);
111
116
  }
package/dist/app.js CHANGED
@@ -103,7 +103,10 @@ export default async function createApp() {
103
103
  const app = express();
104
104
  app.disable('x-powered-by');
105
105
  app.set('trust proxy', env['IP_TRUST_PROXY']);
106
- app.set('query parser', (str) => qs.parse(str, { depth: Number(env['QUERYSTRING_MAX_PARSE_DEPTH']) }));
106
+ app.set('query parser', (str) => qs.parse(str, {
107
+ depth: Number(env['QUERYSTRING_MAX_PARSE_DEPTH']),
108
+ arrayLimit: Number(env['QUERYSTRING_ARRAY_LIMIT']),
109
+ }));
107
110
  if (env['PRESSURE_LIMITER_ENABLED']) {
108
111
  const sampleInterval = Number(env['PRESSURE_LIMITER_SAMPLE_INTERVAL']);
109
112
  if (Number.isNaN(sampleInterval) === true || Number.isFinite(sampleInterval) === false) {
@@ -28,7 +28,8 @@ router.post('/folder/:pk', asyncHandler(async (req, res) => {
28
28
  });
29
29
  const { archive, complete, metadata } = await service.zipFolder(req.params['pk']);
30
30
  res.setHeader('Content-Type', 'application/zip');
31
- res.setHeader('Content-Disposition', `attachment; filename="folder-${metadata['name'] ? metadata['name'] : 'unknown'}-${getDateTimeFormatted()}.zip"`);
31
+ const folderName = `folder-${metadata['name'] ? metadata['name'] : 'unknown'}-${getDateTimeFormatted()}.zip`;
32
+ res.setHeader('Content-Disposition', contentDisposition(folderName, { type: 'attachment' }));
32
33
  archive.pipe(res);
33
34
  await complete();
34
35
  }));
@@ -31,6 +31,7 @@ export class FnHelper extends DatabaseHelper {
31
31
  };
32
32
  countQuery = applyFilter(this.knex, this.schema, countQuery, options.relationalCountOptions.query.filter, relation.collection, aliasMap, options.relationalCountOptions.cases, options.relationalCountOptions.permissions).query;
33
33
  }
34
- return this.knex.raw('(' + countQuery.toQuery() + ')');
34
+ const { sql, bindings } = countQuery.toSQL();
35
+ return this.knex.raw(`(${sql})`, bindings);
35
36
  }
36
37
  }
@@ -1 +1,2 @@
1
+ export * from './netlify.js';
1
2
  export * from './vercel.js';
@@ -1 +1,2 @@
1
+ export * from './netlify.js';
1
2
  export * from './vercel.js';
@@ -0,0 +1,33 @@
1
+ import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
2
+ import { DeploymentDriver } from '../deployment.js';
3
+ export interface NetlifyCredentials extends Credentials {
4
+ access_token: string;
5
+ }
6
+ export interface NetlifyOptions extends Options {
7
+ account_slug?: string;
8
+ }
9
+ export declare class NetlifyDriver extends DeploymentDriver<NetlifyCredentials, NetlifyOptions> {
10
+ private api;
11
+ constructor(credentials: NetlifyCredentials, options?: NetlifyOptions);
12
+ private handleApiError;
13
+ private mapStatus;
14
+ testConnection(): Promise<void>;
15
+ private mapSiteBase;
16
+ listProjects(): Promise<Project[]>;
17
+ getProject(projectId: string): Promise<Project>;
18
+ private mapDeployUrl;
19
+ listDeployments(projectId: string, limit?: number): Promise<Deployment[]>;
20
+ getDeployment(deploymentId: string): Promise<Details>;
21
+ triggerDeployment(projectId: string, options?: {
22
+ preview?: boolean;
23
+ clearCache?: boolean;
24
+ }): Promise<TriggerResult>;
25
+ cancelDeployment(deploymentId: string): Promise<void>;
26
+ private closeWsConnection;
27
+ private setupWsIdleTimeout;
28
+ private setupWsConnectionTimeout;
29
+ private getWsConnection;
30
+ getDeploymentLogs(deploymentId: string, options?: {
31
+ since?: Date;
32
+ }): Promise<Log[]>;
33
+ }
@@ -0,0 +1,260 @@
1
+ import { InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors';
2
+ import { NetlifyAPI } from '@netlify/api';
3
+ import { isNumber } from 'lodash-es';
4
+ import { DeploymentDriver } from '../deployment.js';
5
+ const WS_CONNECTIONS = new Map();
6
+ const WS_IDLE_TIMEOUT = 60_000; // 60 seconds
7
+ const WS_CONNECTION_TIMEOUT = 10_000; // 10 seconds
8
+ // eslint-disable-next-line no-control-regex
9
+ const ANSI_REGEX = /[\x1b]\[[0-9;]*m/g;
10
+ const WS_URL = 'wss://socketeer.services.netlify.com/build/logs';
11
+ export class NetlifyDriver extends DeploymentDriver {
12
+ api;
13
+ constructor(credentials, options = {}) {
14
+ super(credentials, options);
15
+ this.api = new NetlifyAPI(this.credentials.access_token);
16
+ }
17
+ async handleApiError(cb) {
18
+ try {
19
+ return await cb(this.api);
20
+ }
21
+ catch (error) {
22
+ if (error instanceof Error && 'status' in error && isNumber(error.status) && error.status >= 400) {
23
+ if (error.status === 401 || error.status === 403) {
24
+ throw new InvalidCredentialsError();
25
+ }
26
+ throw new ServiceUnavailableError({ service: 'netlify', reason: 'Netlify API error: ' + error.message });
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ mapStatus(netlifyState) {
32
+ const normalized = netlifyState?.toLowerCase();
33
+ switch (normalized) {
34
+ case 'ready':
35
+ return 'ready';
36
+ case 'error':
37
+ return 'error';
38
+ case 'canceled':
39
+ return 'canceled';
40
+ default:
41
+ return 'building';
42
+ }
43
+ }
44
+ async testConnection() {
45
+ await this.handleApiError((api) => api.listSites({ per_page: 1 }));
46
+ }
47
+ mapSiteBase(site) {
48
+ const result = {
49
+ id: site.id,
50
+ name: site.name,
51
+ deployable: Boolean(site.build_settings?.provider && site.build_settings?.repo_url),
52
+ };
53
+ // Use custom domain if available, otherwise ssl_url or url
54
+ if (site.custom_domain) {
55
+ result.url = `https://${site.custom_domain}`;
56
+ }
57
+ else if (site.ssl_url) {
58
+ result.url = site.ssl_url;
59
+ }
60
+ else if (site.url) {
61
+ result.url = site.url;
62
+ }
63
+ return result;
64
+ }
65
+ async listProjects() {
66
+ const params = { per_page: '100' };
67
+ const response = await this.handleApiError((api) => {
68
+ return this.options.account_slug
69
+ ? api.listSitesForAccount({
70
+ account_slug: this.options.account_slug,
71
+ ...params,
72
+ })
73
+ : api.listSites(params);
74
+ });
75
+ return response.map((site) => this.mapSiteBase(site));
76
+ }
77
+ async getProject(projectId) {
78
+ const site = await this.handleApiError((api) => api.getSite({ siteId: projectId }));
79
+ const result = this.mapSiteBase(site);
80
+ // Add published deploy info if available
81
+ if (site.published_deploy) {
82
+ const deploy = site.published_deploy;
83
+ if (deploy.state && deploy.created_at) {
84
+ result.latest_deployment = {
85
+ status: this.mapStatus(deploy.state),
86
+ created_at: new Date(deploy.created_at),
87
+ ...(deploy.published_at && { finished_at: new Date(deploy.published_at) }),
88
+ };
89
+ }
90
+ }
91
+ if (site.created_at) {
92
+ result.created_at = new Date(site.created_at);
93
+ }
94
+ if (site.updated_at) {
95
+ result.updated_at = new Date(site.updated_at);
96
+ }
97
+ return result;
98
+ }
99
+ mapDeployUrl(deploy) {
100
+ return deploy['ssl_url'] ?? deploy['deploy_ssl_url'] ?? deploy['deploy_url'] ?? deploy['url'];
101
+ }
102
+ async listDeployments(projectId, limit = 20) {
103
+ const response = await this.handleApiError((api) => api.listSiteDeploys({ site_id: projectId, per_page: limit }));
104
+ return response.map((deploy) => {
105
+ const result = {
106
+ id: deploy.id,
107
+ project_id: deploy.site_id,
108
+ status: this.mapStatus(deploy.state),
109
+ created_at: new Date(deploy.created_at),
110
+ };
111
+ const url = this.mapDeployUrl(deploy);
112
+ if (url)
113
+ result.url = url;
114
+ if (deploy.published_at) {
115
+ result.finished_at = new Date(deploy.published_at);
116
+ }
117
+ if (deploy.error_message) {
118
+ result.error_message = deploy.error_message;
119
+ }
120
+ return result;
121
+ });
122
+ }
123
+ async getDeployment(deploymentId) {
124
+ const deploy = await this.handleApiError((api) => api.getDeploy({ deployId: deploymentId }));
125
+ const result = {
126
+ id: deploy.id,
127
+ project_id: deploy.site_id,
128
+ status: this.mapStatus(deploy.state),
129
+ created_at: new Date(deploy.created_at),
130
+ };
131
+ const url = this.mapDeployUrl(deploy);
132
+ if (url)
133
+ result.url = url;
134
+ if (deploy.published_at) {
135
+ result.finished_at = new Date(deploy.published_at);
136
+ }
137
+ if (deploy.error_message) {
138
+ result.error_message = deploy.error_message;
139
+ }
140
+ return result;
141
+ }
142
+ async triggerDeployment(projectId, options) {
143
+ // Netlify builds endpoint returns a Build object with deploy_id and deploy_state
144
+ const buildResponse = await this.handleApiError((api) => api.createSiteBuild({
145
+ site_id: projectId,
146
+ clear_cache: options?.clearCache || false,
147
+ }));
148
+ const deployState = await this.handleApiError((api) => api.getDeploy({ deployId: buildResponse.deploy_id }));
149
+ const triggerResult = {
150
+ deployment_id: buildResponse.deploy_id,
151
+ status: this.mapStatus(deployState.state),
152
+ };
153
+ return triggerResult;
154
+ }
155
+ async cancelDeployment(deploymentId) {
156
+ await this.handleApiError((api) => api.cancelSiteDeploy({ deployId: deploymentId }));
157
+ this.closeWsConnection(deploymentId);
158
+ }
159
+ closeWsConnection(deploymentId, remove = true) {
160
+ const connection = WS_CONNECTIONS.get(deploymentId);
161
+ if (!connection)
162
+ return;
163
+ connection.ws.close();
164
+ if (remove) {
165
+ WS_CONNECTIONS.delete(deploymentId);
166
+ }
167
+ }
168
+ setupWsIdleTimeout(connection) {
169
+ if (connection.idleTimeout) {
170
+ clearTimeout(connection.idleTimeout);
171
+ }
172
+ connection.idleTimeout = setTimeout(() => {
173
+ this.closeWsConnection(connection.deploymentId);
174
+ }, WS_IDLE_TIMEOUT);
175
+ }
176
+ setupWsConnectionTimeout(connection, reject) {
177
+ if (connection.connectionTimeout) {
178
+ clearTimeout(connection.connectionTimeout);
179
+ }
180
+ connection.connectionTimeout = setTimeout(() => {
181
+ this.closeWsConnection(connection.deploymentId);
182
+ reject(new ServiceUnavailableError({ service: 'netlify', reason: 'WebSocket connection timeout' }));
183
+ }, WS_CONNECTION_TIMEOUT);
184
+ }
185
+ getWsConnection(deploymentId) {
186
+ return new Promise((resolve, reject) => {
187
+ const existingConnection = WS_CONNECTIONS.get(deploymentId);
188
+ if (existingConnection) {
189
+ this.setupWsIdleTimeout(existingConnection);
190
+ return resolve(existingConnection);
191
+ }
192
+ let resolveCompleted;
193
+ const completed = new Promise((res) => {
194
+ resolveCompleted = res;
195
+ });
196
+ const connection = {
197
+ ws: new WebSocket(WS_URL),
198
+ logs: [],
199
+ deploymentId,
200
+ completed,
201
+ resolveCompleted: resolveCompleted,
202
+ };
203
+ this.setupWsConnectionTimeout(connection, reject);
204
+ connection.ws.addEventListener('open', () => {
205
+ if (connection.connectionTimeout) {
206
+ clearTimeout(connection.connectionTimeout);
207
+ connection.connectionTimeout = undefined;
208
+ }
209
+ this.setupWsIdleTimeout(connection);
210
+ const payload = JSON.stringify({
211
+ deploy_id: deploymentId,
212
+ access_token: this.credentials.access_token,
213
+ });
214
+ connection.ws.send(payload);
215
+ resolve(connection);
216
+ WS_CONNECTIONS.set(deploymentId, connection);
217
+ });
218
+ connection.ws.addEventListener('message', (event) => {
219
+ const data = JSON.parse(event.data);
220
+ const cleanMessage = data.message.replace(/\r/g, '').replace(ANSI_REGEX, '');
221
+ let logType = 'stdout';
222
+ if (data.type === 'report') {
223
+ logType = cleanMessage.includes('Failing build') ? 'stderr' : 'info';
224
+ }
225
+ connection.logs.push({
226
+ timestamp: new Date(data.ts),
227
+ type: logType,
228
+ message: cleanMessage,
229
+ });
230
+ // If we receive a "report" type message, the build is complete.
231
+ // Close the WebSocket connection but don't yet remove the logs, allowing the client to fetch them until the idle timeout expires.
232
+ if (data.type === 'report') {
233
+ connection.resolveCompleted();
234
+ this.closeWsConnection(deploymentId, false);
235
+ }
236
+ });
237
+ connection.ws.addEventListener('error', () => {
238
+ this.closeWsConnection(deploymentId);
239
+ reject(new ServiceUnavailableError({ service: 'netlify', reason: 'WebSocket connection error' }));
240
+ });
241
+ connection.ws.addEventListener('close', () => {
242
+ if (connection.connectionTimeout) {
243
+ clearTimeout(connection.connectionTimeout);
244
+ }
245
+ });
246
+ });
247
+ }
248
+ async getDeploymentLogs(deploymentId, options) {
249
+ const deploy = await this.handleApiError((api) => api.getDeploy({ deployId: deploymentId }));
250
+ const connection = await this.getWsConnection(deploymentId);
251
+ // Build already finished — WS is replaying logs, wait for all of them
252
+ if (this.mapStatus(deploy.state) !== 'building') {
253
+ await connection.completed;
254
+ }
255
+ if (options?.since) {
256
+ return connection.logs.filter((log) => log.timestamp >= options.since);
257
+ }
258
+ return connection.logs;
259
+ }
260
+ }
@@ -1,4 +1,4 @@
1
- import { VercelDriver } from './deployment/drivers/index.js';
1
+ import { NetlifyDriver, VercelDriver } from './deployment/drivers/index.js';
2
2
  /**
3
3
  * Registry of deployment driver constructors
4
4
  */
@@ -8,6 +8,7 @@ const drivers = new Map();
8
8
  */
9
9
  export function registerDeploymentDrivers() {
10
10
  drivers.set('vercel', VercelDriver);
11
+ drivers.set('netlify', NetlifyDriver);
11
12
  }
12
13
  /**
13
14
  * Get a deployment driver instance
@@ -80,10 +80,15 @@ function sanitizeFields(rawFields) {
80
80
  if (!rawFields)
81
81
  return null;
82
82
  let fields = [];
83
- if (typeof rawFields === 'string')
83
+ if (typeof rawFields === 'string') {
84
84
  fields = rawFields.split(',');
85
- else if (Array.isArray(rawFields))
85
+ }
86
+ else if (Array.isArray(rawFields)) {
86
87
  fields = rawFields;
88
+ }
89
+ else {
90
+ throw new InvalidQueryError({ reason: '"fields" must be a string or array' });
91
+ }
87
92
  // Case where array item includes CSV (fe fields[]=id,name):
88
93
  fields = flatten(fields.map((field) => (field.includes(',') ? field.split(',') : field)));
89
94
  fields = fields.map((field) => field.trim());
@@ -11,8 +11,19 @@ export function resolvePreset({ transformationParams, acceptFormat }, file) {
11
11
  ]);
12
12
  }
13
13
  if ((transformationParams.width || transformationParams.height) && file.width && file.height) {
14
- const toWidth = transformationParams.width ? Number(transformationParams.width) : undefined;
15
- const toHeight = transformationParams.height ? Number(transformationParams.height) : undefined;
14
+ let toWidth = transformationParams.width ? Number(transformationParams.width) : undefined;
15
+ let toHeight = transformationParams.height ? Number(transformationParams.height) : undefined;
16
+ /*
17
+ * When withoutEnlargement is true, clamp target dimensions to original dimensions to prevent "bad extract area" errors when using focal points.
18
+ */
19
+ if (transformationParams.withoutEnlargement) {
20
+ if (toWidth !== undefined) {
21
+ toWidth = Math.min(toWidth, file.width);
22
+ }
23
+ if (toHeight !== undefined) {
24
+ toHeight = Math.min(toHeight, file.height);
25
+ }
26
+ }
16
27
  const toFocalPointX = transformationParams.focal_point_x
17
28
  ? Number(transformationParams.focal_point_x)
18
29
  : file.focal_point_x;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "33.1.0",
3
+ "version": "33.2.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -66,6 +66,7 @@
66
66
  "@aws-sdk/client-sesv2": "3.928.0",
67
67
  "@godaddy/terminus": "4.12.1",
68
68
  "@modelcontextprotocol/sdk": "1.26.0",
69
+ "@netlify/api": "14.0.14",
69
70
  "@rollup/plugin-alias": "5.1.1",
70
71
  "@rollup/plugin-node-resolve": "16.0.3",
71
72
  "@rollup/plugin-virtual": "3.0.2",
@@ -161,30 +162,30 @@
161
162
  "zod": "4.1.12",
162
163
  "zod-validation-error": "4.0.2",
163
164
  "@directus/ai": "1.1.0",
164
- "@directus/app": "15.1.0",
165
- "@directus/env": "5.5.0",
166
- "@directus/extensions": "3.0.17",
167
- "@directus/constants": "14.0.0",
165
+ "@directus/env": "5.5.2",
166
+ "@directus/app": "15.2.0",
168
167
  "@directus/errors": "2.2.0",
169
- "@directus/extensions-registry": "3.0.17",
170
- "@directus/memory": "3.1.0",
168
+ "@directus/extensions": "3.0.18",
169
+ "@directus/extensions-registry": "3.0.18",
170
+ "@directus/extensions-sdk": "17.0.8",
171
171
  "@directus/format-title": "12.1.1",
172
- "@directus/pressure": "3.0.15",
172
+ "@directus/memory": "3.1.1",
173
+ "@directus/pressure": "3.0.16",
173
174
  "@directus/schema": "13.0.5",
174
- "@directus/extensions-sdk": "17.0.6",
175
- "@directus/schema-builder": "0.0.12",
175
+ "@directus/schema-builder": "0.0.13",
176
176
  "@directus/specs": "12.0.0",
177
177
  "@directus/storage": "12.0.3",
178
- "@directus/storage-driver-azure": "12.0.15",
179
- "@directus/storage-driver-s3": "12.1.1",
180
- "@directus/storage-driver-gcs": "12.0.15",
178
+ "@directus/storage-driver-azure": "12.0.16",
179
+ "@directus/storage-driver-cloudinary": "12.0.16",
181
180
  "@directus/storage-driver-local": "12.0.3",
182
- "@directus/storage-driver-supabase": "3.0.15",
181
+ "@directus/constants": "14.0.0",
182
+ "@directus/storage-driver-gcs": "12.0.16",
183
+ "@directus/storage-driver-supabase": "3.0.16",
183
184
  "@directus/system-data": "4.1.0",
184
- "@directus/utils": "13.2.0",
185
- "directus": "11.15.0",
186
- "@directus/validation": "2.0.15",
187
- "@directus/storage-driver-cloudinary": "12.0.15"
185
+ "@directus/utils": "13.2.1",
186
+ "@directus/storage-driver-s3": "12.1.2",
187
+ "@directus/validation": "2.0.16",
188
+ "directus": "11.15.2"
188
189
  },
189
190
  "devDependencies": {
190
191
  "@directus/tsconfig": "3.0.0",
@@ -227,8 +228,8 @@
227
228
  "knex-mock-client": "3.0.2",
228
229
  "typescript": "5.9.3",
229
230
  "vitest": "3.2.4",
230
- "@directus/schema-builder": "0.0.12",
231
- "@directus/types": "14.1.0"
231
+ "@directus/schema-builder": "0.0.13",
232
+ "@directus/types": "14.2.0"
232
233
  },
233
234
  "optionalDependencies": {
234
235
  "@keyv/redis": "3.0.1",