@yunsoft/yuncms-core 0.1.2 → 0.1.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/bootstrap.js CHANGED
@@ -7,6 +7,9 @@ import { authActionTokensMigration } from './migrations/0004-auth-action-tokens.
7
7
  import { defaultPublicRoleMigration } from './migrations/0005-default-public-role.js';
8
8
  import { studioSettingsMigration } from './migrations/0006-studio-settings.js';
9
9
  import { systemPermissionResourcesMigration } from './migrations/0007-system-permission-resources.js';
10
+ import { studioLogoFileMigration } from './migrations/0008-studio-logo-file.js';
11
+ import { schemaDisplayNamesMigration } from './migrations/0009-schema-display-names.js';
12
+ import { studioFaviconFileMigration } from './migrations/0010-studio-favicon-file.js';
10
13
  import { readSchemaVersion } from './schema-version.js';
11
14
  import { ensurePublicRole } from './setup.js';
12
15
 
@@ -18,6 +21,9 @@ export const CORE_MIGRATIONS = Object.freeze([
18
21
  defaultPublicRoleMigration,
19
22
  studioSettingsMigration,
20
23
  systemPermissionResourcesMigration,
24
+ studioLogoFileMigration,
25
+ schemaDisplayNamesMigration,
26
+ studioFaviconFileMigration,
21
27
  ]);
22
28
 
23
29
  export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.
2
2
  export { createDatabasePool, pingDatabase, closeDatabasePool } from './database.js';
3
3
  export { withTransaction, withConnectionTransaction } from './transaction.js';
4
4
  export { assertIdentifier, quoteIdentifier } from './identifier.js';
5
+ export { normalizeDisplayName, normalizeSchemaKey, resolveSchemaName } from './schema-key.js';
5
6
  export { YunCmsDatabaseError, normalizeDatabaseError, isRetryableDatabaseError } from './errors.js';
6
7
  export { withDatabaseRetry } from './retry.js';
