@yunsoft/yuncms-core 0.1.5 → 0.1.7
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/auth/external-state.js +76 -0
- package/src/bootstrap.js +2 -0
- package/src/config.js +102 -57
- package/src/hooks.js +117 -32
- package/src/index.js +23 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/query.js +131 -71
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +511 -223
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/items-service.js +80 -93
|
@@ -3,7 +3,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
4
4
|
import { enforcePermissionValidation } from '../permission-validation.js';
|
|
5
5
|
import {
|
|
6
|
+
assertQueryCost,
|
|
7
|
+
compileAggregate,
|
|
6
8
|
compileFilter,
|
|
9
|
+
compileSearch,
|
|
7
10
|
compileSelectFields,
|
|
8
11
|
compileSort,
|
|
9
12
|
parseItemsQuery,
|
|
@@ -176,10 +179,37 @@ export class ItemsService extends BaseService {
|
|
|
176
179
|
return entries;
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema) {
|
|
182
|
+
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null) {
|
|
180
183
|
const permissionSql = compileFilter(permissionFilter, fullSchema);
|
|
181
184
|
const userSql = compileFilter(userFilter, userSchema);
|
|
182
|
-
|
|
185
|
+
const searchSql = compileSearch(search, userSchema);
|
|
186
|
+
return combineCompiledFilters(permissionSql, userSql, searchSql);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async normalizeReadQuery(rawQuery) {
|
|
190
|
+
const parsed = parseItemsQuery(rawQuery);
|
|
191
|
+
const filtered = this.emitter
|
|
192
|
+
? await this.emitter.filter('items.query', parsed, this.hookContext({ operation: 'read' }))
|
|
193
|
+
: parsed;
|
|
194
|
+
const query = parseItemsQuery(filtered);
|
|
195
|
+
assertQueryCost(query);
|
|
196
|
+
return query;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async emitReadAction(query, rows, schema, { single = false } = {}) {
|
|
200
|
+
if (!this.emitter) return;
|
|
201
|
+
const primaryKey = schema.primary_key;
|
|
202
|
+
const keys = rows
|
|
203
|
+
.filter((row) => row && Object.hasOwn(row, primaryKey))
|
|
204
|
+
.map((row) => row[primaryKey])
|
|
205
|
+
.slice(0, QUERY_LIMITS.maxLimit);
|
|
206
|
+
await this.emitter.action('items.read', {
|
|
207
|
+
collection: this.collection,
|
|
208
|
+
query,
|
|
209
|
+
keys,
|
|
210
|
+
count: rows.length,
|
|
211
|
+
single,
|
|
212
|
+
}, this.hookContext({ operation: 'read' }));
|
|
183
213
|
}
|
|
184
214
|
|
|
185
215
|
async readMany(rawQuery = {}) {
|
|
@@ -188,15 +218,34 @@ export class ItemsService extends BaseService {
|
|
|
188
218
|
|
|
189
219
|
async readManyWithMeta(rawQuery = {}) {
|
|
190
220
|
const schema = await this.getCollectionSchema();
|
|
221
|
+
const query = await this.normalizeReadQuery(rawQuery);
|
|
191
222
|
const permission = await this.resolvePermission('read');
|
|
192
223
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
193
|
-
const
|
|
224
|
+
const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search);
|
|
225
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
226
|
+
const aggregate = compileAggregate(query.aggregate, query.groupBy, accessSchema);
|
|
227
|
+
|
|
228
|
+
if (aggregate) {
|
|
229
|
+
const sortSql = compileSort(query.sort, accessSchema);
|
|
230
|
+
const [rows] = await this.database.query(
|
|
231
|
+
`SELECT ${aggregate.sql} FROM ${table}${filter.sql}${aggregate.groupSql}${sortSql} LIMIT ? OFFSET ?`,
|
|
232
|
+
[...filter.params, query.limit, query.offset],
|
|
233
|
+
);
|
|
234
|
+
await this.emitReadAction(query, rows, schema);
|
|
235
|
+
return {
|
|
236
|
+
data: rows,
|
|
237
|
+
meta: {
|
|
238
|
+
total_count: rows.length,
|
|
239
|
+
limit: query.limit,
|
|
240
|
+
offset: query.offset,
|
|
241
|
+
aggregate: true,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
194
246
|
const requestedFields = normalizeFields(query.fields);
|
|
195
247
|
const selected = compileSelectFields(requestedFields, accessSchema);
|
|
196
|
-
const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema);
|
|
197
248
|
const sortSql = compileSort(query.sort, accessSchema);
|
|
198
|
-
const table = quoteIdentifier(this.collection, 'collection name');
|
|
199
|
-
|
|
200
249
|
const [rows] = await this.database.query(
|
|
201
250
|
`SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
|
|
202
251
|
[...filter.params, query.limit, query.offset],
|
|
@@ -205,6 +254,7 @@ export class ItemsService extends BaseService {
|
|
|
205
254
|
`SELECT COUNT(*) AS total_count FROM ${table}${filter.sql}`,
|
|
206
255
|
filter.params,
|
|
207
256
|
);
|
|
257
|
+
await this.emitReadAction(query, rows, schema);
|
|
208
258
|
|
|
209
259
|
return {
|
|
210
260
|
data: rows,
|
|
@@ -220,11 +270,7 @@ export class ItemsService extends BaseService {
|
|
|
220
270
|
const schema = await this.getCollectionSchema();
|
|
221
271
|
const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
|
|
222
272
|
if (!schema.fields[trustedLookupField]) {
|
|
223
|
-
throw serviceError(
|
|
224
|
-
'INVALID_QUERY',
|
|
225
|
-
`Unknown relation lookup field: ${trustedLookupField}`,
|
|
226
|
-
trustedLookupField,
|
|
227
|
-
);
|
|
273
|
+
throw serviceError('INVALID_QUERY', `Unknown relation lookup field: ${trustedLookupField}`, trustedLookupField);
|
|
228
274
|
}
|
|
229
275
|
if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
|
|
230
276
|
throw serviceError(
|
|
@@ -239,15 +285,9 @@ export class ItemsService extends BaseService {
|
|
|
239
285
|
const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
|
|
240
286
|
const internalSchema = {
|
|
241
287
|
...accessSchema,
|
|
242
|
-
fields: {
|
|
243
|
-
...accessSchema.fields,
|
|
244
|
-
[trustedLookupField]: schema.fields[trustedLookupField],
|
|
245
|
-
},
|
|
288
|
+
fields: { ...accessSchema.fields, [trustedLookupField]: schema.fields[trustedLookupField] },
|
|
246
289
|
};
|
|
247
|
-
const internalSelection = compileSelectFields(
|
|
248
|
-
[...visibleSelection.fields, trustedLookupField],
|
|
249
|
-
internalSchema,
|
|
250
|
-
);
|
|
290
|
+
const internalSelection = compileSelectFields([...visibleSelection.fields, trustedLookupField], internalSchema);
|
|
251
291
|
const permissionFilter = compileFilter(permission.filter, schema);
|
|
252
292
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
253
293
|
const data = [];
|
|
@@ -283,7 +323,9 @@ export class ItemsService extends BaseService {
|
|
|
283
323
|
`SELECT ${selected.sql} FROM ${table}${filter.sql} LIMIT 1`,
|
|
284
324
|
filter.params,
|
|
285
325
|
);
|
|
286
|
-
|
|
326
|
+
const record = rows[0] ?? null;
|
|
327
|
+
if (record) await this.emitReadAction({ fields: normalizeFields(fields) }, [record], schema, { single: true });
|
|
328
|
+
return record;
|
|
287
329
|
}
|
|
288
330
|
|
|
289
331
|
async returnCreatedOrUpdated(id, schema) {
|
|
@@ -308,10 +350,8 @@ export class ItemsService extends BaseService {
|
|
|
308
350
|
const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
|
|
309
351
|
const fields = Object.keys(values);
|
|
310
352
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
311
|
-
|
|
312
353
|
await this.database.query(
|
|
313
|
-
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
|
|
314
|
-
VALUES (${fields.map(() => '?').join(', ')})`,
|
|
354
|
+
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
|
|
315
355
|
fields.map((field) => values[field]),
|
|
316
356
|
);
|
|
317
357
|
|
|
@@ -330,29 +370,21 @@ export class ItemsService extends BaseService {
|
|
|
330
370
|
const staged = [];
|
|
331
371
|
|
|
332
372
|
for (const payload of payloads) {
|
|
333
|
-
const filteredPayload = await this.filterMutation('items.create', payload, {
|
|
334
|
-
operation: 'create',
|
|
335
|
-
bulk: true,
|
|
336
|
-
});
|
|
373
|
+
const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create', bulk: true });
|
|
337
374
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
338
375
|
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
|
|
339
376
|
const id = randomUUID();
|
|
340
377
|
const candidate = createCandidateRecord(schema, id, entries);
|
|
341
378
|
enforcePermissionValidation(candidate, permission.validation, schema);
|
|
342
|
-
staged.push({
|
|
343
|
-
id,
|
|
344
|
-
values: { [schema.primary_key]: id, ...Object.fromEntries(entries) },
|
|
345
|
-
});
|
|
379
|
+
staged.push({ id, values: { [schema.primary_key]: id, ...Object.fromEntries(entries) } });
|
|
346
380
|
}
|
|
347
381
|
|
|
348
382
|
await withTransaction(this.database, async (connection) => {
|
|
349
383
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
350
|
-
|
|
351
384
|
for (const entry of staged) {
|
|
352
385
|
const fields = Object.keys(entry.values);
|
|
353
386
|
await connection.query(
|
|
354
|
-
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
|
|
355
|
-
VALUES (${fields.map(() => '?').join(', ')})`,
|
|
387
|
+
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
|
|
356
388
|
fields.map((field) => entry.values[field]),
|
|
357
389
|
);
|
|
358
390
|
}
|
|
@@ -362,10 +394,7 @@ export class ItemsService extends BaseService {
|
|
|
362
394
|
for (const entry of staged) {
|
|
363
395
|
const record = await this.returnCreatedOrUpdated(entry.id, schema);
|
|
364
396
|
records.push(record);
|
|
365
|
-
await this.actionMutation('items.create', { key: entry.id, item: record }, {
|
|
366
|
-
operation: 'create',
|
|
367
|
-
bulk: true,
|
|
368
|
-
});
|
|
397
|
+
await this.actionMutation('items.create', { key: entry.id, item: record }, { operation: 'create', bulk: true });
|
|
369
398
|
}
|
|
370
399
|
return records;
|
|
371
400
|
}
|
|
@@ -373,15 +402,11 @@ export class ItemsService extends BaseService {
|
|
|
373
402
|
async updateOne(id, payload = {}) {
|
|
374
403
|
const schema = await this.getCollectionSchema();
|
|
375
404
|
const permission = await this.resolvePermission('update');
|
|
376
|
-
const filteredPayload = await this.filterMutation('items.update', payload, {
|
|
377
|
-
operation: 'update',
|
|
378
|
-
key: id,
|
|
379
|
-
});
|
|
405
|
+
const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', key: id });
|
|
380
406
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
381
407
|
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
382
408
|
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
|
|
383
409
|
const effectiveChanges = Object.fromEntries(entries);
|
|
384
|
-
|
|
385
410
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
386
411
|
const filter = combineCompiledFilters(
|
|
387
412
|
compileFilter(permission.filter, schema),
|
|
@@ -389,10 +414,7 @@ export class ItemsService extends BaseService {
|
|
|
389
414
|
);
|
|
390
415
|
|
|
391
416
|
if (permission.validation) {
|
|
392
|
-
const [currentRows] = await this.database.query(
|
|
393
|
-
`SELECT * FROM ${table}${filter.sql} LIMIT 1`,
|
|
394
|
-
filter.params,
|
|
395
|
-
);
|
|
417
|
+
const [currentRows] = await this.database.query(`SELECT * FROM ${table}${filter.sql} LIMIT 1`, filter.params);
|
|
396
418
|
const current = currentRows[0];
|
|
397
419
|
if (!current) return null;
|
|
398
420
|
enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
|
|
@@ -403,14 +425,9 @@ export class ItemsService extends BaseService {
|
|
|
403
425
|
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
404
426
|
[...entries.map(([, value]) => value), ...filter.params],
|
|
405
427
|
);
|
|
406
|
-
|
|
407
428
|
if (result.affectedRows === 0) return null;
|
|
408
429
|
const record = await this.returnCreatedOrUpdated(id, schema);
|
|
409
|
-
await this.actionMutation('items.update', {
|
|
410
|
-
key: id,
|
|
411
|
-
item: record,
|
|
412
|
-
changes: effectiveChanges,
|
|
413
|
-
}, { operation: 'update' });
|
|
430
|
+
await this.actionMutation('items.update', { key: id, item: record, changes: effectiveChanges }, { operation: 'update' });
|
|
414
431
|
return record;
|
|
415
432
|
}
|
|
416
433
|
|
|
@@ -422,11 +439,7 @@ export class ItemsService extends BaseService {
|
|
|
422
439
|
const schema = await this.getCollectionSchema();
|
|
423
440
|
const permission = await this.resolvePermission('update');
|
|
424
441
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
425
|
-
const filteredPayload = await this.filterMutation('items.update', payload, {
|
|
426
|
-
operation: 'update',
|
|
427
|
-
bulk: true,
|
|
428
|
-
filter: filterInput,
|
|
429
|
-
});
|
|
442
|
+
const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', bulk: true, filter: filterInput });
|
|
430
443
|
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
431
444
|
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
432
445
|
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
|
|
@@ -440,14 +453,9 @@ export class ItemsService extends BaseService {
|
|
|
440
453
|
[...filter.params, MAX_BULK_VALIDATION_ROWS + 1],
|
|
441
454
|
);
|
|
442
455
|
if (rows.length > MAX_BULK_VALIDATION_ROWS) {
|
|
443
|
-
throw serviceError(
|
|
444
|
-
'VALIDATION_BULK_LIMIT',
|
|
445
|
-
`Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`,
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
for (const row of rows) {
|
|
449
|
-
enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
|
|
456
|
+
throw serviceError('VALIDATION_BULK_LIMIT', `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`);
|
|
450
457
|
}
|
|
458
|
+
for (const row of rows) enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
|
|
451
459
|
}
|
|
452
460
|
|
|
453
461
|
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
@@ -455,35 +463,23 @@ export class ItemsService extends BaseService {
|
|
|
455
463
|
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
456
464
|
[...entries.map(([, value]) => value), ...filter.params],
|
|
457
465
|
);
|
|
458
|
-
await this.actionMutation('items.update', {
|
|
459
|
-
filter: filterInput,
|
|
460
|
-
changes: effectiveChanges,
|
|
461
|
-
affected: result.affectedRows,
|
|
462
|
-
}, { operation: 'update', bulk: true });
|
|
466
|
+
await this.actionMutation('items.update', { filter: filterInput, changes: effectiveChanges, affected: result.affectedRows }, { operation: 'update', bulk: true });
|
|
463
467
|
return result.affectedRows;
|
|
464
468
|
}
|
|
465
469
|
|
|
466
470
|
async deleteOne(id) {
|
|
467
471
|
const schema = await this.getCollectionSchema();
|
|
468
472
|
const permission = await this.resolvePermission('delete');
|
|
469
|
-
const filtered = await this.filterMutation('items.delete', { key: id }, {
|
|
470
|
-
operation: 'delete',
|
|
471
|
-
key: id,
|
|
472
|
-
});
|
|
473
|
+
const filtered = await this.filterMutation('items.delete', { key: id }, { operation: 'delete', key: id });
|
|
473
474
|
const key = filtered?.key ?? id;
|
|
474
475
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
475
476
|
const filter = combineCompiledFilters(
|
|
476
477
|
compileFilter(permission.filter, schema),
|
|
477
478
|
compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
|
|
478
479
|
);
|
|
479
|
-
const [result] = await this.database.query(
|
|
480
|
-
`DELETE FROM ${table}${filter.sql}`,
|
|
481
|
-
filter.params,
|
|
482
|
-
);
|
|
480
|
+
const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
|
|
483
481
|
const deleted = result.affectedRows > 0;
|
|
484
|
-
if (deleted) {
|
|
485
|
-
await this.actionMutation('items.delete', { key }, { operation: 'delete' });
|
|
486
|
-
}
|
|
482
|
+
if (deleted) await this.actionMutation('items.delete', { key }, { operation: 'delete' });
|
|
487
483
|
return deleted;
|
|
488
484
|
}
|
|
489
485
|
|
|
@@ -495,24 +491,15 @@ export class ItemsService extends BaseService {
|
|
|
495
491
|
const schema = await this.getCollectionSchema();
|
|
496
492
|
const permission = await this.resolvePermission('delete');
|
|
497
493
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
498
|
-
const filtered = await this.filterMutation('items.delete', { filter: filterInput }, {
|
|
499
|
-
operation: 'delete',
|
|
500
|
-
bulk: true,
|
|
501
|
-
});
|
|
494
|
+
const filtered = await this.filterMutation('items.delete', { filter: filterInput }, { operation: 'delete', bulk: true });
|
|
502
495
|
const effectiveFilter = filtered?.filter ?? filterInput;
|
|
503
496
|
if (!effectiveFilter || typeof effectiveFilter !== 'object' || Array.isArray(effectiveFilter) || Object.keys(effectiveFilter).length === 0) {
|
|
504
497
|
throw serviceError('FILTER_REQUIRED', 'deleteMany hook result must preserve a non-empty filter');
|
|
505
498
|
}
|
|
506
499
|
const filter = this.compileActionFilters(effectiveFilter, permission.filter, accessSchema, schema);
|
|
507
500
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
508
|
-
const [result] = await this.database.query(
|
|
509
|
-
|
|
510
|
-
filter.params,
|
|
511
|
-
);
|
|
512
|
-
await this.actionMutation('items.delete', {
|
|
513
|
-
filter: effectiveFilter,
|
|
514
|
-
affected: result.affectedRows,
|
|
515
|
-
}, { operation: 'delete', bulk: true });
|
|
501
|
+
const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
|
|
502
|
+
await this.actionMutation('items.delete', { filter: effectiveFilter, affected: result.affectedRows }, { operation: 'delete', bulk: true });
|
|
516
503
|
return result.affectedRows;
|
|
517
504
|
}
|
|
518
505
|
}
|