@yunsoft/yuncms-core 0.1.0

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/package.json +36 -0
  4. package/src/accountability.js +39 -0
  5. package/src/advisory-lock.js +30 -0
  6. package/src/auth/password.js +100 -0
  7. package/src/auth/tokens.js +38 -0
  8. package/src/auth/users-repository.js +25 -0
  9. package/src/bootstrap.js +41 -0
  10. package/src/config.js +106 -0
  11. package/src/context.js +33 -0
  12. package/src/database.js +31 -0
  13. package/src/errors.js +35 -0
  14. package/src/field-types.js +110 -0
  15. package/src/hooks.js +121 -0
  16. package/src/identifier.js +13 -0
  17. package/src/index.js +67 -0
  18. package/src/logger.js +56 -0
  19. package/src/m2m-lifecycle.js +139 -0
  20. package/src/mail/smtp-mailer.js +65 -0
  21. package/src/migrations/0001-system-schema.js +164 -0
  22. package/src/migrations/0002-session-access-tokens.js +10 -0
  23. package/src/migrations/0003-public-role-constraints.js +10 -0
  24. package/src/migrations/0004-auth-action-tokens.js +19 -0
  25. package/src/migrations.js +81 -0
  26. package/src/permission-validation.js +63 -0
  27. package/src/query.js +193 -0
  28. package/src/relation-expansion.js +216 -0
  29. package/src/retry.js +29 -0
  30. package/src/schema-metadata-repository.js +284 -0
  31. package/src/schema-version.js +21 -0
  32. package/src/schema.js +82 -0
  33. package/src/services/api-tokens-service.js +117 -0
  34. package/src/services/audit-service.js +182 -0
  35. package/src/services/auth-service.js +164 -0
  36. package/src/services/auth-tokens-service.js +215 -0
  37. package/src/services/base-service.js +26 -0
  38. package/src/services/collections-service.js +249 -0
  39. package/src/services/core-services.js +32 -0
  40. package/src/services/fields-service.js +471 -0
  41. package/src/services/file-reconciliation-service.js +127 -0
  42. package/src/services/files-service.js +227 -0
  43. package/src/services/items-service.js +445 -0
  44. package/src/services/permissions-service.js +282 -0
  45. package/src/services/relations-service.js +455 -0
  46. package/src/services/roles-service.js +160 -0
  47. package/src/services/schema-access.js +7 -0
  48. package/src/services/service-registry.js +32 -0
  49. package/src/services/sessions-service.js +179 -0
  50. package/src/services/users-service.js +215 -0
  51. package/src/setup.js +66 -0
  52. package/src/storage/local-storage-driver.js +105 -0
  53. package/src/storage/s3-storage-driver.js +150 -0
  54. package/src/storage/storage-registry.js +39 -0
  55. package/src/transaction.js +46 -0
