includio-cms 0.20.0 → 0.22.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.
Files changed (101) hide show
  1. package/API.md +22 -21
  2. package/CHANGELOG.md +147 -0
  3. package/DOCS.md +1 -1
  4. package/README.md +138 -32
  5. package/ROADMAP.md +11 -4
  6. package/dist/admin/api/rest/handler.d.ts +13 -1
  7. package/dist/admin/api/rest/handler.js +13 -1
  8. package/dist/admin/api/rest/middleware/apiKey.js +9 -1
  9. package/dist/admin/api/rest/middleware/generateApiKey.d.ts +16 -0
  10. package/dist/admin/api/rest/middleware/generateApiKey.js +19 -0
  11. package/dist/admin/client/collection/collection-entries.svelte +1 -1
  12. package/dist/admin/client/collection/empty-state.svelte +1 -1
  13. package/dist/admin/client/collection/row-actions.svelte +3 -3
  14. package/dist/admin/client/collection/table-toolbar.svelte +3 -1
  15. package/dist/admin/client/entry/entry-header.svelte +3 -1
  16. package/dist/admin/client/users/create-user-dialog.svelte +4 -4
  17. package/dist/admin/client/users/delete-user-dialog.svelte +4 -2
  18. package/dist/admin/client/users/lang.d.ts +10 -2
  19. package/dist/admin/client/users/lang.js +10 -4
  20. package/dist/admin/client/users/users-page.svelte +3 -2
  21. package/dist/admin/components/media/file-upload.svelte +2 -0
  22. package/dist/ai-claude/index.d.ts +9 -1
  23. package/dist/ai-claude/index.js +9 -1
  24. package/dist/ai-openai/index.d.ts +9 -1
  25. package/dist/ai-openai/index.js +9 -1
  26. package/dist/cli/index.js +115 -13
  27. package/dist/cms/runtime/schema.d.ts +2 -0
  28. package/dist/cms/runtime/schema.js +4 -0
  29. package/dist/cms/runtime/types.d.ts +1 -1
  30. package/dist/core/cms.d.ts +13 -1
  31. package/dist/core/cms.js +13 -1
  32. package/dist/core/errors.d.ts +71 -0
  33. package/dist/core/errors.js +179 -0
  34. package/dist/core/server/consentLogs/operations/create.d.ts +13 -1
  35. package/dist/core/server/consentLogs/operations/create.js +13 -1
  36. package/dist/core/server/entries/operations/create.js +6 -1
  37. package/dist/core/server/entries/operations/get.js +14 -3
  38. package/dist/core/server/entries/operations/resolveEntry.d.ts +32 -1
  39. package/dist/core/server/entries/operations/resolveEntry.js +36 -4
  40. package/dist/core/server/entries/operations/update.js +5 -1
  41. package/dist/core/server/fields/utils/resolveMedia.d.ts +18 -1
  42. package/dist/core/server/fields/utils/resolveMedia.js +13 -1
  43. package/dist/core/server/forms/submissions/operations/create.d.ts +21 -1
  44. package/dist/core/server/forms/submissions/operations/create.js +18 -2
  45. package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +15 -1
  46. package/dist/core/server/forms/submissions/utils/parseMultipart.js +15 -1
  47. package/dist/core/server/media/operations/uploadFile.js +4 -3
  48. package/dist/core/server/media/styles/sharp/generateImageStyle.js +3 -2
  49. package/dist/core/server/media/utils/generateAdminThumbnail.js +3 -2
  50. package/dist/core/server/media/utils/generateBlurDataUrl.js +2 -1
  51. package/dist/db-postgres/index.d.ts +10 -0
  52. package/dist/db-postgres/index.js +10 -0
  53. package/dist/email-nodemailer/index.d.ts +13 -1
  54. package/dist/email-nodemailer/index.js +13 -1
  55. package/dist/entity/index.d.ts +16 -1
  56. package/dist/entity/index.js +16 -1
  57. package/dist/files-local/index.d.ts +12 -1
  58. package/dist/files-local/index.js +12 -1
  59. package/dist/paraglide/messages/_index.d.ts +3 -36
  60. package/dist/paraglide/messages/_index.js +3 -71
  61. package/dist/paraglide/messages/hello_world.d.ts +5 -0
  62. package/dist/paraglide/messages/hello_world.js +33 -0
  63. package/dist/paraglide/messages/login_hello.d.ts +16 -0
  64. package/dist/paraglide/messages/login_hello.js +34 -0
  65. package/dist/paraglide/messages/login_please_login.d.ts +16 -0
  66. package/dist/paraglide/messages/login_please_login.js +34 -0
  67. package/dist/server/auth.d.ts +11 -0
  68. package/dist/server/auth.js +11 -0
  69. package/dist/server/security/csp.d.ts +16 -0
  70. package/dist/server/security/csp.js +33 -0
  71. package/dist/server/security/csrf.d.ts +13 -0
  72. package/dist/server/security/csrf.js +49 -0
  73. package/dist/server/security/index.d.ts +3 -0
  74. package/dist/server/security/index.js +3 -0
  75. package/dist/server/security/rate-limit.d.ts +44 -0
  76. package/dist/server/security/rate-limit.js +97 -0
  77. package/dist/server/utils/withTimeout.d.ts +21 -0
  78. package/dist/server/utils/withTimeout.js +37 -0
  79. package/dist/sveltekit/config.d.ts +67 -4
  80. package/dist/sveltekit/config.js +73 -4
  81. package/dist/sveltekit/server/handle.d.ts +15 -1
  82. package/dist/sveltekit/server/handle.js +22 -1
  83. package/dist/sveltekit/server/index.d.ts +1 -0
  84. package/dist/sveltekit/server/index.js +1 -0
  85. package/dist/sveltekit/server/layout.d.ts +12 -1
  86. package/dist/sveltekit/server/layout.js +12 -1
  87. package/dist/sveltekit/server/preview.d.ts +21 -1
  88. package/dist/sveltekit/server/preview.js +21 -1
  89. package/dist/types/cms.d.ts +4 -0
  90. package/dist/types/cms.schema.d.ts +452 -0
  91. package/dist/types/cms.schema.js +629 -0
  92. package/dist/updates/0.21.0/index.d.ts +2 -0
  93. package/dist/updates/0.21.0/index.js +55 -0
  94. package/dist/updates/0.22.0/index.d.ts +2 -0
  95. package/dist/updates/0.22.0/index.js +75 -0
  96. package/dist/updates/index.js +3 -1
  97. package/package.json +12 -2
  98. package/dist/paraglide/messages/en.d.ts +0 -5
  99. package/dist/paraglide/messages/en.js +0 -14
  100. package/dist/paraglide/messages/pl.d.ts +0 -5
  101. package/dist/paraglide/messages/pl.js +0 -14
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Base error class for CMS runtime failures. Carries a stable `code` and a
3
+ * `context` bag so callers (and clients) can branch on the failure mode without
4
+ * string-matching on `message`.
5
+ *
6
+ * `code` is a SCREAMING_SNAKE_CASE constant; see callers for the canonical list
7
+ * (`ENTRY_NOT_FOUND`, `ENTRY_VERSION_NOT_FOUND`, `INVALID_DATA`,
8
+ * `MISSING_REQUIRED_PARAM`, `CONFIG_VALIDATION_FAILED`).
9
+ *
10
+ * @public
11
+ * @example
12
+ * ```ts
13
+ * try { await resolveEntry({ id }); }
14
+ * catch (e) {
15
+ * if (e instanceof CmsError && e.code === 'ENTRY_NOT_FOUND') { ... }
16
+ * }
17
+ * ```
18
+ */
19
+ export class CmsError extends Error {
20
+ code;
21
+ context;
22
+ constructor(code, message, context = {}, options) {
23
+ super(message, options);
24
+ this.name = 'CmsError';
25
+ this.code = code;
26
+ this.context = context;
27
+ }
28
+ toString() {
29
+ const ctx = Object.entries(this.context)
30
+ .filter(([, v]) => v !== undefined)
31
+ .map(([k, v]) => `${k}=${formatContextValue(v)}`)
32
+ .join(', ');
33
+ return ctx
34
+ ? `[${this.code}] ${this.message} (${ctx})`
35
+ : `[${this.code}] ${this.message}`;
36
+ }
37
+ }
38
+ function formatContextValue(v) {
39
+ if (v === null)
40
+ return 'null';
41
+ if (typeof v === 'string')
42
+ return v;
43
+ if (typeof v === 'number' || typeof v === 'boolean')
44
+ return String(v);
45
+ try {
46
+ return JSON.stringify(v);
47
+ }
48
+ catch {
49
+ return '[unserializable]';
50
+ }
51
+ }
52
+ /**
53
+ * Thrown by {@link defineConfig} when the runtime config fails Zod validation.
54
+ * Aggregates ALL issues (not just the first) so the user can fix everything in
55
+ * one pass.
56
+ *
57
+ * @public
58
+ */
59
+ export class ConfigValidationError extends CmsError {
60
+ issues;
61
+ constructor(issues) {
62
+ const header = `CMSConfig validation failed (${issues.length} issue${issues.length === 1 ? '' : 's'}):`;
63
+ const body = issues
64
+ .map((i) => {
65
+ const hint = i.hint ? ` — Hint: ${i.hint}` : '';
66
+ return ` - ${i.path}: ${i.message}${hint}`;
67
+ })
68
+ .join('\n');
69
+ super('CONFIG_VALIDATION_FAILED', `${header}\n${body}`, { issueCount: issues.length });
70
+ this.name = 'ConfigValidationError';
71
+ this.issues = issues;
72
+ }
73
+ }
74
+ /**
75
+ * Render a Zod issue path (`(string | number)[]`) into JS-style notation:
76
+ * - string segments → `.foo`
77
+ * - numeric segments → `[3]`
78
+ * - leading string segment has no leading dot.
79
+ */
80
+ export function formatIssuePath(path) {
81
+ if (path.length === 0)
82
+ return '<root>';
83
+ let out = '';
84
+ for (let i = 0; i < path.length; i++) {
85
+ const seg = path[i];
86
+ if (typeof seg === 'number') {
87
+ out += `[${seg}]`;
88
+ }
89
+ else if (i === 0) {
90
+ out += seg;
91
+ }
92
+ else {
93
+ out += `.${seg}`;
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * Maps a config validation issue to a human-readable hint based on its path
100
+ * prefix and Zod issue `code`. Returns `undefined` when no specific hint
101
+ * applies (the bare message is good enough).
102
+ */
103
+ function hintFor(path, code, message) {
104
+ const root = path[0];
105
+ const last = path[path.length - 1];
106
+ // languages[i].code — must be ISO-639-1 (with optional region)
107
+ if (root === 'languages' && last === 'code') {
108
+ return "use a 2-letter ISO code, optionally with region — e.g. 'en' or 'pl-PL'";
109
+ }
110
+ if (root === 'languages' && code === 'too_small') {
111
+ return 'CMSConfig.languages must contain at least one language';
112
+ }
113
+ if (root === 'languages' && code === 'invalid_type') {
114
+ return 'CMSConfig.languages must be an array of { code, label, default? }';
115
+ }
116
+ // adapters
117
+ if (root === 'db') {
118
+ return 'pass a DatabaseAdapter (e.g. import { postgresAdapter } from "includio-cms/db-postgres")';
119
+ }
120
+ if (root === 'files') {
121
+ return 'pass a FilesAdapter (e.g. import { localFilesAdapter } from "includio-cms/files-local")';
122
+ }
123
+ if (root === 'email') {
124
+ return 'pass an EmailAdapter or omit `email` (notifications will be skipped)';
125
+ }
126
+ if (root === 'ai') {
127
+ return 'pass an AIAdapter or omit `ai` (AI features will be disabled)';
128
+ }
129
+ // duplicate slugs / cross-field invariants surface as `custom` issues
130
+ if (code === 'custom' && /duplicate/i.test(message)) {
131
+ return 'each collection/single/form must have a unique `slug`';
132
+ }
133
+ if (code === 'custom' && /default locale/i.test(message)) {
134
+ return 'mark exactly one language with `default: true`, or rely on languages[0]';
135
+ }
136
+ if (code === 'custom' && /relation target/i.test(message)) {
137
+ return 'the relation field references a collection slug that is not declared in `collections`';
138
+ }
139
+ if (code === 'custom' && /apiKeys/i.test(message)) {
140
+ return 'apiKeys[].permissions must reference declared collection slugs';
141
+ }
142
+ // Field-level issues — recurse hint for known shapes
143
+ if (last === 'slug' && code === 'invalid_string') {
144
+ return 'slug must be a non-empty string of [a-z0-9-]';
145
+ }
146
+ if (last === 'type' && code === 'invalid_enum_value') {
147
+ return 'check field.type against the supported list (text, content, number, boolean, date, datetime, file, media, select, radio, checkboxes, relation, object, array, blocks, slug, seo, shop, url, custom)';
148
+ }
149
+ return undefined;
150
+ }
151
+ /**
152
+ * Render a `ZodError` from entry/form data validation as a list of
153
+ * `path: message` lines (one per issue). Used by data-write operations to give
154
+ * callers a readable dump instead of `JSON.stringify(error.flatten())`.
155
+ *
156
+ * @public
157
+ */
158
+ export function formatZodDataIssues(error) {
159
+ if (error.issues.length === 0)
160
+ return '<no issues>';
161
+ return error.issues
162
+ .map((i) => {
163
+ const path = formatIssuePath(i.path);
164
+ return `${path}: ${i.message}`;
165
+ })
166
+ .join('\n');
167
+ }
168
+ /**
169
+ * Convert a `ZodError` into a {@link ConfigValidationError} with friendly,
170
+ * path-prefixed messages and per-issue hints.
171
+ */
172
+ export function formatConfigError(error) {
173
+ const issues = error.issues.map((issue) => ({
174
+ path: formatIssuePath(issue.path),
175
+ message: issue.message,
176
+ hint: hintFor(issue.path, issue.code, issue.message)
177
+ }));
178
+ return new ConfigValidationError(issues);
179
+ }
@@ -1,6 +1,18 @@
1
1
  import type { ConsentLogData } from '../../../../types/consent.js';
2
2
  /**
3
- * Persists a CMP consent log entry via the database adapter.
3
+ * Persists a CMP consent log entry via the database adapter. Used by the CMP
4
+ * banner to record user consent decisions.
5
+ *
6
+ * @param data - The consent payload (categories accepted/rejected + audit metadata).
7
+ * @returns A `Promise` that resolves once the row is written.
4
8
  * @public
9
+ * @example
10
+ * ```ts
11
+ * await createConsentLog({
12
+ * sessionId: 'abc',
13
+ * categories: { necessary: true, analytics: false },
14
+ * ip: '203.0.113.42'
15
+ * });
16
+ * ```
5
17
  */
6
18
  export declare const createConsentLog: (data: ConsentLogData) => Promise<void>;
@@ -1,7 +1,19 @@
1
1
  import { getCMS } from '../../../cms.js';
2
2
  /**
3
- * Persists a CMP consent log entry via the database adapter.
3
+ * Persists a CMP consent log entry via the database adapter. Used by the CMP
4
+ * banner to record user consent decisions.
5
+ *
6
+ * @param data - The consent payload (categories accepted/rejected + audit metadata).
7
+ * @returns A `Promise` that resolves once the row is written.
4
8
  * @public
9
+ * @example
10
+ * ```ts
11
+ * await createConsentLog({
12
+ * sessionId: 'abc',
13
+ * categories: { necessary: true, analytics: false },
14
+ * ip: '203.0.113.42'
15
+ * });
16
+ * ```
5
17
  */
6
18
  export const createConsentLog = async (data) => {
7
19
  await getCMS().databaseAdapter.createConsentLog(data);
@@ -5,6 +5,7 @@ import z from 'zod';
5
5
  import { _getDbEntryOrThrow as getDbEntryOrThrow } from './get.js';
6
6
  import { generateZodSchemaFromFields } from '../../../fields/fieldSchemaToTs.js';
7
7
  import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
8
+ import { CmsError, formatZodDataIssues } from '../../../errors.js';
8
9
  export const createEntrySchema = z.object({
9
10
  slug: z.string(),
10
11
  type: z.enum(entryTypes)
@@ -42,7 +43,11 @@ export const createEntryVersion = async (data, options) => {
42
43
  });
43
44
  const parsedData = schema.safeParse(data.data);
44
45
  if (!parsedData.success) {
45
- throw Error('Invalid data: ' + JSON.stringify(parsedData.error.flatten()));
46
+ throw new CmsError('INVALID_DATA', `Invalid data for entry version:\n${formatZodDataIssues(parsedData.error)}`, {
47
+ collection: entry.slug,
48
+ entryId: data.entryId,
49
+ lang: data.lang
50
+ });
46
51
  }
47
52
  validatedData = parsedData.data;
48
53
  }
@@ -1,5 +1,6 @@
1
1
  import { getCMS } from '../../../cms.js';
2
2
  import { getAtPath } from '../../../../admin/utils/objectPath.js';
3
+ import { CmsError } from '../../../errors.js';
3
4
  import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
4
5
  import { getEntrySlugPath, getSlugFromEntryData, getEntryPath } from '../../fields/slugResolver.js';
5
6
  export const _getDbEntries = async (options) => {
@@ -19,7 +20,10 @@ export const _getDbEntry = async (options) => {
19
20
  export const _getDbEntryOrThrow = async (options) => {
20
21
  const entry = await _getDbEntry(options);
21
22
  if (!entry) {
22
- throw new Error('Entry not found');
23
+ throw new CmsError('ENTRY_NOT_FOUND', 'Entry not found', {
24
+ collection: options.slug,
25
+ id: options.id
26
+ });
23
27
  }
24
28
  return entry;
25
29
  };
@@ -84,7 +88,10 @@ export const _getRawEntry = async (options) => {
84
88
  export const _getRawEntryOrThrow = async (options) => {
85
89
  const entry = await _getRawEntry(options);
86
90
  if (!entry) {
87
- throw new Error('Entry not found');
91
+ throw new CmsError('ENTRY_NOT_FOUND', 'Entry not found', {
92
+ collection: options.slug,
93
+ id: options.id
94
+ });
88
95
  }
89
96
  return entry;
90
97
  };
@@ -156,7 +163,11 @@ export const _getDbEntryVersion = async (options) => {
156
163
  export const _getDbEntryVersionOrThrow = async (options) => {
157
164
  const version = await _getDbEntryVersion(options);
158
165
  if (!version) {
159
- throw new Error('Entry version not found');
166
+ throw new CmsError('ENTRY_VERSION_NOT_FOUND', 'Entry version not found', {
167
+ versionId: options.id,
168
+ entryId: options.entryId,
169
+ lang: options.lang
170
+ });
160
171
  }
161
172
  return version;
162
173
  };
@@ -73,7 +73,15 @@ export type CountEntriesOptions = Omit<ResolveEntriesOptions, 'limit' | 'offset'
73
73
  * - `status` defaults to `'published'`. See {@link ResolveStatus}.
74
74
  * - `populate` controls relation depth + per-field opt-out. See {@link PopulateConfig}.
75
75
  *
76
+ * @param opts - At minimum, an `id` or a `collection` slug.
77
+ * @returns The populated `Entry`, or `null` when nothing matches.
78
+ * @throws {CmsError} with `code: 'MISSING_REQUIRED_PARAM'` when neither `id`
79
+ * nor `collection` is provided.
76
80
  * @public
81
+ * @example
82
+ * ```ts
83
+ * const post = await resolveEntry({ collection: 'posts', locale: 'en' });
84
+ * ```
77
85
  */
78
86
  export declare function resolveEntry(opts: ResolveEntryOptions): Promise<Entry | null>;
79
87
  /**
@@ -84,11 +92,34 @@ export declare function resolveEntry(opts: ResolveEntryOptions): Promise<Entry |
84
92
  * - `filter.{dataValues, dataLike, dataILikeOr}` map onto adapter `getEntryVersions` filters.
85
93
  * - `limit`/`offset` applied after status pick.
86
94
  *
95
+ * @param opts - Must include `collection`. See {@link ResolveEntriesOptions}.
96
+ * @returns Array of populated `Entry` objects in the requested order.
97
+ * @throws {CmsError} with `code: 'MISSING_REQUIRED_PARAM'` when `collection`
98
+ * is omitted.
87
99
  * @public
100
+ * @example
101
+ * ```ts
102
+ * const posts = await resolveEntries({
103
+ * collection: 'posts',
104
+ * locale: 'en',
105
+ * limit: 10,
106
+ * orderBy: { column: 'createdAt', direction: 'desc' }
107
+ * });
108
+ * ```
88
109
  */
89
110
  export declare function resolveEntries(opts: ResolveEntriesOptions): Promise<Entry[]>;
90
111
  /**
91
- * Count entries matching the same filters as `resolveEntries`, without populating.
112
+ * Count entries matching the same filters as `resolveEntries`, without
113
+ * fetching or populating their data.
114
+ *
115
+ * @param opts - Must include `collection`. See {@link CountEntriesOptions}.
116
+ * @returns The number of entries matching the filters.
117
+ * @throws {CmsError} with `code: 'MISSING_REQUIRED_PARAM'` when `collection`
118
+ * is omitted.
92
119
  * @public
120
+ * @example
121
+ * ```ts
122
+ * const total = await countEntries({ collection: 'posts', locale: 'en' });
123
+ * ```
93
124
  */
94
125
  export declare function countEntries(opts: CountEntriesOptions): Promise<number>;
@@ -1,6 +1,7 @@
1
1
  import { getCMS } from '../../../cms.js';
2
2
  import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
3
3
  import { getEntrySlugPath, getSlugFromEntryData, getEntryPath } from '../../fields/slugResolver.js';
4
+ import { CmsError } from '../../../errors.js';
4
5
  function pickVersion(versions, status) {
5
6
  const sorted = versions.slice().sort((a, b) => b.versionNumber - a.versionNumber);
6
7
  const now = new Date();
@@ -51,11 +52,19 @@ async function buildEntry(dbEntry, version, ctx) {
51
52
  * - `status` defaults to `'published'`. See {@link ResolveStatus}.
52
53
  * - `populate` controls relation depth + per-field opt-out. See {@link PopulateConfig}.
53
54
  *
55
+ * @param opts - At minimum, an `id` or a `collection` slug.
56
+ * @returns The populated `Entry`, or `null` when nothing matches.
57
+ * @throws {CmsError} with `code: 'MISSING_REQUIRED_PARAM'` when neither `id`
58
+ * nor `collection` is provided.
54
59
  * @public
60
+ * @example
61
+ * ```ts
62
+ * const post = await resolveEntry({ collection: 'posts', locale: 'en' });
63
+ * ```
55
64
  */
56
65
  export async function resolveEntry(opts) {
57
66
  if (!opts.id && !opts.collection) {
58
- throw new Error('resolveEntry: must provide id or collection');
67
+ throw new CmsError('MISSING_REQUIRED_PARAM', 'resolveEntry: must provide id or collection', { op: 'resolveEntry', missing: 'id|collection' });
59
68
  }
60
69
  const cms = getCMS();
61
70
  const locale = opts.locale ?? cms.languages[0];
@@ -99,11 +108,24 @@ export async function resolveEntry(opts) {
99
108
  * - `filter.{dataValues, dataLike, dataILikeOr}` map onto adapter `getEntryVersions` filters.
100
109
  * - `limit`/`offset` applied after status pick.
101
110
  *
111
+ * @param opts - Must include `collection`. See {@link ResolveEntriesOptions}.
112
+ * @returns Array of populated `Entry` objects in the requested order.
113
+ * @throws {CmsError} with `code: 'MISSING_REQUIRED_PARAM'` when `collection`
114
+ * is omitted.
102
115
  * @public
116
+ * @example
117
+ * ```ts
118
+ * const posts = await resolveEntries({
119
+ * collection: 'posts',
120
+ * locale: 'en',
121
+ * limit: 10,
122
+ * orderBy: { column: 'createdAt', direction: 'desc' }
123
+ * });
124
+ * ```
103
125
  */
104
126
  export async function resolveEntries(opts) {
105
127
  if (!opts.collection) {
106
- throw new Error('resolveEntries: collection is required');
128
+ throw new CmsError('MISSING_REQUIRED_PARAM', 'resolveEntries: collection is required', { op: 'resolveEntries', missing: 'collection' });
107
129
  }
108
130
  const cms = getCMS();
109
131
  const locale = opts.locale ?? cms.languages[0];
@@ -169,12 +191,22 @@ export async function resolveEntries(opts) {
169
191
  return results;
170
192
  }
171
193
  /**
172
- * Count entries matching the same filters as `resolveEntries`, without populating.
194
+ * Count entries matching the same filters as `resolveEntries`, without
195
+ * fetching or populating their data.
196
+ *
197
+ * @param opts - Must include `collection`. See {@link CountEntriesOptions}.
198
+ * @returns The number of entries matching the filters.
199
+ * @throws {CmsError} with `code: 'MISSING_REQUIRED_PARAM'` when `collection`
200
+ * is omitted.
173
201
  * @public
202
+ * @example
203
+ * ```ts
204
+ * const total = await countEntries({ collection: 'posts', locale: 'en' });
205
+ * ```
174
206
  */
175
207
  export async function countEntries(opts) {
176
208
  if (!opts.collection) {
177
- throw new Error('countEntries: collection is required');
209
+ throw new CmsError('MISSING_REQUIRED_PARAM', 'countEntries: collection is required', { op: 'countEntries', missing: 'collection' });
178
210
  }
179
211
  const cms = getCMS();
180
212
  const locale = opts.locale ?? cms.languages[0];
@@ -4,6 +4,7 @@ import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
4
4
  import z from 'zod';
5
5
  import { _getDbEntryOrThrow as getDbEntryOrThrow, _getDbEntryVersionOrThrow as getDbEntryVersionOrThrow, _getDbEntryVersions as getDbEntryVersions } from './get.js';
6
6
  import { createEntryVersion } from './create.js';
7
+ import { CmsError, formatZodDataIssues } from '../../../errors.js';
7
8
  export const updateEntrySchema = z.object({
8
9
  archivedAt: z.date().nullable().optional(),
9
10
  sortOrder: z.number().int().nullable().optional()
@@ -53,7 +54,10 @@ export const updateEntryVersion = async (id, data) => {
53
54
  });
54
55
  const parsedData = schema.safeParse(filteredData.data);
55
56
  if (!parsedData.success) {
56
- throw Error('Invalid data: ' + parsedData.error.flatten());
57
+ throw new CmsError('INVALID_DATA', `Invalid data for entry version:\n${formatZodDataIssues(parsedData.error)}`, {
58
+ collection: entry.slug,
59
+ versionId: id
60
+ });
57
61
  }
58
62
  dataToUpdate.data = parsedData.data;
59
63
  }
@@ -1,15 +1,32 @@
1
1
  import type { ImageFieldStyle } from '../../../../types/fields.js';
2
2
  import type { ImageStyle, MediaFile } from '../../../../types/media.js';
3
3
  /**
4
+ * Resolved media file plus its image styles + blur placeholder. Returned by
5
+ * {@link resolveMediaWithStyles}.
4
6
  * @public
5
7
  */
6
8
  export interface ResolvedMedia {
9
+ /** The original media file row (URL, MIME, dimensions, focal point, ...). */
7
10
  data: MediaFile;
11
+ /** Generated image styles keyed by style name. */
8
12
  styles: Record<string, ImageStyle>;
13
+ /** Tiny base64 blur placeholder, or `null` for non-image media. */
9
14
  blurDataUrl: string | null;
10
15
  }
11
16
  /**
12
- * Resolve media files by IDs and generate image styles. Useful for plugins resolving media references (e.g. photo-grid).
17
+ * Resolve media files by IDs and generate image styles. Useful for plugins or
18
+ * custom resolvers that hold raw media references (e.g. photo-grid blocks).
19
+ *
20
+ * @param mediaIds - The media file UUIDs to resolve.
21
+ * @param styles - Optional list of `ImageFieldStyle` to generate for each file.
22
+ * @returns A map keyed by media id, each entry is a {@link ResolvedMedia}.
13
23
  * @public
24
+ * @example
25
+ * ```ts
26
+ * const resolved = await resolveMediaWithStyles(
27
+ * ['uuid-1', 'uuid-2'],
28
+ * [{ name: 'thumb', width: 400 }]
29
+ * );
30
+ * ```
14
31
  */
15
32
  export declare function resolveMediaWithStyles(mediaIds: string[], styles?: ImageFieldStyle[]): Promise<Record<string, ResolvedMedia>>;
@@ -1,8 +1,20 @@
1
1
  import { getCMS } from '../../../cms.js';
2
2
  import { getImageStyles } from './imageStyles.js';
3
3
  /**
4
- * Resolve media files by IDs and generate image styles. Useful for plugins resolving media references (e.g. photo-grid).
4
+ * Resolve media files by IDs and generate image styles. Useful for plugins or
5
+ * custom resolvers that hold raw media references (e.g. photo-grid blocks).
6
+ *
7
+ * @param mediaIds - The media file UUIDs to resolve.
8
+ * @param styles - Optional list of `ImageFieldStyle` to generate for each file.
9
+ * @returns A map keyed by media id, each entry is a {@link ResolvedMedia}.
5
10
  * @public
11
+ * @example
12
+ * ```ts
13
+ * const resolved = await resolveMediaWithStyles(
14
+ * ['uuid-1', 'uuid-2'],
15
+ * [{ name: 'thumb', width: 400 }]
16
+ * );
17
+ * ```
6
18
  */
7
19
  export async function resolveMediaWithStyles(mediaIds, styles) {
8
20
  if (!mediaIds.length)
@@ -1,14 +1,34 @@
1
1
  /**
2
+ * Options for {@link createFormSubmission}.
2
3
  * @public
3
4
  */
4
5
  export interface CreateFormSubmissionOptions {
6
+ /** Form slug as declared in `defineConfig({ forms })`. */
5
7
  slug: string;
8
+ /** Submission payload. Validated against the form's field Zod schema. */
6
9
  data: Record<string, unknown>;
10
+ /** Optional remote IP for audit / rate-limit attribution. */
7
11
  ip?: string;
12
+ /** Optional user-agent string. */
8
13
  userAgent?: string;
9
14
  }
10
15
  /**
11
- * Persists a form submission and triggers best-effort notification email. Returns `true` if the row was written (email failures are logged, do not affect return).
16
+ * Persists a form submission and triggers a best-effort notification email.
17
+ *
18
+ * Validates `data` against the form's field schema and throws a `CmsError`
19
+ * with `code: 'INVALID_DATA'` listing every issue when invalid. Email failures
20
+ * are logged but do not affect the return value.
21
+ *
22
+ * @param options - See {@link CreateFormSubmissionOptions}.
23
+ * @returns `true` if the row was written, `false` if the DB insert failed.
24
+ * @throws {CmsError} with `code: 'INVALID_DATA'` when validation fails.
12
25
  * @public
26
+ * @example
27
+ * ```ts
28
+ * await createFormSubmission({
29
+ * slug: 'contact',
30
+ * data: { email: 'user@example.com', message: 'Hi' }
31
+ * });
32
+ * ```
13
33
  */
14
34
  export declare const createFormSubmission: (options: CreateFormSubmissionOptions) => Promise<boolean>;
@@ -1,9 +1,25 @@
1
1
  import { getLocalizedLabel } from '../../../../../admin/utils/collectionLabel.js';
2
2
  import { getCMS } from '../../../../cms.js';
3
3
  import { generateZodSchemaFromFormFields } from '../../../../fields/formFieldSchemaToTs.js';
4
+ import { CmsError, formatZodDataIssues } from '../../../../errors.js';
4
5
  /**
5
- * Persists a form submission and triggers best-effort notification email. Returns `true` if the row was written (email failures are logged, do not affect return).
6
+ * Persists a form submission and triggers a best-effort notification email.
7
+ *
8
+ * Validates `data` against the form's field schema and throws a `CmsError`
9
+ * with `code: 'INVALID_DATA'` listing every issue when invalid. Email failures
10
+ * are logged but do not affect the return value.
11
+ *
12
+ * @param options - See {@link CreateFormSubmissionOptions}.
13
+ * @returns `true` if the row was written, `false` if the DB insert failed.
14
+ * @throws {CmsError} with `code: 'INVALID_DATA'` when validation fails.
6
15
  * @public
16
+ * @example
17
+ * ```ts
18
+ * await createFormSubmission({
19
+ * slug: 'contact',
20
+ * data: { email: 'user@example.com', message: 'Hi' }
21
+ * });
22
+ * ```
7
23
  */
8
24
  export const createFormSubmission = async (options) => {
9
25
  const { slug, data, ip, userAgent } = options;
@@ -11,7 +27,7 @@ export const createFormSubmission = async (options) => {
11
27
  const schema = await generateZodSchemaFromFormFields(config.fields);
12
28
  const parsedData = schema.safeParse(data);
13
29
  if (!parsedData.success) {
14
- throw new Error('Invalid data: ' + JSON.stringify(parsedData.error.flatten()));
30
+ throw new CmsError('INVALID_DATA', `Invalid data for form submission:\n${formatZodDataIssues(parsedData.error)}`, { formSlug: slug });
15
31
  }
16
32
  try {
17
33
  await getCMS().databaseAdapter.createFormSubmission({
@@ -1,6 +1,20 @@
1
1
  import type { FormField } from '../../../../../types/formFields.js';
2
2
  /**
3
- * Parses multipart `FormData` into a typed record of field values, handling file uploads via the configured files adapter.
3
+ * Parses multipart `FormData` into a typed record of field values, handling
4
+ * file uploads via the configured `FilesAdapter`.
5
+ *
6
+ * @param formData - The request's `FormData` (e.g. `await request.formData()`).
7
+ * @param fields - The form's `FormField[]` definitions.
8
+ * @returns A plain `Record<string, unknown>` keyed by field slug, with files
9
+ * replaced by their persisted URL/identifier.
4
10
  * @public
11
+ * @example
12
+ * ```ts
13
+ * const data = await parseFormDataForSubmission(
14
+ * await request.formData(),
15
+ * formConfig.fields
16
+ * );
17
+ * await createFormSubmission({ slug: formConfig.slug, data });
18
+ * ```
5
19
  */
6
20
  export declare function parseFormDataForSubmission(formData: FormData, fields: FormField[]): Promise<Record<string, unknown>>;
@@ -26,8 +26,22 @@ function validateMagicBytes(buffer, declaredMime) {
26
26
  return false;
27
27
  }
28
28
  /**
29
- * Parses multipart `FormData` into a typed record of field values, handling file uploads via the configured files adapter.
29
+ * Parses multipart `FormData` into a typed record of field values, handling
30
+ * file uploads via the configured `FilesAdapter`.
31
+ *
32
+ * @param formData - The request's `FormData` (e.g. `await request.formData()`).
33
+ * @param fields - The form's `FormField[]` definitions.
34
+ * @returns A plain `Record<string, unknown>` keyed by field slug, with files
35
+ * replaced by their persisted URL/identifier.
30
36
  * @public
37
+ * @example
38
+ * ```ts
39
+ * const data = await parseFormDataForSubmission(
40
+ * await request.formData(),
41
+ * formConfig.fields
42
+ * );
43
+ * await createFormSubmission({ slug: formConfig.slug, data });
44
+ * ```
31
45
  */
32
46
  export async function parseFormDataForSubmission(formData, fields) {
33
47
  const result = {};
@@ -4,6 +4,7 @@ import { generateAdminThumbnail } from '../utils/generateAdminThumbnail.js';
4
4
  import { generateDefaultStylesInBackground } from '../styles/operations/generateDefaultStyles.js';
5
5
  import { generateDefaultVideoStylesInBackground } from '../styles/operations/generateDefaultVideoStyles.js';
6
6
  import sharp from 'sharp';
7
+ import { withTimeout, sharpTimeoutMs } from '../../../../server/utils/withTimeout.js';
7
8
  async function maybeDownscale(file) {
8
9
  const cms = getCMS();
9
10
  const { maxOriginalWidth, maxOriginalHeight } = cms.mediaConfig;
@@ -14,19 +15,19 @@ async function maybeDownscale(file) {
14
15
  return file;
15
16
  try {
16
17
  const buffer = Buffer.from(await file.arrayBuffer());
17
- const metadata = await sharp(buffer).metadata();
18
+ const metadata = await withTimeout(sharp(buffer).metadata(), sharpTimeoutMs(), 'sharp.metadata');
18
19
  const w = metadata.width || 0;
19
20
  const h = metadata.height || 0;
20
21
  const exceedsWidth = maxOriginalWidth && w > maxOriginalWidth;
21
22
  const exceedsHeight = maxOriginalHeight && h > maxOriginalHeight;
22
23
  if (!exceedsWidth && !exceedsHeight)
23
24
  return file;
24
- const resized = await sharp(buffer)
25
+ const resized = await withTimeout(sharp(buffer)
25
26
  .resize(maxOriginalWidth, maxOriginalHeight, {
26
27
  fit: 'inside',
27
28
  withoutEnlargement: true
28
29
  })
29
- .toBuffer();
30
+ .toBuffer(), sharpTimeoutMs(), 'sharp.resize');
30
31
  return new File([new Uint8Array(resized)], file.name, { type: file.type });
31
32
  }
32
33
  catch (e) {