@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
package/src/hooks.js ADDED
@@ -0,0 +1,121 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ function hookError(code, message) {
5
+ const error = new Error(message);
6
+ error.code = code;
7
+ return error;
8
+ }
9
+
10
+ export class HookEmitter {
11
+ constructor({ maxDepth = 12 } = {}) {
12
+ if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 100) {
13
+ throw new Error('Hook maxDepth must be an integer between 1 and 100');
14
+ }
15
+
16
+ this.maxDepth = maxDepth;
17
+ this.filters = new Map();
18
+ this.actions = new Map();
19
+ this.initializers = new Map();
20
+ this.storage = new AsyncLocalStorage();
21
+ }
22
+
23
+ registerFilter(event, handler) {
24
+ return this.#register(this.filters, event, handler);
25
+ }
26
+
27
+ registerAction(event, handler) {
28
+ return this.#register(this.actions, event, handler);
29
+ }
30
+
31
+ registerInit(event, handler) {
32
+ return this.#register(this.initializers, event, handler);
33
+ }
34
+
35
+ #register(map, event, handler) {
36
+ if (typeof event !== 'string' || event.trim() === '') throw new Error('Hook event is required');
37
+ if (typeof handler !== 'function') throw new Error(`Hook handler for ${event} must be a function`);
38
+
39
+ const name = event.trim();
40
+ const handlers = map.get(name) ?? [];
41
+ handlers.push(handler);
42
+ map.set(name, handlers);
43
+
44
+ return () => {
45
+ const active = map.get(name) ?? [];
46
+ const next = active.filter((candidate) => candidate !== handler);
47
+ if (next.length === 0) map.delete(name);
48
+ else map.set(name, next);
49
+ };
50
+ }
51
+
52
+ #nextExecution(event) {
53
+ const current = this.storage.getStore();
54
+ const depth = (current?.depth ?? 0) + 1;
55
+ if (depth > this.maxDepth) {
56
+ throw hookError(
57
+ 'HOOK_RECURSION_LIMIT',
58
+ `Hook recursion limit exceeded while dispatching ${event}`,
59
+ );
60
+ }
61
+
62
+ return {
63
+ chainId: current?.chainId ?? randomUUID(),
64
+ depth,
65
+ events: [...(current?.events ?? []), event],
66
+ };
67
+ }
68
+
69
+ async #runWithExecution(event, operation) {
70
+ const execution = this.#nextExecution(event);
71
+ return this.storage.run(execution, () => operation(execution));
72
+ }
73
+
74
+ async filter(event, payload, context = {}) {
75
+ const handlers = this.filters.get(event) ?? [];
76
+ if (handlers.length === 0) return payload;
77
+
78
+ return this.#runWithExecution(event, async (execution) => {
79
+ let current = payload;
80
+ for (const handler of handlers) {
81
+ const next = await handler(current, {
82
+ ...context,
83
+ hook: execution,
84
+ event,
85
+ });
86
+ if (next !== undefined) current = next;
87
+ }
88
+ return current;
89
+ });
90
+ }
91
+
92
+ async action(event, payload, context = {}) {
93
+ const handlers = this.actions.get(event) ?? [];
94
+ if (handlers.length === 0) return;
95
+
96
+ await this.#runWithExecution(event, async (execution) => {
97
+ for (const handler of handlers) {
98
+ await handler(payload, {
99
+ ...context,
100
+ hook: execution,
101
+ event,
102
+ });
103
+ }
104
+ });
105
+ }
106
+
107
+ async init(event, context = {}) {
108
+ const handlers = this.initializers.get(event) ?? [];
109
+ if (handlers.length === 0) return;
110
+
111
+ await this.#runWithExecution(`init:${event}`, async (execution) => {
112
+ for (const handler of handlers) {
113
+ await handler({
114
+ ...context,
115
+ hook: execution,
116
+ event,
117
+ });
118
+ }
119
+ });
120
+ }
121
+ }
@@ -0,0 +1,13 @@
1
+ const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+
3
+ export function assertIdentifier(value, label = 'identifier') {
4
+ if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
5
+ throw new Error(`Invalid SQL ${label}: ${String(value)}`);
6
+ }
7
+
8
+ return value;
9
+ }
10
+
11
+ export function quoteIdentifier(value, label) {
12
+ return `\`${assertIdentifier(value, label)}\``;
13
+ }
package/src/index.js ADDED
@@ -0,0 +1,67 @@
1
+ export { loadConfig, loadEnvFileIfPresent } from './config.js';
2
+ export { createDatabasePool, pingDatabase, closeDatabasePool } from './database.js';
3
+ export { withTransaction, withConnectionTransaction } from './transaction.js';
4
+ export { assertIdentifier, quoteIdentifier } from './identifier.js';
5
+ export { YunCmsDatabaseError, normalizeDatabaseError, isRetryableDatabaseError } from './errors.js';
6
+ export { withDatabaseRetry } from './retry.js';
7
+ export {
8
+ createAccountability,
9
+ createPublicAccountability,
10
+ createSystemAccountability,
11
+ requireAccountability,
12
+ } from './accountability.js';
13
+ export { createRequestContext } from './context.js';
14
+ export { createInitialAdmin, findExistingAdmin } from './setup.js';
15
+ export { HookEmitter } from './hooks.js';
16
+ export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
17
+ export { deleteM2MJunction } from './m2m-lifecycle.js';
18
+ export {
19
+ MAX_EXPAND_FIELDS,
20
+ parseExpandInput,
21
+ readManyWithRelations,
22
+ readOneWithRelations,
23
+ } from './relation-expansion.js';
24
+ export { SmtpMailer } from './mail/smtp-mailer.js';
25
+ export { LocalStorageDriver, assertStorageKey } from './storage/local-storage-driver.js';
26
+ export { S3StorageDriver } from './storage/s3-storage-driver.js';
27
+ export { createStorageRegistry } from './storage/storage-registry.js';
28
+ export { BaseService } from './services/base-service.js';
29
+ export { createServiceRegistry } from './services/service-registry.js';
30
+ export { createCoreServiceRegistry } from './services/core-services.js';
31
+ export { AuthService } from './services/auth-service.js';
32
+ export { AuthTokensService } from './services/auth-tokens-service.js';
33
+ export { ApiTokensService } from './services/api-tokens-service.js';
34
+ export { AuditService, redactAuditValue } from './services/audit-service.js';
35
+ export { ItemsService } from './services/items-service.js';
36
+ export { CollectionsService } from './services/collections-service.js';
37
+ export { FieldsService } from './services/fields-service.js';
38
+ export { RelationsService } from './services/relations-service.js';
39
+ export { UsersService } from './services/users-service.js';
40
+ export { RolesService } from './services/roles-service.js';
41
+ export { PermissionsService } from './services/permissions-service.js';
42
+ export { FilesService } from './services/files-service.js';
43
+ export { FileReconciliationService } from './services/file-reconciliation-service.js';
44
+ export { SchemaMetadataRepository } from './schema-metadata-repository.js';
45
+ export { loadSchemaSnapshot, SchemaCache } from './schema.js';
46
+ export { assertFieldType, compileFieldColumn } from './field-types.js';
47
+ export {
48
+ parseItemsQuery,
49
+ compileSelectFields,
50
+ compileSort,
51
+ compileFilter,
52
+ } from './query.js';
53
+ export { withAdvisoryLock } from './advisory-lock.js';
54
+ export {
55
+ ensureMigrationJournal,
56
+ readAppliedMigrations,
57
+ validateMigration,
58
+ applyMigrations,
59
+ assertMigrationsApplied,
60
+ } from './migrations.js';
61
+ export { readSchemaVersion, incrementSchemaVersion } from './schema-version.js';
62
+ export {
63
+ CORE_MIGRATIONS,
64
+ REQUIRED_CORE_MIGRATION_IDS,
65
+ bootstrapDatabase,
66
+ assertDatabaseCompatible,
67
+ } from './bootstrap.js';
package/src/logger.js ADDED
@@ -0,0 +1,56 @@
1
+ import { redactAuditValue } from './services/audit-service.js';
2
+
3
+ const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
4
+
5
+ function normalizeError(error) {
6
+ if (!(error instanceof Error)) return error;
7
+ return {
8
+ name: error.name,
9
+ code: error.code ?? null,
10
+ message: error.message,
11
+ stack: error.stack,
12
+ };
13
+ }
14
+
15
+ function normalizeMeta(meta) {
16
+ if (meta == null) return {};
17
+ if (meta instanceof Error) return { error: normalizeError(meta) };
18
+ if (typeof meta !== 'object' || Array.isArray(meta)) return { value: meta };
19
+
20
+ return Object.fromEntries(
21
+ Object.entries(meta).map(([key, value]) => [
22
+ key,
23
+ value instanceof Error ? normalizeError(value) : value,
24
+ ]),
25
+ );
26
+ }
27
+
28
+ export function createJsonLogger({
29
+ level = 'info',
30
+ output = process.stdout,
31
+ errorOutput = process.stderr,
32
+ now = () => new Date(),
33
+ } = {}) {
34
+ const threshold = LEVELS[level] ?? LEVELS.info;
35
+
36
+ function write(logLevel, message, meta) {
37
+ if (LEVELS[logLevel] < threshold) return;
38
+ const record = redactAuditValue({
39
+ timestamp: now().toISOString(),
40
+ level: logLevel,
41
+ message: String(message),
42
+ ...normalizeMeta(meta),
43
+ });
44
+ const stream = logLevel === 'error' ? errorOutput : output;
45
+ stream.write(`${JSON.stringify(record)}\n`);
46
+ }
47
+
48
+ return Object.freeze({
49
+ debug(message, meta) { write('debug', message, meta); },
50
+ info(message, meta) { write('info', message, meta); },
51
+ warn(message, meta) { write('warn', message, meta); },
52
+ error(message, meta) { write('error', message, meta); },
53
+ });
54
+ }
55
+
56
+ export { LEVELS };
@@ -0,0 +1,139 @@
1
+ import { randomBytes } 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 { assertSchemaManager } from './services/schema-access.js';
8
+ import { withConnectionTransaction } from './transaction.js';
9
+
10
+ function lifecycleError(code, message) {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ return error;
14
+ }
15
+
16
+ function parseMetadata(value) {
17
+ if (value == null || typeof value === 'object') return value ?? {};
18
+ try {
19
+ return JSON.parse(value);
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function tombstoneName(collection) {
26
+ const suffix = randomBytes(6).toString('hex');
27
+ return `ydel_${collection.slice(0, Math.max(1, 46 - suffix.length))}_${suffix}`;
28
+ }
29
+
30
+ export async function deleteM2MJunction({
31
+ database,
32
+ accountability,
33
+ junctionCollection,
34
+ destructive = false,
35
+ } = {}) {
36
+ assertSchemaManager(accountability);
37
+ if (!database) throw new Error('Database handle is required');
38
+ assertIdentifier(junctionCollection, 'junction collection');
39
+ if (destructive !== true) {
40
+ throw lifecycleError(
41
+ 'DESTRUCTIVE_OPERATION_REQUIRED',
42
+ 'M2M junction deletion requires destructive: true',
43
+ );
44
+ }
45
+
46
+ return withAdvisoryLock(database, 'yuncms:schema', async (connection) => {
47
+ const metadata = new SchemaMetadataRepository(connection);
48
+ const [collection, allRelations] = await Promise.all([
49
+ metadata.readCollection(junctionCollection),
50
+ metadata.listRelations(),
51
+ ]);
52
+
53
+ if (!collection) {
54
+ throw lifecycleError('COLLECTION_NOT_FOUND', `Unknown junction collection: ${junctionCollection}`);
55
+ }
56
+ if (collection.system) {
57
+ throw lifecycleError('SYSTEM_SCHEMA_READ_ONLY', 'System collections cannot be deleted as M2M junctions');
58
+ }
59
+
60
+ const relations = allRelations.filter((relation) => relation.junction_collection === junctionCollection);
61
+ const m2mRelations = relations.filter((relation) => parseMetadata(relation.metadata).kind === 'm2m');
62
+ if (m2mRelations.length !== 2 || relations.length !== 2) {
63
+ throw lifecycleError(
64
+ 'M2M_JUNCTION_INVALID',
65
+ `Collection ${junctionCollection} is not a complete YunCMS M2M junction`,
66
+ );
67
+ }
68
+
69
+ const relationFields = new Set(m2mRelations.map((relation) => relation.many_field));
70
+ if (relationFields.size !== 2 || m2mRelations.some((relation) => relation.many_collection !== junctionCollection)) {
71
+ throw lifecycleError(
72
+ 'M2M_JUNCTION_INVALID',
73
+ `M2M junction ${junctionCollection} has inconsistent relation metadata`,
74
+ );
75
+ }
76
+
77
+ const tombstone = tombstoneName(junctionCollection);
78
+ const tableSql = quoteIdentifier(junctionCollection, 'junction collection');
79
+ const tombstoneSql = quoteIdentifier(tombstone, 'junction tombstone');
80
+ await connection.query(`RENAME TABLE ${tableSql} TO ${tombstoneSql}`);
81
+
82
+ let committed = false;
83
+ try {
84
+ const result = await withConnectionTransaction(connection, async () => {
85
+ for (const relation of m2mRelations) {
86
+ const deleted = await metadata.deleteRelation(relation.many_collection, relation.many_field);
87
+ if (deleted !== 1) {
88
+ throw lifecycleError(
89
+ 'SCHEMA_METADATA_DRIFT',
90
+ `M2M relation metadata disappeared during delete: ${relation.many_collection}.${relation.many_field}`,
91
+ );
92
+ }
93
+ }
94
+
95
+ await connection.query('DELETE FROM yuncms_permissions WHERE collection = ?', [junctionCollection]);
96
+ const deletedCollection = await metadata.deleteCollection(junctionCollection);
97
+ if (deletedCollection !== 1) {
98
+ throw lifecycleError(
99
+ 'SCHEMA_METADATA_DRIFT',
100
+ `M2M junction metadata disappeared during delete: ${junctionCollection}`,
101
+ );
102
+ }
103
+ const schemaVersion = await incrementSchemaVersion(connection);
104
+ return {
105
+ deleted: true,
106
+ junctionCollection,
107
+ relations: m2mRelations,
108
+ schemaVersion,
109
+ };
110
+ });
111
+ committed = true;
112
+
113
+ try {
114
+ await connection.query(`DROP TABLE ${tombstoneSql}`);
115
+ } catch (cleanupError) {
116
+ const error = lifecycleError(
117
+ 'SCHEMA_PARTIAL_FAILURE',
118
+ `M2M junction was logically deleted but tombstone cleanup failed: ${tombstone}`,
119
+ );
120
+ error.cleanupError = cleanupError;
121
+ error.cleanupObject = tombstone;
122
+ error.result = result;
123
+ throw error;
124
+ }
125
+
126
+ return result;
127
+ } catch (error) {
128
+ if (!committed) {
129
+ try {
130
+ await connection.query(`RENAME TABLE ${tombstoneSql} TO ${tableSql}`);
131
+ } catch (restoreError) {
132
+ error.restoreError = restoreError;
133
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
134
+ }
135
+ }
136
+ throw error;
137
+ }
138
+ });
139
+ }
@@ -0,0 +1,65 @@
1
+ import nodemailer from 'nodemailer';
2
+
3
+ function mailError(code, message) {
4
+ const error = new Error(message);
5
+ error.code = code;
6
+ return error;
7
+ }
8
+
9
+ function assertAddress(value, label) {
10
+ if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) {
11
+ throw mailError('INVALID_MAIL_MESSAGE', `${label} is invalid`);
12
+ }
13
+ return value.trim();
14
+ }
15
+
16
+ export class SmtpMailer {
17
+ constructor({
18
+ host,
19
+ port = 587,
20
+ secure = false,
21
+ user = null,
22
+ password = null,
23
+ from,
24
+ transport = null,
25
+ } = {}) {
26
+ if (!transport && (!host || typeof host !== 'string')) throw new Error('SMTP host is required');
27
+ this.from = assertAddress(from, 'SMTP from address');
28
+ this.transport = transport ?? nodemailer.createTransport({
29
+ host,
30
+ port,
31
+ secure: secure === true,
32
+ ...(user || password ? {
33
+ auth: {
34
+ user: user ?? '',
35
+ pass: password ?? '',
36
+ },
37
+ } : {}),
38
+ disableFileAccess: true,
39
+ disableUrlAccess: true,
40
+ });
41
+ }
42
+
43
+ async verify() {
44
+ if (typeof this.transport.verify !== 'function') return true;
45
+ return this.transport.verify();
46
+ }
47
+
48
+ async send({ to, subject, text, html = undefined } = {}) {
49
+ const recipient = assertAddress(to, 'Recipient');
50
+ if (typeof subject !== 'string' || !subject.trim() || /[\r\n]/.test(subject)) {
51
+ throw mailError('INVALID_MAIL_MESSAGE', 'Mail subject is invalid');
52
+ }
53
+ if (typeof text !== 'string' || !text) {
54
+ throw mailError('INVALID_MAIL_MESSAGE', 'Mail text body is required');
55
+ }
56
+
57
+ return this.transport.sendMail({
58
+ from: this.from,
59
+ to: recipient,
60
+ subject: subject.trim(),
61
+ text,
62
+ ...(html ? { html } : {}),
63
+ });
64
+ }
65
+ }
@@ -0,0 +1,164 @@
1
+ export const systemSchemaMigration = {
2
+ id: '0001-system-schema',
3
+ statements: [
4
+ `CREATE TABLE IF NOT EXISTS yuncms_schema_state (
5
+ id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
6
+ version BIGINT UNSIGNED NOT NULL DEFAULT 0,
7
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
8
+ CONSTRAINT chk_yuncms_schema_state_singleton CHECK (id = 1)
9
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
10
+
11
+ `INSERT IGNORE INTO yuncms_schema_state (id, version) VALUES (1, 0)`,
12
+
13
+ `CREATE TABLE IF NOT EXISTS yuncms_collections (
14
+ collection VARCHAR(64) NOT NULL PRIMARY KEY,
15
+ primary_key VARCHAR(64) NOT NULL DEFAULT 'id',
16
+ note TEXT NULL,
17
+ singleton TINYINT(1) NOT NULL DEFAULT 0,
18
+ hidden TINYINT(1) NOT NULL DEFAULT 0,
19
+ \`system\` TINYINT(1) NOT NULL DEFAULT 0,
20
+ metadata JSON NULL,
21
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
22
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
23
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
24
+
25
+ `CREATE TABLE IF NOT EXISTS yuncms_fields (
26
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
27
+ collection VARCHAR(64) NOT NULL,
28
+ field VARCHAR(64) NOT NULL,
29
+ type VARCHAR(32) NOT NULL,
30
+ required TINYINT(1) NOT NULL DEFAULT 0,
31
+ readonly TINYINT(1) NOT NULL DEFAULT 0,
32
+ hidden TINYINT(1) NOT NULL DEFAULT 0,
33
+ sort INT NULL,
34
+ interface VARCHAR(64) NULL,
35
+ options JSON NULL,
36
+ schema_metadata JSON NULL,
37
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
38
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
39
+ UNIQUE KEY uq_yuncms_fields_collection_field (collection, field),
40
+ CONSTRAINT fk_yuncms_fields_collection FOREIGN KEY (collection)
41
+ REFERENCES yuncms_collections (collection) ON DELETE CASCADE
42
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
43
+
44
+ `CREATE TABLE IF NOT EXISTS yuncms_relations (
45
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
46
+ many_collection VARCHAR(64) NOT NULL,
47
+ many_field VARCHAR(64) NOT NULL,
48
+ one_collection VARCHAR(64) NOT NULL,
49
+ one_field VARCHAR(64) NULL,
50
+ junction_collection VARCHAR(64) NULL,
51
+ junction_field VARCHAR(64) NULL,
52
+ on_delete VARCHAR(16) NOT NULL DEFAULT 'RESTRICT',
53
+ metadata JSON NULL,
54
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
55
+ UNIQUE KEY uq_yuncms_relation_many (many_collection, many_field)
56
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
57
+
58
+ `CREATE TABLE IF NOT EXISTS yuncms_roles (
59
+ id CHAR(36) NOT NULL PRIMARY KEY,
60
+ name VARCHAR(100) NOT NULL,
61
+ description TEXT NULL,
62
+ admin TINYINT(1) NOT NULL DEFAULT 0,
63
+ public TINYINT(1) NOT NULL DEFAULT 0,
64
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
65
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
66
+ UNIQUE KEY uq_yuncms_roles_name (name)
67
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
68
+
69
+ `CREATE TABLE IF NOT EXISTS yuncms_users (
70
+ id CHAR(36) NOT NULL PRIMARY KEY,
71
+ email VARCHAR(191) NOT NULL,
72
+ password_hash VARCHAR(255) NULL,
73
+ role CHAR(36) NULL,
74
+ status VARCHAR(32) NOT NULL DEFAULT 'active',
75
+ email_verified_at DATETIME(3) NULL,
76
+ last_access DATETIME(3) NULL,
77
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
78
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
79
+ UNIQUE KEY uq_yuncms_users_email (email),
80
+ KEY idx_yuncms_users_role (role),
81
+ CONSTRAINT fk_yuncms_users_role FOREIGN KEY (role)
82
+ REFERENCES yuncms_roles (id) ON DELETE SET NULL
83
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
84
+
85
+ `CREATE TABLE IF NOT EXISTS yuncms_sessions (
86
+ id CHAR(36) NOT NULL PRIMARY KEY,
87
+ user CHAR(36) NOT NULL,
88
+ token_hash CHAR(64) NOT NULL,
89
+ expires_at DATETIME(3) NOT NULL,
90
+ last_used_at DATETIME(3) NULL,
91
+ ip VARCHAR(45) NULL,
92
+ user_agent VARCHAR(512) NULL,
93
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
94
+ UNIQUE KEY uq_yuncms_sessions_token_hash (token_hash),
95
+ KEY idx_yuncms_sessions_user (user),
96
+ KEY idx_yuncms_sessions_expires_at (expires_at),
97
+ CONSTRAINT fk_yuncms_sessions_user FOREIGN KEY (user)
98
+ REFERENCES yuncms_users (id) ON DELETE CASCADE
99
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
100
+
101
+ `CREATE TABLE IF NOT EXISTS yuncms_permissions (
102
+ id CHAR(36) NOT NULL PRIMARY KEY,
103
+ role CHAR(36) NOT NULL,
104
+ collection VARCHAR(64) NOT NULL,
105
+ action VARCHAR(16) NOT NULL,
106
+ fields JSON NULL,
107
+ filter JSON NULL,
108
+ validation JSON NULL,
109
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
110
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
111
+ UNIQUE KEY uq_yuncms_permissions_scope (role, collection, action),
112
+ CONSTRAINT fk_yuncms_permissions_role FOREIGN KEY (role)
113
+ REFERENCES yuncms_roles (id) ON DELETE CASCADE
114
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
115
+
116
+ `CREATE TABLE IF NOT EXISTS yuncms_api_tokens (
117
+ id CHAR(36) NOT NULL PRIMARY KEY,
118
+ user CHAR(36) NOT NULL,
119
+ name VARCHAR(100) NOT NULL,
120
+ token_hash CHAR(64) NOT NULL,
121
+ expires_at DATETIME(3) NULL,
122
+ last_used_at DATETIME(3) NULL,
123
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
124
+ UNIQUE KEY uq_yuncms_api_tokens_hash (token_hash),
125
+ KEY idx_yuncms_api_tokens_user (user),
126
+ CONSTRAINT fk_yuncms_api_tokens_user FOREIGN KEY (user)
127
+ REFERENCES yuncms_users (id) ON DELETE CASCADE
128
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
129
+
130
+ `CREATE TABLE IF NOT EXISTS yuncms_files (
131
+ id CHAR(36) NOT NULL PRIMARY KEY,
132
+ storage VARCHAR(64) NOT NULL DEFAULT 'local',
133
+ filename_disk VARCHAR(255) NOT NULL,
134
+ filename_download VARCHAR(255) NOT NULL,
135
+ title VARCHAR(255) NULL,
136
+ mimetype VARCHAR(191) NULL,
137
+ filesize BIGINT UNSIGNED NULL,
138
+ uploaded_by CHAR(36) NULL,
139
+ uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
140
+ metadata JSON NULL,
141
+ UNIQUE KEY uq_yuncms_files_disk (storage, filename_disk),
142
+ KEY idx_yuncms_files_uploaded_by (uploaded_by),
143
+ CONSTRAINT fk_yuncms_files_uploaded_by FOREIGN KEY (uploaded_by)
144
+ REFERENCES yuncms_users (id) ON DELETE SET NULL
145
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
146
+
147
+ `CREATE TABLE IF NOT EXISTS yuncms_audit_log (
148
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
149
+ user CHAR(36) NULL,
150
+ action VARCHAR(32) NOT NULL,
151
+ collection VARCHAR(64) NULL,
152
+ item_key VARCHAR(191) NULL,
153
+ request_id VARCHAR(64) NULL,
154
+ ip VARCHAR(45) NULL,
155
+ payload JSON NULL,
156
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
157
+ KEY idx_yuncms_audit_user (user),
158
+ KEY idx_yuncms_audit_collection_item (collection, item_key),
159
+ KEY idx_yuncms_audit_created_at (created_at),
160
+ CONSTRAINT fk_yuncms_audit_user FOREIGN KEY (user)
161
+ REFERENCES yuncms_users (id) ON DELETE SET NULL
162
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
163
+ ],
164
+ };
@@ -0,0 +1,10 @@
1
+ export const sessionAccessTokensMigration = {
2
+ id: '0002-session-access-tokens',
3
+ statements: [
4
+ `ALTER TABLE yuncms_sessions
5
+ ADD COLUMN access_token_hash CHAR(64) NULL AFTER token_hash,
6
+ ADD COLUMN access_expires_at DATETIME(3) NULL AFTER access_token_hash,
7
+ ADD UNIQUE KEY uq_yuncms_sessions_access_token_hash (access_token_hash),
8
+ ADD KEY idx_yuncms_sessions_access_expires_at (access_expires_at)`,
9
+ ],
10
+ };
@@ -0,0 +1,10 @@
1
+ export const publicRoleConstraintsMigration = {
2
+ id: '0003-public-role-constraints',
3
+ statements: [
4
+ `ALTER TABLE yuncms_roles
5
+ ADD COLUMN public_singleton TINYINT
6
+ GENERATED ALWAYS AS (CASE WHEN public = 1 THEN 1 ELSE NULL END) STORED,
7
+ ADD UNIQUE KEY uq_yuncms_roles_single_public (public_singleton),
8
+ ADD CONSTRAINT chk_yuncms_roles_admin_not_public CHECK (NOT (admin = 1 AND public = 1))`,
9
+ ],
10
+ };
@@ -0,0 +1,19 @@
1
+ export const authActionTokensMigration = {
2
+ id: '0004-auth-action-tokens',
3
+ statements: [
4
+ `CREATE TABLE IF NOT EXISTS yuncms_auth_tokens (
5
+ id CHAR(36) NOT NULL PRIMARY KEY,
6
+ user CHAR(36) NOT NULL,
7
+ type VARCHAR(24) NOT NULL,
8
+ token_hash CHAR(64) NOT NULL,
9
+ expires_at DATETIME(3) NOT NULL,
10
+ used_at DATETIME(3) NULL,
11
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
12
+ UNIQUE KEY uq_yuncms_auth_tokens_hash (token_hash),
13
+ KEY idx_yuncms_auth_tokens_user_type (user, type),
14
+ KEY idx_yuncms_auth_tokens_expires (expires_at),
15
+ CONSTRAINT fk_yuncms_auth_tokens_user FOREIGN KEY (user)
16
+ REFERENCES yuncms_users (id) ON DELETE CASCADE
17
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
18
+ ],
19
+ };