@yunsoft/yuncms-core 0.1.3 → 0.1.5
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 +4 -0
- package/src/cache.js +85 -0
- package/src/config.js +50 -0
- package/src/context.js +5 -2
- package/src/index.js +9 -0
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations.js +113 -3
- package/src/query.js +54 -12
- package/src/relation-expansion.js +191 -57
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +54 -1
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
package/package.json
CHANGED
package/src/bootstrap.js
CHANGED
|
@@ -10,6 +10,8 @@ import { systemPermissionResourcesMigration } from './migrations/0007-system-per
|
|
|
10
10
|
import { studioLogoFileMigration } from './migrations/0008-studio-logo-file.js';
|
|
11
11
|
import { schemaDisplayNamesMigration } from './migrations/0009-schema-display-names.js';
|
|
12
12
|
import { studioFaviconFileMigration } from './migrations/0010-studio-favicon-file.js';
|
|
13
|
+
import { rolePermissionActionsMigration } from './migrations/0011-role-permission-actions.js';
|
|
14
|
+
import { filesReadFiltersMigration } from './migrations/0012-files-read-filters.js';
|
|
13
15
|
import { readSchemaVersion } from './schema-version.js';
|
|
14
16
|
import { ensurePublicRole } from './setup.js';
|
|
15
17
|
|
|
@@ -24,6 +26,8 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
24
26
|
studioLogoFileMigration,
|
|
25
27
|
schemaDisplayNamesMigration,
|
|
26
28
|
studioFaviconFileMigration,
|
|
29
|
+
rolePermissionActionsMigration,
|
|
30
|
+
filesReadFiltersMigration,
|
|
27
31
|
]);
|
|
28
32
|
|
|
29
33
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
function cacheError(message) {
|
|
2
|
+
const error = new Error(message);
|
|
3
|
+
error.code = 'INVALID_CACHE_CONFIG';
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isCacheStore(store) {
|
|
8
|
+
return Boolean(
|
|
9
|
+
store
|
|
10
|
+
&& typeof store.get === 'function'
|
|
11
|
+
&& typeof store.set === 'function'
|
|
12
|
+
&& typeof store.delete === 'function'
|
|
13
|
+
&& typeof store.clear === 'function',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryCacheStore {
|
|
18
|
+
constructor({
|
|
19
|
+
ttlMs = 30_000,
|
|
20
|
+
maxEntries = 5_000,
|
|
21
|
+
now = () => Date.now(),
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 1) throw cacheError('Cache ttlMs must be a positive integer');
|
|
24
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
25
|
+
throw cacheError('Cache maxEntries must be a positive integer');
|
|
26
|
+
}
|
|
27
|
+
if (typeof now !== 'function') throw cacheError('Cache now must be a function');
|
|
28
|
+
|
|
29
|
+
this.ttlMs = ttlMs;
|
|
30
|
+
this.maxEntries = maxEntries;
|
|
31
|
+
this.now = now;
|
|
32
|
+
this.entries = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#deleteExpired(timestamp) {
|
|
36
|
+
for (const [key, entry] of this.entries) {
|
|
37
|
+
if (entry.expiresAt <= timestamp) this.entries.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#ensureCapacity(timestamp, incomingKey) {
|
|
42
|
+
this.#deleteExpired(timestamp);
|
|
43
|
+
if (this.entries.has(incomingKey)) return;
|
|
44
|
+
while (this.entries.size >= this.maxEntries) {
|
|
45
|
+
const oldestKey = this.entries.keys().next().value;
|
|
46
|
+
if (oldestKey === undefined) break;
|
|
47
|
+
this.entries.delete(oldestKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async get(key) {
|
|
52
|
+
const entry = this.entries.get(String(key));
|
|
53
|
+
if (!entry) return undefined;
|
|
54
|
+
if (entry.expiresAt <= this.now()) {
|
|
55
|
+
this.entries.delete(String(key));
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return entry.value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async set(key, value, { ttlMs = this.ttlMs } = {}) {
|
|
62
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 1) throw cacheError('Cache entry ttlMs must be a positive integer');
|
|
63
|
+
const normalizedKey = String(key);
|
|
64
|
+
const timestamp = this.now();
|
|
65
|
+
this.#ensureCapacity(timestamp, normalizedKey);
|
|
66
|
+
this.entries.delete(normalizedKey);
|
|
67
|
+
this.entries.set(normalizedKey, {
|
|
68
|
+
value,
|
|
69
|
+
expiresAt: timestamp + ttlMs,
|
|
70
|
+
});
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async delete(key) {
|
|
75
|
+
return this.entries.delete(String(key));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async clear() {
|
|
79
|
+
this.entries.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get size() {
|
|
83
|
+
return this.entries.size;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/config.js
CHANGED
|
@@ -24,6 +24,14 @@ function readString(value, fallback = '') {
|
|
|
24
24
|
return value === undefined ? fallback : String(value);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function readCacheStore(value) {
|
|
28
|
+
const store = readString(value, 'memory').trim().toLowerCase();
|
|
29
|
+
if (store !== 'memory') {
|
|
30
|
+
throw new Error(`CACHE_STORE must be memory until a shared-store adapter is configured, received: ${store}`);
|
|
31
|
+
}
|
|
32
|
+
return store;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export function loadEnvFileIfPresent(path = '.env') {
|
|
28
36
|
try {
|
|
29
37
|
loadEnvFile(path);
|
|
@@ -44,10 +52,52 @@ export function loadConfig(env = process.env) {
|
|
|
44
52
|
port: serverPort,
|
|
45
53
|
studioOrigin,
|
|
46
54
|
trustProxyHops: readInteger(env.TRUST_PROXY_HOPS, 0, 'TRUST_PROXY_HOPS', { min: 0, max: 10 }),
|
|
55
|
+
rateLimit: {
|
|
56
|
+
enabled: readBoolean(env.API_RATE_LIMIT_ENABLED, true),
|
|
57
|
+
windowMs: readInteger(env.API_RATE_LIMIT_WINDOW_MS, 60_000, 'API_RATE_LIMIT_WINDOW_MS', {
|
|
58
|
+
min: 1000,
|
|
59
|
+
max: 24 * 60 * 60 * 1000,
|
|
60
|
+
}),
|
|
61
|
+
max: readInteger(env.API_RATE_LIMIT_MAX, 300, 'API_RATE_LIMIT_MAX', {
|
|
62
|
+
min: 1,
|
|
63
|
+
max: 1_000_000,
|
|
64
|
+
}),
|
|
65
|
+
maxBuckets: readInteger(env.API_RATE_LIMIT_MAX_BUCKETS, 10_000, 'API_RATE_LIMIT_MAX_BUCKETS', {
|
|
66
|
+
min: 1,
|
|
67
|
+
max: 1_000_000,
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
pressure: {
|
|
71
|
+
enabled: readBoolean(env.PRESSURE_LIMIT_ENABLED, true),
|
|
72
|
+
maxConcurrent: readInteger(env.PRESSURE_MAX_CONCURRENT, 250, 'PRESSURE_MAX_CONCURRENT', {
|
|
73
|
+
min: 1,
|
|
74
|
+
max: 100_000,
|
|
75
|
+
}),
|
|
76
|
+
maxHeapPercent: readInteger(env.PRESSURE_MAX_HEAP_PERCENT, 95, 'PRESSURE_MAX_HEAP_PERCENT', {
|
|
77
|
+
min: 1,
|
|
78
|
+
max: 100,
|
|
79
|
+
}),
|
|
80
|
+
retryAfterSeconds: readInteger(env.PRESSURE_RETRY_AFTER_SECONDS, 1, 'PRESSURE_RETRY_AFTER_SECONDS', {
|
|
81
|
+
min: 1,
|
|
82
|
+
max: 3600,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
47
85
|
},
|
|
48
86
|
logging: {
|
|
49
87
|
level: readString(env.LOG_LEVEL, 'info'),
|
|
50
88
|
},
|
|
89
|
+
cache: {
|
|
90
|
+
enabled: readBoolean(env.CACHE_ENABLED, true),
|
|
91
|
+
store: readCacheStore(env.CACHE_STORE),
|
|
92
|
+
ttlMs: readInteger(env.CACHE_TTL_MS, 30_000, 'CACHE_TTL_MS', {
|
|
93
|
+
min: 1,
|
|
94
|
+
max: 24 * 60 * 60 * 1000,
|
|
95
|
+
}),
|
|
96
|
+
maxEntries: readInteger(env.CACHE_MAX_ENTRIES, 5_000, 'CACHE_MAX_ENTRIES', {
|
|
97
|
+
min: 1,
|
|
98
|
+
max: 1_000_000,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
51
101
|
database: {
|
|
52
102
|
host: readString(env.DB_HOST, '127.0.0.1'),
|
|
53
103
|
port: readInteger(env.DB_PORT, 3306, 'DB_PORT', { min: 1, max: 65535 }),
|
package/src/context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requireAccountability } from './accountability.js';
|
|
2
|
+
import { isCacheStore } from './cache.js';
|
|
2
3
|
|
|
3
4
|
export function createRequestContext({
|
|
4
5
|
accountability,
|
|
@@ -9,14 +10,16 @@ export function createRequestContext({
|
|
|
9
10
|
env = {},
|
|
10
11
|
emitter = null,
|
|
11
12
|
storage = null,
|
|
12
|
-
permissionCache =
|
|
13
|
+
permissionCache = null,
|
|
13
14
|
requestId = null,
|
|
14
15
|
} = {}) {
|
|
15
16
|
requireAccountability(accountability);
|
|
16
17
|
|
|
17
18
|
if (!services) throw new Error('Service registry is required');
|
|
18
19
|
if (!database) throw new Error('Database handle is required');
|
|
19
|
-
if (
|
|
20
|
+
if (permissionCache !== null && !isCacheStore(permissionCache)) {
|
|
21
|
+
throw new Error('Permission cache must implement the cache-store contract');
|
|
22
|
+
}
|
|
20
23
|
|
|
21
24
|
return Object.freeze({
|
|
22
25
|
accountability,
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.js';
|
|
2
|
+
export { isCacheStore, MemoryCacheStore } from './cache.js';
|
|
2
3
|
export { createDatabasePool, pingDatabase, closeDatabasePool } from './database.js';
|
|
3
4
|
export { withTransaction, withConnectionTransaction } from './transaction.js';
|
|
4
5
|
export { assertIdentifier, quoteIdentifier } from './identifier.js';
|
|
5
6
|
export { normalizeDisplayName, normalizeSchemaKey, resolveSchemaName } from './schema-key.js';
|
|
6
7
|
export { YunCmsDatabaseError, normalizeDatabaseError, isRetryableDatabaseError } from './errors.js';
|
|
7
8
|
export { withDatabaseRetry } from './retry.js';
|
|
9
|
+
export {
|
|
10
|
+
MAINTENANCE_BYPASS_ENV,
|
|
11
|
+
maintenanceLockPath,
|
|
12
|
+
hashMaintenanceBypassToken,
|
|
13
|
+
assertMaintenanceStartupAllowed,
|
|
14
|
+
} from './maintenance-state.js';
|
|
8
15
|
export {
|
|
9
16
|
createAccountability,
|
|
10
17
|
createPublicAccountability,
|
|
@@ -63,6 +70,7 @@ export {
|
|
|
63
70
|
} from './system-fields.js';
|
|
64
71
|
export {
|
|
65
72
|
assertActionOnlyPermissionPayload,
|
|
73
|
+
assertSystemPermissionPayload,
|
|
66
74
|
assertSystemResourceAction,
|
|
67
75
|
isPermissionManagedSystemResource,
|
|
68
76
|
systemPermissionConfig,
|
|
@@ -77,6 +85,7 @@ export { withAdvisoryLock } from './advisory-lock.js';
|
|
|
77
85
|
export {
|
|
78
86
|
ensureMigrationJournal,
|
|
79
87
|
readAppliedMigrations,
|
|
88
|
+
readMigrationAttempts,
|
|
80
89
|
validateMigration,
|
|
81
90
|
applyMigrations,
|
|
82
91
|
assertMigrationsApplied,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const MAINTENANCE_BYPASS_ENV = 'YUNCMS_MAINTENANCE_BYPASS_TOKEN';
|
|
8
|
+
|
|
9
|
+
function sha256(value) {
|
|
10
|
+
return createHash('sha256').update(String(value)).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function canonicalProjectPath(cwd = process.cwd()) {
|
|
14
|
+
const absolute = resolve(cwd);
|
|
15
|
+
try {
|
|
16
|
+
return realpathSync.native ? realpathSync.native(absolute) : realpathSync(absolute);
|
|
17
|
+
} catch {
|
|
18
|
+
return absolute;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function maintenanceLockPath(cwd = process.cwd()) {
|
|
23
|
+
const projectKey = sha256(canonicalProjectPath(cwd)).slice(0, 32);
|
|
24
|
+
return join(tmpdir(), 'yuncms-update-locks', `${projectKey}.lock`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hashMaintenanceBypassToken(token) {
|
|
28
|
+
if (typeof token !== 'string' || token.length < 32) {
|
|
29
|
+
const error = new Error('Maintenance bypass token must contain at least 32 characters');
|
|
30
|
+
error.code = 'MAINTENANCE_BYPASS_TOKEN_INVALID';
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
return sha256(token);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hashesEqual(left, right) {
|
|
37
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
38
|
+
if (!/^[0-9a-f]{64}$/i.test(left) || !/^[0-9a-f]{64}$/i.test(right)) return false;
|
|
39
|
+
const leftBuffer = Buffer.from(left, 'hex');
|
|
40
|
+
const rightBuffer = Buffer.from(right, 'hex');
|
|
41
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readMaintenanceState(path, readFileFn) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = await readFileFn(path, 'utf8');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error?.code === 'ENOENT') return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const state = JSON.parse(text);
|
|
55
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('invalid state');
|
|
56
|
+
return state;
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
const error = new Error(`YunCMS maintenance lock is unreadable or invalid: ${path}`);
|
|
59
|
+
error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
|
|
60
|
+
error.lockPath = path;
|
|
61
|
+
error.cause = cause;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function assertMaintenanceStartupAllowed({
|
|
67
|
+
cwd = process.cwd(),
|
|
68
|
+
env = process.env,
|
|
69
|
+
readFileFn = readFile,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const path = maintenanceLockPath(cwd);
|
|
72
|
+
const state = await readMaintenanceState(path, readFileFn);
|
|
73
|
+
if (!state) return true;
|
|
74
|
+
|
|
75
|
+
const suppliedToken = env?.[MAINTENANCE_BYPASS_ENV];
|
|
76
|
+
if (typeof suppliedToken === 'string' && suppliedToken.length >= 32) {
|
|
77
|
+
const suppliedHash = sha256(suppliedToken);
|
|
78
|
+
if (hashesEqual(suppliedHash, state.bypassTokenHash)) return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const error = new Error(
|
|
82
|
+
`YunCMS maintenance is active for this project. Do not start the application until the maintenance operation finishes: ${path}`,
|
|
83
|
+
);
|
|
84
|
+
error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
|
|
85
|
+
error.lockPath = path;
|
|
86
|
+
error.startedAt = typeof state.startedAt === 'string' ? state.startedAt : null;
|
|
87
|
+
error.pid = Number.isInteger(state.pid) ? state.pid : null;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const rolePermissionActionsMigration = {
|
|
2
|
+
id: '0011-role-permission-actions',
|
|
3
|
+
statements: [
|
|
4
|
+
`UPDATE yuncms_collections
|
|
5
|
+
SET metadata = JSON_SET(
|
|
6
|
+
COALESCE(metadata, JSON_OBJECT()),
|
|
7
|
+
'$.permissionManaged', TRUE,
|
|
8
|
+
'$.permissionMode', 'action-only',
|
|
9
|
+
'$.resource', 'roles',
|
|
10
|
+
'$.allowedActions', JSON_ARRAY('read', 'create', 'update', 'delete')
|
|
11
|
+
)
|
|
12
|
+
WHERE collection = 'yuncms_roles' AND \`system\` = 1`,
|
|
13
|
+
`UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1`,
|
|
14
|
+
],
|
|
15
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const filesReadFiltersMigration = {
|
|
2
|
+
id: '0012-files-read-filters',
|
|
3
|
+
statements: [
|
|
4
|
+
`UPDATE yuncms_collections
|
|
5
|
+
SET metadata = JSON_SET(
|
|
6
|
+
COALESCE(metadata, JSON_OBJECT()),
|
|
7
|
+
'$.permissionMode',
|
|
8
|
+
'filter-read'
|
|
9
|
+
)
|
|
10
|
+
WHERE collection = 'yuncms_files'
|
|
11
|
+
AND \`system\` = 1`,
|
|
12
|
+
`UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1`,
|
|
13
|
+
],
|
|
14
|
+
};
|
package/src/migrations.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
const JOURNAL_TABLE = 'yuncms_schema_migrations';
|
|
2
|
+
const ATTEMPT_TABLE = 'yuncms_schema_migration_attempts';
|
|
3
|
+
|
|
4
|
+
function truncateErrorMessage(value, maxLength = 1000) {
|
|
5
|
+
const message = String(value ?? 'Migration failed');
|
|
6
|
+
return message.length > maxLength ? message.slice(0, maxLength) : message;
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
export async function ensureMigrationJournal(database) {
|
|
4
10
|
await database.query(`
|
|
@@ -7,6 +13,20 @@ export async function ensureMigrationJournal(database) {
|
|
|
7
13
|
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
|
8
14
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
9
15
|
`);
|
|
16
|
+
|
|
17
|
+
await database.query(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS ${ATTEMPT_TABLE} (
|
|
19
|
+
migration_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
|
20
|
+
status VARCHAR(16) NOT NULL,
|
|
21
|
+
statement_index INT UNSIGNED NOT NULL DEFAULT 0,
|
|
22
|
+
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
23
|
+
finished_at DATETIME(3) NULL,
|
|
24
|
+
error_code VARCHAR(128) NULL,
|
|
25
|
+
error_message VARCHAR(1000) NULL,
|
|
26
|
+
CONSTRAINT chk_yuncms_schema_migration_attempt_status
|
|
27
|
+
CHECK (status IN ('applying', 'applied', 'failed'))
|
|
28
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
29
|
+
`);
|
|
10
30
|
}
|
|
11
31
|
|
|
12
32
|
export async function readAppliedMigrations(database) {
|
|
@@ -14,6 +34,15 @@ export async function readAppliedMigrations(database) {
|
|
|
14
34
|
return new Set(rows.map((row) => row.id));
|
|
15
35
|
}
|
|
16
36
|
|
|
37
|
+
export async function readMigrationAttempts(database) {
|
|
38
|
+
const [rows] = await database.query(
|
|
39
|
+
`SELECT migration_id, status, statement_index, started_at, finished_at, error_code, error_message
|
|
40
|
+
FROM ${ATTEMPT_TABLE}
|
|
41
|
+
ORDER BY started_at ASC, migration_id ASC`,
|
|
42
|
+
);
|
|
43
|
+
return rows;
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export function validateMigration(migration) {
|
|
18
47
|
if (!migration || typeof migration !== 'object') throw new Error('Migration must be an object');
|
|
19
48
|
if (!migration.id || typeof migration.id !== 'string') throw new Error('Migration id is required');
|
|
@@ -26,23 +55,102 @@ export function validateMigration(migration) {
|
|
|
26
55
|
return migration;
|
|
27
56
|
}
|
|
28
57
|
|
|
58
|
+
function migrationRecoveryError(attempts) {
|
|
59
|
+
const ids = attempts.map((attempt) => attempt.migration_id);
|
|
60
|
+
const error = new Error(
|
|
61
|
+
`Database contains an incomplete migration attempt and must be restored before retrying: ${ids.join(', ')}`,
|
|
62
|
+
);
|
|
63
|
+
error.code = 'DATABASE_MIGRATION_RECOVERY_REQUIRED';
|
|
64
|
+
error.migrationAttempts = attempts.map((attempt) => ({ ...attempt }));
|
|
65
|
+
return error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function assertNoIncompleteMigrationAttempts(database, applied, { allowMissingJournal = false } = {}) {
|
|
69
|
+
let attempts;
|
|
70
|
+
try {
|
|
71
|
+
attempts = await readMigrationAttempts(database);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (allowMissingJournal && error?.code === 'ER_NO_SUCH_TABLE') return;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
const inconsistent = attempts.filter((attempt) => !applied.has(attempt.migration_id));
|
|
77
|
+
if (inconsistent.length > 0) throw migrationRecoveryError(inconsistent);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function beginMigrationAttempt(database, migration) {
|
|
81
|
+
await database.query(
|
|
82
|
+
`INSERT INTO ${ATTEMPT_TABLE}
|
|
83
|
+
(migration_id, status, statement_index, started_at, finished_at, error_code, error_message)
|
|
84
|
+
VALUES (?, 'applying', 0, CURRENT_TIMESTAMP(3), NULL, NULL, NULL)`,
|
|
85
|
+
[migration.id],
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function advanceMigrationAttempt(database, migrationId, statementIndex) {
|
|
90
|
+
await database.query(
|
|
91
|
+
`UPDATE ${ATTEMPT_TABLE}
|
|
92
|
+
SET statement_index = ?
|
|
93
|
+
WHERE migration_id = ? AND status = 'applying'`,
|
|
94
|
+
[statementIndex, migrationId],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function completeMigrationAttempt(database, migrationId) {
|
|
99
|
+
await database.query(
|
|
100
|
+
`UPDATE ${ATTEMPT_TABLE}
|
|
101
|
+
SET status = 'applied', finished_at = CURRENT_TIMESTAMP(3), error_code = NULL, error_message = NULL
|
|
102
|
+
WHERE migration_id = ?`,
|
|
103
|
+
[migrationId],
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function failMigrationAttempt(database, migrationId, error) {
|
|
108
|
+
await database.query(
|
|
109
|
+
`UPDATE ${ATTEMPT_TABLE}
|
|
110
|
+
SET status = 'failed', finished_at = CURRENT_TIMESTAMP(3), error_code = ?, error_message = ?
|
|
111
|
+
WHERE migration_id = ?`,
|
|
112
|
+
[
|
|
113
|
+
error?.code == null ? null : String(error.code).slice(0, 128),
|
|
114
|
+
truncateErrorMessage(error?.message),
|
|
115
|
+
migrationId,
|
|
116
|
+
],
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
29
120
|
export async function applyMigrations(database, migrations) {
|
|
30
121
|
if (!database) throw new Error('Database handle is required');
|
|
31
122
|
if (!Array.isArray(migrations)) throw new Error('Migrations must be an array');
|
|
32
123
|
|
|
33
124
|
await ensureMigrationJournal(database);
|
|
34
125
|
const applied = await readAppliedMigrations(database);
|
|
126
|
+
await assertNoIncompleteMigrationAttempts(database, applied);
|
|
35
127
|
const newlyApplied = [];
|
|
36
128
|
|
|
37
129
|
for (const rawMigration of migrations) {
|
|
38
130
|
const migration = validateMigration(rawMigration);
|
|
39
131
|
if (applied.has(migration.id)) continue;
|
|
40
132
|
|
|
41
|
-
|
|
42
|
-
|
|
133
|
+
await beginMigrationAttempt(database, migration);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
for (let index = 0; index < migration.statements.length; index += 1) {
|
|
137
|
+
await database.query(migration.statements[index]);
|
|
138
|
+
await advanceMigrationAttempt(database, migration.id, index + 1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await database.query(`INSERT INTO ${JOURNAL_TABLE} (id) VALUES (?)`, [migration.id]);
|
|
142
|
+
await completeMigrationAttempt(database, migration.id);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
await failMigrationAttempt(database, migration.id, error);
|
|
146
|
+
} catch {
|
|
147
|
+
// The original migration error remains the source of truth. A stale 'applying'
|
|
148
|
+
// row still fails closed on the next bootstrap and requires restore/recovery.
|
|
149
|
+
}
|
|
150
|
+
error.migrationId ||= migration.id;
|
|
151
|
+
throw error;
|
|
43
152
|
}
|
|
44
153
|
|
|
45
|
-
await database.query(`INSERT INTO ${JOURNAL_TABLE} (id) VALUES (?)`, [migration.id]);
|
|
46
154
|
applied.add(migration.id);
|
|
47
155
|
newlyApplied.push(migration.id);
|
|
48
156
|
}
|
|
@@ -68,6 +176,8 @@ export async function assertMigrationsApplied(database, requiredMigrationIds) {
|
|
|
68
176
|
throw error;
|
|
69
177
|
}
|
|
70
178
|
|
|
179
|
+
await assertNoIncompleteMigrationAttempts(database, applied, { allowMissingJournal: true });
|
|
180
|
+
|
|
71
181
|
const missing = requiredMigrationIds.filter((id) => !applied.has(id));
|
|
72
182
|
|
|
73
183
|
if (missing.length > 0) {
|
package/src/query.js
CHANGED
|
@@ -7,6 +7,18 @@ const FILTER_OPERATORS = new Set([
|
|
|
7
7
|
'_contains', '_starts_with', '_ends_with',
|
|
8
8
|
]);
|
|
9
9
|
|
|
10
|
+
export const QUERY_LIMITS = Object.freeze({
|
|
11
|
+
defaultLimit: 100,
|
|
12
|
+
maxLimit: 500,
|
|
13
|
+
maxFields: 100,
|
|
14
|
+
maxRelationExpansions: 20,
|
|
15
|
+
maxSortFields: 20,
|
|
16
|
+
maxOffset: 1_000_000,
|
|
17
|
+
maxFilterDepth: 8,
|
|
18
|
+
maxFilterNodes: 100,
|
|
19
|
+
maxInValues: 100,
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
function queryError(message, path = null) {
|
|
11
23
|
const error = new Error(message);
|
|
12
24
|
error.code = 'INVALID_QUERY';
|
|
@@ -14,11 +26,14 @@ function queryError(message, path = null) {
|
|
|
14
26
|
return error;
|
|
15
27
|
}
|
|
16
28
|
|
|
17
|
-
function normalizeDelimited(value, label) {
|
|
29
|
+
function normalizeDelimited(value, label, { maxItems }) {
|
|
18
30
|
if (value == null || value === '') return null;
|
|
19
31
|
const values = Array.isArray(value) ? value : String(value).split(',');
|
|
20
32
|
const normalized = values.map((item) => String(item).trim()).filter(Boolean);
|
|
21
33
|
if (normalized.length === 0) throw queryError(`${label} cannot be empty`, label);
|
|
34
|
+
if (normalized.length > maxItems) {
|
|
35
|
+
throw queryError(`${label} cannot contain more than ${maxItems} entries`, label);
|
|
36
|
+
}
|
|
22
37
|
return normalized;
|
|
23
38
|
}
|
|
24
39
|
|
|
@@ -50,7 +65,8 @@ function normalizeFilter(value) {
|
|
|
50
65
|
return value;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
export function parseItemsQuery(raw = {},
|
|
68
|
+
export function parseItemsQuery(raw = {}, options = {}) {
|
|
69
|
+
const limits = { ...QUERY_LIMITS, ...options };
|
|
54
70
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
55
71
|
throw queryError('Query must be an object');
|
|
56
72
|
}
|
|
@@ -60,11 +76,19 @@ export function parseItemsQuery(raw = {}, { defaultLimit = 100, maxLimit = 500 }
|
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
return {
|
|
63
|
-
fields: normalizeDelimited(raw.fields, 'fields'),
|
|
79
|
+
fields: normalizeDelimited(raw.fields, 'fields', { maxItems: limits.maxFields }),
|
|
64
80
|
filter: normalizeFilter(raw.filter),
|
|
65
|
-
sort: normalizeDelimited(raw.sort, 'sort'),
|
|
66
|
-
limit: normalizeInteger(raw.limit, defaultLimit, {
|
|
67
|
-
|
|
81
|
+
sort: normalizeDelimited(raw.sort, 'sort', { maxItems: limits.maxSortFields }),
|
|
82
|
+
limit: normalizeInteger(raw.limit, limits.defaultLimit, {
|
|
83
|
+
label: 'limit',
|
|
84
|
+
min: 1,
|
|
85
|
+
max: limits.maxLimit,
|
|
86
|
+
}),
|
|
87
|
+
offset: normalizeInteger(raw.offset, 0, {
|
|
88
|
+
label: 'offset',
|
|
89
|
+
min: 0,
|
|
90
|
+
max: limits.maxOffset,
|
|
91
|
+
}),
|
|
68
92
|
};
|
|
69
93
|
}
|
|
70
94
|
|
|
@@ -105,7 +129,7 @@ function escapeLike(value) {
|
|
|
105
129
|
return String(value).replace(/[\\%_]/g, '\\$&');
|
|
106
130
|
}
|
|
107
131
|
|
|
108
|
-
function compileOperator(fieldSql, operator, value, path) {
|
|
132
|
+
function compileOperator(fieldSql, operator, value, path, limits) {
|
|
109
133
|
if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
110
134
|
|
|
111
135
|
switch (operator) {
|
|
@@ -122,6 +146,9 @@ function compileOperator(fieldSql, operator, value, path) {
|
|
|
122
146
|
case '_in':
|
|
123
147
|
case '_nin': {
|
|
124
148
|
if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
|
|
149
|
+
if (value.length > limits.maxInValues) {
|
|
150
|
+
throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
151
|
+
}
|
|
125
152
|
if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
|
|
126
153
|
const placeholders = value.map(() => '?').join(', ');
|
|
127
154
|
return {
|
|
@@ -146,10 +173,18 @@ function compileOperator(fieldSql, operator, value, path) {
|
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
function compileFilterObject(filter, schema, path
|
|
176
|
+
function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
150
177
|
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
151
178
|
throw queryError('Filter node must be an object', path);
|
|
152
179
|
}
|
|
180
|
+
if (depth > limits.maxFilterDepth) {
|
|
181
|
+
throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
state.nodes += 1;
|
|
185
|
+
if (state.nodes > limits.maxFilterNodes) {
|
|
186
|
+
throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
|
|
187
|
+
}
|
|
153
188
|
|
|
154
189
|
const fragments = [];
|
|
155
190
|
const params = [];
|
|
@@ -160,7 +195,7 @@ function compileFilterObject(filter, schema, path = 'filter') {
|
|
|
160
195
|
throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
|
|
161
196
|
}
|
|
162
197
|
const children = value.map((child, index) =>
|
|
163
|
-
compileFilterObject(child, schema, `${path}.${key}.${index}
|
|
198
|
+
compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
|
|
164
199
|
fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
|
|
165
200
|
for (const child of children) params.push(...child.params);
|
|
166
201
|
continue;
|
|
@@ -174,7 +209,13 @@ function compileFilterObject(filter, schema, path = 'filter') {
|
|
|
174
209
|
const fieldSql = quoteIdentifier(key, 'field name');
|
|
175
210
|
const fieldFragments = [];
|
|
176
211
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
177
|
-
const compiled = compileOperator(
|
|
212
|
+
const compiled = compileOperator(
|
|
213
|
+
fieldSql,
|
|
214
|
+
operator,
|
|
215
|
+
operatorValue,
|
|
216
|
+
`${path}.${key}.${operator}`,
|
|
217
|
+
limits,
|
|
218
|
+
);
|
|
178
219
|
fieldFragments.push(compiled.sql);
|
|
179
220
|
params.push(...compiled.params);
|
|
180
221
|
}
|
|
@@ -186,8 +227,9 @@ function compileFilterObject(filter, schema, path = 'filter') {
|
|
|
186
227
|
return { sql: fragments.join(' AND '), params };
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
export function compileFilter(filter, schema) {
|
|
230
|
+
export function compileFilter(filter, schema, options = {}) {
|
|
190
231
|
if (!filter) return { sql: '', params: [] };
|
|
191
|
-
const
|
|
232
|
+
const limits = { ...QUERY_LIMITS, ...options };
|
|
233
|
+
const compiled = compileFilterObject(filter, schema, 'filter', limits, { nodes: 0 }, 1);
|
|
192
234
|
return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
|
|
193
235
|
}
|