7
8
  export {
@@ -41,6 +42,7 @@ export { AuditService, redactAuditValue } from './services/audit-service.js';
41
42
  export { ItemsService } from './services/items-service.js';
42
43
  export { CollectionsService } from './services/collections-service.js';
43
44
  export { FieldsService } from './services/fields-service.js';
45
+ export { SystemCollectionFieldsService } from './services/system-collection-fields-service.js';
44
46
  export { RelationsService } from './services/relations-service.js';
45
47
  export { UsersService } from './services/users-service.js';
46
48
  export { RolesService } from './services/roles-service.js';
@@ -85,4 +87,4 @@ export {
85
87
  REQUIRED_CORE_MIGRATION_IDS,
86
88
  bootstrapDatabase,
87
89
  assertDatabaseCompatible,
88
- } from './bootstrap.js';
90
+ } from './bootstrap.js';
@@ -0,0 +1,10 @@
1
+ export const studioLogoFileMigration = {
2
+ id: '0008-studio-logo-file',
3
+ statements: [
4
+ `ALTER TABLE yuncms_studio_settings
5
+ ADD COLUMN logo_file CHAR(36) NULL AFTER logo_url,
6
+ ADD KEY idx_yuncms_studio_settings_logo_file (logo_file),
7
+ ADD CONSTRAINT fk_yuncms_studio_settings_logo_file
8
+ FOREIGN KEY (logo_file) REFERENCES yuncms_files (id) ON DELETE SET NULL`,
9
+ ],
10
+ };
@@ -0,0 +1,19 @@
1
+ export const schemaDisplayNamesMigration = {
2
+ id: '0009-schema-display-names',
3
+ statements: [
4
+ `ALTER TABLE yuncms_collections
5
+ ADD COLUMN name VARCHAR(255) NULL AFTER collection`,
6
+ `UPDATE yuncms_collections
7
+ SET name = collection
8
+ WHERE name IS NULL OR TRIM(name) = ''`,
9
+ `ALTER TABLE yuncms_collections
10
+ MODIFY COLUMN name VARCHAR(255) NOT NULL`,
11
+ `ALTER TABLE yuncms_fields
12
+ ADD COLUMN name VARCHAR(255) NULL AFTER field`,
13
+ `UPDATE yuncms_fields
14
+ SET name = field
15
+ WHERE name IS NULL OR TRIM(name) = ''`,
16
+ `ALTER TABLE yuncms_fields
17
+ MODIFY COLUMN name VARCHAR(255) NOT NULL`,
18
+ ],
19
+ };
@@ -0,0 +1,12 @@
1
+ export const studioFaviconFileMigration = {
2
+ id: '0010-studio-favicon-file',
3
+ statements: [
4
+ `ALTER TABLE yuncms_studio_settings
5
+ ADD COLUMN favicon_file CHAR(36) NULL AFTER logo_file`,
6
+ `ALTER TABLE yuncms_studio_settings
7
+ ADD KEY idx_yuncms_studio_settings_favicon_file (favicon_file)`,
8
+ `ALTER TABLE yuncms_studio_settings
9
+ ADD CONSTRAINT fk_yuncms_studio_settings_favicon_file
10
+ FOREIGN KEY (favicon_file) REFERENCES yuncms_files (id) ON DELETE SET NULL`,
11
+ ],
12
+ };
@@ -0,0 +1,54 @@
1
+ const TURKISH_ASCII = Object.freeze({
2
+ ç: 'c',
3
+ Ç: 'c',
4
+ ğ: 'g',
5
+ Ğ: 'g',
6
+ ı: 'i',
7
+ İ: 'i',
8
+ ö: 'o',
9
+ Ö: 'o',
10
+ ş: 's',
11
+ Ş: 's',
12
+ ü: 'u',
13
+ Ü: 'u',
14
+ });
15
+
16
+ function schemaNameError(message) {
17
+ const error = new Error(message);
18
+ error.code = 'INVALID_SCHEMA_NAME';
19
+ return error;
20
+ }
21
+
22
+ export function normalizeDisplayName(value, { fallback = null, maxLength = 255 } = {}) {
23
+ const candidate = typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '';
24
+ const normalized = candidate || (typeof fallback === 'string' ? fallback.trim().replace(/\s+/g, ' ') : '');
25
+ if (!normalized) throw schemaNameError('A display name is required');
26
+ if (normalized.length > maxLength) throw schemaNameError(`Display name cannot exceed ${maxLength} characters`);
27
+ return normalized;
28
+ }
29
+
30
+ export function normalizeSchemaKey(value, { prefix = 'field', maxLength = 64 } = {}) {
31
+ const source = normalizeDisplayName(value, { maxLength: 512 });
32
+ const transliterated = [...source].map((character) => TURKISH_ASCII[character] ?? character).join('');
33
+ let key = transliterated
34
+ .normalize('NFKD')
35
+ .replace(/[\u0300-\u036f]/g, '')
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, '_')
38
+ .replace(/^_+|_+$/g, '')
39
+ .replace(/_+/g, '_');
40
+
41
+ if (!key) throw schemaNameError('Name must contain at least one letter or number');
42
+ if (/^[0-9]/.test(key)) key = `${prefix}_${key}`;
43
+ if (key.length > maxLength) key = key.slice(0, maxLength).replace(/_+$/g, '');
44
+ if (!key) throw schemaNameError('Name cannot be normalized to a schema key');
45
+ return key;
46
+ }
47
+
48
+ export function resolveSchemaName({ displayName, key, prefix = 'field' } = {}) {
49
+ const name = normalizeDisplayName(displayName ?? key);
50
+ return {
51
+ name,
52
+ key: normalizeSchemaKey(key ?? name, { prefix }),
53
+ };
54
+ }
@@ -12,7 +12,7 @@ export class SchemaMetadataRepository {
12
12
 
13
13
  async listCollections() {
14
14
  const [rows] = await this.database.query(
15
- `SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
15
+ `SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
16
16
  FROM yuncms_collections
17
17
  ORDER BY collection ASC`,
18
18
  );
@@ -21,7 +21,7 @@ export class SchemaMetadataRepository {
21
21
 
22
22
  async readCollection(collection) {
23
23
  const [rows] = await this.database.query(
24
- `SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
24
+ `SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
25
25
  FROM yuncms_collections
26
26
  WHERE collection = ?
27
27
  LIMIT 1`,
@@ -32,6 +32,7 @@ export class SchemaMetadataRepository {
32
32
 
33
33
  async createCollection({
34
34
  collection,
35
+ name = collection,
35
36
  primaryKey = 'id',
36
37
  note = null,
37
38
  singleton = false,
@@ -41,10 +42,11 @@ export class SchemaMetadataRepository {
41
42
  }) {
42
43
  await this.database.query(
43
44
  `INSERT INTO yuncms_collections
44
- (collection, primary_key, note, singleton, hidden, \`system\`, metadata)
45
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
45
+ (collection, name, primary_key, note, singleton, hidden, \`system\`, metadata)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
46
47
  [
47
48
  collection,
49
+ name,
48
50
  primaryKey,
49
51
  note,
50
52
  singleton ? 1 : 0,
@@ -60,6 +62,10 @@ export class SchemaMetadataRepository {
60
62
  const assignments = [];
61
63
  const params = [];
62
64
 
65
+ if (Object.hasOwn(patch, 'name')) {
66
+ assignments.push('name = ?');
67
+ params.push(patch.name);
68
+ }
63
69
  if (Object.hasOwn(patch, 'note')) {
64
70
  assignments.push('note = ?');
65
71
  params.push(patch.note ?? null);
@@ -96,7 +102,7 @@ export class SchemaMetadataRepository {
96
102
 
97
103
  async listFields(collection) {
98
104
  const [rows] = await this.database.query(
99
- `SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
105
+ `SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
100
106
  created_at, updated_at
101
107
  FROM yuncms_fields
102
108
  WHERE collection = ?
@@ -108,7 +114,7 @@ export class SchemaMetadataRepository {
108
114
 
109
115
  async readField(collection, field) {
110
116
  const [rows] = await this.database.query(
111
- `SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
117
+ `SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
112
118
  created_at, updated_at
113
119
  FROM yuncms_fields
114
120
  WHERE collection = ? AND field = ?
@@ -121,6 +127,7 @@ export class SchemaMetadataRepository {
121
127
  async createField({
122
128
  collection,
123
129
  field,
130
+ name = field,
124
131
  type,
125
132
  required = false,
126
133
  readonly = false,
@@ -137,11 +144,12 @@ export class SchemaMetadataRepository {
137
144
  }
138
145
  await this.database.query(
139
146
  `INSERT INTO yuncms_fields
140
- (collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
141
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
147
+ (collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
142
149
  [
143
150
  collection,
144
151
  field,
152
+ name,
145
153
  type,
146
154
  required ? 1 : 0,
147
155
  readonly ? 1 : 0,
@@ -169,6 +177,10 @@ export class SchemaMetadataRepository {
169
177
  }
170
178
  }
171
179
 
180
+ if (Object.hasOwn(patch, 'name')) {
181
+ assignments.push('name = ?');
182
+ params.push(patch.name);
183
+ }
172
184
  if (Object.hasOwn(patch, 'readonly')) {
173
185
  assignments.push('readonly = ?');
174
186
  params.push(patch.readonly ? 1 : 0);
@@ -3,13 +3,14 @@ import { randomUUID } from 'node:crypto';
3
3
  import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
5
5
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
6
+ import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
6
7
  import { incrementSchemaVersion } from '../schema-version.js';
7
8
  import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
8
9
  import { withConnectionTransaction } from '../transaction.js';
9
10
  import { BaseService } from './base-service.js';
10
11
  import { assertSchemaManager } from './schema-access.js';
11
12
 
12
- const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
13
+ const COLLECTION_METADATA_KEYS = new Set(['name', 'note', 'singleton', 'hidden', 'metadata']);
13
14
 
14
15
  function assertUserCollectionName(collection) {
15
16
  assertIdentifier(collection, 'collection name');
@@ -47,6 +48,9 @@ function assertCollectionMetadataPatch(patch) {
47
48
  throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
48
49
  }
49
50
  }
51
+ if (Object.hasOwn(patch, 'name')) {
52
+ patch.name = normalizeDisplayName(patch.name);
53
+ }
50
54
  if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
51
55
  throw invalidSchemaPayload('Collection note must be a string or null');
52
56
  }
@@ -93,7 +97,13 @@ export class CollectionsService extends BaseService {
93
97
  async createOne(input = {}) {
94
98
  assertSchemaManager(this.accountability);
95
99
  assertCollectionCreateMetadata(input);
96
- const collection = assertUserCollectionName(input.collection);
100
+ const resolvedName = resolveSchemaName({
101
+ displayName: input.name ?? input.collection,
102
+ key: input.collection,
103
+ prefix: 'collection',
104
+ });
105
+ const collection = assertUserCollectionName(resolvedName.key);
106
+ const name = resolvedName.name;
97
107
  const primaryKey = input.primaryKey ?? 'id';
98
108
 
99
109
  if (primaryKey !== 'id') {
@@ -132,6 +142,7 @@ export class CollectionsService extends BaseService {
132
142
  return await withConnectionTransaction(connection, async () => {
133
143
  const created = await metadata.createCollection({
134
144
  collection,
145
+ name,
135
146
  primaryKey,
136
147
  note: input.note ?? null,
137
148
  singleton: input.singleton === true,
@@ -145,6 +156,7 @@ export class CollectionsService extends BaseService {
145
156
  await metadata.createField({
146
157
  collection,
147
158
  field: 'id',
159
+ name: 'ID',
148
160
  type: 'uuid',
149
161
  required: true,
150
162
  readonly: true,
@@ -11,6 +11,7 @@ import { PermissionsService } from './permissions-service.js';
11
11
  import { RelationsService } from './relations-service.js';
12
12
  import { RolesService } from './roles-service.js';
13
13
  import { StudioSettingsService } from './studio-settings-service.js';
14
+ import { SystemCollectionFieldsService } from './system-collection-fields-service.js';
14
15
  import { UsersService } from './users-service.js';
15
16
  import { createServiceRegistry } from './service-registry.js';
16
17
 
@@ -23,6 +24,7 @@ export function createCoreServiceRegistry() {
23
24
  ItemsService,
24
25
  CollectionsService,
25
26
  FieldsService,
27
+ SystemCollectionFieldsService,
26
28
  RelationsService,
27
29
  UsersService,
28
30
  RolesService,
@@ -4,12 +4,13 @@ import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { compileFieldColumn } from '../field-types.js';
5
5
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
6
6
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
7
+ import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
7
8
  import { incrementSchemaVersion } from '../schema-version.js';
8
9
  import { withConnectionTransaction } from '../transaction.js';
9
10
  import { BaseService } from './base-service.js';
10
11
  import { assertSchemaManager } from './schema-access.js';
11
12
 
12
- const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
13
+ const FIELD_METADATA_KEYS = new Set(['name', 'readonly', 'hidden', 'sort', 'interface', 'options']);
13
14
  const FIELD_PHYSICAL_KEYS = new Set([
14
15
  'required',
15
16
  'defaultValue',
@@ -46,6 +47,7 @@ function assertFieldMetadataPatch(patch) {
46
47
  if (Object.keys(patch).length === 0) {
47
48
  throw invalidSchemaPayload('Field metadata patch cannot be empty');
48
49
  }
50
+ if (Object.hasOwn(patch, 'name')) patch.name = normalizeDisplayName(patch.name);
49
51
  }
50
52
 
51
53
  function assertPhysicalPatch(patch) {
@@ -150,7 +152,13 @@ export class FieldsService extends BaseService {
150
152
  async createOne(collection, input = {}) {
151
153
  assertSchemaManager(this.accountability);
152
154
  assertIdentifier(collection, 'collection name');
153
- const field = assertFieldName(input.field);
155
+ const resolvedName = resolveSchemaName({
156
+ displayName: input.name ?? input.field,
157
+ key: input.field,
158
+ prefix: 'field',
159
+ });
160
+ const field = assertFieldName(resolvedName.key);
161
+ const name = resolvedName.name;
154
162
 
155
163
  if (field === 'id') {
156
164
  const error = new Error('The id field is created with the collection and cannot be added again');
@@ -196,6 +204,7 @@ export class FieldsService extends BaseService {
196
204
  const created = await metadata.createField({
197
205
  collection,
198
206
  field,
207
+ name,
199
208
  type: input.type,
200
209
  required: input.required === true,
201
210
  readonly: input.readonly === true,
@@ -3,6 +3,11 @@ import { BaseService } from './base-service.js';
3
3
  const THEMES = new Set(['system', 'light', 'dark']);
4
4
  const LOCALES = new Set(['en', 'tr']);
5
5
  const ACCENT_PATTERN = /^#[0-9a-f]{6}$/i;
6
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+ const YUNSOFT_LOGOS = new Set([
8
+ 'https://yunsoft.com/light-logo.png',
9
+ 'https://yunsoft.com/dark-logo.png',
10
+ ]);
6
11
 
7
12
  function invalid(message) {
8
13
  const error = new Error(message);
@@ -10,6 +15,12 @@ function invalid(message) {
10
15
  return error;
11
16
  }
12
17
 
18
+ function notFound(message) {
19
+ const error = new Error(message);
20
+ error.code = 'NOT_FOUND';
21
+ return error;
22
+ }
23
+
13
24
  function assertManager(accountability) {
14
25
  if (accountability.admin === true || accountability.system === true) return;
15
26
  const error = new Error('Studio settings require administrator accountability');
@@ -25,16 +36,17 @@ function normalizeBrandName(value) {
25
36
  }
26
37
 
27
38
  function normalizeLogoUrl(value) {
28
- if (typeof value !== 'string' || !value.trim()) throw invalid('Logo URL is required');
29
- const normalized = value.trim();
30
- if (normalized.length > 512) throw invalid('Logo URL cannot exceed 512 characters');
31
- let url;
32
- try {
33
- url = new URL(normalized);
34
- } catch {
35
- throw invalid('Logo URL must be a valid URL');
39
+ const normalized = String(value ?? '').trim();
40
+ if (!YUNSOFT_LOGOS.has(normalized)) {
41
+ throw invalid('External logo URLs are not supported; choose an image from Files');
36
42
  }
37
- if (!['http:', 'https:'].includes(url.protocol)) throw invalid('Logo URL must use HTTP or HTTPS');
43
+ return normalized;
44
+ }
45
+
46
+ function normalizeImageFile(value, label) {
47
+ if (value == null || value === '') return null;
48
+ const normalized = String(value).trim();
49
+ if (!UUID_PATTERN.test(normalized)) throw invalid(`${label} file id must be a UUID`);
38
50
  return normalized;
39
51
  }
40
52
 
@@ -60,6 +72,8 @@ function publicSettings(row) {
60
72
  return {
61
73
  brand_name: row.brand_name,
62
74
  logo_url: row.logo_url,
75
+ logo_file: row.logo_file ?? null,
76
+ favicon_file: row.favicon_file ?? null,
63
77
  accent_color: row.accent_color,
64
78
  theme: row.theme,
65
79
  default_locale: row.default_locale,
@@ -70,7 +84,7 @@ function publicSettings(row) {
70
84
  export class StudioSettingsService extends BaseService {
71
85
  async readPublic() {
72
86
  const [rows] = await this.database.query(
73
- `SELECT brand_name, logo_url, accent_color, theme, default_locale, updated_at
87
+ `SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale, updated_at
74
88
  FROM yuncms_studio_settings
75
89
  WHERE id = 1
76
90
  LIMIT 1`,
@@ -89,12 +103,56 @@ export class StudioSettingsService extends BaseService {
89
103
  return this.readPublic();
90
104
  }
91
105
 
106
+ async readImageAssetContent(settingKey, label) {
107
+ const settings = await this.readPublic();
108
+ const fileId = settings[settingKey];
109
+ if (!fileId) throw notFound(`No file-backed Studio ${label.toLowerCase()} is configured`);
110
+ if (!this.storage) throw new Error(`StudioSettingsService requires storage to read a file-backed ${label.toLowerCase()}`);
111
+
112
+ const [rows] = await this.database.query(
113
+ `SELECT id, storage, filename_disk, mimetype, filesize
114
+ FROM yuncms_files
115
+ WHERE id = ?
116
+ LIMIT 1`,
117
+ [fileId],
118
+ );
119
+ const file = rows[0];
120
+ if (!file) throw notFound(`Configured Studio ${label.toLowerCase()} file does not exist`);
121
+ if (!String(file.mimetype || '').toLowerCase().startsWith('image/')) {
122
+ throw invalid(`Configured Studio ${label.toLowerCase()} must be an image`);
123
+ }
124
+
125
+ const driver = this.storage.get(file.storage);
126
+ const contents = await driver.get(file.filename_disk);
127
+ return { file, contents };
128
+ }
129
+
130
+ async readLogoContent() {
131
+ return this.readImageAssetContent('logo_file', 'Logo');
132
+ }
133
+
134
+ async readFaviconContent() {
135
+ return this.readImageAssetContent('favicon_file', 'Favicon');
136
+ }
137
+
138
+ async validateSelectedImage(fileId, label) {
139
+ if (!fileId) return;
140
+ const [files] = await this.database.query(
141
+ 'SELECT id, mimetype FROM yuncms_files WHERE id = ? LIMIT 1',
142
+ [fileId],
143
+ );
144
+ if (!files[0]) throw invalid(`Selected ${label.toLowerCase()} file does not exist`);
145
+ if (!String(files[0].mimetype || '').toLowerCase().startsWith('image/')) {
146
+ throw invalid(`Selected ${label.toLowerCase()} file must be an image`);
147
+ }
148
+ }
149
+
92
150
  async updateOne(patch = {}) {
93
151
  assertManager(this.accountability);
94
152
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
95
153
 
96
154
  const keys = Object.keys(patch);
97
- const allowed = new Set(['brand_name', 'logo_url', 'accent_color', 'theme', 'default_locale']);
155
+ const allowed = new Set(['brand_name', 'logo_url', 'logo_file', 'favicon_file', 'accent_color', 'theme', 'default_locale']);
98
156
  if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
99
157
  throw invalid('Studio settings patch contains unsupported properties');
100
158
  }
@@ -109,6 +167,18 @@ export class StudioSettingsService extends BaseService {
109
167
  assignments.push('logo_url = ?');
110
168
  params.push(normalizeLogoUrl(patch.logo_url));
111
169
  }
170
+ if (Object.hasOwn(patch, 'logo_file')) {
171
+ const fileId = normalizeImageFile(patch.logo_file, 'Logo');
172
+ await this.validateSelectedImage(fileId, 'Logo');
173
+ assignments.push('logo_file = ?');
174
+ params.push(fileId);
175
+ }
176
+ if (Object.hasOwn(patch, 'favicon_file')) {
177
+ const fileId = normalizeImageFile(patch.favicon_file, 'Favicon');
178
+ await this.validateSelectedImage(fileId, 'Favicon');
179
+ assignments.push('favicon_file = ?');
180
+ params.push(fileId);
181
+ }
112
182
  if (Object.hasOwn(patch, 'accent_color')) {
113
183
  assignments.push('accent_color = ?');
114
184
  params.push(normalizeAccent(patch.accent_color));
@@ -134,6 +204,8 @@ export class StudioSettingsService extends BaseService {
134
204
  export const STUDIO_SETTING_DEFAULTS = Object.freeze({
135
205
  brand_name: 'YunCMS',
136
206
  logo_url: 'https://yunsoft.com/light-logo.png',
207
+ logo_file: null,
208
+ favicon_file: null,
137
209
  accent_color: '#2563eb',
138
210
  theme: 'system',
139
211
  default_locale: 'en',
@@ -0,0 +1,122 @@
1
+ import { withAdvisoryLock } from '../advisory-lock.js';
2
+ import { compileFieldColumn } from '../field-types.js';
3
+ import { assertIdentifier, quoteIdentifier } from '../identifier.js';
4
+ import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
5
+ import { resolveSchemaName } from '../schema-key.js';
6
+ import { incrementSchemaVersion } from '../schema-version.js';
7
+ import { isPermissionManagedSystemResource } from '../system-permissions.js';
8
+ import { withConnectionTransaction } from '../transaction.js';
9
+ import { BaseService } from './base-service.js';
10
+ import { assertSchemaManager } from './schema-access.js';
11
+
12
+ function fieldName(value) {
13
+ assertIdentifier(value, 'field name');
14
+ if (value.length > 64) {
15
+ const error = new Error('Field name cannot exceed 64 characters');
16
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
17
+ throw error;
18
+ }
19
+ if (value === 'id') {
20
+ const error = new Error('The id field cannot be replaced');
21
+ error.code = 'FIELD_EXISTS';
22
+ throw error;
23
+ }
24
+ return value;
25
+ }
26
+
27
+ function assertExtensibleSystemCollection(collection) {
28
+ if (!collection?.system || !isPermissionManagedSystemResource(collection)) {
29
+ const error = new Error(`System collection is not extensible: ${collection?.collection || 'unknown'}`);
30
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ function assertSystemExtensionInput(input = {}) {
36
+ if (input.required === true) {
37
+ const error = new Error('Custom system collection fields must be optional in V1');
38
+ error.code = 'SYSTEM_EXTENSION_REQUIRED_UNSUPPORTED';
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ export class SystemCollectionFieldsService extends BaseService {
44
+ async createOne(collectionName, input = {}) {
45
+ assertSchemaManager(this.accountability);
46
+ assertIdentifier(collectionName, 'collection name');
47
+ assertSystemExtensionInput(input);
48
+ const resolvedName = resolveSchemaName({
49
+ displayName: input.name ?? input.field,
50
+ key: input.field,
51
+ prefix: 'field',
52
+ });
53
+ const field = fieldName(resolvedName.key);
54
+ const name = resolvedName.name;
55
+ const compiled = compileFieldColumn({ ...input, required: false });
56
+
57
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
58
+ const metadata = new SchemaMetadataRepository(connection);
59
+ const collection = await metadata.readCollection(collectionName);
60
+ if (!collection) {
61
+ const error = new Error(`Unknown collection: ${collectionName}`);
62
+ error.code = 'COLLECTION_NOT_FOUND';
63
+ throw error;
64
+ }
65
+ assertExtensibleSystemCollection(collection);
66
+
67
+ const existing = await metadata.readField(collectionName, field);
68
+ if (existing) {
69
+ const error = new Error(`Field already exists: ${collectionName}.${field}`);
70
+ error.code = 'FIELD_EXISTS';
71
+ throw error;
72
+ }
73
+
74
+ const table = quoteIdentifier(collectionName, 'collection name');
75
+ const column = quoteIdentifier(field, 'field name');
76
+ let physicalCreated = false;
77
+
78
+ try {
79
+ await connection.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${compiled.sql}`, compiled.params);
80
+ physicalCreated = true;
81
+
82
+ return await withConnectionTransaction(connection, async () => {
83
+ const created = await metadata.createField({
84
+ collection: collectionName,
85
+ field,
86
+ name,
87
+ type: input.type,
88
+ required: false,
89
+ readonly: input.readonly === true,
90
+ hidden: input.hidden === true,
91
+ sort: input.sort ?? null,
92
+ interface: input.interface ?? null,
93
+ options: input.options ?? null,
94
+ schemaMetadata: {
95
+ ...compiled.schemaMetadata,
96
+ systemExtension: true,
97
+ },
98
+ });
99
+ const schemaVersion = await incrementSchemaVersion(connection);
100
+ return { ...created, schemaVersion };
101
+ });
102
+ } catch (error) {
103
+ try {
104
+ await metadata.deleteField(collectionName, field);
105
+ } catch (cleanupError) {
106
+ error.cleanupMetadataError = cleanupError;
107
+ }
108
+ if (physicalCreated) {
109
+ try {
110
+ await connection.query(`ALTER TABLE ${table} DROP COLUMN ${column}`);
111
+ } catch (cleanupError) {
112
+ error.cleanupPhysicalError = cleanupError;
113
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
114
+ }
115
+ }
116
+ throw error;
117
+ }
118
+ });
119
+ }
120
+ }
121
+
122
+ export { assertExtensibleSystemCollection, assertSystemExtensionInput };