@yunsoft/yuncms-core 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -3,3 +3,9 @@
3
3
  Core services, schema, authentication, permissions, hooks and storage primitives used by YunCMS.
4
4
 
5
5
  This package is installed by `@yunsoft/yuncms` and `@yunsoft/yuncms-api`. See the [project repository](https://github.com/Yunsoft-Software/yuncms) for architecture and service documentation.
6
+
7
+ ## Project status
8
+
9
+ YunCMS is developed and maintained by [Yunsoft Software](https://yunsoft.com). It is under active development, so interfaces and behavior may change between releases. Test upgrades and keep verified backups before production use.
10
+
11
+ Use YunCMS at your own risk. This package is provided under the [MIT License](https://github.com/Yunsoft-Software/yuncms/blob/16-08-2026/LICENSE) without warranty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  export async function readAuthenticationUserByEmail(database, email) {
2
2
  const [rows] = await database.query(
3
3
  `SELECT u.id, u.email, u.password_hash, u.role, u.status, u.email_verified_at,
4
- r.admin AS role_admin, r.public AS role_public
4
+ r.name AS role_name, r.admin AS role_admin, r.public AS role_public
5
5
  FROM yuncms_users u
6
6
  LEFT JOIN yuncms_roles r ON r.id = u.role
7
7
  WHERE u.email = ?
@@ -14,7 +14,7 @@ export async function readAuthenticationUserByEmail(database, email) {
14
14
  export async function readAuthenticationUserById(database, id) {
15
15
  const [rows] = await database.query(
16
16
  `SELECT u.id, u.email, u.password_hash, u.role, u.status, u.email_verified_at,
17
- r.admin AS role_admin, r.public AS role_public
17
+ r.name AS role_name, r.admin AS role_admin, r.public AS role_public
18
18
  FROM yuncms_users u
19
19
  LEFT JOIN yuncms_roles r ON r.id = u.role
20
20
  WHERE u.id = ?
package/src/bootstrap.js CHANGED
@@ -4,13 +4,20 @@ import { systemSchemaMigration } from './migrations/0001-system-schema.js';
4
4
  import { sessionAccessTokensMigration } from './migrations/0002-session-access-tokens.js';
5
5
  import { publicRoleConstraintsMigration } from './migrations/0003-public-role-constraints.js';
6
6
  import { authActionTokensMigration } from './migrations/0004-auth-action-tokens.js';
7
+ import { defaultPublicRoleMigration } from './migrations/0005-default-public-role.js';
8
+ import { studioSettingsMigration } from './migrations/0006-studio-settings.js';
9
+ import { systemPermissionResourcesMigration } from './migrations/0007-system-permission-resources.js';
7
10
  import { readSchemaVersion } from './schema-version.js';
11
+ import { ensurePublicRole } from './setup.js';
8
12
 
9
13
  export const CORE_MIGRATIONS = Object.freeze([
10
14
  systemSchemaMigration,
11
15
  sessionAccessTokensMigration,
12
16
  publicRoleConstraintsMigration,
13
17
  authActionTokensMigration,
18
+ defaultPublicRoleMigration,
19
+ studioSettingsMigration,
20
+ systemPermissionResourcesMigration,
14
21
  ]);
15
22
 
16
23
  export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
@@ -23,10 +30,17 @@ export async function bootstrapDatabase(pool, { lockTimeoutSeconds = 10 } = {})
23
30
  'yuncms:bootstrap',
24
31
  async (connection) => {
25
32
  const migrationResult = await applyMigrations(connection, CORE_MIGRATIONS);
33
+ const publicRole = await ensurePublicRole(connection);
26
34
  const schemaVersion = await readSchemaVersion(connection);
35
+ const publicRoleMigrated = migrationResult.newlyApplied.includes(defaultPublicRoleMigration.id);
27
36
 
28
37
  return {
29
38
  ...migrationResult,
39
+ publicRole: {
40
+ id: publicRole.id,
41
+ name: publicRole.name,
42
+ created: publicRole.created || publicRoleMigrated,
43
+ },
30
44
  schemaVersion,
31
45
  };
32
46
  },
package/src/config.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { loadEnvFile } from 'node:process';
2
2
 
3
+ export const DEFAULT_SERVER_PORT = 3008;
4
+
3
5
  function readInteger(value, fallback, name, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
4
6
  if (value === undefined || value === '') return fallback;
5
7
 
@@ -33,7 +35,7 @@ export function loadEnvFileIfPresent(path = '.env') {
33
35
  }
34
36
 
35
37
  export function loadConfig(env = process.env) {
36
- const serverPort = readInteger(env.PORT, 3008, 'PORT', { min: 1, max: 65535 });
38
+ const serverPort = readInteger(env.PORT, DEFAULT_SERVER_PORT, 'PORT', { min: 1, max: 65535 });
37
39
  const studioOrigin = readString(env.STUDIO_ORIGIN, `http://localhost:${serverPort}`);
38
40
 
39
41
  return {
@@ -41,6 +43,7 @@ export function loadConfig(env = process.env) {
41
43
  host: readString(env.HOST, '127.0.0.1'),
42
44
  port: serverPort,
43
45
  studioOrigin,
46
+ trustProxyHops: readInteger(env.TRUST_PROXY_HOPS, 0, 'TRUST_PROXY_HOPS', { min: 0, max: 10 }),
44
47
  },
45
48
  logging: {
46
49
  level: readString(env.LOG_LEVEL, 'info'),
@@ -12,6 +12,9 @@ const TYPE_NAMES = new Set([
12
12
  'uuid',
13
13
  ]);
14
14
 
15
+ const FILE_INTERFACES = new Set(['file', 'image']);
16
+ const CURRENT_TIME_TYPES = new Set(['datetime', 'timestamp']);
17
+
15
18
  function integerOption(value, fallback, { min, max, label }) {
16
19
  const resolved = value ?? fallback;
17
20
  if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
@@ -29,8 +32,47 @@ export function assertFieldType(type) {
29
32
  return type;
30
33
  }
31
34
 
35
+ function assertInterfaceStorage(type, fieldInterface) {
36
+ if (!FILE_INTERFACES.has(fieldInterface)) return;
37
+ if (type !== 'uuid') {
38
+ const error = new Error(`${fieldInterface} interface requires uuid storage`);
39
+ error.code = 'INVALID_FIELD_INTERFACE';
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ function assertTimePresets(type, input) {
45
+ if (Object.hasOwn(input, 'defaultValue') && input.defaultPreset != null) {
46
+ const error = new Error('defaultValue and defaultPreset cannot be used together');
47
+ error.code = 'INVALID_FIELD_DEFAULT';
48
+ throw error;
49
+ }
50
+ if (input.defaultPreset != null && input.defaultPreset !== 'now') {
51
+ const error = new Error(`Unsupported default preset: ${String(input.defaultPreset)}`);
52
+ error.code = 'UNSUPPORTED_FIELD_DEFAULT';
53
+ throw error;
54
+ }
55
+ if (input.defaultPreset === 'now' && !CURRENT_TIME_TYPES.has(type)) {
56
+ const error = new Error('Current-time defaults require datetime or timestamp storage');
57
+ error.code = 'UNSUPPORTED_FIELD_DEFAULT';
58
+ throw error;
59
+ }
60
+ if (input.autoUpdate != null && typeof input.autoUpdate !== 'boolean') {
61
+ const error = new Error('autoUpdate must be boolean');
62
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
63
+ throw error;
64
+ }
65
+ if (input.autoUpdate === true && !CURRENT_TIME_TYPES.has(type)) {
66
+ const error = new Error('Automatic update timestamps require datetime or timestamp storage');
67
+ error.code = 'UNSUPPORTED_FIELD_DEFAULT';
68
+ throw error;
69
+ }
70
+ }
71
+
32
72
  export function compileFieldColumn(input = {}) {
33
73
  const type = assertFieldType(input.type);
74
+ assertInterfaceStorage(type, input.interface);
75
+ assertTimePresets(type, input);
34
76
  const params = [];
35
77
  let sqlType;
36
78
 
@@ -81,7 +123,9 @@ export function compileFieldColumn(input = {}) {
81
123
  let sql = sqlType;
82
124
  sql += input.required === true ? ' NOT NULL' : ' NULL';
83
125
 
84
- if (Object.hasOwn(input, 'defaultValue')) {
126
+ if (input.defaultPreset === 'now') {
127
+ sql += ' DEFAULT CURRENT_TIMESTAMP(3)';
128
+ } else if (Object.hasOwn(input, 'defaultValue')) {
85
129
  if (type === 'text' || type === 'json') {
86
130
  const error = new Error(`Defaults for ${type} fields are postponed in V1`);
87
131
  error.code = 'UNSUPPORTED_FIELD_DEFAULT';
@@ -97,6 +141,8 @@ export function compileFieldColumn(input = {}) {
97
141
  }
98
142
  }
99
143
 
144
+ if (input.autoUpdate === true) sql += ' ON UPDATE CURRENT_TIMESTAMP(3)';
145
+
100
146
  return {
101
147
  sql,
102
148
  params,
@@ -105,6 +151,8 @@ export function compileFieldColumn(input = {}) {
105
151
  precision: type === 'decimal' ? (input.precision ?? 18) : undefined,
106
152
  scale: type === 'decimal' ? (input.scale ?? 2) : undefined,
107
153
  defaultValue: Object.hasOwn(input, 'defaultValue') ? input.defaultValue : undefined,
154
+ defaultPreset: input.defaultPreset ?? undefined,
155
+ autoUpdate: input.autoUpdate === true ? true : undefined,
108
156
  },
109
157
  };
110
158
  }
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { loadConfig, loadEnvFileIfPresent } from './config.js';
1
+ export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.js';
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';
@@ -11,10 +11,16 @@ export {
11
11
  requireAccountability,
12
12
  } from './accountability.js';
13
13
  export { createRequestContext } from './context.js';
14
- export { createInitialAdmin, findExistingAdmin } from './setup.js';
14
+ export {
15
+ createInitialAdmin,
16
+ findExistingAdmin,
17
+ findPublicRole,
18
+ ensurePublicRole,
19
+ } from './setup.js';
15
20
  export { HookEmitter } from './hooks.js';
16
21
  export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
17
22
  export { deleteM2MJunction } from './m2m-lifecycle.js';
23
+ export { createO2ORelation, deleteO2ORelation, o2oUniqueIndexName } from './o2o-relation.js';
18
24
  export {
19
25
  MAX_EXPAND_FIELDS,
20
26
  parseExpandInput,
@@ -41,9 +47,24 @@ export { RolesService } from './services/roles-service.js';
41
47
  export { PermissionsService } from './services/permissions-service.js';
42
48
  export { FilesService } from './services/files-service.js';
43
49
  export { FileReconciliationService } from './services/file-reconciliation-service.js';
50
+ export { StudioSettingsService, STUDIO_SETTING_DEFAULTS } from './services/studio-settings-service.js';
44
51
  export { SchemaMetadataRepository } from './schema-metadata-repository.js';
45
52
  export { loadSchemaSnapshot, SchemaCache } from './schema.js';
46
53
  export { assertFieldType, compileFieldColumn } from './field-types.js';
54
+ export {
55
+ COLLECTION_SYSTEM_FIELDS,
56
+ compileCollectionSystemFields,
57
+ fieldSpecial,
58
+ isSystemManagedField,
59
+ normalizeCollectionSystemFields,
60
+ systemMutationEntries,
61
+ } from './system-fields.js';
62
+ export {
63
+ assertActionOnlyPermissionPayload,
64
+ assertSystemResourceAction,
65
+ isPermissionManagedSystemResource,
66
+ systemPermissionConfig,
67
+ } from './system-permissions.js';
47
68
  export {
48
69
  parseItemsQuery,
49
70
  compileSelectFields,
@@ -64,4 +85,4 @@ export {
64
85
  REQUIRED_CORE_MIGRATION_IDS,
65
86
  bootstrapDatabase,
66
87
  assertDatabaseCompatible,
67
- } from './bootstrap.js';
88
+ } from './bootstrap.js';
@@ -0,0 +1,16 @@
1
+ export const defaultPublicRoleMigration = {
2
+ id: '0005-default-public-role',
3
+ statements: [
4
+ `INSERT INTO yuncms_roles (id, name, description, admin, public)
5
+ SELECT UUID(),
6
+ CASE
7
+ WHEN EXISTS (SELECT 1 FROM yuncms_roles AS named WHERE named.name = 'Public')
8
+ THEN CONCAT('Public ', LEFT(REPLACE(UUID(), '-', ''), 8))
9
+ ELSE 'Public'
10
+ END,
11
+ 'Unauthenticated public API access. No collection access is granted by default.',
12
+ 0,
13
+ 1
14
+ WHERE NOT EXISTS (SELECT 1 FROM yuncms_roles AS existing WHERE existing.public = 1)`,
15
+ ],
16
+ };
@@ -0,0 +1,20 @@
1
+ export const studioSettingsMigration = {
2
+ id: '0006-studio-settings',
3
+ statements: [
4
+ `CREATE TABLE IF NOT EXISTS yuncms_studio_settings (
5
+ id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
6
+ brand_name VARCHAR(100) NOT NULL DEFAULT 'YunCMS',
7
+ logo_url VARCHAR(512) NOT NULL DEFAULT 'https://yunsoft.com/light-logo.png',
8
+ accent_color CHAR(7) NOT NULL DEFAULT '#2563eb',
9
+ theme VARCHAR(16) NOT NULL DEFAULT 'system',
10
+ default_locale VARCHAR(5) NOT NULL DEFAULT 'en',
11
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
12
+ CONSTRAINT chk_yuncms_studio_settings_singleton CHECK (id = 1),
13
+ CONSTRAINT chk_yuncms_studio_settings_theme CHECK (theme IN ('system', 'light', 'dark')),
14
+ CONSTRAINT chk_yuncms_studio_settings_locale CHECK (default_locale IN ('en', 'tr'))
15
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
16
+ `INSERT IGNORE INTO yuncms_studio_settings
17
+ (id, brand_name, logo_url, accent_color, theme, default_locale)
18
+ VALUES (1, 'YunCMS', 'https://yunsoft.com/light-logo.png', '#2563eb', 'system', 'en')`,
19
+ ],
20
+ };
@@ -0,0 +1,46 @@
1
+ export const systemPermissionResourcesMigration = {
2
+ id: '0007-system-permission-resources',
3
+ statements: [
4
+ `INSERT IGNORE INTO yuncms_collections
5
+ (collection, primary_key, note, singleton, hidden, \`system\`, metadata)
6
+ VALUES
7
+ ('yuncms_users', 'id', 'System users resource', 0, 1, 1,
8
+ JSON_OBJECT('permissionManaged', TRUE, 'permissionMode', 'action-only', 'resource', 'users',
9
+ 'allowedActions', JSON_ARRAY('read', 'create', 'update', 'delete'))),
10
+ ('yuncms_files', 'id', 'System files resource', 0, 1, 1,
11
+ JSON_OBJECT('permissionManaged', TRUE, 'permissionMode', 'action-only', 'resource', 'files',
12
+ 'allowedActions', JSON_ARRAY('read', 'create', 'update', 'delete'))),
13
+ ('yuncms_roles', 'id', 'System roles resource', 0, 1, 1,
14
+ JSON_OBJECT('permissionManaged', TRUE, 'permissionMode', 'action-only', 'resource', 'roles',
15
+ 'allowedActions', JSON_ARRAY('read')))`,
16
+
17
+ `INSERT IGNORE INTO yuncms_fields
18
+ (collection, field, type, required, readonly, hidden, interface, schema_metadata)
19
+ VALUES
20
+ ('yuncms_users', 'id', 'uuid', 1, 1, 0, 'input', JSON_OBJECT('primaryKey', TRUE, 'length', 36)),
21
+ ('yuncms_users', 'email', 'string', 1, 0, 0, 'input', JSON_OBJECT('length', 191)),
22
+ ('yuncms_users', 'role', 'uuid', 0, 0, 0, 'input', JSON_OBJECT('length', 36)),
23
+ ('yuncms_users', 'status', 'string', 1, 0, 0, 'input', JSON_OBJECT('length', 32)),
24
+ ('yuncms_users', 'email_verified_at', 'datetime', 0, 1, 0, 'datetime', NULL),
25
+ ('yuncms_users', 'last_access', 'datetime', 0, 1, 0, 'datetime', NULL),
26
+ ('yuncms_users', 'created_at', 'datetime', 1, 1, 0, 'datetime', JSON_OBJECT('special', 'date-created', 'systemManaged', TRUE)),
27
+ ('yuncms_users', 'updated_at', 'datetime', 1, 1, 0, 'datetime', JSON_OBJECT('special', 'date-updated', 'systemManaged', TRUE)),
28
+
29
+ ('yuncms_files', 'id', 'uuid', 1, 1, 0, 'input', JSON_OBJECT('primaryKey', TRUE, 'length', 36)),
30
+ ('yuncms_files', 'storage', 'string', 1, 1, 0, 'input', JSON_OBJECT('length', 64)),
31
+ ('yuncms_files', 'filename_download', 'string', 1, 0, 0, 'input', JSON_OBJECT('length', 255)),
32
+ ('yuncms_files', 'title', 'string', 0, 0, 0, 'input', JSON_OBJECT('length', 255)),
33
+ ('yuncms_files', 'mimetype', 'string', 0, 1, 0, 'input', JSON_OBJECT('length', 191)),
34
+ ('yuncms_files', 'filesize', 'bigint', 0, 1, 0, 'input', NULL),
35
+ ('yuncms_files', 'uploaded_by', 'uuid', 0, 1, 0, 'user', JSON_OBJECT('length', 36)),
36
+ ('yuncms_files', 'uploaded_at', 'datetime', 1, 1, 0, 'datetime', JSON_OBJECT('special', 'date-created', 'systemManaged', TRUE)),
37
+
38
+ ('yuncms_roles', 'id', 'uuid', 1, 1, 0, 'input', JSON_OBJECT('primaryKey', TRUE, 'length', 36)),
39
+ ('yuncms_roles', 'name', 'string', 1, 1, 0, 'input', JSON_OBJECT('length', 100)),
40
+ ('yuncms_roles', 'description', 'text', 0, 1, 0, 'textarea', NULL),
41
+ ('yuncms_roles', 'admin', 'boolean', 1, 1, 0, 'boolean', NULL),
42
+ ('yuncms_roles', 'public', 'boolean', 1, 1, 0, 'boolean', NULL)`,
43
+
44
+ `UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1`,
45
+ ],
46
+ };
@@ -0,0 +1,231 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { withAdvisoryLock } from './advisory-lock.js';
4
+ import { assertIdentifier, quoteIdentifier } from './identifier.js';
5
+ import { SchemaMetadataRepository } from './schema-metadata-repository.js';
6
+ import { incrementSchemaVersion } from './schema-version.js';
7
+ import { withConnectionTransaction } from './transaction.js';
8
+ import { assertSchemaManager } from './services/schema-access.js';
9
+
10
+ const ON_DELETE_ACTIONS = new Set(['RESTRICT', 'CASCADE', 'SET NULL']);
11
+
12
+ function parseMetadata(value) {
13
+ if (!value) return {};
14
+ if (typeof value === 'object') return value;
15
+ try {
16
+ const parsed = JSON.parse(value);
17
+ return parsed && typeof parsed === 'object' ? parsed : {};
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function assertOnDelete(value) {
24
+ const action = String(value ?? 'RESTRICT').toUpperCase();
25
+ if (!ON_DELETE_ACTIONS.has(action)) {
26
+ const error = new Error(`Unsupported ON DELETE action: ${action}`);
27
+ error.code = 'INVALID_ON_DELETE';
28
+ throw error;
29
+ }
30
+ return action;
31
+ }
32
+
33
+ function constraintName(manyCollection, manyField, oneCollection) {
34
+ const digest = createHash('sha256')
35
+ .update(`${manyCollection}:${manyField}:${oneCollection}`)
36
+ .digest('hex')
37
+ .slice(0, 24);
38
+ return `yfk_${digest}`;
39
+ }
40
+
41
+ export function o2oUniqueIndexName(collection, field) {
42
+ const digest = createHash('sha256')
43
+ .update(`${collection}:${field}:o2o`)
44
+ .digest('hex')
45
+ .slice(0, 24);
46
+ return `yuo_${digest}`;
47
+ }
48
+
49
+ export async function createO2ORelation({ database, accountability, input = {} }) {
50
+ assertSchemaManager(accountability);
51
+ const manyCollection = assertIdentifier(input.manyCollection, 'one-to-one source collection');
52
+ const manyField = assertIdentifier(input.manyField, 'one-to-one source field');
53
+ const oneCollection = assertIdentifier(input.oneCollection, 'one-to-one target collection');
54
+ const onDelete = assertOnDelete(input.onDelete);
55
+
56
+ return withAdvisoryLock(database, 'yuncms:schema', async (connection) => {
57
+ const metadata = new SchemaMetadataRepository(connection);
58
+ const [manyCollectionMetadata, oneCollectionMetadata] = await Promise.all([
59
+ metadata.readCollection(manyCollection),
60
+ metadata.readCollection(oneCollection),
61
+ ]);
62
+
63
+ if (!manyCollectionMetadata || !oneCollectionMetadata) {
64
+ const error = new Error('Both one-to-one collections must exist');
65
+ error.code = 'COLLECTION_NOT_FOUND';
66
+ throw error;
67
+ }
68
+ if (manyCollectionMetadata.system || oneCollectionMetadata.system) {
69
+ const error = new Error('System collections cannot be changed through the dynamic relation API');
70
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
71
+ throw error;
72
+ }
73
+
74
+ const oneField = input.oneField ?? oneCollectionMetadata.primary_key;
75
+ assertIdentifier(oneField, 'one-to-one target field');
76
+ if (oneField !== oneCollectionMetadata.primary_key) {
77
+ const error = new Error('One-to-one relations must reference the target collection primary key');
78
+ error.code = 'UNSUPPORTED_RELATION_TARGET';
79
+ throw error;
80
+ }
81
+
82
+ const [manyFieldMetadata, oneFieldMetadata, existingRelation] = await Promise.all([
83
+ metadata.readField(manyCollection, manyField),
84
+ metadata.readField(oneCollection, oneField),
85
+ metadata.readRelation(manyCollection, manyField),
86
+ ]);
87
+ if (!manyFieldMetadata || !oneFieldMetadata) {
88
+ const error = new Error('Both one-to-one fields must exist in schema metadata');
89
+ error.code = 'FIELD_NOT_FOUND';
90
+ throw error;
91
+ }
92
+ if (existingRelation) {
93
+ const error = new Error(`Relation already exists for ${manyCollection}.${manyField}`);
94
+ error.code = 'RELATION_EXISTS';
95
+ throw error;
96
+ }
97
+ if (manyFieldMetadata.type !== oneFieldMetadata.type) {
98
+ const error = new Error(`Relation field types do not match: ${manyFieldMetadata.type} -> ${oneFieldMetadata.type}`);
99
+ error.code = 'RELATION_TYPE_MISMATCH';
100
+ throw error;
101
+ }
102
+ if (onDelete === 'SET NULL' && Boolean(manyFieldMetadata.required)) {
103
+ const error = new Error('SET NULL cannot be used with a required one-to-one field');
104
+ error.code = 'INVALID_ON_DELETE';
105
+ throw error;
106
+ }
107
+
108
+ const fkName = constraintName(manyCollection, manyField, oneCollection);
109
+ const uniqueIndex = o2oUniqueIndexName(manyCollection, manyField);
110
+ const tableSql = quoteIdentifier(manyCollection, 'one-to-one source collection');
111
+ const fieldSql = quoteIdentifier(manyField, 'one-to-one source field');
112
+ const targetTableSql = quoteIdentifier(oneCollection, 'one-to-one target collection');
113
+ const targetFieldSql = quoteIdentifier(oneField, 'one-to-one target field');
114
+ const constraintSql = quoteIdentifier(fkName, 'one-to-one foreign key');
115
+ const uniqueSql = quoteIdentifier(uniqueIndex, 'one-to-one unique index');
116
+ let physicalRelationCreated = false;
117
+
118
+ try {
119
+ await connection.query(
120
+ `ALTER TABLE ${tableSql}
121
+ ADD CONSTRAINT ${constraintSql}
122
+ FOREIGN KEY (${fieldSql}) REFERENCES ${targetTableSql} (${targetFieldSql})
123
+ ON DELETE ${onDelete},
124
+ ADD UNIQUE INDEX ${uniqueSql} (${fieldSql})`,
125
+ );
126
+ physicalRelationCreated = true;
127
+
128
+ return await withConnectionTransaction(connection, async () => {
129
+ const created = await metadata.createRelation({
130
+ manyCollection,
131
+ manyField,
132
+ oneCollection,
133
+ oneField,
134
+ onDelete,
135
+ metadata: { constraintName: fkName, kind: 'o2o', uniqueIndex },
136
+ });
137
+ const schemaVersion = await incrementSchemaVersion(connection);
138
+ return { ...created, schemaVersion };
139
+ });
140
+ } catch (error) {
141
+ const cleanupErrors = [];
142
+ try {
143
+ await metadata.deleteRelation(manyCollection, manyField);
144
+ } catch (cleanupError) {
145
+ cleanupErrors.push(cleanupError);
146
+ }
147
+ if (physicalRelationCreated) {
148
+ try {
149
+ await connection.query(
150
+ `ALTER TABLE ${tableSql}
151
+ DROP FOREIGN KEY ${constraintSql},
152
+ DROP INDEX ${uniqueSql}`,
153
+ );
154
+ } catch (cleanupError) {
155
+ cleanupErrors.push(cleanupError);
156
+ }
157
+ }
158
+ if (cleanupErrors.length > 0) {
159
+ error.cleanupErrors = cleanupErrors;
160
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
161
+ }
162
+ throw error;
163
+ }
164
+ });
165
+ }
166
+
167
+ export async function deleteO2ORelation({ database, accountability, manyCollection, manyField }) {
168
+ assertSchemaManager(accountability);
169
+ assertIdentifier(manyCollection, 'one-to-one source collection');
170
+ assertIdentifier(manyField, 'one-to-one source field');
171
+
172
+ return withAdvisoryLock(database, 'yuncms:schema', async (connection) => {
173
+ const metadata = new SchemaMetadataRepository(connection);
174
+ const relation = await metadata.readRelation(manyCollection, manyField);
175
+ if (!relation) {
176
+ const error = new Error(`Unknown relation: ${manyCollection}.${manyField}`);
177
+ error.code = 'RELATION_NOT_FOUND';
178
+ throw error;
179
+ }
180
+
181
+ const relationMetadata = parseMetadata(relation.metadata);
182
+ if (relationMetadata.kind !== 'o2o') {
183
+ const error = new Error(`Relation is not one-to-one: ${manyCollection}.${manyField}`);
184
+ error.code = 'RELATION_TYPE_MISMATCH';
185
+ throw error;
186
+ }
187
+
188
+ const tableSql = quoteIdentifier(relation.many_collection, 'one-to-one source collection');
189
+ const fieldSql = quoteIdentifier(relation.many_field, 'one-to-one source field');
190
+ const targetTableSql = quoteIdentifier(relation.one_collection, 'one-to-one target collection');
191
+ const targetFieldSql = quoteIdentifier(relation.one_field, 'one-to-one target field');
192
+ const constraintSql = quoteIdentifier(relationMetadata.constraintName, 'one-to-one foreign key');
193
+ const uniqueSql = quoteIdentifier(
194
+ relationMetadata.uniqueIndex || o2oUniqueIndexName(manyCollection, manyField),
195
+ 'one-to-one unique index',
196
+ );
197
+
198
+ await connection.query(
199
+ `ALTER TABLE ${tableSql}
200
+ DROP FOREIGN KEY ${constraintSql},
201
+ DROP INDEX ${uniqueSql}`,
202
+ );
203
+
204
+ try {
205
+ return await withConnectionTransaction(connection, async () => {
206
+ const deleted = await metadata.deleteRelation(manyCollection, manyField);
207
+ if (deleted !== 1) {
208
+ const error = new Error(`Relation metadata disappeared during delete: ${manyCollection}.${manyField}`);
209
+ error.code = 'SCHEMA_METADATA_DRIFT';
210
+ throw error;
211
+ }
212
+ const schemaVersion = await incrementSchemaVersion(connection);
213
+ return { deleted: true, schemaVersion };
214
+ });
215
+ } catch (error) {
216
+ try {
217
+ await connection.query(
218
+ `ALTER TABLE ${tableSql}
219
+ ADD CONSTRAINT ${constraintSql}
220
+ FOREIGN KEY (${fieldSql}) REFERENCES ${targetTableSql} (${targetFieldSql})
221
+ ON DELETE ${relation.on_delete},
222
+ ADD UNIQUE INDEX ${uniqueSql} (${fieldSql})`,
223
+ );
224
+ } catch (restoreError) {
225
+ error.restoreError = restoreError;
226
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
227
+ }
228
+ throw error;
229
+ }
230
+ });
231
+ }
@@ -2,6 +2,8 @@ function encodeJson(value) {
2
2
  return value == null ? null : JSON.stringify(value);
3
3
  }
4
4
 
5
+ const FILE_INTERFACES = new Set(['file', 'image']);
6
+
5
7
  export class SchemaMetadataRepository {
6
8
  constructor(database) {
7
9
  if (!database) throw new Error('Database handle is required');
@@ -128,6 +130,11 @@ export class SchemaMetadataRepository {
128
130
  options = null,
129
131
  schemaMetadata = null,
130
132
  }) {
133
+ if (FILE_INTERFACES.has(fieldInterface) && type !== 'uuid') {
134
+ const error = new Error(`${fieldInterface} interface requires uuid storage`);
135
+ error.code = 'INVALID_FIELD_INTERFACE';
136
+ throw error;
137
+ }
131
138
  await this.database.query(
132
139
  `INSERT INTO yuncms_fields
133
140
  (collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
@@ -152,6 +159,16 @@ export class SchemaMetadataRepository {
152
159
  const assignments = [];
153
160
  const params = [];
154
161
 
162
+ if (Object.hasOwn(patch, 'interface') && FILE_INTERFACES.has(patch.interface)) {
163
+ const existing = await this.readField(collection, field);
164
+ if (!existing) return null;
165
+ if (existing.type !== 'uuid') {
166
+ const error = new Error(`${patch.interface} interface requires uuid storage`);
167
+ error.code = 'INVALID_FIELD_INTERFACE';
168
+ throw error;
169
+ }
170
+ }
171
+
155
172
  if (Object.hasOwn(patch, 'readonly')) {
156
173
  assignments.push('readonly = ?');
157
174
  params.push(patch.readonly ? 1 : 0);
@@ -31,6 +31,7 @@ function publicUser(user) {
31
31
  id: user.id,
32
32
  email: user.email,
33
33
  role: user.role ?? null,
34
+ role_name: user.role_name ?? null,
34
35
  status: user.status,
35
36
  email_verified_at: user.email_verified_at ?? null,
36
37
  };
@@ -95,7 +96,7 @@ export class AuthService extends BaseService {
95
96
  if (tokenType(token) !== 'api') throw invalidToken();
96
97
  const [rows] = await this.database.query(
97
98
  `SELECT t.id AS api_token_id, t.user, u.email, u.role, u.status,
98
- r.admin AS role_admin
99
+ r.name AS role_name, r.admin AS role_admin
99
100
  FROM yuncms_api_tokens t
100
101
  INNER JOIN yuncms_users u ON u.id = t.user
101
102
  LEFT JOIN yuncms_roles r ON r.id = u.role
@@ -119,6 +120,7 @@ export class AuthService extends BaseService {
119
120
  return {
120
121
  user: row.user,
121
122
  role: row.role ?? null,
123
+ role_name: row.role_name ?? null,
122
124
  admin: Boolean(row.role_admin),
123
125
  email: row.email,
124
126
  session: null,
@@ -141,6 +143,7 @@ export class AuthService extends BaseService {
141
143
  id: result.user,
142
144
  email: result.email,
143
145
  role: result.role,
146
+ role_name: result.role_name ?? null,
144
147
  },
145
148
  access_token: result.access_token,
146
149
  access_expires_at: result.access_expires_at,