@yunsoft/yuncms-core 0.1.1 → 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 +6 -0
- package/package.json +1 -1
- package/src/auth/users-repository.js +2 -2
- package/src/bootstrap.js +2 -0
- package/src/config.js +3 -1
- package/src/field-types.js +49 -1
- package/src/index.js +17 -2
- package/src/migrations/0007-system-permission-resources.js +46 -0
- package/src/o2o-relation.js +231 -0
- package/src/schema-metadata-repository.js +17 -0
- package/src/services/auth-service.js +4 -1
- package/src/services/collections-service.js +18 -2
- package/src/services/fields-service.js +63 -27
- package/src/services/files-service.js +24 -23
- package/src/services/items-service.js +34 -12
- package/src/services/permissions-service.js +48 -13
- package/src/services/roles-service.js +4 -3
- package/src/services/sessions-service.js +3 -3
- package/src/services/system-resource-access.js +23 -0
- package/src/services/users-service.js +71 -28
- package/src/setup.js +1 -2
- package/src/system-fields.js +146 -0
- package/src/system-permissions.js +54 -0
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,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,7 @@ 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';
|
|
9
10
|
import { readSchemaVersion } from './schema-version.js';
|
|
10
11
|
import { ensurePublicRole } from './setup.js';
|
|
11
12
|
|
|
@@ -16,6 +17,7 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
16
17
|
authActionTokensMigration,
|
|
17
18
|
defaultPublicRoleMigration,
|
|
18
19
|
studioSettingsMigration,
|
|
20
|
+
systemPermissionResourcesMigration,
|
|
19
21
|
]);
|
|
20
22
|
|
|
21
23
|
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,
|
|
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 {
|
package/src/field-types.js
CHANGED
|
@@ -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 (
|
|
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';
|
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
export { HookEmitter } from './hooks.js';
|
|
21
21
|
export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
|
|
22
22
|
export { deleteM2MJunction } from './m2m-lifecycle.js';
|
|
23
|
+
export { createO2ORelation, deleteO2ORelation, o2oUniqueIndexName } from './o2o-relation.js';
|
|
23
24
|
export {
|
|
24
25
|
MAX_EXPAND_FIELDS,
|
|
25
26
|
parseExpandInput,
|
|
@@ -50,6 +51,20 @@ export { StudioSettingsService, STUDIO_SETTING_DEFAULTS } from './services/studi
|
|
|
50
51
|
export { SchemaMetadataRepository } from './schema-metadata-repository.js';
|
|
51
52
|
export { loadSchemaSnapshot, SchemaCache } from './schema.js';
|
|
52
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';
|
|
53
68
|
export {
|
|
54
69
|
parseItemsQuery,
|
|
55
70
|
compileSelectFields,
|
|
@@ -70,4 +85,4 @@ export {
|
|
|
70
85
|
REQUIRED_CORE_MIGRATION_IDS,
|
|
71
86
|
bootstrapDatabase,
|
|
72
87
|
assertDatabaseCompatible,
|
|
73
|
-
} from './bootstrap.js';
|
|
88
|
+
} from './bootstrap.js';
|
|
@@ -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,
|
|
@@ -4,6 +4,7 @@ import { withAdvisoryLock } from '../advisory-lock.js';
|
|
|
4
4
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
5
5
|
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
6
6
|
import { incrementSchemaVersion } from '../schema-version.js';
|
|
7
|
+
import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
|
|
7
8
|
import { withConnectionTransaction } from '../transaction.js';
|
|
8
9
|
import { BaseService } from './base-service.js';
|
|
9
10
|
import { assertSchemaManager } from './schema-access.js';
|
|
@@ -70,6 +71,7 @@ function assertCollectionCreateMetadata(input) {
|
|
|
70
71
|
)) {
|
|
71
72
|
throw invalidSchemaPayload('Collection metadata must be an object or null');
|
|
72
73
|
}
|
|
74
|
+
normalizeCollectionSystemFields(input.systemFields);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
function temporaryDropName() {
|
|
@@ -100,6 +102,8 @@ export class CollectionsService extends BaseService {
|
|
|
100
102
|
throw error;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
const systemFields = compileCollectionSystemFields(collection, input.systemFields);
|
|
106
|
+
|
|
103
107
|
return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
|
|
104
108
|
const metadata = new SchemaMetadataRepository(connection);
|
|
105
109
|
const existing = await metadata.readCollection(collection);
|
|
@@ -111,11 +115,16 @@ export class CollectionsService extends BaseService {
|
|
|
111
115
|
|
|
112
116
|
const table = quoteIdentifier(collection, 'collection name');
|
|
113
117
|
let tableCreated = false;
|
|
118
|
+
const physicalDefinitions = [
|
|
119
|
+
'id CHAR(36) NOT NULL PRIMARY KEY',
|
|
120
|
+
...systemFields.columns,
|
|
121
|
+
...systemFields.constraints,
|
|
122
|
+
];
|
|
114
123
|
|
|
115
124
|
try {
|
|
116
125
|
await connection.query(
|
|
117
126
|
`CREATE TABLE ${table} (
|
|
118
|
-
|
|
127
|
+
${physicalDefinitions.join(',\n ')}
|
|
119
128
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
120
129
|
);
|
|
121
130
|
tableCreated = true;
|
|
@@ -127,7 +136,10 @@ export class CollectionsService extends BaseService {
|
|
|
127
136
|
note: input.note ?? null,
|
|
128
137
|
singleton: input.singleton === true,
|
|
129
138
|
hidden: input.hidden === true,
|
|
130
|
-
metadata:
|
|
139
|
+
metadata: {
|
|
140
|
+
...(input.metadata ?? {}),
|
|
141
|
+
systemFields: systemFields.fields,
|
|
142
|
+
},
|
|
131
143
|
});
|
|
132
144
|
|
|
133
145
|
await metadata.createField({
|
|
@@ -140,6 +152,10 @@ export class CollectionsService extends BaseService {
|
|
|
140
152
|
schemaMetadata: { primaryKey: true, length: 36 },
|
|
141
153
|
});
|
|
142
154
|
|
|
155
|
+
for (const systemField of systemFields.metadata) {
|
|
156
|
+
await metadata.createField(systemField);
|
|
157
|
+
}
|
|
158
|
+
|
|
143
159
|
const schemaVersion = await incrementSchemaVersion(connection);
|
|
144
160
|
return { ...created, schemaVersion };
|
|
145
161
|
});
|