@@ -0,0 +1,164 @@
1
+ import { createPublicAccountability } from '../accountability.js';
2
+ import { verifyPassword } from '../auth/password.js';
3
+ import { hashToken, tokenType } from '../auth/tokens.js';
4
+ import { readAuthenticationUserByEmail } from '../auth/users-repository.js';
5
+ import { BaseService } from './base-service.js';
6
+ import { SessionsService } from './sessions-service.js';
7
+
8
+ const DUMMY_PASSWORD_HASH = 'scrypt$N=65536,r=8,p=1,keyLength=64$AAAAAAAAAAAAAAAAAAAAAA$wPIB-ojIevW9SJ6ou99EIix5AukscH1McxhCNVsi1eVkhsxb5QzXquW0YeAFglU5Vh-NthiqKH90soC0JgEJPQ';
9
+
10
+ function invalidLogin() {
11
+ const error = new Error('Invalid email or password');
12
+ error.code = 'INVALID_CREDENTIALS';
13
+ return error;
14
+ }
15
+
16
+ function invalidToken() {
17
+ const error = new Error('Invalid or expired authentication token');
18
+ error.code = 'INVALID_CREDENTIALS';
19
+ return error;
20
+ }
21
+
22
+ function normalizeLoginEmail(email) {
23
+ if (typeof email !== 'string') return null;
24
+ const normalized = email.trim().toLowerCase();
25
+ if (!normalized || normalized.length > 191 || !normalized.includes('@')) return null;
26
+ return normalized;
27
+ }
28
+
29
+ function publicUser(user) {
30
+ return {
31
+ id: user.id,
32
+ email: user.email,
33
+ role: user.role ?? null,
34
+ status: user.status,
35
+ email_verified_at: user.email_verified_at ?? null,
36
+ };
37
+ }
38
+
39
+ export class AuthService extends BaseService {
40
+ createSessionsService() {
41
+ return new SessionsService({
42
+ accountability: this.accountability,
43
+ database: this.database,
44
+ schema: this.schema,
45
+ emitter: this.emitter,
46
+ logger: this.logger,
47
+ });
48
+ }
49
+
50
+ async resolvePublicAccountability() {
51
+ const [rows] = await this.database.query(
52
+ `SELECT id
53
+ FROM yuncms_roles
54
+ WHERE public = 1
55
+ ORDER BY created_at ASC
56
+ LIMIT 2`,
57
+ );
58
+
59
+ if (rows.length > 1) {
60
+ const error = new Error('Multiple public roles are configured');
61
+ error.code = 'PUBLIC_ROLE_AMBIGUOUS';
62
+ throw error;
63
+ }
64
+
65
+ return createPublicAccountability({ role: rows[0]?.id ?? null });
66
+ }
67
+
68
+ async login({ email, password, ip = null, userAgent = null } = {}) {
69
+ const normalizedEmail = normalizeLoginEmail(email);
70
+ const user = normalizedEmail
71
+ ? await readAuthenticationUserByEmail(this.database, normalizedEmail)
72
+ : null;
73
+
74
+ const passwordMatches = await verifyPassword(
75
+ typeof password === 'string' ? password : '',
76
+ user?.password_hash ?? DUMMY_PASSWORD_HASH,
77
+ );
78
+
79
+ if (!user || !passwordMatches || user.status !== 'active') {
80
+ throw invalidLogin();
81
+ }
82
+
83
+ const tokens = await this.createSessionsService().createForUser(user, { ip, userAgent });
84
+ return {
85
+ user: publicUser(user),
86
+ ...tokens,
87
+ };
88
+ }
89
+
90
+ async authenticateAccessToken(token) {
91
+ return this.createSessionsService().authenticateAccessToken(token);
92
+ }
93
+
94
+ async authenticateApiToken(token) {
95
+ if (tokenType(token) !== 'api') throw invalidToken();
96
+ const [rows] = await this.database.query(
97
+ `SELECT t.id AS api_token_id, t.user, u.email, u.role, u.status,
98
+ r.admin AS role_admin
99
+ FROM yuncms_api_tokens t
100
+ INNER JOIN yuncms_users u ON u.id = t.user
101
+ LEFT JOIN yuncms_roles r ON r.id = u.role
102
+ WHERE t.token_hash = ?
103
+ AND (t.expires_at IS NULL OR t.expires_at > CURRENT_TIMESTAMP(3))
104
+ AND u.status = 'active'
105
+ LIMIT 1`,
106
+ [hashToken(token)],
107
+ );
108
+ const row = rows[0];
109
+ if (!row) throw invalidToken();
110
+
111
+ await this.database.query(
112
+ `UPDATE yuncms_api_tokens t
113
+ INNER JOIN yuncms_users u ON u.id = t.user
114
+ SET t.last_used_at = CURRENT_TIMESTAMP(3), u.last_access = CURRENT_TIMESTAMP(3)
115
+ WHERE t.id = ?`,
116
+ [row.api_token_id],
117
+ );
118
+
119
+ return {
120
+ user: row.user,
121
+ role: row.role ?? null,
122
+ admin: Boolean(row.role_admin),
123
+ email: row.email,
124
+ session: null,
125
+ apiToken: row.api_token_id,
126
+ authMethod: 'api_token',
127
+ };
128
+ }
129
+
130
+ async authenticateBearerToken(token) {
131
+ const type = tokenType(token);
132
+ if (type === 'access') return this.authenticateAccessToken(token);
133
+ if (type === 'api') return this.authenticateApiToken(token);
134
+ throw invalidToken();
135
+ }
136
+
137
+ async refresh(refreshToken) {
138
+ const result = await this.createSessionsService().rotateRefreshToken(refreshToken);
139
+ return {
140
+ user: {
141
+ id: result.user,
142
+ email: result.email,
143
+ role: result.role,
144
+ },
145
+ access_token: result.access_token,
146
+ access_expires_at: result.access_expires_at,
147
+ refresh_token: result.refresh_token,
148
+ refresh_expires_at: result.refresh_expires_at,
149
+ };
150
+ }
151
+
152
+ async logout(accessToken) {
153
+ return this.createSessionsService().revokeByAccessToken(accessToken);
154
+ }
155
+
156
+ async logoutAll() {
157
+ if (!this.accountability.user) {
158
+ const error = new Error('Authenticated user is required');
159
+ error.code = 'UNAUTHORIZED';
160
+ throw error;
161
+ }
162
+ return this.createSessionsService().revokeAllForUser(this.accountability.user);
163
+ }
164
+ }
@@ -0,0 +1,215 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { hashPassword } from '../auth/password.js';
4
+ import { createOpaqueToken, hashToken, tokenType } from '../auth/tokens.js';
5
+ import { readAuthenticationUserByEmail } from '../auth/users-repository.js';
6
+ import { BaseService } from './base-service.js';
7
+ import { normalizeEmail } from './users-service.js';
8
+
9
+ const TOKEN_TYPES = Object.freeze({
10
+ reset: 'password_reset',
11
+ verify: 'email_verification',
12
+ });
13
+
14
+ const DEFAULT_TTL_MS = Object.freeze({
15
+ reset: 60 * 60 * 1000,
16
+ verify: 24 * 60 * 60 * 1000,
17
+ });
18
+
19
+ function invalidActionToken() {
20
+ const error = new Error('Invalid or expired action token');
21
+ error.code = 'INVALID_TOKEN';
22
+ return error;
23
+ }
24
+
25
+ function assertPositiveTtl(ttlMs) {
26
+ if (!Number.isInteger(ttlMs) || ttlMs < 60_000 || ttlMs > 7 * 24 * 60 * 60 * 1000) {
27
+ const error = new Error('Token TTL must be between 1 minute and 7 days');
28
+ error.code = 'INVALID_PAYLOAD';
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ export class AuthTokensService extends BaseService {
34
+ async createForUser(userId, type, { ttlMs = DEFAULT_TTL_MS[type] } = {}) {
35
+ if (!TOKEN_TYPES[type]) {
36
+ const error = new Error(`Unsupported auth token type: ${type}`);
37
+ error.code = 'INVALID_PAYLOAD';
38
+ throw error;
39
+ }
40
+ assertPositiveTtl(ttlMs);
41
+
42
+ const [users] = await this.database.query(
43
+ 'SELECT id, status FROM yuncms_users WHERE id = ? LIMIT 1',
44
+ [userId],
45
+ );
46
+ if (!users[0] || users[0].status !== 'active') {
47
+ const error = new Error(`Active user not found: ${userId}`);
48
+ error.code = 'USER_NOT_FOUND';
49
+ throw error;
50
+ }
51
+
52
+ const generated = createOpaqueToken(type);
53
+ const expiresAt = new Date(Date.now() + ttlMs);
54
+ const connection = await this.database.getConnection();
55
+
56
+ try {
57
+ await connection.beginTransaction();
58
+ await connection.query(
59
+ `DELETE FROM yuncms_auth_tokens
60
+ WHERE user = ? AND type = ? AND used_at IS NULL`,
61
+ [userId, TOKEN_TYPES[type]],
62
+ );
63
+ await connection.query(
64
+ `INSERT INTO yuncms_auth_tokens (id, user, type, token_hash, expires_at)
65
+ VALUES (?, ?, ?, ?, ?)`,
66
+ [randomUUID(), userId, TOKEN_TYPES[type], generated.hash, expiresAt],
67
+ );
68
+ await connection.commit();
69
+ } catch (error) {
70
+ try {
71
+ await connection.rollback();
72
+ } catch (rollbackError) {
73
+ error.rollbackError = rollbackError;
74
+ }
75
+ throw error;
76
+ } finally {
77
+ connection.release();
78
+ }
79
+
80
+ return {
81
+ token: generated.token,
82
+ expires_at: expiresAt,
83
+ };
84
+ }
85
+
86
+ async requestPasswordReset(email, options = {}) {
87
+ let normalized;
88
+ try {
89
+ normalized = normalizeEmail(email);
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ const user = await readAuthenticationUserByEmail(this.database, normalized);
95
+ if (!user || user.status !== 'active') return null;
96
+ return this.createForUser(user.id, 'reset', options);
97
+ }
98
+
99
+ async resetPassword(token, password) {
100
+ if (tokenType(token) !== 'reset') throw invalidActionToken();
101
+ const passwordHash = await hashPassword(password);
102
+ const connection = await this.database.getConnection();
103
+
104
+ try {
105
+ await connection.beginTransaction();
106
+ const [rows] = await connection.query(
107
+ `SELECT id, user
108
+ FROM yuncms_auth_tokens
109
+ WHERE token_hash = ?
110
+ AND type = ?
111
+ AND used_at IS NULL
112
+ AND expires_at > CURRENT_TIMESTAMP(3)
113
+ LIMIT 1
114
+ FOR UPDATE`,
115
+ [hashToken(token), TOKEN_TYPES.reset],
116
+ );
117
+ const actionToken = rows[0];
118
+ if (!actionToken) throw invalidActionToken();
119
+
120
+ const [result] = await connection.query(
121
+ `UPDATE yuncms_users
122
+ SET password_hash = ?
123
+ WHERE id = ? AND status = 'active'`,
124
+ [passwordHash, actionToken.user],
125
+ );
126
+ if (result.affectedRows !== 1) throw invalidActionToken();
127
+
128
+ await connection.query(
129
+ 'UPDATE yuncms_auth_tokens SET used_at = CURRENT_TIMESTAMP(3) WHERE id = ?',
130
+ [actionToken.id],
131
+ );
132
+ await connection.query('DELETE FROM yuncms_sessions WHERE user = ?', [actionToken.user]);
133
+ await connection.query(
134
+ `DELETE FROM yuncms_auth_tokens
135
+ WHERE user = ? AND type = ? AND id <> ?`,
136
+ [actionToken.user, TOKEN_TYPES.reset, actionToken.id],
137
+ );
138
+ await connection.commit();
139
+ return true;
140
+ } catch (error) {
141
+ try {
142
+ await connection.rollback();
143
+ } catch (rollbackError) {
144
+ error.rollbackError = rollbackError;
145
+ }
146
+ throw error;
147
+ } finally {
148
+ connection.release();
149
+ }
150
+ }
151
+
152
+ async createEmailVerification(userId, options = {}) {
153
+ const canIssue =
154
+ this.accountability.system === true ||
155
+ this.accountability.admin === true ||
156
+ this.accountability.user === userId;
157
+ if (!canIssue) {
158
+ const error = new Error('Email verification token can only be issued for the authenticated user');
159
+ error.code = 'FORBIDDEN';
160
+ throw error;
161
+ }
162
+ return this.createForUser(userId, 'verify', options);
163
+ }
164
+
165
+ async verifyEmail(token) {
166
+ if (tokenType(token) !== 'verify') throw invalidActionToken();
167
+ const connection = await this.database.getConnection();
168
+
169
+ try {
170
+ await connection.beginTransaction();
171
+ const [rows] = await connection.query(
172
+ `SELECT id, user
173
+ FROM yuncms_auth_tokens
174
+ WHERE token_hash = ?
175
+ AND type = ?
176
+ AND used_at IS NULL
177
+ AND expires_at > CURRENT_TIMESTAMP(3)
178
+ LIMIT 1
179
+ FOR UPDATE`,
180
+ [hashToken(token), TOKEN_TYPES.verify],
181
+ );
182
+ const actionToken = rows[0];
183
+ if (!actionToken) throw invalidActionToken();
184
+
185
+ const [result] = await connection.query(
186
+ `UPDATE yuncms_users
187
+ SET email_verified_at = COALESCE(email_verified_at, CURRENT_TIMESTAMP(3))
188
+ WHERE id = ? AND status = 'active'`,
189
+ [actionToken.user],
190
+ );
191
+ if (result.affectedRows !== 1) throw invalidActionToken();
192
+
193
+ await connection.query(
194
+ 'UPDATE yuncms_auth_tokens SET used_at = CURRENT_TIMESTAMP(3) WHERE id = ?',
195
+ [actionToken.id],
196
+ );
197
+ await connection.query(
198
+ `DELETE FROM yuncms_auth_tokens
199
+ WHERE user = ? AND type = ? AND id <> ?`,
200
+ [actionToken.user, TOKEN_TYPES.verify, actionToken.id],
201
+ );
202
+ await connection.commit();
203
+ return true;
204
+ } catch (error) {
205
+ try {
206
+ await connection.rollback();
207
+ } catch (rollbackError) {
208
+ error.rollbackError = rollbackError;
209
+ }
210
+ throw error;
211
+ } finally {
212
+ connection.release();
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,26 @@
1
+ import { requireAccountability } from '../accountability.js';
2
+
3
+ export class BaseService {
4
+ constructor({
5
+ accountability,
6
+ database,
7
+ schema = null,
8
+ emitter = null,
9
+ logger = console,
10
+ storage = null,
11
+ permissionCache = null,
12
+ requestId = null,
13
+ } = {}) {
14
+ requireAccountability(accountability);
15
+ if (!database) throw new Error('Database handle is required');
16
+
17
+ this.accountability = accountability;
18
+ this.database = database;
19
+ this.schema = schema;
20
+ this.emitter = emitter;
21
+ this.logger = logger;
22
+ this.storage = storage;
23
+ this.permissionCache = permissionCache;
24
+ this.requestId = requestId;
25
+ }
26
+ }
@@ -0,0 +1,249 @@
1
+ import { randomUUID } 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 { BaseService } from './base-service.js';
9
+ import { assertSchemaManager } from './schema-access.js';
10
+
11
+ const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
12
+
13
+ function assertUserCollectionName(collection) {
14
+ assertIdentifier(collection, 'collection name');
15
+ if (collection.length > 64) throw new Error('Collection name cannot exceed 64 characters');
16
+ if (collection.toLowerCase().startsWith('yuncms_')) {
17
+ const error = new Error('The yuncms_ prefix is reserved for system tables');
18
+ error.code = 'RESERVED_COLLECTION_NAME';
19
+ throw error;
20
+ }
21
+ return collection;
22
+ }
23
+
24
+ function assertCollectionMetadataPatch(patch) {
25
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
26
+ const error = new Error('Collection metadata patch must be an object');
27
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
28
+ throw error;
29
+ }
30
+ for (const key of Object.keys(patch)) {
31
+ if (!COLLECTION_METADATA_KEYS.has(key)) {
32
+ const error = new Error(`Collection property cannot be updated in V1: ${key}`);
33
+ error.code = 'UNSUPPORTED_SCHEMA_UPDATE';
34
+ throw error;
35
+ }
36
+ }
37
+ if (Object.keys(patch).length === 0) {
38
+ const error = new Error('Collection metadata patch cannot be empty');
39
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ function temporaryDropName() {
45
+ return `_yuncms_drop_${randomUUID().replaceAll('-', '').slice(0, 24)}`;
46
+ }
47
+
48
+ export class CollectionsService extends BaseService {
49
+ async readMany() {
50
+ assertSchemaManager(this.accountability);
51
+ return new SchemaMetadataRepository(this.database).listCollections();
52
+ }
53
+
54
+ async readOne(collection) {
55
+ assertSchemaManager(this.accountability);
56
+ assertIdentifier(collection, 'collection name');
57
+ return new SchemaMetadataRepository(this.database).readCollection(collection);
58
+ }
59
+
60
+ async createOne(input = {}) {
61
+ assertSchemaManager(this.accountability);
62
+ const collection = assertUserCollectionName(input.collection);
63
+ const primaryKey = input.primaryKey ?? 'id';
64
+
65
+ if (primaryKey !== 'id') {
66
+ const error = new Error('V1 collection creation currently requires the primary key field to be named id');
67
+ error.code = 'UNSUPPORTED_PRIMARY_KEY';
68
+ throw error;
69
+ }
70
+
71
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
72
+ const metadata = new SchemaMetadataRepository(connection);
73
+ const existing = await metadata.readCollection(collection);
74
+ if (existing) {
75
+ const error = new Error(`Collection already exists: ${collection}`);
76
+ error.code = 'COLLECTION_EXISTS';
77
+ throw error;
78
+ }
79
+
80
+ const table = quoteIdentifier(collection, 'collection name');
81
+ let tableCreated = false;
82
+
83
+ try {
84
+ await connection.query(
85
+ `CREATE TABLE ${table} (
86
+ id CHAR(36) NOT NULL PRIMARY KEY
87
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
88
+ );
89
+ tableCreated = true;
90
+
91
+ return await withConnectionTransaction(connection, async () => {
92
+ const created = await metadata.createCollection({
93
+ collection,
94
+ primaryKey,
95
+ note: input.note ?? null,
96
+ singleton: input.singleton === true,
97
+ hidden: input.hidden === true,
98
+ metadata: input.metadata ?? null,
99
+ });
100
+
101
+ await metadata.createField({
102
+ collection,
103
+ field: 'id',
104
+ type: 'uuid',
105
+ required: true,
106
+ readonly: true,
107
+ interface: 'input',
108
+ schemaMetadata: { primaryKey: true, length: 36 },
109
+ });
110
+
111
+ const schemaVersion = await incrementSchemaVersion(connection);
112
+ return { ...created, schemaVersion };
113
+ });
114
+ } catch (error) {
115
+ const cleanupErrors = [];
116
+
117
+ try {
118
+ await metadata.deleteCollection(collection);
119
+ } catch (cleanupError) {
120
+ cleanupErrors.push(cleanupError);
121
+ }
122
+
123
+ if (tableCreated) {
124
+ try {
125
+ await connection.query(`DROP TABLE IF EXISTS ${table}`);
126
+ } catch (cleanupError) {
127
+ cleanupErrors.push(cleanupError);
128
+ }
129
+ }
130
+
131
+ if (cleanupErrors.length > 0) {
132
+ error.cleanupErrors = cleanupErrors;
133
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
134
+ }
135
+ throw error;
136
+ }
137
+ });
138
+ }
139
+
140
+ async updateOne(collection, patch) {
141
+ assertSchemaManager(this.accountability);
142
+ assertIdentifier(collection, 'collection name');
143
+ assertCollectionMetadataPatch(patch);
144
+
145
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
146
+ const metadata = new SchemaMetadataRepository(connection);
147
+ const existing = await metadata.readCollection(collection);
148
+ if (!existing) {
149
+ const error = new Error(`Unknown collection: ${collection}`);
150
+ error.code = 'COLLECTION_NOT_FOUND';
151
+ throw error;
152
+ }
153
+ if (existing.system) {
154
+ const error = new Error('System collection metadata cannot be changed through the dynamic schema API');
155
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
156
+ throw error;
157
+ }
158
+
159
+ return withConnectionTransaction(connection, async () => {
160
+ const updated = await metadata.updateCollectionMetadata(collection, patch);
161
+ const schemaVersion = await incrementSchemaVersion(connection);
162
+ return { ...updated, schemaVersion };
163
+ });
164
+ });
165
+ }
166
+
167
+ async deleteOne(collection, { destructive = false } = {}) {
168
+ assertSchemaManager(this.accountability);
169
+ assertIdentifier(collection, 'collection name');
170
+ if (destructive !== true) {
171
+ const error = new Error('Collection deletion requires destructive: true');
172
+ error.code = 'DESTRUCTIVE_OPERATION_REQUIRED';
173
+ throw error;
174
+ }
175
+
176
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
177
+ const metadata = new SchemaMetadataRepository(connection);
178
+ const existing = await metadata.readCollection(collection);
179
+ if (!existing) {
180
+ const error = new Error(`Unknown collection: ${collection}`);
181
+ error.code = 'COLLECTION_NOT_FOUND';
182
+ throw error;
183
+ }
184
+ if (existing.system) {
185
+ const error = new Error('System collections cannot be deleted through the dynamic schema API');
186
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
187
+ throw error;
188
+ }
189
+
190
+ const relations = await metadata.listRelations();
191
+ const blockingRelations = relations.filter((relation) =>
192
+ relation.many_collection === collection ||
193
+ relation.one_collection === collection ||
194
+ relation.junction_collection === collection);
195
+ if (blockingRelations.length > 0) {
196
+ const error = new Error(`Collection has relations and cannot be deleted: ${collection}`);
197
+ error.code = 'COLLECTION_HAS_RELATIONS';
198
+ error.relations = blockingRelations.map((relation) => ({
199
+ many_collection: relation.many_collection,
200
+ many_field: relation.many_field,
201
+ one_collection: relation.one_collection,
202
+ }));
203
+ throw error;
204
+ }
205
+
206
+ const originalTable = quoteIdentifier(collection, 'collection name');
207
+ const tombstoneName = temporaryDropName();
208
+ const tombstoneTable = quoteIdentifier(tombstoneName, 'temporary collection name');
209
+
210
+ await connection.query(`RENAME TABLE ${originalTable} TO ${tombstoneTable}`);
211
+
212
+ let result;
213
+ try {
214
+ result = await withConnectionTransaction(connection, async () => {
215
+ await connection.query('DELETE FROM yuncms_permissions WHERE collection = ?', [collection]);
216
+ const deleted = await metadata.deleteCollection(collection);
217
+ if (deleted !== 1) {
218
+ const error = new Error(`Collection metadata disappeared during delete: ${collection}`);
219
+ error.code = 'SCHEMA_METADATA_DRIFT';
220
+ throw error;
221
+ }
222
+ const schemaVersion = await incrementSchemaVersion(connection);
223
+ return { deleted: true, collection, schemaVersion };
224
+ });
225
+ } catch (error) {
226
+ try {
227
+ await connection.query(`RENAME TABLE ${tombstoneTable} TO ${originalTable}`);
228
+ } catch (restoreError) {
229
+ error.restoreError = restoreError;
230
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
231
+ }
232
+ throw error;
233
+ }
234
+
235
+ try {
236
+ await connection.query(`DROP TABLE ${tombstoneTable}`);
237
+ } catch (cleanupError) {
238
+ const error = new Error(`Collection was logically deleted but physical cleanup failed: ${collection}`);
239
+ error.code = 'SCHEMA_PARTIAL_FAILURE';
240
+ error.cleanupError = cleanupError;
241
+ error.cleanupTable = tombstoneName;
242
+ error.logicalDelete = result;
243
+ throw error;
244
+ }
245
+
246
+ return result;
247
+ });
248
+ }
249
+ }
@@ -0,0 +1,32 @@
1
+ import { ApiTokensService } from './api-tokens-service.js';
2
+ import { AuditService } from './audit-service.js';
3
+ import { AuthService } from './auth-service.js';
4
+ import { AuthTokensService } from './auth-tokens-service.js';
5
+ import { CollectionsService } from './collections-service.js';
6
+ import { FieldsService } from './fields-service.js';
7
+ import { FileReconciliationService } from './file-reconciliation-service.js';
8
+ import { FilesService } from './files-service.js';
9
+ import { ItemsService } from './items-service.js';
10
+ import { PermissionsService } from './permissions-service.js';
11
+ import { RelationsService } from './relations-service.js';
12
+ import { RolesService } from './roles-service.js';
13
+ import { UsersService } from './users-service.js';
14
+ import { createServiceRegistry } from './service-registry.js';
15
+
16
+ export function createCoreServiceRegistry() {
17
+ return createServiceRegistry({
18
+ AuthService,
19
+ AuthTokensService,
20
+ ApiTokensService,
21
+ AuditService,
22
+ ItemsService,
23
+ CollectionsService,
24
+ FieldsService,
25
+ RelationsService,
26
+ UsersService,
27
+ RolesService,
28
+ PermissionsService,
29
+ FilesService,
30
+ FileReconciliationService,
31
+ });
32
+ }