@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,284 @@
1
+ function encodeJson(value) {
2
+ return value == null ? null : JSON.stringify(value);
3
+ }
4
+
5
+ export class SchemaMetadataRepository {
6
+ constructor(database) {
7
+ if (!database) throw new Error('Database handle is required');
8
+ this.database = database;
9
+ }
10
+
11
+ async listCollections() {
12
+ const [rows] = await this.database.query(
13
+ `SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
14
+ FROM yuncms_collections
15
+ ORDER BY collection ASC`,
16
+ );
17
+ return rows;
18
+ }
19
+
20
+ async readCollection(collection) {
21
+ const [rows] = await this.database.query(
22
+ `SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
23
+ FROM yuncms_collections
24
+ WHERE collection = ?
25
+ LIMIT 1`,
26
+ [collection],
27
+ );
28
+ return rows[0] ?? null;
29
+ }
30
+
31
+ async createCollection({
32
+ collection,
33
+ primaryKey = 'id',
34
+ note = null,
35
+ singleton = false,
36
+ hidden = false,
37
+ system = false,
38
+ metadata = null,
39
+ }) {
40
+ await this.database.query(
41
+ `INSERT INTO yuncms_collections
42
+ (collection, primary_key, note, singleton, hidden, \`system\`, metadata)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
44
+ [
45
+ collection,
46
+ primaryKey,
47
+ note,
48
+ singleton ? 1 : 0,
49
+ hidden ? 1 : 0,
50
+ system ? 1 : 0,
51
+ encodeJson(metadata),
52
+ ],
53
+ );
54
+ return this.readCollection(collection);
55
+ }
56
+
57
+ async updateCollectionMetadata(collection, patch = {}) {
58
+ const assignments = [];
59
+ const params = [];
60
+
61
+ if (Object.hasOwn(patch, 'note')) {
62
+ assignments.push('note = ?');
63
+ params.push(patch.note ?? null);
64
+ }
65
+ if (Object.hasOwn(patch, 'singleton')) {
66
+ assignments.push('singleton = ?');
67
+ params.push(patch.singleton ? 1 : 0);
68
+ }
69
+ if (Object.hasOwn(patch, 'hidden')) {
70
+ assignments.push('hidden = ?');
71
+ params.push(patch.hidden ? 1 : 0);
72
+ }
73
+ if (Object.hasOwn(patch, 'metadata')) {
74
+ assignments.push('metadata = ?');
75
+ params.push(encodeJson(patch.metadata));
76
+ }
77
+
78
+ if (assignments.length === 0) return this.readCollection(collection);
79
+ params.push(collection);
80
+ await this.database.query(
81
+ `UPDATE yuncms_collections SET ${assignments.join(', ')} WHERE collection = ?`,
82
+ params,
83
+ );
84
+ return this.readCollection(collection);
85
+ }
86
+
87
+ async deleteCollection(collection) {
88
+ const [result] = await this.database.query(
89
+ 'DELETE FROM yuncms_collections WHERE collection = ?',
90
+ [collection],
91
+ );
92
+ return result.affectedRows;
93
+ }
94
+
95
+ async listFields(collection) {
96
+ const [rows] = await this.database.query(
97
+ `SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
98
+ created_at, updated_at
99
+ FROM yuncms_fields
100
+ WHERE collection = ?
101
+ ORDER BY COALESCE(sort, 2147483647), id ASC`,
102
+ [collection],
103
+ );
104
+ return rows;
105
+ }
106
+
107
+ async readField(collection, field) {
108
+ const [rows] = await this.database.query(
109
+ `SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
110
+ created_at, updated_at
111
+ FROM yuncms_fields
112
+ WHERE collection = ? AND field = ?
113
+ LIMIT 1`,
114
+ [collection, field],
115
+ );
116
+ return rows[0] ?? null;
117
+ }
118
+
119
+ async createField({
120
+ collection,
121
+ field,
122
+ type,
123
+ required = false,
124
+ readonly = false,
125
+ hidden = false,
126
+ sort = null,
127
+ interface: fieldInterface = null,
128
+ options = null,
129
+ schemaMetadata = null,
130
+ }) {
131
+ await this.database.query(
132
+ `INSERT INTO yuncms_fields
133
+ (collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
135
+ [
136
+ collection,
137
+ field,
138
+ type,
139
+ required ? 1 : 0,
140
+ readonly ? 1 : 0,
141
+ hidden ? 1 : 0,
142
+ sort,
143
+ fieldInterface,
144
+ encodeJson(options),
145
+ encodeJson(schemaMetadata),
146
+ ],
147
+ );
148
+ return this.readField(collection, field);
149
+ }
150
+
151
+ async updateFieldMetadata(collection, field, patch = {}) {
152
+ const assignments = [];
153
+ const params = [];
154
+
155
+ if (Object.hasOwn(patch, 'readonly')) {
156
+ assignments.push('readonly = ?');
157
+ params.push(patch.readonly ? 1 : 0);
158
+ }
159
+ if (Object.hasOwn(patch, 'hidden')) {
160
+ assignments.push('hidden = ?');
161
+ params.push(patch.hidden ? 1 : 0);
162
+ }
163
+ if (Object.hasOwn(patch, 'sort')) {
164
+ assignments.push('sort = ?');
165
+ params.push(patch.sort ?? null);
166
+ }
167
+ if (Object.hasOwn(patch, 'interface')) {
168
+ assignments.push('interface = ?');
169
+ params.push(patch.interface ?? null);
170
+ }
171
+ if (Object.hasOwn(patch, 'options')) {
172
+ assignments.push('options = ?');
173
+ params.push(encodeJson(patch.options));
174
+ }
175
+
176
+ if (assignments.length === 0) return this.readField(collection, field);
177
+ params.push(collection, field);
178
+ await this.database.query(
179
+ `UPDATE yuncms_fields SET ${assignments.join(', ')} WHERE collection = ? AND field = ?`,
180
+ params,
181
+ );
182
+ return this.readField(collection, field);
183
+ }
184
+
185
+ async updateFieldPhysicalMetadata(collection, field, { required, schemaMetadata } = {}) {
186
+ const assignments = [];
187
+ const params = [];
188
+
189
+ if (required !== undefined) {
190
+ assignments.push('required = ?');
191
+ params.push(required ? 1 : 0);
192
+ }
193
+ if (schemaMetadata !== undefined) {
194
+ assignments.push('schema_metadata = ?');
195
+ params.push(encodeJson(schemaMetadata));
196
+ }
197
+
198
+ if (assignments.length === 0) return this.readField(collection, field);
199
+ params.push(collection, field);
200
+ await this.database.query(
201
+ `UPDATE yuncms_fields SET ${assignments.join(', ')} WHERE collection = ? AND field = ?`,
202
+ params,
203
+ );
204
+ return this.readField(collection, field);
205
+ }
206
+
207
+ async deleteField(collection, field) {
208
+ const [result] = await this.database.query(
209
+ 'DELETE FROM yuncms_fields WHERE collection = ? AND field = ?',
210
+ [collection, field],
211
+ );
212
+ return result.affectedRows;
213
+ }
214
+
215
+ async listRelations() {
216
+ const [rows] = await this.database.query(
217
+ `SELECT id, many_collection, many_field, one_collection, one_field, junction_collection,
218
+ junction_field, on_delete, metadata, created_at
219
+ FROM yuncms_relations
220
+ ORDER BY many_collection, many_field`,
221
+ );
222
+ return rows;
223
+ }
224
+
225
+ async listRelationsForOne(oneCollection) {
226
+ const [rows] = await this.database.query(
227
+ `SELECT id, many_collection, many_field, one_collection, one_field, junction_collection,
228
+ junction_field, on_delete, metadata, created_at
229
+ FROM yuncms_relations
230
+ WHERE one_collection = ?
231
+ ORDER BY many_collection, many_field`,
232
+ [oneCollection],
233
+ );
234
+ return rows;
235
+ }
236
+
237
+ async readRelation(manyCollection, manyField) {
238
+ const [rows] = await this.database.query(
239
+ `SELECT id, many_collection, many_field, one_collection, one_field, junction_collection,
240
+ junction_field, on_delete, metadata, created_at
241
+ FROM yuncms_relations
242
+ WHERE many_collection = ? AND many_field = ?
243
+ LIMIT 1`,
244
+ [manyCollection, manyField],
245
+ );
246
+ return rows[0] ?? null;
247
+ }
248
+
249
+ async createRelation({
250
+ manyCollection,
251
+ manyField,
252
+ oneCollection,
253
+ oneField = null,
254
+ junctionCollection = null,
255
+ junctionField = null,
256
+ onDelete = 'RESTRICT',
257
+ metadata = null,
258
+ }) {
259
+ await this.database.query(
260
+ `INSERT INTO yuncms_relations
261
+ (many_collection, many_field, one_collection, one_field, junction_collection, junction_field, on_delete, metadata)
262
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
263
+ [
264
+ manyCollection,
265
+ manyField,
266
+ oneCollection,
267
+ oneField,
268
+ junctionCollection,
269
+ junctionField,
270
+ onDelete,
271
+ encodeJson(metadata),
272
+ ],
273
+ );
274
+ return this.readRelation(manyCollection, manyField);
275
+ }
276
+
277
+ async deleteRelation(manyCollection, manyField) {
278
+ const [result] = await this.database.query(
279
+ 'DELETE FROM yuncms_relations WHERE many_collection = ? AND many_field = ?',
280
+ [manyCollection, manyField],
281
+ );
282
+ return result.affectedRows;
283
+ }
284
+ }
@@ -0,0 +1,21 @@
1
+ export async function readSchemaVersion(database) {
2
+ const [rows] = await database.query('SELECT version FROM yuncms_schema_state WHERE id = 1 LIMIT 1');
3
+ if (!rows?.[0]) {
4
+ const error = new Error('YunCMS schema state is missing');
5
+ error.code = 'SCHEMA_STATE_MISSING';
6
+ throw error;
7
+ }
8
+
9
+ return Number(rows[0].version);
10
+ }
11
+
12
+ export async function incrementSchemaVersion(database) {
13
+ const [result] = await database.query('UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1');
14
+ if (result.affectedRows !== 1) {
15
+ const error = new Error('Could not update YunCMS schema version');
16
+ error.code = 'SCHEMA_STATE_MISSING';
17
+ throw error;
18
+ }
19
+
20
+ return readSchemaVersion(database);
21
+ }
package/src/schema.js ADDED
@@ -0,0 +1,82 @@
1
+ import { SchemaMetadataRepository } from './schema-metadata-repository.js';
2
+ import { readSchemaVersion } from './schema-version.js';
3
+
4
+ export async function loadSchemaSnapshot(database) {
5
+ const version = await readSchemaVersion(database);
6
+ const repository = new SchemaMetadataRepository(database);
7
+ const [collections, relations] = await Promise.all([
8
+ repository.listCollections(),
9
+ repository.listRelations(),
10
+ ]);
11
+
12
+ const fieldsByCollection = new Map();
13
+ await Promise.all(
14
+ collections.map(async (collection) => {
15
+ fieldsByCollection.set(
16
+ collection.collection,
17
+ await repository.listFields(collection.collection),
18
+ );
19
+ }),
20
+ );
21
+
22
+ const relationByManyField = new Map(
23
+ relations.map((relation) => [
24
+ `${relation.many_collection}.${relation.many_field}`,
25
+ relation,
26
+ ]),
27
+ );
28
+
29
+ return Object.freeze({
30
+ version,
31
+ collections: Object.freeze(
32
+ Object.fromEntries(
33
+ collections.map((collection) => [
34
+ collection.collection,
35
+ Object.freeze({
36
+ ...collection,
37
+ fields: Object.freeze(
38
+ Object.fromEntries(
39
+ (fieldsByCollection.get(collection.collection) ?? []).map((field) => [field.field, field]),
40
+ ),
41
+ ),
42
+ }),
43
+ ]),
44
+ ),
45
+ ),
46
+ relations: Object.freeze(relations),
47
+ relationByManyField,
48
+ });
49
+ }
50
+
51
+ export class SchemaCache {
52
+ constructor({ versionCheckTtlMs = 250 } = {}) {
53
+ this.versionCheckTtlMs = versionCheckTtlMs;
54
+ this.snapshot = null;
55
+ this.lastVersionCheckAt = 0;
56
+ }
57
+
58
+ async get(database, { force = false } = {}) {
59
+ const now = Date.now();
60
+
61
+ if (!force && this.snapshot && now - this.lastVersionCheckAt < this.versionCheckTtlMs) {
62
+ return this.snapshot;
63
+ }
64
+
65
+ const currentVersion = await readSchemaVersion(database);
66
+ this.lastVersionCheckAt = now;
67
+
68
+ if (!force && this.snapshot?.version === currentVersion) {
69
+ return this.snapshot;
70
+ }
71
+
72
+ const snapshot = await loadSchemaSnapshot(database);
73
+ this.snapshot = snapshot;
74
+ this.lastVersionCheckAt = Date.now();
75
+ return snapshot;
76
+ }
77
+
78
+ clear() {
79
+ this.snapshot = null;
80
+ this.lastVersionCheckAt = 0;
81
+ }
82
+ }
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { createOpaqueToken } from '../auth/tokens.js';
4
+ import { BaseService } from './base-service.js';
5
+
6
+ function forbidden(message) {
7
+ const error = new Error(message);
8
+ error.code = 'FORBIDDEN';
9
+ return error;
10
+ }
11
+
12
+ function targetUser(accountability, requestedUser = null) {
13
+ if (accountability.admin === true || accountability.system === true) {
14
+ const user = requestedUser ?? accountability.user;
15
+ if (!user) {
16
+ const error = new Error('API token user is required');
17
+ error.code = 'INVALID_PAYLOAD';
18
+ throw error;
19
+ }
20
+ return user;
21
+ }
22
+
23
+ if (!accountability.user) throw forbidden('Authenticated user is required');
24
+ if (requestedUser && requestedUser !== accountability.user) {
25
+ throw forbidden('API tokens can only be managed for the authenticated user');
26
+ }
27
+ return accountability.user;
28
+ }
29
+
30
+ function parseExpiry(value) {
31
+ if (value == null || value === '') return null;
32
+ const date = value instanceof Date ? value : new Date(value);
33
+ if (Number.isNaN(date.getTime())) {
34
+ const error = new Error('API token expiry is invalid');
35
+ error.code = 'INVALID_PAYLOAD';
36
+ throw error;
37
+ }
38
+ if (date.getTime() <= Date.now()) {
39
+ const error = new Error('API token expiry must be in the future');
40
+ error.code = 'INVALID_PAYLOAD';
41
+ throw error;
42
+ }
43
+ return date;
44
+ }
45
+
46
+ export class ApiTokensService extends BaseService {
47
+ async readMany(userId = null) {
48
+ const user = targetUser(this.accountability, userId);
49
+ const [rows] = await this.database.query(
50
+ `SELECT id, user, name, expires_at, last_used_at, created_at
51
+ FROM yuncms_api_tokens
52
+ WHERE user = ?
53
+ ORDER BY created_at DESC`,
54
+ [user],
55
+ );
56
+ return rows;
57
+ }
58
+
59
+ async createOne(input = {}) {
60
+ const user = targetUser(this.accountability, input.user ?? null);
61
+ if (!input.name || typeof input.name !== 'string' || input.name.trim().length === 0) {
62
+ const error = new Error('API token name is required');
63
+ error.code = 'INVALID_PAYLOAD';
64
+ throw error;
65
+ }
66
+ const name = input.name.trim();
67
+ if (name.length > 100) {
68
+ const error = new Error('API token name cannot exceed 100 characters');
69
+ error.code = 'INVALID_PAYLOAD';
70
+ throw error;
71
+ }
72
+ const expiresAt = parseExpiry(input.expires_at ?? input.expiresAt ?? null);
73
+
74
+ const [userRows] = await this.database.query(
75
+ `SELECT id FROM yuncms_users WHERE id = ? AND status = 'active' LIMIT 1`,
76
+ [user],
77
+ );
78
+ if (!userRows[0]) {
79
+ const error = new Error(`Unknown or inactive user: ${user}`);
80
+ error.code = 'USER_NOT_FOUND';
81
+ throw error;
82
+ }
83
+
84
+ const id = randomUUID();
85
+ const generated = createOpaqueToken('api', { bytes: 40 });
86
+ await this.database.query(
87
+ `INSERT INTO yuncms_api_tokens (id, user, name, token_hash, expires_at)
88
+ VALUES (?, ?, ?, ?, ?)`,
89
+ [id, user, name, generated.hash, expiresAt],
90
+ );
91
+
92
+ return {
93
+ id,
94
+ user,
95
+ name,
96
+ token: generated.token,
97
+ expires_at: expiresAt,
98
+ };
99
+ }
100
+
101
+ async deleteOne(id, userId = null) {
102
+ const user = targetUser(this.accountability, userId);
103
+ let sql = 'DELETE FROM yuncms_api_tokens WHERE id = ?';
104
+ const params = [id];
105
+
106
+ if (this.accountability.admin !== true && this.accountability.system !== true) {
107
+ sql += ' AND user = ?';
108
+ params.push(user);
109
+ } else if (userId) {
110
+ sql += ' AND user = ?';
111
+ params.push(user);
112
+ }
113
+
114
+ const [result] = await this.database.query(sql, params);
115
+ return result.affectedRows > 0;
116
+ }
117
+ }
@@ -0,0 +1,182 @@
1
+ import { BaseService } from './base-service.js';
2
+
3
+ const SENSITIVE_KEY = /(password|passwd|token|secret|authorization|cookie|api[_-]?key|credential)/i;
4
+ const MAX_DEPTH = 12;
5
+ const DEFAULT_RETENTION_DAYS = 90;
6
+ const DEFAULT_CLEANUP_BATCH_SIZE = 1000;
7
+
8
+ function auditError(code, message) {
9
+ const error = new Error(message);
10
+ error.code = code;
11
+ return error;
12
+ }
13
+
14
+ function assertAuditReader(accountability) {
15
+ if (accountability.admin === true || accountability.system === true) return;
16
+ throw auditError('FORBIDDEN', 'Audit log access requires administrator accountability');
17
+ }
18
+
19
+ function boundedInteger(value, fallback, { min, max, label }) {
20
+ const normalized = value ?? fallback;
21
+ if (!Number.isInteger(normalized) || normalized < min || normalized > max) {
22
+ throw auditError('INVALID_PAYLOAD', `${label} must be an integer between ${min} and ${max}`);
23
+ }
24
+ return normalized;
25
+ }
26
+
27
+ export function redactAuditValue(value, depth = 0, seen = new WeakSet()) {
28
+ if (value == null || typeof value !== 'object') return value;
29
+ if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
30
+ if (seen.has(value)) return '[CIRCULAR]';
31
+ seen.add(value);
32
+
33
+ if (Array.isArray(value)) {
34
+ const result = value.map((entry) => redactAuditValue(entry, depth + 1, seen));
35
+ seen.delete(value);
36
+ return result;
37
+ }
38
+
39
+ const result = {};
40
+ for (const [key, entry] of Object.entries(value)) {
41
+ result[key] = SENSITIVE_KEY.test(key)
42
+ ? '[REDACTED]'
43
+ : redactAuditValue(entry, depth + 1, seen);
44
+ }
45
+ seen.delete(value);
46
+ return result;
47
+ }
48
+
49
+ function decodePayload(value) {
50
+ if (value == null || typeof value === 'object') return value ?? null;
51
+ try {
52
+ return JSON.parse(value);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export class AuditService extends BaseService {
59
+ async record({
60
+ user = this.accountability.user ?? null,
61
+ action,
62
+ collection = null,
63
+ itemKey = null,
64
+ requestId = this.requestId ?? null,
65
+ ip = null,
66
+ payload = null,
67
+ } = {}) {
68
+ if (typeof action !== 'string' || !action.trim() || action.length > 32) {
69
+ throw auditError('INVALID_AUDIT_EVENT', 'Audit action is required and must not exceed 32 characters');
70
+ }
71
+ if (collection != null && (typeof collection !== 'string' || collection.length > 64)) {
72
+ throw auditError('INVALID_AUDIT_EVENT', 'Audit collection is invalid');
73
+ }
74
+ if (itemKey != null && String(itemKey).length > 191) {
75
+ throw auditError('INVALID_AUDIT_EVENT', 'Audit item key is too long');
76
+ }
77
+
78
+ const redacted = payload == null ? null : redactAuditValue(payload);
79
+ const [result] = await this.database.query(
80
+ `INSERT INTO yuncms_audit_log
81
+ (user, action, collection, item_key, request_id, ip, payload)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
83
+ [
84
+ user,
85
+ action.trim(),
86
+ collection,
87
+ itemKey == null ? null : String(itemKey),
88
+ requestId == null ? null : String(requestId).slice(0, 64),
89
+ ip == null ? null : String(ip).slice(0, 45),
90
+ redacted == null ? null : JSON.stringify(redacted),
91
+ ],
92
+ );
93
+ return result.insertId ?? null;
94
+ }
95
+
96
+ async readMany({ limit = 100, offset = 0, collection = null, user = null } = {}) {
97
+ assertAuditReader(this.accountability);
98
+ const safeLimit = Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 500) : 100;
99
+ const safeOffset = Number.isInteger(offset) ? Math.max(offset, 0) : 0;
100
+ const where = [];
101
+ const params = [];
102
+
103
+ if (collection) {
104
+ where.push('collection = ?');
105
+ params.push(collection);
106
+ }
107
+ if (user) {
108
+ where.push('user = ?');
109
+ params.push(user);
110
+ }
111
+ const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
112
+
113
+ const [rows] = await this.database.query(
114
+ `SELECT id, user, action, collection, item_key, request_id, ip, payload, created_at
115
+ FROM yuncms_audit_log${whereSql}
116
+ ORDER BY id DESC
117
+ LIMIT ? OFFSET ?`,
118
+ [...params, safeLimit, safeOffset],
119
+ );
120
+ return rows.map((row) => ({ ...row, payload: decodePayload(row.payload) }));
121
+ }
122
+
123
+ async cleanup({
124
+ retentionDays = DEFAULT_RETENTION_DAYS,
125
+ batchSize = DEFAULT_CLEANUP_BATCH_SIZE,
126
+ maxBatches = 100,
127
+ } = {}) {
128
+ assertAuditReader(this.accountability);
129
+ const days = boundedInteger(retentionDays, DEFAULT_RETENTION_DAYS, {
130
+ min: 1,
131
+ max: 3650,
132
+ label: 'Audit retention days',
133
+ });
134
+ const size = boundedInteger(batchSize, DEFAULT_CLEANUP_BATCH_SIZE, {
135
+ min: 1,
136
+ max: 5000,
137
+ label: 'Audit cleanup batch size',
138
+ });
139
+ const batchesLimit = boundedInteger(maxBatches, 100, {
140
+ min: 1,
141
+ max: 1000,
142
+ label: 'Audit cleanup max batches',
143
+ });
144
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
145
+
146
+ let deleted = 0;
147
+ let batches = 0;
148
+ let complete = false;
149
+
150
+ while (batches < batchesLimit) {
151
+ const [result] = await this.database.query(
152
+ `DELETE FROM yuncms_audit_log
153
+ WHERE created_at < ?
154
+ ORDER BY id ASC
155
+ LIMIT ?`,
156
+ [cutoff, size],
157
+ );
158
+ const affected = Number(result.affectedRows ?? 0);
159
+ deleted += affected;
160
+ batches += 1;
161
+ if (affected < size) {
162
+ complete = true;
163
+ break;
164
+ }
165
+ }
166
+
167
+ return {
168
+ retentionDays: days,
169
+ batchSize: size,
170
+ maxBatches: batchesLimit,
171
+ cutoff,
172
+ deleted,
173
+ batches,
174
+ complete,
175
+ };
176
+ }
177
+ }
178
+
179
+ export {
180
+ DEFAULT_CLEANUP_BATCH_SIZE,
181
+ DEFAULT_RETENTION_DAYS,
182
+ };