@yunsoft/yuncms-core 0.1.8 → 0.1.10
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 +1 -1
- package/src/bootstrap.js +6 -0
- package/src/index.js +5 -2
- package/src/migrations/0014-ai-settings.js +23 -0
- package/src/migrations/0015-navigation-groups.js +13 -0
- package/src/migrations/0016-navigation-group-collapse.js +7 -0
- package/src/services/core-services.js +5 -4
- package/src/services/navigation-groups-service.js +136 -0
- package/src/services/singleton-services.js +80 -0
package/package.json
CHANGED
package/src/bootstrap.js
CHANGED
|
@@ -13,6 +13,9 @@ import { studioFaviconFileMigration } from './migrations/0010-studio-favicon-fil
|
|
|
13
13
|
import { rolePermissionActionsMigration } from './migrations/0011-role-permission-actions.js';
|
|
14
14
|
import { filesReadFiltersMigration } from './migrations/0012-files-read-filters.js';
|
|
15
15
|
import { externalAuthFoundationMigration } from './migrations/0013-external-auth-foundation.js';
|
|
16
|
+
import { aiSettingsMigration } from './migrations/0014-ai-settings.js';
|
|
17
|
+
import { navigationGroupsMigration } from './migrations/0015-navigation-groups.js';
|
|
18
|
+
import { navigationGroupCollapseMigration } from './migrations/0016-navigation-group-collapse.js';
|
|
16
19
|
import { readSchemaVersion } from './schema-version.js';
|
|
17
20
|
import { ensurePublicRole } from './setup.js';
|
|
18
21
|
|
|
@@ -30,6 +33,9 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
30
33
|
rolePermissionActionsMigration,
|
|
31
34
|
filesReadFiltersMigration,
|
|
32
35
|
externalAuthFoundationMigration,
|
|
36
|
+
aiSettingsMigration,
|
|
37
|
+
navigationGroupsMigration,
|
|
38
|
+
navigationGroupCollapseMigration,
|
|
33
39
|
]);
|
|
34
40
|
|
|
35
41
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
package/src/index.js
CHANGED
|
@@ -57,8 +57,10 @@ export { AuthTokensService } from './services/auth-tokens-service.js';
|
|
|
57
57
|
export { ExternalAuthService, AUTH_TRANSACTION_TTL_MS } from './services/external-auth-service.js';
|
|
58
58
|
export { ApiTokensService } from './services/api-tokens-service.js';
|
|
59
59
|
export { AuditService, redactAuditValue } from './services/audit-service.js';
|
|
60
|
-
export {
|
|
61
|
-
|
|
60
|
+
export {
|
|
61
|
+
SingletonItemsService as ItemsService,
|
|
62
|
+
SingletonCollectionsService as CollectionsService,
|
|
63
|
+
} from './services/singleton-services.js';
|
|
62
64
|
export { FieldsService } from './services/fields-service.js';
|
|
63
65
|
export { SystemCollectionFieldsService } from './services/system-collection-fields-service.js';
|
|
64
66
|
export { RelationsService } from './services/relations-service.js';
|
|
@@ -68,6 +70,7 @@ export { PermissionsService } from './services/permissions-service.js';
|
|
|
68
70
|
export { FilesService } from './services/files-service.js';
|
|
69
71
|
export { FileReconciliationService } from './services/file-reconciliation-service.js';
|
|
70
72
|
export { StudioSettingsService, STUDIO_SETTING_DEFAULTS } from './services/studio-settings-service.js';
|
|
73
|
+
export { NavigationGroupsService } from './services/navigation-groups-service.js';
|
|
71
74
|
export { SchemaMetadataRepository } from './schema-metadata-repository.js';
|
|
72
75
|
export { loadSchemaSnapshot, SchemaCache } from './schema.js';
|
|
73
76
|
export { assertFieldType, compileFieldColumn } from './field-types.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const aiSettingsMigration = Object.freeze({
|
|
2
|
+
id: '0014-ai-settings',
|
|
3
|
+
statements: [
|
|
4
|
+
`CREATE TABLE yuncms_ai_settings (
|
|
5
|
+
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
6
|
+
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
7
|
+
base_url VARCHAR(2048) NOT NULL DEFAULT 'https://api.openai.com/v1',
|
|
8
|
+
model VARCHAR(200) NULL,
|
|
9
|
+
api_key_ciphertext TEXT NULL,
|
|
10
|
+
writes_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
11
|
+
max_tool_rounds INT UNSIGNED NOT NULL DEFAULT 6,
|
|
12
|
+
max_tool_calls_per_round INT UNSIGNED NOT NULL DEFAULT 8,
|
|
13
|
+
max_history INT UNSIGNED NOT NULL DEFAULT 20,
|
|
14
|
+
max_message_chars INT UNSIGNED NOT NULL DEFAULT 12000,
|
|
15
|
+
max_tool_result_bytes INT UNSIGNED NOT NULL DEFAULT 250000,
|
|
16
|
+
max_output_tokens INT UNSIGNED NOT NULL DEFAULT 1500,
|
|
17
|
+
timeout_ms INT UNSIGNED NOT NULL DEFAULT 60000,
|
|
18
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
19
|
+
CONSTRAINT chk_yuncms_ai_settings_singleton CHECK (id = 1)
|
|
20
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
21
|
+
`INSERT INTO yuncms_ai_settings (id) VALUES (1)`,
|
|
22
|
+
],
|
|
23
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const navigationGroupsMigration = Object.freeze({
|
|
2
|
+
id: '0015-navigation-groups',
|
|
3
|
+
statements: [
|
|
4
|
+
`CREATE TABLE yuncms_navigation_groups (
|
|
5
|
+
id CHAR(36) NOT NULL PRIMARY KEY,
|
|
6
|
+
name VARCHAR(100) NOT NULL,
|
|
7
|
+
sort INT NOT NULL DEFAULT 0,
|
|
8
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
9
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
10
|
+
INDEX idx_yuncms_navigation_groups_sort (sort, name)
|
|
11
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
12
|
+
],
|
|
13
|
+
});
|
|
@@ -2,15 +2,15 @@ import { ApiTokensService } from './api-tokens-service.js';
|
|
|
2
2
|
import { AuditService } from './audit-service.js';
|
|
3
3
|
import { AuthService } from './auth-service.js';
|
|
4
4
|
import { AuthTokensService } from './auth-tokens-service.js';
|
|
5
|
-
import { CollectionsService } from './collections-service.js';
|
|
6
5
|
import { ExternalAuthService } from './external-auth-service.js';
|
|
7
6
|
import { FieldsService } from './fields-service.js';
|
|
8
7
|
import { FileReconciliationService } from './file-reconciliation-service.js';
|
|
9
8
|
import { FilesService } from './files-service.js';
|
|
10
|
-
import {
|
|
9
|
+
import { NavigationGroupsService } from './navigation-groups-service.js';
|
|
11
10
|
import { PermissionsService } from './permissions-service.js';
|
|
12
11
|
import { RelationsService } from './relations-service.js';
|
|
13
12
|
import { RolesService } from './roles-service.js';
|
|
13
|
+
import { SingletonCollectionsService, SingletonItemsService } from './singleton-services.js';
|
|
14
14
|
import { StudioSettingsService } from './studio-settings-service.js';
|
|
15
15
|
import { SystemCollectionFieldsService } from './system-collection-fields-service.js';
|
|
16
16
|
import { UsersService } from './users-service.js';
|
|
@@ -23,8 +23,8 @@ export function createCoreServiceRegistry() {
|
|
|
23
23
|
ExternalAuthService,
|
|
24
24
|
ApiTokensService,
|
|
25
25
|
AuditService,
|
|
26
|
-
ItemsService,
|
|
27
|
-
CollectionsService,
|
|
26
|
+
ItemsService: SingletonItemsService,
|
|
27
|
+
CollectionsService: SingletonCollectionsService,
|
|
28
28
|
FieldsService,
|
|
29
29
|
SystemCollectionFieldsService,
|
|
30
30
|
RelationsService,
|
|
@@ -34,5 +34,6 @@ export function createCoreServiceRegistry() {
|
|
|
34
34
|
FilesService,
|
|
35
35
|
FileReconciliationService,
|
|
36
36
|
StudioSettingsService,
|
|
37
|
+
NavigationGroupsService,
|
|
37
38
|
});
|
|
38
39
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { incrementSchemaVersion } from '../schema-version.js';
|
|
4
|
+
import { withTransaction } from '../transaction.js';
|
|
5
|
+
import { BaseService } from './base-service.js';
|
|
6
|
+
import { assertSchemaManager } from './schema-access.js';
|
|
7
|
+
|
|
8
|
+
function invalid(message) {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.code = 'INVALID_PAYLOAD';
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function notFound(id) {
|
|
15
|
+
const error = new Error(`Unknown navigation group: ${id}`);
|
|
16
|
+
error.code = 'NAVIGATION_GROUP_NOT_FOUND';
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeName(value) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim()) throw invalid('Navigation group name is required');
|
|
22
|
+
const name = value.trim();
|
|
23
|
+
if (name.length > 100) throw invalid('Navigation group name cannot exceed 100 characters');
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeSort(value, fallback = 0) {
|
|
28
|
+
if (value == null || value === '') return fallback;
|
|
29
|
+
const sort = Number(value);
|
|
30
|
+
if (!Number.isInteger(sort) || sort < -1_000_000 || sort > 1_000_000) {
|
|
31
|
+
throw invalid('Navigation group sort must be an integer between -1000000 and 1000000');
|
|
32
|
+
}
|
|
33
|
+
return sort;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeCollapse(value, fallback = 'open') {
|
|
37
|
+
if (value == null || value === '') return fallback;
|
|
38
|
+
const collapse = String(value).trim().toLowerCase();
|
|
39
|
+
if (!['open', 'closed', 'locked'].includes(collapse)) {
|
|
40
|
+
throw invalid('Navigation group collapse must be open, closed or locked');
|
|
41
|
+
}
|
|
42
|
+
return collapse;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class NavigationGroupsService extends BaseService {
|
|
46
|
+
async readMany() {
|
|
47
|
+
const [rows] = await this.database.query(
|
|
48
|
+
`SELECT id, name, sort, collapse, created_at, updated_at
|
|
49
|
+
FROM yuncms_navigation_groups
|
|
50
|
+
ORDER BY sort ASC, name ASC, id ASC`,
|
|
51
|
+
);
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async readOne(id) {
|
|
56
|
+
const [rows] = await this.database.query(
|
|
57
|
+
`SELECT id, name, sort, collapse, created_at, updated_at
|
|
58
|
+
FROM yuncms_navigation_groups
|
|
59
|
+
WHERE id = ?
|
|
60
|
+
LIMIT 1`,
|
|
61
|
+
[String(id ?? '')],
|
|
62
|
+
);
|
|
63
|
+
return rows[0] ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async createOne(input = {}) {
|
|
67
|
+
assertSchemaManager(this.accountability);
|
|
68
|
+
const id = randomUUID();
|
|
69
|
+
const name = normalizeName(input.name);
|
|
70
|
+
const sort = normalizeSort(input.sort, 0);
|
|
71
|
+
const collapse = normalizeCollapse(input.collapse);
|
|
72
|
+
await this.database.query(
|
|
73
|
+
'INSERT INTO yuncms_navigation_groups (id, name, sort, collapse) VALUES (?, ?, ?, ?)',
|
|
74
|
+
[id, name, sort, collapse],
|
|
75
|
+
);
|
|
76
|
+
return this.readOne(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async updateOne(id, patch = {}) {
|
|
80
|
+
assertSchemaManager(this.accountability);
|
|
81
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Navigation group patch must be an object');
|
|
82
|
+
const keys = Object.keys(patch);
|
|
83
|
+
if (keys.length === 0 || keys.some((key) => !['name', 'sort', 'collapse'].includes(key))) {
|
|
84
|
+
throw invalid('Navigation group patch contains unsupported properties');
|
|
85
|
+
}
|
|
86
|
+
const assignments = [];
|
|
87
|
+
const params = [];
|
|
88
|
+
if (Object.hasOwn(patch, 'name')) {
|
|
89
|
+
assignments.push('name = ?');
|
|
90
|
+
params.push(normalizeName(patch.name));
|
|
91
|
+
}
|
|
92
|
+
if (Object.hasOwn(patch, 'sort')) {
|
|
93
|
+
assignments.push('sort = ?');
|
|
94
|
+
params.push(normalizeSort(patch.sort));
|
|
95
|
+
}
|
|
96
|
+
if (Object.hasOwn(patch, 'collapse')) {
|
|
97
|
+
assignments.push('collapse = ?');
|
|
98
|
+
params.push(normalizeCollapse(patch.collapse));
|
|
99
|
+
}
|
|
100
|
+
params.push(String(id ?? ''));
|
|
101
|
+
const [result] = await this.database.query(
|
|
102
|
+
`UPDATE yuncms_navigation_groups SET ${assignments.join(', ')} WHERE id = ?`,
|
|
103
|
+
params,
|
|
104
|
+
);
|
|
105
|
+
if (!result.affectedRows && !await this.readOne(id)) throw notFound(id);
|
|
106
|
+
return this.readOne(id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async deleteOne(id) {
|
|
110
|
+
assertSchemaManager(this.accountability);
|
|
111
|
+
const groupId = String(id ?? '');
|
|
112
|
+
return withTransaction(this.database, async (connection) => {
|
|
113
|
+
const [existing] = await connection.query(
|
|
114
|
+
'SELECT id FROM yuncms_navigation_groups WHERE id = ? LIMIT 1',
|
|
115
|
+
[groupId],
|
|
116
|
+
);
|
|
117
|
+
if (!existing[0]) throw notFound(groupId);
|
|
118
|
+
const [cleanup] = await connection.query(
|
|
119
|
+
`UPDATE yuncms_collections
|
|
120
|
+
SET metadata = JSON_REMOVE(metadata, '$.group')
|
|
121
|
+
WHERE metadata IS NOT NULL
|
|
122
|
+
AND JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.group')) = ?`,
|
|
123
|
+
[groupId],
|
|
124
|
+
);
|
|
125
|
+
await connection.query('DELETE FROM yuncms_navigation_groups WHERE id = ?', [groupId]);
|
|
126
|
+
if (Number(cleanup.affectedRows ?? 0) > 0) await incrementSchemaVersion(connection);
|
|
127
|
+
return { deleted: true, id: groupId };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
normalizeCollapse as normalizeNavigationGroupCollapse,
|
|
134
|
+
normalizeName as normalizeNavigationGroupName,
|
|
135
|
+
normalizeSort as normalizeNavigationGroupSort,
|
|
136
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { withAdvisoryLock } from '../advisory-lock.js';
|
|
4
|
+
import { quoteIdentifier } from '../identifier.js';
|
|
5
|
+
import { CollectionsService } from './collections-service.js';
|
|
6
|
+
import { ItemsService } from './items-service.js';
|
|
7
|
+
|
|
8
|
+
function singletonError(code, message) {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.code = code;
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function singletonLockName(collection) {
|
|
15
|
+
const digest = createHash('sha256').update(String(collection)).digest('hex').slice(0, 40);
|
|
16
|
+
return `yuncms:singleton:${digest}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function assertSingletonVacant(database, collection, schema) {
|
|
20
|
+
if (!schema?.singleton) return;
|
|
21
|
+
const table = quoteIdentifier(collection, 'collection name');
|
|
22
|
+
const [rows] = await database.query(`SELECT 1 AS present FROM ${table} LIMIT 1`);
|
|
23
|
+
if (rows[0]) {
|
|
24
|
+
throw singletonError('SINGLETON_ITEM_EXISTS', `Singleton collection already contains an item: ${collection}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SingletonItemsService extends ItemsService {
|
|
29
|
+
async createOne(payload = {}) {
|
|
30
|
+
const schema = await this.getCollectionSchema();
|
|
31
|
+
if (!schema.singleton) return super.createOne(payload);
|
|
32
|
+
return withAdvisoryLock(
|
|
33
|
+
this.database,
|
|
34
|
+
singletonLockName(this.collection),
|
|
35
|
+
async (connection) => {
|
|
36
|
+
await assertSingletonVacant(connection, this.collection, schema);
|
|
37
|
+
return super.createOne(payload);
|
|
38
|
+
},
|
|
39
|
+
{ timeoutSeconds: 10 },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async createMany(payloads = []) {
|
|
44
|
+
const schema = await this.getCollectionSchema();
|
|
45
|
+
if (!schema.singleton) return super.createMany(payloads);
|
|
46
|
+
if (!Array.isArray(payloads) || payloads.length !== 1) {
|
|
47
|
+
throw singletonError('SINGLETON_BULK_CREATE_FORBIDDEN', 'Singleton collections accept exactly one create payload');
|
|
48
|
+
}
|
|
49
|
+
return withAdvisoryLock(
|
|
50
|
+
this.database,
|
|
51
|
+
singletonLockName(this.collection),
|
|
52
|
+
async (connection) => {
|
|
53
|
+
await assertSingletonVacant(connection, this.collection, schema);
|
|
54
|
+
return super.createMany(payloads);
|
|
55
|
+
},
|
|
56
|
+
{ timeoutSeconds: 10 },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class SingletonCollectionsService extends CollectionsService {
|
|
62
|
+
async updateOne(collection, patch) {
|
|
63
|
+
if (patch?.singleton === true) {
|
|
64
|
+
const existing = await this.readOne(collection);
|
|
65
|
+
if (existing && !existing.singleton && !existing.system) {
|
|
66
|
+
const table = quoteIdentifier(collection, 'collection name');
|
|
67
|
+
const [rows] = await this.database.query(`SELECT COUNT(*) AS count FROM ${table}`);
|
|
68
|
+
if (Number(rows?.[0]?.count ?? 0) > 1) {
|
|
69
|
+
throw singletonError(
|
|
70
|
+
'SINGLETON_MULTIPLE_ITEMS',
|
|
71
|
+
'A collection with more than one item cannot be converted to singleton',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return super.updateOne(collection, patch);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { assertSingletonVacant, singletonLockName };
|