@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,471 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ import { withAdvisoryLock } from '../advisory-lock.js';
4
+ import { compileFieldColumn } from '../field-types.js';
5
+ import { assertIdentifier, quoteIdentifier } from '../identifier.js';
6
+ import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
7
+ import { incrementSchemaVersion } from '../schema-version.js';
8
+ import { withConnectionTransaction } from '../transaction.js';
9
+ import { BaseService } from './base-service.js';
10
+ import { assertSchemaManager } from './schema-access.js';
11
+
12
+ const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
13
+ const FIELD_PHYSICAL_KEYS = new Set(['required', 'defaultValue', 'removeDefault', 'indexed']);
14
+
15
+ function assertFieldName(field) {
16
+ assertIdentifier(field, 'field name');
17
+ if (field.length > 64) throw new Error('Field name cannot exceed 64 characters');
18
+ return field;
19
+ }
20
+
21
+ function assertFieldMetadataPatch(patch) {
22
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
23
+ const error = new Error('Field metadata patch must be an object');
24
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
25
+ throw error;
26
+ }
27
+ for (const key of Object.keys(patch)) {
28
+ if (!FIELD_METADATA_KEYS.has(key)) {
29
+ const error = new Error(`Field property cannot be updated through metadata-only V1 update: ${key}`);
30
+ error.code = 'UNSUPPORTED_SCHEMA_UPDATE';
31
+ throw error;
32
+ }
33
+ }
34
+ if (Object.keys(patch).length === 0) {
35
+ const error = new Error('Field metadata patch cannot be empty');
36
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ function assertPhysicalPatch(patch) {
42
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
43
+ const error = new Error('Physical field patch must be an object');
44
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
45
+ throw error;
46
+ }
47
+ const keys = Object.keys(patch);
48
+ if (keys.length === 0) {
49
+ const error = new Error('Physical field patch cannot be empty');
50
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
51
+ throw error;
52
+ }
53
+ for (const key of keys) {
54
+ if (!FIELD_PHYSICAL_KEYS.has(key)) {
55
+ const error = new Error(`Physical field property cannot be changed in V1: ${key}`);
56
+ error.code = 'UNSUPPORTED_SCHEMA_UPDATE';
57
+ throw error;
58
+ }
59
+ }
60
+ if (Object.hasOwn(patch, 'required') && typeof patch.required !== 'boolean') {
61
+ const error = new Error('required must be boolean');
62
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
63
+ throw error;
64
+ }
65
+ if (Object.hasOwn(patch, 'indexed') && typeof patch.indexed !== 'boolean') {
66
+ const error = new Error('indexed must be boolean');
67
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
68
+ throw error;
69
+ }
70
+ if (Object.hasOwn(patch, 'removeDefault') && patch.removeDefault !== true) {
71
+ const error = new Error('removeDefault must be true when provided');
72
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
73
+ throw error;
74
+ }
75
+ if (Object.hasOwn(patch, 'defaultValue') && patch.removeDefault === true) {
76
+ const error = new Error('defaultValue and removeDefault cannot be used together');
77
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ function parseSchemaMetadata(value) {
83
+ if (value == null) return {};
84
+ if (typeof value === 'object') return value;
85
+ try {
86
+ return JSON.parse(value);
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ function fieldInputFromMetadata(field, schemaMetadata) {
93
+ const input = {
94
+ type: field.type,
95
+ required: Boolean(field.required),
96
+ };
97
+ if (schemaMetadata.length !== undefined && field.type === 'string') input.length = schemaMetadata.length;
98
+ if (schemaMetadata.precision !== undefined && field.type === 'decimal') input.precision = schemaMetadata.precision;
99
+ if (schemaMetadata.scale !== undefined && field.type === 'decimal') input.scale = schemaMetadata.scale;
100
+ if (Object.hasOwn(schemaMetadata, 'defaultValue')) input.defaultValue = schemaMetadata.defaultValue;
101
+ return input;
102
+ }
103
+
104
+ function indexName(collection, field) {
105
+ const digest = createHash('sha256').update(`${collection}:${field}:index`).digest('hex').slice(0, 24);
106
+ return `yidx_${digest}`;
107
+ }
108
+
109
+ function temporaryDropName() {
110
+ return `_yuncms_drop_${randomUUID().replaceAll('-', '').slice(0, 24)}`;
111
+ }
112
+
113
+ export class FieldsService extends BaseService {
114
+ async readMany(collection) {
115
+ assertSchemaManager(this.accountability);
116
+ assertIdentifier(collection, 'collection name');
117
+ return new SchemaMetadataRepository(this.database).listFields(collection);
118
+ }
119
+
120
+ async readOne(collection, field) {
121
+ assertSchemaManager(this.accountability);
122
+ assertIdentifier(collection, 'collection name');
123
+ assertFieldName(field);
124
+ return new SchemaMetadataRepository(this.database).readField(collection, field);
125
+ }
126
+
127
+ async createOne(collection, input = {}) {
128
+ assertSchemaManager(this.accountability);
129
+ assertIdentifier(collection, 'collection name');
130
+ const field = assertFieldName(input.field);
131
+
132
+ if (field === 'id') {
133
+ const error = new Error('The id field is created with the collection and cannot be added again');
134
+ error.code = 'FIELD_EXISTS';
135
+ throw error;
136
+ }
137
+
138
+ const compiled = compileFieldColumn(input);
139
+
140
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
141
+ const metadata = new SchemaMetadataRepository(connection);
142
+ const collectionMetadata = await metadata.readCollection(collection);
143
+ if (!collectionMetadata) {
144
+ const error = new Error(`Unknown collection: ${collection}`);
145
+ error.code = 'COLLECTION_NOT_FOUND';
146
+ throw error;
147
+ }
148
+ if (collectionMetadata.system) {
149
+ const error = new Error('System collection fields cannot be changed through the dynamic schema API');
150
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
151
+ throw error;
152
+ }
153
+
154
+ const existing = await metadata.readField(collection, field);
155
+ if (existing) {
156
+ const error = new Error(`Field already exists: ${collection}.${field}`);
157
+ error.code = 'FIELD_EXISTS';
158
+ throw error;
159
+ }
160
+
161
+ const tableName = quoteIdentifier(collection, 'collection name');
162
+ const fieldName = quoteIdentifier(field, 'field name');
163
+ let physicalFieldCreated = false;
164
+
165
+ try {
166
+ await connection.query(
167
+ `ALTER TABLE ${tableName} ADD COLUMN ${fieldName} ${compiled.sql}`,
168
+ compiled.params,
169
+ );
170
+ physicalFieldCreated = true;
171
+
172
+ return await withConnectionTransaction(connection, async () => {
173
+ const created = await metadata.createField({
174
+ collection,
175
+ field,
176
+ type: input.type,
177
+ required: input.required === true,
178
+ readonly: input.readonly === true,
179
+ hidden: input.hidden === true,
180
+ sort: input.sort ?? null,
181
+ interface: input.interface ?? null,
182
+ options: input.options ?? null,
183
+ schemaMetadata: compiled.schemaMetadata,
184
+ });
185
+
186
+ const schemaVersion = await incrementSchemaVersion(connection);
187
+ return { ...created, schemaVersion };
188
+ });
189
+ } catch (error) {
190
+ const cleanupErrors = [];
191
+
192
+ try {
193
+ await metadata.deleteField(collection, field);
194
+ } catch (cleanupError) {
195
+ cleanupErrors.push(cleanupError);
196
+ }
197
+
198
+ if (physicalFieldCreated) {
199
+ try {
200
+ await connection.query(`ALTER TABLE ${tableName} DROP COLUMN ${fieldName}`);
201
+ } catch (cleanupError) {
202
+ cleanupErrors.push(cleanupError);
203
+ }
204
+ }
205
+
206
+ if (cleanupErrors.length > 0) {
207
+ error.cleanupErrors = cleanupErrors;
208
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
209
+ }
210
+ throw error;
211
+ }
212
+ });
213
+ }
214
+
215
+ async updateOne(collection, field, patch) {
216
+ assertSchemaManager(this.accountability);
217
+ assertIdentifier(collection, 'collection name');
218
+ assertFieldName(field);
219
+ assertFieldMetadataPatch(patch);
220
+
221
+ if (field === 'id') {
222
+ const error = new Error('Primary key field metadata is read-only in V1');
223
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
224
+ throw error;
225
+ }
226
+
227
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
228
+ const metadata = new SchemaMetadataRepository(connection);
229
+ const [collectionMetadata, existing] = await Promise.all([
230
+ metadata.readCollection(collection),
231
+ metadata.readField(collection, field),
232
+ ]);
233
+
234
+ if (!collectionMetadata) {
235
+ const error = new Error(`Unknown collection: ${collection}`);
236
+ error.code = 'COLLECTION_NOT_FOUND';
237
+ throw error;
238
+ }
239
+ if (collectionMetadata.system) {
240
+ const error = new Error('System collection fields cannot be changed through the dynamic schema API');
241
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
242
+ throw error;
243
+ }
244
+ if (!existing) {
245
+ const error = new Error(`Unknown field: ${collection}.${field}`);
246
+ error.code = 'FIELD_NOT_FOUND';
247
+ throw error;
248
+ }
249
+
250
+ return withConnectionTransaction(connection, async () => {
251
+ const updated = await metadata.updateFieldMetadata(collection, field, patch);
252
+ const schemaVersion = await incrementSchemaVersion(connection);
253
+ return { ...updated, schemaVersion };
254
+ });
255
+ });
256
+ }
257
+
258
+ async updateSchema(collection, field, patch) {
259
+ assertSchemaManager(this.accountability);
260
+ assertIdentifier(collection, 'collection name');
261
+ assertFieldName(field);
262
+ assertPhysicalPatch(patch);
263
+ if (field === 'id') {
264
+ const error = new Error('Primary key field schema cannot be changed in V1');
265
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
266
+ throw error;
267
+ }
268
+
269
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
270
+ const metadata = new SchemaMetadataRepository(connection);
271
+ const [collectionMetadata, existing, relations] = await Promise.all([
272
+ metadata.readCollection(collection),
273
+ metadata.readField(collection, field),
274
+ metadata.listRelations(),
275
+ ]);
276
+ if (!collectionMetadata) {
277
+ const error = new Error(`Unknown collection: ${collection}`);
278
+ error.code = 'COLLECTION_NOT_FOUND';
279
+ throw error;
280
+ }
281
+ if (collectionMetadata.system) {
282
+ const error = new Error('System collection fields cannot be changed through the dynamic schema API');
283
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
284
+ throw error;
285
+ }
286
+ if (!existing) {
287
+ const error = new Error(`Unknown field: ${collection}.${field}`);
288
+ error.code = 'FIELD_NOT_FOUND';
289
+ throw error;
290
+ }
291
+
292
+ const relation = relations.find((candidate) =>
293
+ candidate.many_collection === collection && candidate.many_field === field);
294
+ const nextRequired = Object.hasOwn(patch, 'required') ? patch.required : Boolean(existing.required);
295
+ if (relation?.on_delete === 'SET NULL' && nextRequired) {
296
+ const error = new Error('A SET NULL relation field cannot be changed to required');
297
+ error.code = 'INVALID_ON_DELETE';
298
+ throw error;
299
+ }
300
+
301
+ const currentMetadata = parseSchemaMetadata(existing.schema_metadata);
302
+ const currentInput = fieldInputFromMetadata(existing, currentMetadata);
303
+ const nextInput = { ...currentInput, required: nextRequired };
304
+ if (patch.removeDefault === true) delete nextInput.defaultValue;
305
+ else if (Object.hasOwn(patch, 'defaultValue')) nextInput.defaultValue = patch.defaultValue;
306
+
307
+ const currentCompiled = compileFieldColumn(currentInput);
308
+ const nextCompiled = compileFieldColumn(nextInput);
309
+ const currentIndexed = currentMetadata.indexed === true;
310
+ const nextIndexed = Object.hasOwn(patch, 'indexed') ? patch.indexed : currentIndexed;
311
+ const tableName = quoteIdentifier(collection, 'collection name');
312
+ const fieldName = quoteIdentifier(field, 'field name');
313
+ const physicalIndexName = indexName(collection, field);
314
+ const indexSql = quoteIdentifier(physicalIndexName, 'index name');
315
+
316
+ const restorePhysical = async () => {
317
+ const restoreErrors = [];
318
+ try {
319
+ await connection.query(
320
+ `ALTER TABLE ${tableName} MODIFY COLUMN ${fieldName} ${currentCompiled.sql}`,
321
+ currentCompiled.params,
322
+ );
323
+ } catch (error) {
324
+ restoreErrors.push(error);
325
+ }
326
+ if (currentIndexed !== nextIndexed) {
327
+ try {
328
+ if (currentIndexed) {
329
+ await connection.query(`ALTER TABLE ${tableName} ADD INDEX ${indexSql} (${fieldName})`);
330
+ } else {
331
+ await connection.query(`ALTER TABLE ${tableName} DROP INDEX ${indexSql}`);
332
+ }
333
+ } catch (error) {
334
+ restoreErrors.push(error);
335
+ }
336
+ }
337
+ return restoreErrors;
338
+ };
339
+
340
+ try {
341
+ await connection.query(
342
+ `ALTER TABLE ${tableName} MODIFY COLUMN ${fieldName} ${nextCompiled.sql}`,
343
+ nextCompiled.params,
344
+ );
345
+ if (currentIndexed !== nextIndexed) {
346
+ if (nextIndexed) {
347
+ await connection.query(`ALTER TABLE ${tableName} ADD INDEX ${indexSql} (${fieldName})`);
348
+ } else {
349
+ await connection.query(`ALTER TABLE ${tableName} DROP INDEX ${indexSql}`);
350
+ }
351
+ }
352
+
353
+ return await withConnectionTransaction(connection, async () => {
354
+ const updated = await metadata.updateFieldPhysicalMetadata(collection, field, {
355
+ required: nextRequired,
356
+ schemaMetadata: {
357
+ ...nextCompiled.schemaMetadata,
358
+ indexed: nextIndexed,
359
+ },
360
+ });
361
+ const schemaVersion = await incrementSchemaVersion(connection);
362
+ return { ...updated, schemaVersion };
363
+ });
364
+ } catch (error) {
365
+ const restoreErrors = await restorePhysical();
366
+ if (restoreErrors.length > 0) {
367
+ error.restoreErrors = restoreErrors;
368
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
369
+ }
370
+ throw error;
371
+ }
372
+ });
373
+ }
374
+
375
+ async deleteOne(collection, field, { destructive = false } = {}) {
376
+ assertSchemaManager(this.accountability);
377
+ assertIdentifier(collection, 'collection name');
378
+ assertFieldName(field);
379
+ if (destructive !== true) {
380
+ const error = new Error('Field deletion requires destructive: true');
381
+ error.code = 'DESTRUCTIVE_OPERATION_REQUIRED';
382
+ throw error;
383
+ }
384
+ if (field === 'id') {
385
+ const error = new Error('Primary key fields cannot be deleted in V1');
386
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
387
+ throw error;
388
+ }
389
+
390
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
391
+ const metadata = new SchemaMetadataRepository(connection);
392
+ const [collectionMetadata, existing, relations] = await Promise.all([
393
+ metadata.readCollection(collection),
394
+ metadata.readField(collection, field),
395
+ metadata.listRelations(),
396
+ ]);
397
+
398
+ if (!collectionMetadata) {
399
+ const error = new Error(`Unknown collection: ${collection}`);
400
+ error.code = 'COLLECTION_NOT_FOUND';
401
+ throw error;
402
+ }
403
+ if (collectionMetadata.system) {
404
+ const error = new Error('System collection fields cannot be deleted through the dynamic schema API');
405
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
406
+ throw error;
407
+ }
408
+ if (!existing) {
409
+ const error = new Error(`Unknown field: ${collection}.${field}`);
410
+ error.code = 'FIELD_NOT_FOUND';
411
+ throw error;
412
+ }
413
+
414
+ const blockingRelation = relations.find((relation) =>
415
+ (relation.many_collection === collection && relation.many_field === field) ||
416
+ (relation.one_collection === collection && relation.one_field === field) ||
417
+ (relation.junction_collection === collection && relation.junction_field === field));
418
+ if (blockingRelation) {
419
+ const error = new Error(`Field participates in a relation and cannot be deleted: ${collection}.${field}`);
420
+ error.code = 'FIELD_HAS_RELATION';
421
+ throw error;
422
+ }
423
+
424
+ const tableName = quoteIdentifier(collection, 'collection name');
425
+ const fieldName = quoteIdentifier(field, 'field name');
426
+ const tombstoneName = temporaryDropName();
427
+ const tombstoneField = quoteIdentifier(tombstoneName, 'temporary field name');
428
+
429
+ await connection.query(
430
+ `ALTER TABLE ${tableName} RENAME COLUMN ${fieldName} TO ${tombstoneField}`,
431
+ );
432
+
433
+ let result;
434
+ try {
435
+ result = await withConnectionTransaction(connection, async () => {
436
+ const deleted = await metadata.deleteField(collection, field);
437
+ if (deleted !== 1) {
438
+ const error = new Error(`Field metadata disappeared during delete: ${collection}.${field}`);
439
+ error.code = 'SCHEMA_METADATA_DRIFT';
440
+ throw error;
441
+ }
442
+ const schemaVersion = await incrementSchemaVersion(connection);
443
+ return { deleted: true, collection, field, schemaVersion };
444
+ });
445
+ } catch (error) {
446
+ try {
447
+ await connection.query(
448
+ `ALTER TABLE ${tableName} RENAME COLUMN ${tombstoneField} TO ${fieldName}`,
449
+ );
450
+ } catch (restoreError) {
451
+ error.restoreError = restoreError;
452
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
453
+ }
454
+ throw error;
455
+ }
456
+
457
+ try {
458
+ await connection.query(`ALTER TABLE ${tableName} DROP COLUMN ${tombstoneField}`);
459
+ } catch (cleanupError) {
460
+ const error = new Error(`Field was logically deleted but physical cleanup failed: ${collection}.${field}`);
461
+ error.code = 'SCHEMA_PARTIAL_FAILURE';
462
+ error.cleanupError = cleanupError;
463
+ error.cleanupField = tombstoneName;
464
+ error.logicalDelete = result;
465
+ throw error;
466
+ }
467
+
468
+ return result;
469
+ });
470
+ }
471
+ }
@@ -0,0 +1,127 @@
1
+ import { BaseService } from './base-service.js';
2
+
3
+ const DEFAULT_MIN_AGE_MS = 60 * 60 * 1000;
4
+ const MIN_SAFE_AGE_MS = 60 * 1000;
5
+ const MAX_INVENTORY_OBJECTS = 100_000;
6
+
7
+ function serviceError(code, message) {
8
+ const error = new Error(message);
9
+ error.code = code;
10
+ return error;
11
+ }
12
+
13
+ function assertFileManager(accountability) {
14
+ if (accountability?.admin === true || accountability?.system === true) return;
15
+ throw serviceError('FORBIDDEN', 'Storage reconciliation requires administrator accountability');
16
+ }
17
+
18
+ function normalizeAge(value) {
19
+ const age = value ?? DEFAULT_MIN_AGE_MS;
20
+ if (!Number.isInteger(age) || age < MIN_SAFE_AGE_MS || age > 30 * 24 * 60 * 60 * 1000) {
21
+ throw serviceError(
22
+ 'INVALID_PAYLOAD',
23
+ 'Storage orphan minimum age must be between 1 minute and 30 days',
24
+ );
25
+ }
26
+ return age;
27
+ }
28
+
29
+ function objectAgeMs(object, nowMs) {
30
+ if (!object?.modifiedAt) return null;
31
+ const modifiedMs = new Date(object.modifiedAt).getTime();
32
+ if (!Number.isFinite(modifiedMs)) return null;
33
+ return Math.max(0, nowMs - modifiedMs);
34
+ }
35
+
36
+ export class FileReconciliationService extends BaseService {
37
+ constructor(options = {}) {
38
+ super(options);
39
+ if (!options.storage) throw new Error('FileReconciliationService requires a storage registry');
40
+ this.storage = options.storage;
41
+ }
42
+
43
+ async scan({
44
+ storage = 'local',
45
+ deleteOrphans = false,
46
+ minimumAgeMs = DEFAULT_MIN_AGE_MS,
47
+ } = {}) {
48
+ assertFileManager(this.accountability);
49
+ const ageGuardMs = normalizeAge(minimumAgeMs);
50
+ const driver = this.storage.get(storage);
51
+ if (typeof driver.list !== 'function') {
52
+ throw serviceError(
53
+ 'STORAGE_INVENTORY_UNSUPPORTED',
54
+ `Storage driver ${storage} does not support inventory listing`,
55
+ );
56
+ }
57
+
58
+ const [rows] = await this.database.query(
59
+ `SELECT id, filename_disk, filename_download, filesize, uploaded_at
60
+ FROM yuncms_files
61
+ WHERE storage = ?
62
+ ORDER BY uploaded_at ASC, id ASC`,
63
+ [storage],
64
+ );
65
+ const objects = await driver.list();
66
+ if (objects.length > MAX_INVENTORY_OBJECTS) {
67
+ throw serviceError(
68
+ 'STORAGE_INVENTORY_LIMIT',
69
+ `Storage inventory exceeds the V1 safety limit of ${MAX_INVENTORY_OBJECTS} objects`,
70
+ );
71
+ }
72
+
73
+ const metadataByKey = new Map(rows.map((row) => [row.filename_disk, row]));
74
+ const objectByKey = new Map(objects.map((object) => [object.key, object]));
75
+ const missingObjects = rows
76
+ .filter((row) => !objectByKey.has(row.filename_disk))
77
+ .map((row) => ({
78
+ id: row.id,
79
+ key: row.filename_disk,
80
+ filename: row.filename_download,
81
+ expectedSize: Number(row.filesize ?? 0),
82
+ uploadedAt: row.uploaded_at,
83
+ }));
84
+
85
+ const nowMs = Date.now();
86
+ const orphanObjects = objects
87
+ .filter((object) => !metadataByKey.has(object.key))
88
+ .map((object) => {
89
+ const ageMs = objectAgeMs(object, nowMs);
90
+ return {
91
+ key: object.key,
92
+ size: Number(object.size ?? 0),
93
+ modifiedAt: object.modifiedAt ?? null,
94
+ ageMs,
95
+ eligibleForDelete: ageMs !== null && ageMs >= ageGuardMs,
96
+ };
97
+ });
98
+
99
+ const deletedOrphans = [];
100
+ if (deleteOrphans === true) {
101
+ for (const orphan of orphanObjects) {
102
+ if (!orphan.eligibleForDelete) continue;
103
+ await driver.delete(orphan.key);
104
+ deletedOrphans.push(orphan.key);
105
+ }
106
+ }
107
+
108
+ return {
109
+ storage,
110
+ minimumAgeMs: ageGuardMs,
111
+ deleteOrphans: deleteOrphans === true,
112
+ metadataCount: rows.length,
113
+ storageObjectCount: objects.length,
114
+ missingObjects,
115
+ orphanObjects,
116
+ deletedOrphans,
117
+ };
118
+ }
119
+ }
120
+
121
+ export {
122
+ DEFAULT_MIN_AGE_MS,
123
+ MAX_INVENTORY_OBJECTS,
124
+ MIN_SAFE_AGE_MS,
125
+ normalizeAge,
126
+ objectAgeMs,
127
+ };