@yunsoft/yuncms-core 0.1.1 → 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/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.1",
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",
@@ -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
@@ -6,6 +6,10 @@ import { publicRoleConstraintsMigration } from './migrations/0003-public-role-co
6
6
  import { authActionTokensMigration } from './migrations/0004-auth-action-tokens.js';
7
7
  import { defaultPublicRoleMigration } from './migrations/0005-default-public-role.js';
8
8
  import { studioSettingsMigration } from './migrations/0006-studio-settings.js';
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';
9
13
  import { readSchemaVersion } from './schema-version.js';
10
14
  import { ensurePublicRole } from './setup.js';
11
15
 
@@ -16,6 +20,10 @@ export const CORE_MIGRATIONS = Object.freeze([
16
20
  authActionTokensMigration,
17
21
  defaultPublicRoleMigration,
18
22
  studioSettingsMigration,
23
+ systemPermissionResourcesMigration,
24
+ studioLogoFileMigration,
25
+ schemaDisplayNamesMigration,
26
+ studioFaviconFileMigration,
19
27
  ]);
20
28
 
21
29
  export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
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 {
@@ -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,7 +1,8 @@
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';
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 {
@@ -20,6 +21,7 @@ export {
20
21
  export { HookEmitter } from './hooks.js';
21
22
  export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
22
23
  export { deleteM2MJunction } from './m2m-lifecycle.js';
24
+ export { createO2ORelation, deleteO2ORelation, o2oUniqueIndexName } from './o2o-relation.js';
23
25
  export {
24
26
  MAX_EXPAND_FIELDS,
25
27
  parseExpandInput,
@@ -40,6 +42,7 @@ export { AuditService, redactAuditValue } from './services/audit-service.js';
40
42
  export { ItemsService } from './services/items-service.js';
41
43
  export { CollectionsService } from './services/collections-service.js';
42
44
  export { FieldsService } from './services/fields-service.js';
45
+ export { SystemCollectionFieldsService } from './services/system-collection-fields-service.js';
43
46
  export { RelationsService } from './services/relations-service.js';
44
47
  export { UsersService } from './services/users-service.js';
45
48
  export { RolesService } from './services/roles-service.js';
@@ -50,6 +53,20 @@ export { StudioSettingsService, STUDIO_SETTING_DEFAULTS } from './services/studi
50
53
  export { SchemaMetadataRepository } from './schema-metadata-repository.js';
51
54
  export { loadSchemaSnapshot, SchemaCache } from './schema.js';
52
55
  export { assertFieldType, compileFieldColumn } from './field-types.js';
56
+ export {
57
+ COLLECTION_SYSTEM_FIELDS,
58
+ compileCollectionSystemFields,
59
+ fieldSpecial,
60
+ isSystemManagedField,
61
+ normalizeCollectionSystemFields,
62
+ systemMutationEntries,
63
+ } from './system-fields.js';
64
+ export {
65
+ assertActionOnlyPermissionPayload,
66
+ assertSystemResourceAction,
67
+ isPermissionManagedSystemResource,
68
+ systemPermissionConfig,
69
+ } from './system-permissions.js';
53
70
  export {
54
71
  parseItemsQuery,
55
72
  compileSelectFields,
@@ -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,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,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
+ }
@@ -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
+ }