@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yunsoft Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @yunsoft/yuncms-core
2
+
3
+ Core services, schema, authentication, permissions, hooks and storage primitives used by YunCMS.
4
+
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.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@yunsoft/yuncms-core",
3
+ "version": "0.1.0",
4
+ "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24 <25"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.js"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Yunsoft-Software/yuncms.git",
19
+ "directory": "packages/core"
20
+ },
21
+ "homepage": "https://github.com/Yunsoft-Software/yuncms#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/Yunsoft-Software/yuncms/issues"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test"
30
+ },
31
+ "dependencies": {
32
+ "@aws-sdk/client-s3": "3.1100.0",
33
+ "mysql2": "3.23.2",
34
+ "nodemailer": "9.0.3"
35
+ }
36
+ }
@@ -0,0 +1,39 @@
1
+ function normalizeIdentity(value) {
2
+ return value == null ? null : String(value);
3
+ }
4
+
5
+ export function createAccountability(input = {}) {
6
+ const accountability = {
7
+ user: normalizeIdentity(input.user),
8
+ role: normalizeIdentity(input.role),
9
+ admin: input.admin === true,
10
+ public: input.public === true,
11
+ system: input.system === true,
12
+ };
13
+
14
+ if (accountability.public && (accountability.user || accountability.admin || accountability.system)) {
15
+ throw new Error('Public accountability cannot also be user, admin, or system accountability');
16
+ }
17
+
18
+ if (accountability.system && !accountability.admin) {
19
+ throw new Error('System accountability must explicitly be administrative');
20
+ }
21
+
22
+ return Object.freeze(accountability);
23
+ }
24
+
25
+ export function createPublicAccountability({ role = null } = {}) {
26
+ return createAccountability({ role, public: true });
27
+ }
28
+
29
+ export function createSystemAccountability() {
30
+ return createAccountability({ admin: true, system: true });
31
+ }
32
+
33
+ export function requireAccountability(accountability) {
34
+ if (!accountability || typeof accountability !== 'object') {
35
+ throw new Error('Explicit accountability is required');
36
+ }
37
+
38
+ return accountability;
39
+ }
@@ -0,0 +1,30 @@
1
+ export async function withAdvisoryLock(pool, lockName, operation, { timeoutSeconds = 10 } = {}) {
2
+ if (!pool) throw new Error('Database pool is required');
3
+ if (!lockName || typeof lockName !== 'string') throw new Error('Advisory lock name is required');
4
+ if (typeof operation !== 'function') throw new Error('Advisory lock operation is required');
5
+
6
+ const connection = await pool.getConnection();
7
+ let acquired = false;
8
+
9
+ try {
10
+ const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS acquired', [lockName, timeoutSeconds]);
11
+ acquired = Number(rows?.[0]?.acquired) === 1;
12
+
13
+ if (!acquired) {
14
+ const error = new Error(`Could not acquire advisory lock: ${lockName}`);
15
+ error.code = 'SCHEMA_LOCK_UNAVAILABLE';
16
+ throw error;
17
+ }
18
+
19
+ return await operation(connection);
20
+ } finally {
21
+ if (acquired) {
22
+ try {
23
+ await connection.query('SELECT RELEASE_LOCK(?) AS released', [lockName]);
24
+ } catch {
25
+ // Releasing the connection also drops connection-scoped MySQL locks.
26
+ }
27
+ }
28
+ connection.release();
29
+ }
30
+ }
@@ -0,0 +1,100 @@
1
+ import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
2
+ import { promisify } from 'node:util';
3
+
4
+ const scrypt = promisify(scryptCallback);
5
+ const DEFAULTS = Object.freeze({
6
+ N: 65_536,
7
+ r: 8,
8
+ p: 1,
9
+ keyLength: 64,
10
+ saltLength: 16,
11
+ maxmem: 128 * 1024 * 1024,
12
+ });
13
+
14
+ function passwordError(message) {
15
+ const error = new Error(message);
16
+ error.code = 'INVALID_PASSWORD';
17
+ return error;
18
+ }
19
+
20
+ export function assertPasswordInput(password) {
21
+ if (typeof password !== 'string') throw passwordError('Password must be a string');
22
+ if (password.length < 8) throw passwordError('Password must contain at least 8 characters');
23
+ if (password.length > 1024) throw passwordError('Password is too long');
24
+ return password;
25
+ }
26
+
27
+ function assertScryptParameters({ N, r, p, keyLength }) {
28
+ const powerOfTwo = Number.isInteger(N) && N > 1 && (N & (N - 1)) === 0;
29
+ if (!powerOfTwo || N > 131_072) throw passwordError('Invalid scrypt cost parameter');
30
+ if (!Number.isInteger(r) || r < 1 || r > 16) throw passwordError('Invalid scrypt block size');
31
+ if (!Number.isInteger(p) || p < 1 || p > 4) throw passwordError('Invalid scrypt parallelization');
32
+ if (!Number.isInteger(keyLength) || keyLength < 32 || keyLength > 128) {
33
+ throw passwordError('Invalid scrypt key length');
34
+ }
35
+ }
36
+
37
+ export async function hashPassword(password, options = {}) {
38
+ assertPasswordInput(password);
39
+ const params = { ...DEFAULTS, ...options };
40
+ assertScryptParameters(params);
41
+ const salt = randomBytes(params.saltLength);
42
+ const derived = await scrypt(password, salt, params.keyLength, {
43
+ N: params.N,
44
+ r: params.r,
45
+ p: params.p,
46
+ maxmem: params.maxmem,
47
+ });
48
+
49
+ return [
50
+ 'scrypt',
51
+ `N=${params.N},r=${params.r},p=${params.p},keyLength=${params.keyLength}`,
52
+ salt.toString('base64url'),
53
+ Buffer.from(derived).toString('base64url'),
54
+ ].join('$');
55
+ }
56
+
57
+ function parseHash(encoded) {
58
+ if (typeof encoded !== 'string') return null;
59
+ const [algorithm, parameterString, saltValue, hashValue, extra] = encoded.split('$');
60
+ if (algorithm !== 'scrypt' || !parameterString || !saltValue || !hashValue || extra !== undefined) return null;
61
+
62
+ const parameters = Object.fromEntries(
63
+ parameterString.split(',').map((part) => {
64
+ const [key, value] = part.split('=');
65
+ return [key, Number(value)];
66
+ }),
67
+ );
68
+
69
+ const { N, r, p, keyLength } = parameters;
70
+ try {
71
+ assertScryptParameters({ N, r, p, keyLength });
72
+ } catch {
73
+ return null;
74
+ }
75
+
76
+ try {
77
+ const salt = Buffer.from(saltValue, 'base64url');
78
+ const expected = Buffer.from(hashValue, 'base64url');
79
+ if (salt.length < 8 || salt.length > 64 || expected.length !== keyLength) return null;
80
+ return { N, r, p, keyLength, salt, expected };
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export async function verifyPassword(password, encoded) {
87
+ if (typeof password !== 'string' || password.length > 1024) return false;
88
+ const parsed = parseHash(encoded);
89
+ if (!parsed) return false;
90
+
91
+ const maxmem = Math.max(DEFAULTS.maxmem, 128 * parsed.N * parsed.r + 1024 * 1024);
92
+ const derived = Buffer.from(await scrypt(password, parsed.salt, parsed.keyLength, {
93
+ N: parsed.N,
94
+ r: parsed.r,
95
+ p: parsed.p,
96
+ maxmem,
97
+ }));
98
+
99
+ return derived.length === parsed.expected.length && timingSafeEqual(derived, parsed.expected);
100
+ }
@@ -0,0 +1,38 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ const PREFIXES = Object.freeze({
4
+ access: 'yca',
5
+ refresh: 'ycr',
6
+ api: 'yct',
7
+ reset: 'ycp',
8
+ verify: 'ycv',
9
+ });
10
+
11
+ export function hashToken(token) {
12
+ if (typeof token !== 'string' || token.length === 0) {
13
+ const error = new Error('Token is required');
14
+ error.code = 'INVALID_TOKEN';
15
+ throw error;
16
+ }
17
+ return createHash('sha256').update(token, 'utf8').digest('hex');
18
+ }
19
+
20
+ export function createOpaqueToken(type, { bytes = 32 } = {}) {
21
+ const prefix = PREFIXES[type];
22
+ if (!prefix) throw new Error(`Unknown token type: ${type}`);
23
+ if (!Number.isInteger(bytes) || bytes < 24 || bytes > 128) {
24
+ throw new Error('Token byte length must be between 24 and 128');
25
+ }
26
+
27
+ const token = `${prefix}_${randomBytes(bytes).toString('base64url')}`;
28
+ return {
29
+ token,
30
+ hash: hashToken(token),
31
+ };
32
+ }
33
+
34
+ export function tokenType(token) {
35
+ if (typeof token !== 'string') return null;
36
+ const prefix = token.slice(0, 3);
37
+ return Object.entries(PREFIXES).find(([, value]) => value === prefix)?.[0] ?? null;
38
+ }
@@ -0,0 +1,25 @@
1
+ export async function readAuthenticationUserByEmail(database, email) {
2
+ const [rows] = await database.query(
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
5
+ FROM yuncms_users u
6
+ LEFT JOIN yuncms_roles r ON r.id = u.role
7
+ WHERE u.email = ?
8
+ LIMIT 1`,
9
+ [email],
10
+ );
11
+ return rows[0] ?? null;
12
+ }
13
+
14
+ export async function readAuthenticationUserById(database, id) {
15
+ const [rows] = await database.query(
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
18
+ FROM yuncms_users u
19
+ LEFT JOIN yuncms_roles r ON r.id = u.role
20
+ WHERE u.id = ?
21
+ LIMIT 1`,
22
+ [id],
23
+ );
24
+ return rows[0] ?? null;
25
+ }
@@ -0,0 +1,41 @@
1
+ import { withAdvisoryLock } from './advisory-lock.js';
2
+ import { applyMigrations, assertMigrationsApplied } from './migrations.js';
3
+ import { systemSchemaMigration } from './migrations/0001-system-schema.js';
4
+ import { sessionAccessTokensMigration } from './migrations/0002-session-access-tokens.js';
5
+ import { publicRoleConstraintsMigration } from './migrations/0003-public-role-constraints.js';
6
+ import { authActionTokensMigration } from './migrations/0004-auth-action-tokens.js';
7
+ import { readSchemaVersion } from './schema-version.js';
8
+
9
+ export const CORE_MIGRATIONS = Object.freeze([
10
+ systemSchemaMigration,
11
+ sessionAccessTokensMigration,
12
+ publicRoleConstraintsMigration,
13
+ authActionTokensMigration,
14
+ ]);
15
+
16
+ export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
17
+ CORE_MIGRATIONS.map((migration) => migration.id),
18
+ );
19
+
20
+ export async function bootstrapDatabase(pool, { lockTimeoutSeconds = 10 } = {}) {
21
+ return withAdvisoryLock(
22
+ pool,
23
+ 'yuncms:bootstrap',
24
+ async (connection) => {
25
+ const migrationResult = await applyMigrations(connection, CORE_MIGRATIONS);
26
+ const schemaVersion = await readSchemaVersion(connection);
27
+
28
+ return {
29
+ ...migrationResult,
30
+ schemaVersion,
31
+ };
32
+ },
33
+ { timeoutSeconds: lockTimeoutSeconds },
34
+ );
35
+ }
36
+
37
+ export async function assertDatabaseCompatible(pool) {
38
+ await assertMigrationsApplied(pool, REQUIRED_CORE_MIGRATION_IDS);
39
+ await readSchemaVersion(pool);
40
+ return true;
41
+ }
package/src/config.js ADDED
@@ -0,0 +1,106 @@
1
+ import { loadEnvFile } from 'node:process';
2
+
3
+ function readInteger(value, fallback, name, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
4
+ if (value === undefined || value === '') return fallback;
5
+
6
+ const parsed = Number(value);
7
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
8
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
9
+ }
10
+
11
+ return parsed;
12
+ }
13
+
14
+ function readBoolean(value, fallback = false) {
15
+ if (value === undefined || value === '') return fallback;
16
+ if (value === true || value === 'true' || value === '1') return true;
17
+ if (value === false || value === 'false' || value === '0') return false;
18
+ throw new Error(`Expected boolean value, received: ${value}`);
19
+ }
20
+
21
+ function readString(value, fallback = '') {
22
+ return value === undefined ? fallback : String(value);
23
+ }
24
+
25
+ export function loadEnvFileIfPresent(path = '.env') {
26
+ try {
27
+ loadEnvFile(path);
28
+ return true;
29
+ } catch (error) {
30
+ if (error?.code === 'ENOENT') return false;
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ export function loadConfig(env = process.env) {
36
+ const serverPort = readInteger(env.PORT, 3008, 'PORT', { min: 1, max: 65535 });
37
+ const studioOrigin = readString(env.STUDIO_ORIGIN, `http://localhost:${serverPort}`);
38
+
39
+ return {
40
+ server: {
41
+ host: readString(env.HOST, '127.0.0.1'),
42
+ port: serverPort,
43
+ studioOrigin,
44
+ },
45
+ logging: {
46
+ level: readString(env.LOG_LEVEL, 'info'),
47
+ },
48
+ database: {
49
+ host: readString(env.DB_HOST, '127.0.0.1'),
50
+ port: readInteger(env.DB_PORT, 3306, 'DB_PORT', { min: 1, max: 65535 }),
51
+ database: readString(env.DB_DATABASE, 'yuncms'),
52
+ user: readString(env.DB_USER, 'yuncms'),
53
+ password: readString(env.DB_PASSWORD, ''),
54
+ connectionLimit: readInteger(env.DB_CONNECTION_LIMIT, 10, 'DB_CONNECTION_LIMIT', { min: 1, max: 1000 }),
55
+ ssl: readBoolean(env.DB_SSL, false),
56
+ },
57
+ storage: {
58
+ localRoot: readString(env.FILES_LOCAL_ROOT, '.yuncms/uploads'),
59
+ maxUploadBytes: readInteger(env.FILES_MAX_UPLOAD_BYTES, 25 * 1024 * 1024, 'FILES_MAX_UPLOAD_BYTES', {
60
+ min: 1,
61
+ max: 1024 * 1024 * 1024,
62
+ }),
63
+ s3: {
64
+ bucket: readString(env.S3_BUCKET, ''),
65
+ region: readString(env.S3_REGION, 'us-east-1'),
66
+ endpoint: readString(env.S3_ENDPOINT, '') || null,
67
+ accessKeyId: readString(env.S3_ACCESS_KEY_ID, '') || null,
68
+ secretAccessKey: readString(env.S3_SECRET_ACCESS_KEY, '') || null,
69
+ forcePathStyle: readBoolean(env.S3_FORCE_PATH_STYLE, false),
70
+ },
71
+ },
72
+ audit: {
73
+ retentionDays: readInteger(env.AUDIT_RETENTION_DAYS, 90, 'AUDIT_RETENTION_DAYS', {
74
+ min: 1,
75
+ max: 3650,
76
+ }),
77
+ cleanupBatchSize: readInteger(env.AUDIT_CLEANUP_BATCH_SIZE, 1000, 'AUDIT_CLEANUP_BATCH_SIZE', {
78
+ min: 1,
79
+ max: 5000,
80
+ }),
81
+ cleanupMaxBatches: readInteger(env.AUDIT_CLEANUP_MAX_BATCHES, 100, 'AUDIT_CLEANUP_MAX_BATCHES', {
82
+ min: 1,
83
+ max: 1000,
84
+ }),
85
+ },
86
+ mail: {
87
+ host: readString(env.SMTP_HOST, ''),
88
+ port: readInteger(env.SMTP_PORT, 587, 'SMTP_PORT', { min: 1, max: 65535 }),
89
+ secure: readBoolean(env.SMTP_SECURE, false),
90
+ user: readString(env.SMTP_USER, '') || null,
91
+ password: readString(env.SMTP_PASSWORD, '') || null,
92
+ from: readString(env.SMTP_FROM, '') || null,
93
+ },
94
+ auth: {
95
+ publicUrl: readString(env.AUTH_PUBLIC_URL, studioOrigin).replace(/\/$/, ''),
96
+ rateLimit: {
97
+ loginWindowMs: readInteger(env.AUTH_LOGIN_RATE_WINDOW_MS, 60_000, 'AUTH_LOGIN_RATE_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
98
+ loginMax: readInteger(env.AUTH_LOGIN_RATE_MAX, 10, 'AUTH_LOGIN_RATE_MAX', { min: 1, max: 100_000 }),
99
+ refreshWindowMs: readInteger(env.AUTH_REFRESH_RATE_WINDOW_MS, 60_000, 'AUTH_REFRESH_RATE_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
100
+ refreshMax: readInteger(env.AUTH_REFRESH_RATE_MAX, 30, 'AUTH_REFRESH_RATE_MAX', { min: 1, max: 100_000 }),
101
+ actionWindowMs: readInteger(env.AUTH_ACTION_RATE_WINDOW_MS, 15 * 60_000, 'AUTH_ACTION_RATE_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
102
+ actionMax: readInteger(env.AUTH_ACTION_RATE_MAX, 5, 'AUTH_ACTION_RATE_MAX', { min: 1, max: 100_000 }),
103
+ },
104
+ },
105
+ };
106
+ }
package/src/context.js ADDED
@@ -0,0 +1,33 @@
1
+ import { requireAccountability } from './accountability.js';
2
+
3
+ export function createRequestContext({
4
+ accountability,
5
+ services,
6
+ database,
7
+ schema = null,
8
+ logger = console,
9
+ env = {},
10
+ emitter = null,
11
+ storage = null,
12
+ permissionCache = new Map(),
13
+ requestId = null,
14
+ } = {}) {
15
+ requireAccountability(accountability);
16
+
17
+ if (!services) throw new Error('Service registry is required');
18
+ if (!database) throw new Error('Database handle is required');
19
+ if (!(permissionCache instanceof Map)) throw new Error('Permission cache must be a Map');
20
+
21
+ return Object.freeze({
22
+ accountability,
23
+ services,
24
+ database,
25
+ schema,
26
+ logger,
27
+ env,
28
+ emitter,
29
+ storage,
30
+ permissionCache,
31
+ requestId,
32
+ });
33
+ }
@@ -0,0 +1,31 @@
1
+ import mysql from 'mysql2/promise';
2
+
3
+ export function createDatabasePool(config) {
4
+ if (!config) throw new Error('Database config is required');
5
+
6
+ return mysql.createPool({
7
+ host: config.host,
8
+ port: config.port,
9
+ database: config.database,
10
+ user: config.user,
11
+ password: config.password,
12
+ waitForConnections: true,
13
+ connectionLimit: config.connectionLimit ?? 10,
14
+ queueLimit: 0,
15
+ enableKeepAlive: true,
16
+ keepAliveInitialDelay: 0,
17
+ supportBigNumbers: true,
18
+ bigNumberStrings: true,
19
+ multipleStatements: false,
20
+ ssl: config.ssl ? { minVersion: 'TLSv1.2' } : undefined,
21
+ });
22
+ }
23
+
24
+ export async function pingDatabase(pool) {
25
+ const [rows] = await pool.query('SELECT 1 AS ok');
26
+ return Number(rows?.[0]?.ok) === 1;
27
+ }
28
+
29
+ export async function closeDatabasePool(pool) {
30
+ if (pool) await pool.end();
31
+ }
package/src/errors.js ADDED
@@ -0,0 +1,35 @@
1
+ const MYSQL_ERROR_CODES = {
2
+ ER_DUP_ENTRY: 'DUPLICATE_KEY',
3
+ ER_NO_REFERENCED_ROW_2: 'FOREIGN_KEY_MISSING',
4
+ ER_ROW_IS_REFERENCED_2: 'FOREIGN_KEY_RESTRICTED',
5
+ ER_LOCK_DEADLOCK: 'DEADLOCK',
6
+ ER_LOCK_WAIT_TIMEOUT: 'LOCK_WAIT_TIMEOUT',
7
+ PROTOCOL_CONNECTION_LOST: 'CONNECTION_LOST',
8
+ ECONNREFUSED: 'CONNECTION_REFUSED',
9
+ };
10
+
11
+ export class YunCmsDatabaseError extends Error {
12
+ constructor(code, message, options = {}) {
13
+ super(message, { cause: options.cause });
14
+ this.name = 'YunCmsDatabaseError';
15
+ this.code = code;
16
+ this.mysqlCode = options.mysqlCode ?? null;
17
+ this.errno = options.errno ?? null;
18
+ }
19
+ }
20
+
21
+ export function normalizeDatabaseError(error) {
22
+ if (error instanceof YunCmsDatabaseError) return error;
23
+
24
+ const mappedCode = MYSQL_ERROR_CODES[error?.code] ?? 'DATABASE_ERROR';
25
+ return new YunCmsDatabaseError(mappedCode, error?.message ?? 'Database operation failed', {
26
+ cause: error,
27
+ mysqlCode: error?.code,
28
+ errno: error?.errno,
29
+ });
30
+ }
31
+
32
+ export function isRetryableDatabaseError(error) {
33
+ const code = error?.code;
34
+ return code === 'ER_LOCK_DEADLOCK' || code === 'ER_LOCK_WAIT_TIMEOUT' || code === 'DEADLOCK' || code === 'LOCK_WAIT_TIMEOUT';
35
+ }
@@ -0,0 +1,110 @@
1
+ const TYPE_NAMES = new Set([
2
+ 'integer',
3
+ 'bigint',
4
+ 'decimal',
5
+ 'string',
6
+ 'text',
7
+ 'boolean',
8
+ 'date',
9
+ 'datetime',
10
+ 'timestamp',
11
+ 'json',
12
+ 'uuid',
13
+ ]);
14
+
15
+ function integerOption(value, fallback, { min, max, label }) {
16
+ const resolved = value ?? fallback;
17
+ if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
18
+ throw new Error(`${label} must be an integer between ${min} and ${max}`);
19
+ }
20
+ return resolved;
21
+ }
22
+
23
+ export function assertFieldType(type) {
24
+ if (!TYPE_NAMES.has(type)) {
25
+ const error = new Error(`Unsupported field type: ${String(type)}`);
26
+ error.code = 'UNSUPPORTED_FIELD_TYPE';
27
+ throw error;
28
+ }
29
+ return type;
30
+ }
31
+
32
+ export function compileFieldColumn(input = {}) {
33
+ const type = assertFieldType(input.type);
34
+ const params = [];
35
+ let sqlType;
36
+
37
+ switch (type) {
38
+ case 'integer':
39
+ sqlType = 'INT';
40
+ break;
41
+ case 'bigint':
42
+ sqlType = 'BIGINT';
43
+ break;
44
+ case 'decimal': {
45
+ const precision = integerOption(input.precision, 18, { min: 1, max: 65, label: 'Decimal precision' });
46
+ const scale = integerOption(input.scale, 2, { min: 0, max: 30, label: 'Decimal scale' });
47
+ if (scale > precision) throw new Error('Decimal scale cannot exceed precision');
48
+ sqlType = `DECIMAL(${precision}, ${scale})`;
49
+ break;
50
+ }
51
+ case 'string': {
52
+ const length = integerOption(input.length, 255, { min: 1, max: 4096, label: 'String length' });
53
+ sqlType = `VARCHAR(${length})`;
54
+ break;
55
+ }
56
+ case 'text':
57
+ sqlType = 'TEXT';
58
+ break;
59
+ case 'boolean':
60
+ sqlType = 'TINYINT(1)';
61
+ break;
62
+ case 'date':
63
+ sqlType = 'DATE';
64
+ break;
65
+ case 'datetime':
66
+ sqlType = 'DATETIME(3)';
67
+ break;
68
+ case 'timestamp':
69
+ sqlType = 'TIMESTAMP(3)';
70
+ break;
71
+ case 'json':
72
+ sqlType = 'JSON';
73
+ break;
74
+ case 'uuid':
75
+ sqlType = 'CHAR(36)';
76
+ break;
77
+ default:
78
+ throw new Error(`Unsupported field type: ${type}`);
79
+ }
80
+
81
+ let sql = sqlType;
82
+ sql += input.required === true ? ' NOT NULL' : ' NULL';
83
+
84
+ if (Object.hasOwn(input, 'defaultValue')) {
85
+ if (type === 'text' || type === 'json') {
86
+ const error = new Error(`Defaults for ${type} fields are postponed in V1`);
87
+ error.code = 'UNSUPPORTED_FIELD_DEFAULT';
88
+ throw error;
89
+ }
90
+
91
+ if (input.defaultValue === null) {
92
+ if (input.required === true) throw new Error('Required fields cannot default to NULL');
93
+ sql += ' DEFAULT NULL';
94
+ } else {
95
+ sql += ' DEFAULT ?';
96
+ params.push(type === 'boolean' ? (input.defaultValue ? 1 : 0) : input.defaultValue);
97
+ }
98
+ }
99
+
100
+ return {
101
+ sql,
102
+ params,
103
+ schemaMetadata: {
104
+ length: type === 'string' ? (input.length ?? 255) : type === 'uuid' ? 36 : undefined,
105
+ precision: type === 'decimal' ? (input.precision ?? 18) : undefined,
106
+ scale: type === 'decimal' ? (input.scale ?? 2) : undefined,
107
+ defaultValue: Object.hasOwn(input, 'defaultValue') ? input.defaultValue : undefined,
108
+ },
109
+ };
110
+ }