@appweaver/data 0.0.0-stage → 2.0.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.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/config.d.ts +6 -0
- package/config.js +22 -0
- package/database/create-client.d.ts +11 -0
- package/database/create-client.js +145 -0
- package/database/database-config.d.ts +14 -0
- package/database/database-config.js +40 -0
- package/database/database-error.d.ts +49 -0
- package/database/database-error.js +216 -0
- package/database/index.d.ts +3 -0
- package/database/index.js +19 -0
- package/database/prisma-database.d.ts +33 -0
- package/database/prisma-database.js +178 -0
- package/database/run-transaction.d.ts +21 -0
- package/database/run-transaction.js +15 -0
- package/export/export-config.d.ts +7 -0
- package/export/export-config.js +17 -0
- package/export/export-service.d.ts +22 -0
- package/export/export-service.js +188 -0
- package/export/index.d.ts +1 -0
- package/export/index.js +17 -0
- package/factory/create-model.d.ts +11 -0
- package/factory/create-model.js +479 -0
- package/factory/create-policy.d.ts +12 -0
- package/factory/create-policy.js +20 -0
- package/factory/create-service.d.ts +14 -0
- package/factory/create-service.js +116 -0
- package/index.d.ts +22 -0
- package/index.js +38 -0
- package/internal.d.ts +2 -0
- package/internal.js +6 -0
- package/module.d.ts +3 -0
- package/module.js +81 -0
- package/package.json +67 -3
- package/prisma/client/browser.d.ts +39 -0
- package/prisma/client/browser.js +56 -0
- package/prisma/client/client.d.ts +58 -0
- package/prisma/client/client.js +70 -0
- package/prisma/client/commonInputTypes.d.ts +345 -0
- package/prisma/client/commonInputTypes.js +11 -0
- package/prisma/client/enums.d.ts +1 -0
- package/prisma/client/enums.js +11 -0
- package/prisma/client/internal/class.d.ts +198 -0
- package/prisma/client/internal/class.js +85 -0
- package/prisma/client/internal/prismaNamespace.d.ts +1125 -0
- package/prisma/client/internal/prismaNamespace.js +204 -0
- package/prisma/client/internal/prismaNamespaceBrowser.d.ts +137 -0
- package/prisma/client/internal/prismaNamespaceBrowser.js +175 -0
- package/prisma/client/models/ApiKey.d.ts +1167 -0
- package/prisma/client/models/ApiKey.js +2 -0
- package/prisma/client/models/ConnectedAccount.d.ts +1094 -0
- package/prisma/client/models/ConnectedAccount.js +2 -0
- package/prisma/client/models/File.d.ts +1291 -0
- package/prisma/client/models/File.js +2 -0
- package/prisma/client/models/OneTimeToken.d.ts +1055 -0
- package/prisma/client/models/OneTimeToken.js +2 -0
- package/prisma/client/models/Permission.d.ts +1209 -0
- package/prisma/client/models/Permission.js +2 -0
- package/prisma/client/models/Role.d.ts +1209 -0
- package/prisma/client/models/Role.js +2 -0
- package/prisma/client/models/Seeder.d.ts +1023 -0
- package/prisma/client/models/Seeder.js +2 -0
- package/prisma/client/models.d.ts +8 -0
- package/prisma/client/models.js +2 -0
- package/resource/load-data-resources.d.ts +13 -0
- package/resource/load-data-resources.js +37 -0
- package/resource/model-schema.d.ts +34 -0
- package/resource/model-schema.js +54 -0
- package/resource/resource-config.d.ts +16 -0
- package/resource/resource-config.js +62 -0
- package/resource/resource-loader.d.ts +25 -0
- package/resource/resource-loader.js +138 -0
- package/resource/resource-service.d.ts +462 -0
- package/resource/resource-service.js +1042 -0
- package/resource/schemas/index.d.ts +3 -0
- package/resource/schemas/index.js +19 -0
- package/resource/schemas/resource-aggregate-schema.d.ts +53 -0
- package/resource/schemas/resource-aggregate-schema.js +130 -0
- package/resource/schemas/resource-filter-schema.d.ts +39 -0
- package/resource/schemas/resource-filter-schema.js +153 -0
- package/resource/schemas/resource-sort-schema.d.ts +38 -0
- package/resource/schemas/resource-sort-schema.js +113 -0
- package/resource/utils/aggregate-util.d.ts +113 -0
- package/resource/utils/aggregate-util.js +322 -0
- package/resource/utils/cursor-util.d.ts +60 -0
- package/resource/utils/cursor-util.js +116 -0
- package/resource/utils/date-util.d.ts +22 -0
- package/resource/utils/date-util.js +112 -0
- package/resource/utils/delete-util.d.ts +131 -0
- package/resource/utils/delete-util.js +385 -0
- package/resource/utils/filter-util.d.ts +24 -0
- package/resource/utils/filter-util.js +289 -0
- package/resource/utils/prune-util.d.ts +11 -0
- package/resource/utils/prune-util.js +56 -0
- package/resource/utils/relation-util.d.ts +75 -0
- package/resource/utils/relation-util.js +400 -0
- package/resource/utils/sort-util.d.ts +31 -0
- package/resource/utils/sort-util.js +235 -0
- package/resource/utils/text-search-util.d.ts +14 -0
- package/resource/utils/text-search-util.js +39 -0
- package/resources.d.ts +10 -0
- package/resources.js +26 -0
- package/security/auth-config.d.ts +23 -0
- package/security/auth-config.js +43 -0
- package/security/create-auth-resources.d.ts +28 -0
- package/security/create-auth-resources.js +163 -0
- package/security/helper.d.ts +109 -0
- package/security/helper.js +298 -0
- package/security/resources/api-key/model.d.ts +2 -0
- package/security/resources/api-key/model.js +67 -0
- package/security/resources/api-key/policy.d.ts +2 -0
- package/security/resources/api-key/policy.js +27 -0
- package/security/resources/api-key/service.d.ts +3 -0
- package/security/resources/api-key/service.js +34 -0
- package/security/resources/connected-account/model.d.ts +2 -0
- package/security/resources/connected-account/model.js +57 -0
- package/security/resources/connected-account/service.d.ts +2 -0
- package/security/resources/connected-account/service.js +7 -0
- package/security/resources/one-time-token/model.d.ts +2 -0
- package/security/resources/one-time-token/model.js +36 -0
- package/security/resources/permission/model.d.ts +2 -0
- package/security/resources/permission/model.js +16 -0
- package/security/resources/role/model.d.ts +2 -0
- package/security/resources/role/model.js +22 -0
- package/security/store/database-security-store.d.ts +5 -0
- package/security/store/database-security-store.js +54 -0
- package/seeder/create-seeder.d.ts +29 -0
- package/seeder/create-seeder.js +28 -0
- package/seeder/index.d.ts +2 -0
- package/seeder/index.js +18 -0
- package/seeder/resources/seeder/model.d.ts +2 -0
- package/seeder/resources/seeder/model.js +38 -0
- package/seeder/seeder.d.ts +29 -0
- package/seeder/seeder.js +197 -0
- package/storage/file-service.d.ts +133 -0
- package/storage/file-service.js +586 -0
- package/storage/image-config.d.ts +3 -0
- package/storage/image-config.js +11 -0
- package/storage/resources/file/model.d.ts +2 -0
- package/storage/resources/file/model.js +80 -0
- package/types/auth.d.ts +23 -0
- package/types/auth.js +2 -0
- package/types/generated.d.ts +366 -0
- package/types/generated.js +3 -0
- package/types/storage.d.ts +35 -0
- package/types/storage.js +2 -0
- package/utils/export-util.d.ts +8 -0
- package/utils/export-util.js +26 -0
- package/utils/file-util.d.ts +106 -0
- package/utils/file-util.js +254 -0
- package/utils/model-util.d.ts +36 -0
- package/utils/model-util.js +154 -0
- package/utils/policy-util.d.ts +18 -0
- package/utils/policy-util.js +28 -0
- package/utils/virtual-util.d.ts +11 -0
- package/utils/virtual-util.js +53 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ResourceService = void 0;
|
|
4
|
+
const common_1 = require("@appweaver/common");
|
|
5
|
+
const resource_config_1 = require("./resource-config");
|
|
6
|
+
const core_1 = require("@appweaver/core");
|
|
7
|
+
const policy_util_1 = require("../utils/policy-util");
|
|
8
|
+
const model_util_1 = require("../utils/model-util");
|
|
9
|
+
const virtual_util_1 = require("../utils/virtual-util");
|
|
10
|
+
const database_error_1 = require("../database/database-error");
|
|
11
|
+
const file_service_1 = require("../storage/file-service");
|
|
12
|
+
const delete_util_1 = require("./utils/delete-util");
|
|
13
|
+
const aggregate_util_1 = require("./utils/aggregate-util");
|
|
14
|
+
const relation_util_1 = require("./utils/relation-util");
|
|
15
|
+
const cursor_util_1 = require("./utils/cursor-util");
|
|
16
|
+
const filter_util_1 = require("./utils/filter-util");
|
|
17
|
+
const sort_util_1 = require("./utils/sort-util");
|
|
18
|
+
class ResourceService {
|
|
19
|
+
modelName;
|
|
20
|
+
/** @internal */
|
|
21
|
+
_db = (0, core_1.inject)(common_1.Database);
|
|
22
|
+
/** @internal */
|
|
23
|
+
_events = (0, core_1.inject)(common_1.Events);
|
|
24
|
+
/** @internal */
|
|
25
|
+
_cacheService = (0, core_1.inject)(core_1.CacheService);
|
|
26
|
+
/** @internal */
|
|
27
|
+
_modelKey;
|
|
28
|
+
/**
|
|
29
|
+
* Creates the service for the given resource model and resolves its database
|
|
30
|
+
* client from the Prisma client instance.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} modelName The resource model name this service operates on,
|
|
33
|
+
* as defined by its model file (i.e. `User`, `Post`).
|
|
34
|
+
* @throws An error if no database client exists for the provided model name.
|
|
35
|
+
*/
|
|
36
|
+
constructor(modelName) {
|
|
37
|
+
this.modelName = modelName;
|
|
38
|
+
this._modelKey = (0, common_1.uncapitalize)(modelName);
|
|
39
|
+
if (!this._client) {
|
|
40
|
+
throw new common_1.ConfigurationError(common_1.ErrorCode.ConfigurationInvalid, `ResourceService initialized with invalid model name: ${modelName}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The underlying Prisma model delegate for this resource, useful for
|
|
45
|
+
* executing custom database operations that the service methods do not cover.
|
|
46
|
+
*
|
|
47
|
+
* @return {ResourceClient} The resource client of the model this service was
|
|
48
|
+
* created for, bound to the current transaction (see `runTransaction`).
|
|
49
|
+
*/
|
|
50
|
+
get client() {
|
|
51
|
+
return this._client;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Finds a single resource by its id, applying the read restrictions and the
|
|
55
|
+
* access check of this service, and including all relation and file fields
|
|
56
|
+
* configured for output on the find action. A resource event is emitted after
|
|
57
|
+
* a successful lookup.
|
|
58
|
+
*
|
|
59
|
+
* @param {ResourceId} id The id of the resource to find.
|
|
60
|
+
* @return {Promise<Object>} The found resource with its virtual fields and
|
|
61
|
+
* relation counts projected.
|
|
62
|
+
* @throws {@link ResourceError} `RESOURCE_NOT_FOUND` if the resource does not
|
|
63
|
+
* exist or is filtered out by the read restrictions, `RESOURCE_FORBIDDEN` if
|
|
64
|
+
* the access check denies it, and a {@link DatabaseError} on a database error.
|
|
65
|
+
*/
|
|
66
|
+
async find(id) {
|
|
67
|
+
const restrictions = await this.applyReadRestrictions('find', id);
|
|
68
|
+
const includeRelations = (0, relation_util_1.mapRelationInclusions)(this._client.name, 'find');
|
|
69
|
+
let resource;
|
|
70
|
+
try {
|
|
71
|
+
resource = await this._client.findFirst({
|
|
72
|
+
where: { id, ...restrictions, ...(0, model_util_1.liveRecordFilter)(this._client.name) },
|
|
73
|
+
include: includeRelations
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
throw this.databaseError(e, id);
|
|
78
|
+
}
|
|
79
|
+
if (!resource || resource.id !== id) {
|
|
80
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceNotFound, `${this._client.name} not found`, { model: this._client.name, id });
|
|
81
|
+
}
|
|
82
|
+
const access = await this.applyAccessCheck('find', resource);
|
|
83
|
+
if (!access) {
|
|
84
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceForbidden, `${this._client.name} access is forbidden`, { model: this._client.name, action: 'find' });
|
|
85
|
+
}
|
|
86
|
+
this._events.emitResourceEvent(this._client.name, 'find', {
|
|
87
|
+
current: resource
|
|
88
|
+
});
|
|
89
|
+
return this.projectResource(resource);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Queries a page of resources matching the provided filter. The filter is
|
|
93
|
+
* mapped to a database query, combined with the optional `searchText` full
|
|
94
|
+
* text search query and the read restrictions of this service, and executed
|
|
95
|
+
* together with the total count in a single transaction. A resource event is
|
|
96
|
+
* emitted after a successful query.
|
|
97
|
+
*
|
|
98
|
+
* @param {QueryOptions} [options] The query options:
|
|
99
|
+
* - `filter` The query filter object, supporting the logical (`_and`, `_or`,
|
|
100
|
+
* `_not`, `_nor`), comparison (`_eq`, `_ne`, `_gt`, `_gte`, `_lt`, `_lte`,
|
|
101
|
+
* `_in`, `_nin`, `_between`, `_like`, `_ilike`, `_starts`, `_ends`,
|
|
102
|
+
* `_contains`, `_exists`), list (`_has`, `_hasSome`, `_hasEvery`,
|
|
103
|
+
* `_isEmpty`), and relation (`_some`, `_every`, `_none`) operators, as well
|
|
104
|
+
* as plain field values. Its `searchText` property, if present, is passed to
|
|
105
|
+
* {@link ResourceService.textSearchQuery} instead of being matched as a
|
|
106
|
+
* field.
|
|
107
|
+
* - `page` The one-based page number of results to return, ignored when a
|
|
108
|
+
* cursor is given (default: `1`).
|
|
109
|
+
* - `size` The maximum number of results per page (default: `50`).
|
|
110
|
+
* - `sort` The fields to sort by, either as a comma-separated list where a
|
|
111
|
+
* field prefixed with `-` is sorted in descending order (i.e.
|
|
112
|
+
* `-createdAt,id`), or as an object of field directions (i.e.
|
|
113
|
+
* `{ createdAt: 'desc', id: 'asc' }`). Both forms support the fields of the
|
|
114
|
+
* included to-one relations, given with a dot notation (`author.createdAt`)
|
|
115
|
+
* or as a nested object (`{ author: { createdAt: 'desc' } }`) (default:
|
|
116
|
+
* `-createdAt`).
|
|
117
|
+
* - `cursor` The cursor of the page to return, as issued in the `nextCursor`
|
|
118
|
+
* or `prevCursor` of an earlier response, which also carries the direction
|
|
119
|
+
* the page runs in. Takes precedence over `page`.
|
|
120
|
+
* - `totalCount` Whether to count all matching resources, which costs a scan
|
|
121
|
+
* of every one of them (default: `true`).
|
|
122
|
+
* @return {Promise<QueryResponse<Object>>} The paged query response containing
|
|
123
|
+
* the returned resources, the count of the returned items, the cursors of the
|
|
124
|
+
* adjacent pages, and the total count unless it was opted out of.
|
|
125
|
+
* @throws {@link ResourceError} `RESOURCE_INVALID_SORT` if the sort input names
|
|
126
|
+
* a field that cannot be sorted by, `RESOURCE_INVALID_CURSOR` if the cursor
|
|
127
|
+
* was issued for another filter or sort order, and a {@link DatabaseError} on
|
|
128
|
+
* a database error.
|
|
129
|
+
*/
|
|
130
|
+
async query(options = {}) {
|
|
131
|
+
const { filter = {}, page = 1, size = 50, sort = '-createdAt', cursor, totalCount = true } = options;
|
|
132
|
+
const query = await this.queryConditions('query', filter);
|
|
133
|
+
const includeRelations = (0, relation_util_1.mapRelationInclusions)(this._client.name, 'query');
|
|
134
|
+
const orderBy = (0, sort_util_1.mapStableSortValues)(sort, this._client.name, 'query');
|
|
135
|
+
// Binding to the mapped query rather than the filter also invalidates a
|
|
136
|
+
// cursor on a changed text search or read restriction
|
|
137
|
+
const fingerprint = (0, cursor_util_1.queryFingerprint)(this._client.name, query, orderBy);
|
|
138
|
+
const decodedCursor = (0, cursor_util_1.decodeCursor)(cursor, fingerprint);
|
|
139
|
+
// One record past the page tells whether a further page exists
|
|
140
|
+
const backward = decodedCursor?.backward === true;
|
|
141
|
+
const take = size > 0 ? size + 1 : 0;
|
|
142
|
+
const findMany = this._client.findMany({
|
|
143
|
+
where: { ...query },
|
|
144
|
+
include: includeRelations,
|
|
145
|
+
// A cursor skips the record it addresses, an offset the pages before it
|
|
146
|
+
cursor: decodedCursor ? { id: decodedCursor.id } : undefined,
|
|
147
|
+
skip: decodedCursor ? 1 : (page - 1) * size,
|
|
148
|
+
take: backward ? -take : take,
|
|
149
|
+
orderBy
|
|
150
|
+
});
|
|
151
|
+
let resources;
|
|
152
|
+
let count;
|
|
153
|
+
try {
|
|
154
|
+
if (totalCount) {
|
|
155
|
+
[resources, count] = await this._db
|
|
156
|
+
.client()
|
|
157
|
+
.$transaction([
|
|
158
|
+
findMany,
|
|
159
|
+
this._client.count({ where: { ...query } })
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
resources = await findMany;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
throw this.databaseError(e);
|
|
168
|
+
}
|
|
169
|
+
// The over-fetched record leads a backward page and trails a forward one
|
|
170
|
+
const hasMore = resources.length > size;
|
|
171
|
+
if (hasMore) {
|
|
172
|
+
resources = backward
|
|
173
|
+
? resources.slice(resources.length - size)
|
|
174
|
+
: resources.slice(0, size);
|
|
175
|
+
}
|
|
176
|
+
// The page a cursor was followed from always exists
|
|
177
|
+
const hasNext = backward || hasMore;
|
|
178
|
+
const hasPrev = backward ? hasMore : !!decodedCursor || page > 1;
|
|
179
|
+
this._events.emitResourceEvent(this._client.name, 'query', {
|
|
180
|
+
current: resources
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
resultCount: resources.length,
|
|
184
|
+
totalCount: count ?? null,
|
|
185
|
+
...(0, cursor_util_1.pageCursors)(resources, fingerprint, hasNext, hasPrev),
|
|
186
|
+
items: resources.map((resource) => this.projectResource(resource))
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Finds the first resource matching the provided filter, applying the read
|
|
191
|
+
* restrictions of the query action and the access check of the find action,
|
|
192
|
+
* and including the relation and file fields configured for output on the
|
|
193
|
+
* find action. A resource event of the find action is emitted when a resource
|
|
194
|
+
* matches.
|
|
195
|
+
*
|
|
196
|
+
* @param {Object} [filter] The query filter object, mapped the same way as in
|
|
197
|
+
* {@link ResourceService.query}.
|
|
198
|
+
* @param {QuerySort} [sort] The fields to sort the matching resources by, of
|
|
199
|
+
* which the first one is returned, in the same form as in
|
|
200
|
+
* {@link ResourceService.query} (default: `-createdAt`).
|
|
201
|
+
* @return {Promise<Object|null>} The first matching resource with its virtual
|
|
202
|
+
* fields and relation counts projected, or null when none matches.
|
|
203
|
+
* @throws {@link ResourceError} `RESOURCE_FORBIDDEN` if the access check denies
|
|
204
|
+
* the matching resource, `RESOURCE_INVALID_SORT` if the sort input names a
|
|
205
|
+
* field that cannot be sorted by, and a {@link DatabaseError} on a database
|
|
206
|
+
* error.
|
|
207
|
+
*/
|
|
208
|
+
async single(filter = {}, sort = '-createdAt') {
|
|
209
|
+
const where = await this.queryConditions('query', filter);
|
|
210
|
+
const includeRelations = (0, relation_util_1.mapRelationInclusions)(this._client.name, 'find');
|
|
211
|
+
const orderBy = (0, sort_util_1.mapStableSortValues)(sort, this._client.name, 'find');
|
|
212
|
+
let resource;
|
|
213
|
+
try {
|
|
214
|
+
resource = await this._client.findFirst({
|
|
215
|
+
where,
|
|
216
|
+
include: includeRelations,
|
|
217
|
+
orderBy
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
throw this.databaseError(e);
|
|
222
|
+
}
|
|
223
|
+
if (!resource) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const access = await this.applyAccessCheck('find', resource);
|
|
227
|
+
if (!access) {
|
|
228
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceForbidden, `${this._client.name} access is forbidden`, { model: this._client.name, action: 'find' });
|
|
229
|
+
}
|
|
230
|
+
this._events.emitResourceEvent(this._client.name, 'find', {
|
|
231
|
+
current: resource
|
|
232
|
+
});
|
|
233
|
+
return this.projectResource(resource);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Counts the resources matching the provided filter, applying the read
|
|
237
|
+
* restrictions of the query action.
|
|
238
|
+
*
|
|
239
|
+
* @param {Object} [filter] The query filter object, mapped the same way as in
|
|
240
|
+
* {@link ResourceService.query}.
|
|
241
|
+
* @return {Promise<number>} The number of matching resources.
|
|
242
|
+
* @throws {@link DatabaseError} On a database error.
|
|
243
|
+
*/
|
|
244
|
+
async count(filter = {}) {
|
|
245
|
+
const where = await this.queryConditions('query', filter);
|
|
246
|
+
try {
|
|
247
|
+
return await this._client.count({ where });
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
throw this.databaseError(e);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Checks whether any resource matches the provided filter, applying the read
|
|
255
|
+
* restrictions of the query action.
|
|
256
|
+
*
|
|
257
|
+
* @param {Object} [filter] The query filter object, mapped the same way as in
|
|
258
|
+
* {@link ResourceService.query}.
|
|
259
|
+
* @return {Promise<boolean>} True if at least one resource matches, otherwise
|
|
260
|
+
* false.
|
|
261
|
+
* @throws {@link DatabaseError} On a database error.
|
|
262
|
+
*/
|
|
263
|
+
async exists(filter = {}) {
|
|
264
|
+
const where = await this.queryConditions('query', filter);
|
|
265
|
+
let resource;
|
|
266
|
+
try {
|
|
267
|
+
resource = await this._client.findFirst({ where, select: { id: true } });
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
throw this.databaseError(e);
|
|
271
|
+
}
|
|
272
|
+
return resource !== null;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Aggregates resources matching the provided filter over a date range, both
|
|
276
|
+
* as a single overall result and as a series of results for the equally sized
|
|
277
|
+
* periods the range is split into. All aggregations are executed in a single
|
|
278
|
+
* transaction.
|
|
279
|
+
*
|
|
280
|
+
* @param {AggregateOptions} options The aggregation options:
|
|
281
|
+
* - `select` The aggregation operations to perform per field (i.e.
|
|
282
|
+
* `{ views: { count: true, sum: true } }`). Only the numeric fields of the
|
|
283
|
+
* model accept every operator, while its date fields accept `count`, `min`,
|
|
284
|
+
* `max`, `first` and `last`. The `first` and `last` operators take the value
|
|
285
|
+
* the earliest and the latest record of a period holds, which costs one
|
|
286
|
+
* additional query per period and boundary, skipped for the periods holding
|
|
287
|
+
* no record.
|
|
288
|
+
* - `filter` The query filter object, mapped the same way as in
|
|
289
|
+
* {@link ResourceService.query}.
|
|
290
|
+
* - `dateField` The date field the range is applied on (default:
|
|
291
|
+
* `createdAt`).
|
|
292
|
+
* - `from` The ISO date string of the range start. Defaults to seven days
|
|
293
|
+
* before the range end.
|
|
294
|
+
* - `to` The ISO date string of the range end. Defaults to the current date
|
|
295
|
+
* and time.
|
|
296
|
+
* - `step` The size of a single period in units of the automatically selected
|
|
297
|
+
* time unit. If not provided, one unit is used and the unit is derived from
|
|
298
|
+
* the range length (seconds up to a minute, minutes up to an hour, hours up
|
|
299
|
+
* to a day, days up to a month, months up to a year, and years beyond that).
|
|
300
|
+
* - `safeIncrement` Whether the period increments use the derived time unit
|
|
301
|
+
* and stay consistent across daylight saving time changes. When false, the
|
|
302
|
+
* step is interpreted in seconds (default: `true`).
|
|
303
|
+
* @return {Promise<AggregateResponse<Object>>} The aggregation response with
|
|
304
|
+
* the overall total and one result per period, each labeled with the median
|
|
305
|
+
* date of its period. It is typed by the fields the selection named, not by
|
|
306
|
+
* the whole model, whenever the selection is passed as an object literal or
|
|
307
|
+
* annotated with `satisfies`.
|
|
308
|
+
* @throws {@link ResourceError} `RESOURCE_INVALID_AGGREGATE` if the selection
|
|
309
|
+
* is empty or names a field or operator that cannot be aggregated, and a
|
|
310
|
+
* {@link DatabaseError} on a database error.
|
|
311
|
+
*/
|
|
312
|
+
async aggregate(options) {
|
|
313
|
+
const { select, filter = {}, dateField = 'createdAt', from, to, step, safeIncrement = true } = options;
|
|
314
|
+
const { fromDate, toDate, ranges: dateRanges } = (0, aggregate_util_1.buildAggregationPeriods)(from, to, step, safeIncrement);
|
|
315
|
+
const operations = (0, aggregate_util_1.mapAggregationSelect)(select, this._client.name);
|
|
316
|
+
(0, aggregate_util_1.checkAggregationDateField)(dateField, this._client.name);
|
|
317
|
+
const query = await this.queryConditions('aggregate', filter);
|
|
318
|
+
const rangeQuery = (rangeFrom, rangeTo) => ({
|
|
319
|
+
AND: [query, { [dateField]: { gte: rangeFrom, lt: rangeTo } }]
|
|
320
|
+
});
|
|
321
|
+
// Aggregates a single range and reads the boundary records it holds the
|
|
322
|
+
// first and last values of which the database cannot aggregate
|
|
323
|
+
const aggregateRange = async (txModel, where) => {
|
|
324
|
+
const result = await txModel.aggregate({
|
|
325
|
+
...operations.aggregate,
|
|
326
|
+
where
|
|
327
|
+
});
|
|
328
|
+
const boundaries = await (0, aggregate_util_1.readAggregationBoundaries)(txModel, where, dateField, operations, (0, aggregate_util_1.aggregationRecordCount)(result));
|
|
329
|
+
return { ...result, ...boundaries };
|
|
330
|
+
};
|
|
331
|
+
let total = {};
|
|
332
|
+
let items = [];
|
|
333
|
+
try {
|
|
334
|
+
[total, items] = await this._db.client().$transaction(async (tx) => {
|
|
335
|
+
const txModel = tx[this._client.name];
|
|
336
|
+
const overall = await aggregateRange(txModel, rangeQuery(fromDate, toDate));
|
|
337
|
+
// Skip executing the same query if only one range value is generated
|
|
338
|
+
if (dateRanges.length === 1) {
|
|
339
|
+
return [overall, [overall]];
|
|
340
|
+
}
|
|
341
|
+
const ranges = await Promise.all(dateRanges.map((dateRange) => aggregateRange(txModel, rangeQuery(dateRange.from, dateRange.to))));
|
|
342
|
+
return [overall, ranges];
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
throw this.databaseError(e);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
total: (0, aggregate_util_1.mapAggregationResult)(total),
|
|
350
|
+
items: items.map((item, index) => ({
|
|
351
|
+
date: dateRanges[index].median,
|
|
352
|
+
result: (0, aggregate_util_1.mapAggregationResult)(item)
|
|
353
|
+
}))
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Creates a new resource. The provided data is merged with the write
|
|
358
|
+
* restrictions, checked for access, sanitized against the model configuration
|
|
359
|
+
* and mapped to the relation write actions (connect, create, or
|
|
360
|
+
* connect-or-create) before the record is created. The `createdBy` audit
|
|
361
|
+
* relation is connected to the currently authenticated user when configured.
|
|
362
|
+
* The resource cache is invalidated and a resource event is emitted after a
|
|
363
|
+
* successful create.
|
|
364
|
+
*
|
|
365
|
+
* @param {Object} data The data of the resource to create, including any inline
|
|
366
|
+
* relation and file payloads.
|
|
367
|
+
* @return {Promise<Object>} The created resource with its virtual fields and
|
|
368
|
+
* relation counts projected.
|
|
369
|
+
* @throws {@link ResourceError} `RESOURCE_FORBIDDEN` if the access check denies
|
|
370
|
+
* the action, `RESOURCE_INVALID_RELATION` if an inline relation payload is
|
|
371
|
+
* missing required fields or the relation does not accept new records, and a
|
|
372
|
+
* {@link DatabaseError} on a database error, i.e. `DATABASE_UNIQUE_VIOLATION`.
|
|
373
|
+
*/
|
|
374
|
+
async create(data) {
|
|
375
|
+
const createdBy = (0, relation_util_1.createdByConnect)(this._client.name);
|
|
376
|
+
const restrictions = await this.applyWriteRestrictions('create', data);
|
|
377
|
+
const createData = (0, common_1.removeUndefined)({
|
|
378
|
+
...data,
|
|
379
|
+
...restrictions
|
|
380
|
+
});
|
|
381
|
+
const access = await this.applyAccessCheck('create', createData);
|
|
382
|
+
if (!access) {
|
|
383
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceForbidden, `${this._client.name} create action is forbidden`, { model: this._client.name, action: 'create' });
|
|
384
|
+
}
|
|
385
|
+
const sanitizedData = this.sanitizeData('create', createData);
|
|
386
|
+
const connectRelations = (0, relation_util_1.mapRelationActions)(this._client.name, 'create', sanitizedData);
|
|
387
|
+
const includeRelations = (0, relation_util_1.mapRelationInclusions)(this._client.name, 'create');
|
|
388
|
+
await (0, delete_util_1.assertLiveRelationTargets)(this._db.client(), this._client.name, connectRelations);
|
|
389
|
+
let resource;
|
|
390
|
+
try {
|
|
391
|
+
resource = await this._client.create({
|
|
392
|
+
data: {
|
|
393
|
+
...sanitizedData,
|
|
394
|
+
...connectRelations,
|
|
395
|
+
createdBy
|
|
396
|
+
},
|
|
397
|
+
include: includeRelations
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
throw this.databaseError(e);
|
|
402
|
+
}
|
|
403
|
+
await this._cacheService.invalidateCache(this._client.name, 'create');
|
|
404
|
+
this._events.emitResourceEvent(this._client.name, 'create', {
|
|
405
|
+
current: resource
|
|
406
|
+
});
|
|
407
|
+
return this.projectResource(resource);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Updates an existing resource by its id. The current record is loaded with
|
|
411
|
+
* the read restrictions applied and checked for access, then updated with the
|
|
412
|
+
* data merged with the write restrictions inside a single transaction.
|
|
413
|
+
* Relations missing from the new value are disconnected, or deleted when
|
|
414
|
+
* `orphanRemoval` is configured for them, the same way a delete does. The resource cache is invalidated and a
|
|
415
|
+
* resource event carrying both the previous and the current state is emitted
|
|
416
|
+
* after a successful update.
|
|
417
|
+
*
|
|
418
|
+
* @param {ResourceId} id The id of the resource to update.
|
|
419
|
+
* @param {Object} data The partial data to update the resource with, including
|
|
420
|
+
* any inline relation and file payloads.
|
|
421
|
+
* @return {Promise<Object>} The updated resource with its virtual fields and
|
|
422
|
+
* relation counts projected.
|
|
423
|
+
* @throws {@link ResourceError} `RESOURCE_NOT_FOUND` if the resource does not
|
|
424
|
+
* exist or is filtered out by the read restrictions, `RESOURCE_FORBIDDEN` if
|
|
425
|
+
* the access check denies the action, `RESOURCE_INVALID_RELATION` if an
|
|
426
|
+
* inline relation payload is missing required fields or the relation does not
|
|
427
|
+
* accept new records, `RESOURCE_DELETE_RESTRICTED` if a live record
|
|
428
|
+
* references a soft deleted orphan through a restricting relation, and a
|
|
429
|
+
* {@link DatabaseError} on a database error, i.e. `DATABASE_UNIQUE_VIOLATION`.
|
|
430
|
+
*/
|
|
431
|
+
async update(id, data) {
|
|
432
|
+
const { current } = await this.updateWithPrevious(id, data);
|
|
433
|
+
return current;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Replaces an existing resource by its id as a whole. The updatable scalar
|
|
437
|
+
* fields missing from the data are reset to their configured default value,
|
|
438
|
+
* or to null when they are optional (see
|
|
439
|
+
* {@link ResourceService.replacementData}), and the resource is then updated
|
|
440
|
+
* the same way as {@link ResourceService.update}, applying the same policies,
|
|
441
|
+
* invalidating the same cache, and emitting the same update event. The
|
|
442
|
+
* optional relations missing from the data are left untouched, and a null or
|
|
443
|
+
* an empty list clears them.
|
|
444
|
+
*
|
|
445
|
+
* @param {ResourceId} id The id of the resource to replace.
|
|
446
|
+
* @param {Object} data The new state of the resource, including any inline
|
|
447
|
+
* relation payloads.
|
|
448
|
+
* @return {Promise<Object>} The replaced resource with its virtual fields and
|
|
449
|
+
* relation counts projected.
|
|
450
|
+
* @throws The same errors as {@link ResourceService.update}.
|
|
451
|
+
*/
|
|
452
|
+
async replace(id, data) {
|
|
453
|
+
const { current } = await this.updateWithPrevious(id, this.replacementData(data));
|
|
454
|
+
return current;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Deletes an existing resource by its id. The current record is loaded with
|
|
458
|
+
* the read restrictions applied and checked for access before it is deleted
|
|
459
|
+
* inside a single transaction.
|
|
460
|
+
*
|
|
461
|
+
* A model with `softDelete` enabled marks the record with the `deletedAt`
|
|
462
|
+
* and `deletedById` columns instead, soft deleting its cascade as well.
|
|
463
|
+
*
|
|
464
|
+
* The stored files of the deleted records follow the `onResourceDeleted`, or
|
|
465
|
+
* for soft deleted records the `onResourceSoftDeleted`, option of each file
|
|
466
|
+
* field: kept files are marked deleted in the transaction and no longer
|
|
467
|
+
* served, the others are removed once it commits. The cache of every affected
|
|
468
|
+
* model is invalidated and a resource event is emitted.
|
|
469
|
+
*
|
|
470
|
+
* @param {ResourceId} id The id of the resource to delete.
|
|
471
|
+
* @return {Promise<Object>} The deleted resource with its virtual fields and
|
|
472
|
+
* relation counts projected.
|
|
473
|
+
* @throws {@link ResourceError} `RESOURCE_NOT_FOUND` if the resource does not
|
|
474
|
+
* exist or is filtered out by the read restrictions, `RESOURCE_FORBIDDEN` if
|
|
475
|
+
* the access check denies it, `RESOURCE_DELETE_RESTRICTED` if a restricting
|
|
476
|
+
* relation still references a soft deleted resource, and a
|
|
477
|
+
* {@link DatabaseError} on a database error, i.e.
|
|
478
|
+
* `DATABASE_FOREIGN_KEY_VIOLATION` for a restricting relation of a removed
|
|
479
|
+
* resource.
|
|
480
|
+
*/
|
|
481
|
+
async delete(id) {
|
|
482
|
+
const restrictions = await this.applyReadRestrictions('delete', id);
|
|
483
|
+
const softDelete = (0, common_1.hasSoftDelete)((0, core_1.injectModel)(this._client.name).config);
|
|
484
|
+
const includeRelations = (0, relation_util_1.mapRelationInclusions)(this._client.name, 'delete');
|
|
485
|
+
let resource;
|
|
486
|
+
let deleted;
|
|
487
|
+
try {
|
|
488
|
+
[resource, deleted] = await this._db.client().$transaction(async (tx) => {
|
|
489
|
+
const txModel = tx[this._client.name];
|
|
490
|
+
// A soft deleted record is read with its relations up front, as a
|
|
491
|
+
// database delete returns them before the cascade removes them
|
|
492
|
+
const current = await txModel.findFirst({
|
|
493
|
+
where: {
|
|
494
|
+
id,
|
|
495
|
+
...restrictions,
|
|
496
|
+
...(0, model_util_1.liveRecordFilter)(this._client.name)
|
|
497
|
+
},
|
|
498
|
+
include: softDelete ? includeRelations : undefined
|
|
499
|
+
});
|
|
500
|
+
if (!current || current.id !== id) {
|
|
501
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceNotFound, `${this._client.name} not found`, { model: this._client.name, id });
|
|
502
|
+
}
|
|
503
|
+
const access = await this.applyAccessCheck('delete', current);
|
|
504
|
+
if (!access) {
|
|
505
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceForbidden, `${this._client.name} delete action is forbidden`, { model: this._client.name, action: 'delete' });
|
|
506
|
+
}
|
|
507
|
+
const data = (0, delete_util_1.softDeleteData)();
|
|
508
|
+
let result;
|
|
509
|
+
let records;
|
|
510
|
+
if (softDelete) {
|
|
511
|
+
records = await (0, delete_util_1.softDeleteCascade)(tx, this._client.name, [id], data);
|
|
512
|
+
const updated = await txModel.update({ where: { id }, data });
|
|
513
|
+
result = { ...current, ...updated };
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
records = await (0, delete_util_1.cascadedRecords)(tx, this._client.name, [id]);
|
|
517
|
+
result = await txModel.delete({
|
|
518
|
+
where: { id },
|
|
519
|
+
include: includeRelations
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// Merged rather than spread, since a self cascade holds the same model
|
|
523
|
+
(0, delete_util_1.mergeAffectedRecords)(records, { [this._client.name]: [id] });
|
|
524
|
+
const removed = softDelete
|
|
525
|
+
? { soft: records, hard: {} }
|
|
526
|
+
: { soft: {}, hard: records };
|
|
527
|
+
await (0, delete_util_1.retainDeletedFiles)(tx, removed, data);
|
|
528
|
+
return [result, removed];
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (e) {
|
|
532
|
+
throw this.databaseError(e, id);
|
|
533
|
+
}
|
|
534
|
+
await this.cleanupDeletedRecords(deleted);
|
|
535
|
+
this._events.emitResourceEvent(this._client.name, 'delete', {
|
|
536
|
+
current: resource
|
|
537
|
+
});
|
|
538
|
+
return this.projectResource(resource);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Creates many resources in a single transaction, one after another the same
|
|
542
|
+
* way as {@link ResourceService.create}, so every item runs through the same
|
|
543
|
+
* policies and hooks. Either every resource is created or, when any item
|
|
544
|
+
* fails, none: the error of the failed item is thrown and the transaction is
|
|
545
|
+
* rolled back. The cache invalidation and the create resource event of every
|
|
546
|
+
* item wait for the commit, and are dropped on a rollback.
|
|
547
|
+
*
|
|
548
|
+
* @param {Object[]} items The data of every resource to create.
|
|
549
|
+
* @return {Promise<Object[]>} The created resources, in the order of the
|
|
550
|
+
* items.
|
|
551
|
+
* @throws The error of the first item that failed, the same errors as
|
|
552
|
+
* {@link ResourceService.create}.
|
|
553
|
+
*/
|
|
554
|
+
async bulkCreate(items) {
|
|
555
|
+
return this.bulk(items, (item) => this.create(item));
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Updates the given fields of many resources in a single transaction, one
|
|
559
|
+
* after another the same way as {@link ResourceService.update}. Either every
|
|
560
|
+
* resource is updated or none, see {@link ResourceService.bulkCreate}.
|
|
561
|
+
*
|
|
562
|
+
* @param {BulkItem[]} items The id and the partial data of every resource.
|
|
563
|
+
* @return {Promise<Object[]>} The updated resources, in the order of the
|
|
564
|
+
* items.
|
|
565
|
+
* @throws The error of the first item that failed, the same errors as
|
|
566
|
+
* {@link ResourceService.update}.
|
|
567
|
+
*/
|
|
568
|
+
async bulkUpdate(items) {
|
|
569
|
+
return this.bulk(items, (item) => this.update(item.id, item.data));
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Replaces many resources as a whole in a single transaction, one after
|
|
573
|
+
* another the same way as {@link ResourceService.replace}. Either every
|
|
574
|
+
* resource is replaced or none, see {@link ResourceService.bulkCreate}.
|
|
575
|
+
*
|
|
576
|
+
* @param {BulkItem[]} items The id and the new state of every resource.
|
|
577
|
+
* @return {Promise<Object[]>} The replaced resources, in the order of the
|
|
578
|
+
* items.
|
|
579
|
+
* @throws The error of the first item that failed, the same errors as
|
|
580
|
+
* {@link ResourceService.replace}.
|
|
581
|
+
*/
|
|
582
|
+
async bulkReplace(items) {
|
|
583
|
+
return this.bulk(items, (item) => this.replace(item.id, item.data));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Deletes many resources in a single transaction, one after another the same
|
|
587
|
+
* way as {@link ResourceService.delete}, cascades and stored files included.
|
|
588
|
+
* Either every resource is deleted or none, see
|
|
589
|
+
* {@link ResourceService.bulkCreate}. Every id must exist up front, while an
|
|
590
|
+
* id the cascade of an earlier one already removed is skipped.
|
|
591
|
+
*
|
|
592
|
+
* @param {ResourceId[]} ids The ids of the resources to delete.
|
|
593
|
+
* @return {Promise<Object[]>} The resources deleted by their own id, in the
|
|
594
|
+
* order of the ids.
|
|
595
|
+
* @throws {@link ResourceError} `RESOURCE_NOT_FOUND` if an id does not exist,
|
|
596
|
+
* and otherwise the error of the first resource that failed, the same errors
|
|
597
|
+
* as {@link ResourceService.delete}.
|
|
598
|
+
*/
|
|
599
|
+
async bulkDelete(ids) {
|
|
600
|
+
return this.bulk(ids, async (id) => (await this.liveIds([id])).length > 0 ? this.delete(id) : undefined, async () => {
|
|
601
|
+
const live = (await this.liveIds(ids)).map(String);
|
|
602
|
+
const missing = ids.find((id) => !live.includes(String(id)));
|
|
603
|
+
if (missing !== undefined) {
|
|
604
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceNotFound, `${this._client.name} not found`, { model: this._client.name, id: missing });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Updates an existing resource the same way as {@link ResourceService.update},
|
|
610
|
+
* returning the state the resource had before the update along with the
|
|
611
|
+
* updated one.
|
|
612
|
+
*
|
|
613
|
+
* @param {ResourceId} id The id of the resource to update.
|
|
614
|
+
* @param {Object} data The partial data to update the resource with.
|
|
615
|
+
* @return {Promise<{ previous: Object, current: Object }>} The resource before
|
|
616
|
+
* and after the update, both with their virtual fields and relation counts
|
|
617
|
+
* projected.
|
|
618
|
+
*/
|
|
619
|
+
async updateWithPrevious(id, data) {
|
|
620
|
+
const readRestrictions = await this.applyReadRestrictions('update', {
|
|
621
|
+
id,
|
|
622
|
+
...data
|
|
623
|
+
});
|
|
624
|
+
const writeRestrictions = await this.applyWriteRestrictions('update', {
|
|
625
|
+
id,
|
|
626
|
+
...data
|
|
627
|
+
});
|
|
628
|
+
const updateData = (0, common_1.removeUndefined)({
|
|
629
|
+
...data,
|
|
630
|
+
...writeRestrictions
|
|
631
|
+
});
|
|
632
|
+
const sanitizedData = this.sanitizeData('update', updateData);
|
|
633
|
+
const includeRelations = (0, relation_util_1.mapRelationInclusions)(this._client.name, 'update');
|
|
634
|
+
let updateResource;
|
|
635
|
+
let resource;
|
|
636
|
+
let orphans;
|
|
637
|
+
try {
|
|
638
|
+
[updateResource, resource, orphans] = await this._db
|
|
639
|
+
.client()
|
|
640
|
+
.$transaction(async (tx) => {
|
|
641
|
+
const txModel = tx[this._client.name];
|
|
642
|
+
const current = await txModel.findFirst({
|
|
643
|
+
where: {
|
|
644
|
+
id,
|
|
645
|
+
...readRestrictions,
|
|
646
|
+
...(0, model_util_1.liveRecordFilter)(this._client.name)
|
|
647
|
+
},
|
|
648
|
+
include: includeRelations
|
|
649
|
+
});
|
|
650
|
+
if (!current || current.id !== id) {
|
|
651
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceNotFound, `${this._client.name} not found`, { model: this._client.name, id });
|
|
652
|
+
}
|
|
653
|
+
const access = await this.applyAccessCheck('update', current);
|
|
654
|
+
if (!access) {
|
|
655
|
+
throw new common_1.ResourceError(common_1.ErrorCode.ResourceForbidden, `${this._client.name} update action is forbidden`, { model: this._client.name, action: 'update' });
|
|
656
|
+
}
|
|
657
|
+
const setRelations = (0, relation_util_1.mapRelationActions)(this._client.name, 'update', sanitizedData, current);
|
|
658
|
+
await (0, delete_util_1.assertLiveRelationTargets)(tx, this._client.name, setRelations);
|
|
659
|
+
// Soft deleted orphans are marked before the update reads the
|
|
660
|
+
// relations back, so the response no longer holds them
|
|
661
|
+
const deleteData = (0, delete_util_1.softDeleteData)();
|
|
662
|
+
const removed = await (0, delete_util_1.removeOrphans)(tx, this._client.name, setRelations, deleteData);
|
|
663
|
+
await (0, delete_util_1.retainDeletedFiles)(tx, removed, deleteData);
|
|
664
|
+
const updated = await txModel.update({
|
|
665
|
+
where: { id },
|
|
666
|
+
include: includeRelations,
|
|
667
|
+
data: { ...sanitizedData, ...setRelations }
|
|
668
|
+
});
|
|
669
|
+
return [current, updated, removed];
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
throw this.databaseError(e, id);
|
|
674
|
+
}
|
|
675
|
+
await this._cacheService.invalidateCache(this._client.name, 'update');
|
|
676
|
+
await this.cleanupDeletedRecords(orphans);
|
|
677
|
+
this._events.emitResourceEvent(this._client.name, 'update', {
|
|
678
|
+
previous: updateResource,
|
|
679
|
+
current: resource
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
previous: this.projectResource(updateResource),
|
|
683
|
+
current: this.projectResource(resource)
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Completes the data of a replacement with the updatable scalar fields it
|
|
688
|
+
* leaves out: each one is set to the `default` value of its configuration,
|
|
689
|
+
* or to null when it is optional. A required field without a default value,
|
|
690
|
+
* which the request validation of the PUT route never lets a request leave
|
|
691
|
+
* out, is left untouched, and so are the hidden fields, the fields the
|
|
692
|
+
* `update` option of the model excludes, the virtual fields, and the
|
|
693
|
+
* relations.
|
|
694
|
+
*
|
|
695
|
+
* @param {Object} data The data of the replacement.
|
|
696
|
+
* @return {Object} A shallow copy of the data, with the missing fields set.
|
|
697
|
+
*/
|
|
698
|
+
replacementData(data) {
|
|
699
|
+
const resourceModel = (0, core_1.injectModel)(this._client.name);
|
|
700
|
+
const scalars = resourceModel.config.scalars ?? {};
|
|
701
|
+
const fields = (0, common_1.extractSchemaProperties)(resourceModel.replaceOneModel) ?? {};
|
|
702
|
+
const replacement = { ...data };
|
|
703
|
+
for (const name of Object.keys(fields)) {
|
|
704
|
+
const scalar = scalars[name];
|
|
705
|
+
if (!scalar || replacement[name] !== undefined) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (scalar.default !== undefined) {
|
|
709
|
+
replacement[name] = structuredClone(scalar.default);
|
|
710
|
+
}
|
|
711
|
+
else if (scalar.required === false) {
|
|
712
|
+
replacement[name] = null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return replacement;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* This method should be overridden with custom logic for restricting read
|
|
719
|
+
* operations on specific data for currently logged-in user and other
|
|
720
|
+
* authorization rules. The returned object will be applied as a filter on
|
|
721
|
+
* all actions (except create action) which will prevent unwanted data access
|
|
722
|
+
* and modifications. This method can also cancel the current action by
|
|
723
|
+
* throwing an error, i.e. a {@link ResourceError} with the
|
|
724
|
+
* `RESOURCE_FORBIDDEN` code or an `ApplicationError` with a code of the
|
|
725
|
+
* application.
|
|
726
|
+
*
|
|
727
|
+
* Not called for the calls run with internal access (see `withoutPolicies`).
|
|
728
|
+
*
|
|
729
|
+
* @param {ActionType} action The called action method on this service (find,
|
|
730
|
+
* query, aggregate, update, or delete)
|
|
731
|
+
* @param {Object|ResourceId} data The passed data to the called function can be
|
|
732
|
+
* number or object. If the data is a type of number, then it represents the
|
|
733
|
+
* resource id, otherwise it depends on the action and can be one of the
|
|
734
|
+
* following:
|
|
735
|
+
*
|
|
736
|
+
* - query and aggregate -> filter object
|
|
737
|
+
* - update -> combined id and the data object (i.e. { id, ...data })
|
|
738
|
+
*
|
|
739
|
+
* For other actions (find and delete) it represents the resource id.
|
|
740
|
+
* @return {Promise<Object>} The database-level `where` conditions containing
|
|
741
|
+
* additional query restrictions. The returned object is applied directly to
|
|
742
|
+
* the database query, so it uses the native Prisma filter syntax.
|
|
743
|
+
*/
|
|
744
|
+
async readRestrictions(action, data) {
|
|
745
|
+
return {};
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* This method should be overridden with custom logic for restricting write
|
|
749
|
+
* operations on specific data for currently logged-in users and other
|
|
750
|
+
* authorization rules. The returned object will be applied as a filter on
|
|
751
|
+
* all actions (create and update) to ensure that users can only modify
|
|
752
|
+
* data they are authorized to access. This method can also cancel the
|
|
753
|
+
* current action by throwing an error, i.e. a {@link ResourceError} with the
|
|
754
|
+
* `RESOURCE_FORBIDDEN` code or an `ApplicationError` with a code of the
|
|
755
|
+
* application.
|
|
756
|
+
*
|
|
757
|
+
* Not called for the calls run with internal access (see `withoutPolicies`).
|
|
758
|
+
*
|
|
759
|
+
* @param {'create'|'update'} action The called action method on this service
|
|
760
|
+
* (create or update)
|
|
761
|
+
* @param {Object} data The passed data to the called function, which should be
|
|
762
|
+
* an object representing the resource data to be created or updated. For
|
|
763
|
+
* update operations, this object will also include the resource ID.
|
|
764
|
+
*
|
|
765
|
+
* @return {Promise<Object>} A partial object containing additional data
|
|
766
|
+
* restrictions to be applied, or an empty object if no restrictions are
|
|
767
|
+
* necessary.
|
|
768
|
+
*/
|
|
769
|
+
async writeRestrictions(action, data) {
|
|
770
|
+
return {};
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* This method should provide logic for checking the access permissions for
|
|
774
|
+
* the provided resource object. If the resource should not be accessible by
|
|
775
|
+
* a currently authenticated user or other logic, this method should return
|
|
776
|
+
* false. Otherwise, it returns true and continues with the request execution.
|
|
777
|
+
*
|
|
778
|
+
* Not called for the calls run with internal access (see `withoutPolicies`).
|
|
779
|
+
*
|
|
780
|
+
* @param {ActionType} action The called action method on this service (find,
|
|
781
|
+
* query, aggregate, create, update, or delete)
|
|
782
|
+
* @param {Object} resource The resource object that is being checked for access.
|
|
783
|
+
* @return {Promise<boolean>} True if the access for resource is granted, false
|
|
784
|
+
* otherwise.
|
|
785
|
+
*/
|
|
786
|
+
async checkAccess(action, resource) {
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Constructs a query object for performing a text search on resources in the
|
|
791
|
+
* database based on the provided search text. This method will transform the
|
|
792
|
+
* search text into a format suitable for text search functionality, returning
|
|
793
|
+
* a query object that can be used to filter results.
|
|
794
|
+
*
|
|
795
|
+
* @param {string} searchText The text string used for searching resources.
|
|
796
|
+
* @return {Object} A query object that represents the conditions for the text
|
|
797
|
+
* search operation, using the native Prisma filter syntax. This will be used
|
|
798
|
+
* by the database query methods to retrieve matching resources.
|
|
799
|
+
*/
|
|
800
|
+
textSearchQuery(searchText) {
|
|
801
|
+
return {};
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Returns the read restrictions of {@link ResourceService.readRestrictions},
|
|
805
|
+
* or none when the call runs with internal access (see `withoutPolicies`).
|
|
806
|
+
*
|
|
807
|
+
* @internal
|
|
808
|
+
*/
|
|
809
|
+
async applyReadRestrictions(action, data) {
|
|
810
|
+
return (0, policy_util_1.arePoliciesSkipped)() ? {} : this.readRestrictions(action, data);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Returns the write restrictions of {@link ResourceService.writeRestrictions},
|
|
814
|
+
* or none when the call runs with internal access (see `withoutPolicies`).
|
|
815
|
+
*
|
|
816
|
+
* @internal
|
|
817
|
+
*/
|
|
818
|
+
async applyWriteRestrictions(action, data) {
|
|
819
|
+
return (0, policy_util_1.arePoliciesSkipped)() ? {} : this.writeRestrictions(action, data);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Runs the access check of {@link ResourceService.checkAccess}, which always
|
|
823
|
+
* grants the access when the call runs with internal access (see
|
|
824
|
+
* `withoutPolicies`).
|
|
825
|
+
*
|
|
826
|
+
* @internal
|
|
827
|
+
*/
|
|
828
|
+
async applyAccessCheck(action, resource) {
|
|
829
|
+
return (0, policy_util_1.arePoliciesSkipped)() || this.checkAccess(action, resource);
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Builds the database conditions of a query filter: the mapped filter, its
|
|
833
|
+
* text search, the read restrictions of the action, and the conditions
|
|
834
|
+
* hiding the soft deleted records.
|
|
835
|
+
*
|
|
836
|
+
* @internal
|
|
837
|
+
*/
|
|
838
|
+
async queryConditions(action, filter) {
|
|
839
|
+
const restrictions = await this.applyReadRestrictions(action, filter);
|
|
840
|
+
const textSearch = this.extractTextSearchQuery(filter);
|
|
841
|
+
const mappedFilter = (0, filter_util_1.mapQueryFilter)(filter, this._client.name);
|
|
842
|
+
return {
|
|
843
|
+
AND: [mappedFilter, textSearch, restrictions, ...this.liveFilters()]
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Removes the `searchText` property from the provided filter and converts it
|
|
848
|
+
* into a text search query using
|
|
849
|
+
* {@link ResourceService.textSearchQuery}. The filter object is mutated so the
|
|
850
|
+
* search text is not matched as a regular resource field.
|
|
851
|
+
*
|
|
852
|
+
* @param {Object} filter The request filter object, possibly containing a
|
|
853
|
+
* `searchText` property. The property is deleted from this object when present.
|
|
854
|
+
* @return {Object} The text search query for the extracted search text, or an
|
|
855
|
+
* empty object if the filter contains no search text.
|
|
856
|
+
*/
|
|
857
|
+
extractTextSearchQuery(filter) {
|
|
858
|
+
if (filter.searchText) {
|
|
859
|
+
const searchQuery = this.textSearchQuery(filter.searchText);
|
|
860
|
+
delete filter.searchText;
|
|
861
|
+
return searchQuery;
|
|
862
|
+
}
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Prepares a resource for the response by resolving the virtual fields of its
|
|
867
|
+
* model and flattening the Prisma `_count` aggregation into the individual
|
|
868
|
+
* relation count fields (i.e. `_count.posts` becomes `postsCount`).
|
|
869
|
+
*
|
|
870
|
+
* @param {Object} resource The resource object as returned by the database
|
|
871
|
+
* client.
|
|
872
|
+
* @return {Object} The same resource with the virtual fields resolved and, if a
|
|
873
|
+
* `_count` selection was present, with a count property per counted relation
|
|
874
|
+
* and the `_count` property removed.
|
|
875
|
+
*/
|
|
876
|
+
projectResource(resource) {
|
|
877
|
+
const projectedResource = (0, virtual_util_1.projectVirtualFields)((0, delete_util_1.hideDeletedRelations)(resource, this._client.name), this._client.name);
|
|
878
|
+
if (!(0, common_1.isPlainObject)(projectedResource['_count'])) {
|
|
879
|
+
return projectedResource;
|
|
880
|
+
}
|
|
881
|
+
// Create new relation count properties on the resource object
|
|
882
|
+
for (const [key, count] of Object.entries(projectedResource['_count'])) {
|
|
883
|
+
projectedResource[(0, common_1.countFieldName)(key)] = count;
|
|
884
|
+
}
|
|
885
|
+
delete projectedResource['_count'];
|
|
886
|
+
return projectedResource;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Lists the conditions hiding the soft deleted resources from a query. It is
|
|
890
|
+
* empty for a model that deletes its records, so its query and the cursors
|
|
891
|
+
* bound to it stay the same.
|
|
892
|
+
*
|
|
893
|
+
* @return {Object[]} The `deletedAt: null` condition, or no condition.
|
|
894
|
+
*/
|
|
895
|
+
liveFilters() {
|
|
896
|
+
const liveFilter = (0, model_util_1.liveRecordFilter)(this._client.name);
|
|
897
|
+
return Object.keys(liveFilter).length > 0 ? [liveFilter] : [];
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Cleans up after records were deleted, once the deleting transaction
|
|
901
|
+
* commits: removes the stored files their fields do not keep, since the
|
|
902
|
+
* storage cannot take part in the transaction, and invalidates the cache of
|
|
903
|
+
* every model they belong to.
|
|
904
|
+
*
|
|
905
|
+
* @param {DeletedRecords} deleted The soft deleted and the removed records.
|
|
906
|
+
*/
|
|
907
|
+
async cleanupDeletedRecords(deleted) {
|
|
908
|
+
const fileService = (0, core_1.inject)(file_service_1.FileService, false);
|
|
909
|
+
const groups = [
|
|
910
|
+
[true, deleted.soft],
|
|
911
|
+
[false, deleted.hard]
|
|
912
|
+
];
|
|
913
|
+
for (const [softDeleted, records] of groups) {
|
|
914
|
+
for (const [modelName, ids] of Object.entries(records)) {
|
|
915
|
+
await fileService?.deleteResourcesFiles(modelName, ids, softDeleted);
|
|
916
|
+
await this._cacheService.invalidateCache(modelName, 'delete');
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Prepares a write payload for the database by removing the virtual fields
|
|
922
|
+
* that have no column, filling in default values for the hidden required
|
|
923
|
+
* scalars of a create action, and recursively applying the same rules to the
|
|
924
|
+
* nested relation and file payloads. Unique key values, arrays of them and null
|
|
925
|
+
* values are left untouched, so the relation actions can still map them to the
|
|
926
|
+
* connect and disconnect operations.
|
|
927
|
+
*
|
|
928
|
+
* @param {'create'|'update'} action The write action the payload is sanitized
|
|
929
|
+
* for. Default values for the hidden required scalars are only applied on a
|
|
930
|
+
* create action.
|
|
931
|
+
* @param {Object} data The write payload to sanitize.
|
|
932
|
+
* @param {string} [resourceName] The name of the model the payload belongs to.
|
|
933
|
+
* Defaults to the model of this service and is set to the related model name
|
|
934
|
+
* when recursing into a nested relation or file payload.
|
|
935
|
+
* @return {Object} A shallow copy of the payload without the virtual fields,
|
|
936
|
+
* with the missing hidden required scalars defaulted, and with the nested
|
|
937
|
+
* payloads sanitized against their own models.
|
|
938
|
+
*/
|
|
939
|
+
sanitizeData(action, data, resourceName) {
|
|
940
|
+
const sanitizedData = { ...data };
|
|
941
|
+
const resourceModel = (0, core_1.injectModel)(resourceName ?? this._client.name, false);
|
|
942
|
+
const relationsModel = resourceModel?.relationsModel;
|
|
943
|
+
const filesModel = resourceModel?.filesModel;
|
|
944
|
+
// Delete virtual fields from data object to avoid database errors
|
|
945
|
+
for (const fieldName of Object.keys(resourceModel?.config?.virtual ?? {})) {
|
|
946
|
+
delete sanitizedData[fieldName];
|
|
947
|
+
}
|
|
948
|
+
// Map default values for hidden scalars if a property is required without
|
|
949
|
+
// a provided default value, and scalar values are not set yet, this is only
|
|
950
|
+
// required for create actions
|
|
951
|
+
for (const [fieldName, scalar] of Object.entries(resourceModel?.config?.scalars ?? {})) {
|
|
952
|
+
if (action === 'create' &&
|
|
953
|
+
scalar.hidden &&
|
|
954
|
+
scalar.required !== false &&
|
|
955
|
+
scalar.default === undefined &&
|
|
956
|
+
sanitizedData[fieldName] === undefined) {
|
|
957
|
+
sanitizedData[fieldName] = (0, common_1.defaultScalarValue)(scalar);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// Recursively sanitize nested objects and arrays of objects
|
|
961
|
+
for (const key in sanitizedData) {
|
|
962
|
+
const value = sanitizedData[key];
|
|
963
|
+
const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
|
|
964
|
+
const fileSchema = (0, common_1.extractSchemaProperties)(filesModel, key);
|
|
965
|
+
// Only nested resource payloads are sanitized. Unique key values, arrays
|
|
966
|
+
// of them and null values are left untouched, so the relation actions can
|
|
967
|
+
// still map them to connect and disconnect operations.
|
|
968
|
+
if ((0, common_1.isPlainObject)(value) || ((0, common_1.isArray)(value) && (0, common_1.isPlainObject)(value[0]))) {
|
|
969
|
+
const resourceName = (0, common_1.extractResourceName)(relationSchema ?? fileSchema);
|
|
970
|
+
if (resourceName) {
|
|
971
|
+
sanitizedData[key] = (0, common_1.isArray)(value)
|
|
972
|
+
? value.map((item) => this.sanitizeData(action, item, resourceName))
|
|
973
|
+
: this.sanitizeData(action, value, resourceName);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return sanitizedData;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Runs the operation of every item one after another in a single
|
|
981
|
+
* transaction, or in the current one, which rolls back on the first error.
|
|
982
|
+
*
|
|
983
|
+
* @internal
|
|
984
|
+
*/
|
|
985
|
+
async bulk(items, operation, prepare) {
|
|
986
|
+
try {
|
|
987
|
+
return await this._db.client().$transaction(async () => {
|
|
988
|
+
await prepare?.();
|
|
989
|
+
const results = [];
|
|
990
|
+
for (const item of items) {
|
|
991
|
+
const result = await operation(item);
|
|
992
|
+
if (result !== undefined) {
|
|
993
|
+
results.push(result);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return results;
|
|
997
|
+
}, { timeout: resource_config_1.resourceConfig.BULK_TRANSACTION_TIMEOUT });
|
|
998
|
+
}
|
|
999
|
+
catch (e) {
|
|
1000
|
+
// The errors of the items are translated already, which leaves the
|
|
1001
|
+
// errors of the transaction itself, i.e. its timeout
|
|
1002
|
+
throw this.databaseError(e);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Returns the ids of the given ones that exist and are not soft deleted,
|
|
1007
|
+
* regardless of the read restrictions.
|
|
1008
|
+
*
|
|
1009
|
+
* @internal
|
|
1010
|
+
*/
|
|
1011
|
+
async liveIds(ids) {
|
|
1012
|
+
const records = await this._client.findMany({
|
|
1013
|
+
where: { id: { in: ids }, ...(0, model_util_1.liveRecordFilter)(this._client.name) },
|
|
1014
|
+
select: { id: true }
|
|
1015
|
+
});
|
|
1016
|
+
return records.map((record) => record.id);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The model delegate of the current transaction, or of the connection outside
|
|
1020
|
+
* of one, resolved per call.
|
|
1021
|
+
*
|
|
1022
|
+
* @internal
|
|
1023
|
+
*/
|
|
1024
|
+
get _client() {
|
|
1025
|
+
return this._db.client()[this._modelKey];
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Translates the error of a database operation, a record removed after it
|
|
1029
|
+
* was read, i.e. by a concurrent delete, being one not found.
|
|
1030
|
+
*
|
|
1031
|
+
* @internal
|
|
1032
|
+
*/
|
|
1033
|
+
databaseError(error, id) {
|
|
1034
|
+
const translated = (0, database_error_1.toDatabaseError)(error, this._client.name);
|
|
1035
|
+
if ((0, common_1.isAppweaverError)(translated) &&
|
|
1036
|
+
translated.is(common_1.ErrorCode.DatabaseRecordNotFound)) {
|
|
1037
|
+
return new common_1.ResourceError(common_1.ErrorCode.ResourceNotFound, `${this._client.name} not found`, { model: this._client.name, id }, { cause: translated.cause });
|
|
1038
|
+
}
|
|
1039
|
+
return translated;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
exports.ResourceService = ResourceService;
|