@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +36 -0
- package/src/accountability.js +39 -0
- package/src/advisory-lock.js +30 -0
- package/src/auth/password.js +100 -0
- package/src/auth/tokens.js +38 -0
- package/src/auth/users-repository.js +25 -0
- package/src/bootstrap.js +41 -0
- package/src/config.js +106 -0
- package/src/context.js +33 -0
- package/src/database.js +31 -0
- package/src/errors.js +35 -0
- package/src/field-types.js +110 -0
- package/src/hooks.js +121 -0
- package/src/identifier.js +13 -0
- package/src/index.js +67 -0
- package/src/logger.js +56 -0
- package/src/m2m-lifecycle.js +139 -0
- package/src/mail/smtp-mailer.js +65 -0
- package/src/migrations/0001-system-schema.js +164 -0
- package/src/migrations/0002-session-access-tokens.js +10 -0
- package/src/migrations/0003-public-role-constraints.js +10 -0
- package/src/migrations/0004-auth-action-tokens.js +19 -0
- package/src/migrations.js +81 -0
- package/src/permission-validation.js +63 -0
- package/src/query.js +193 -0
- package/src/relation-expansion.js +216 -0
- package/src/retry.js +29 -0
- package/src/schema-metadata-repository.js +284 -0
- package/src/schema-version.js +21 -0
- package/src/schema.js +82 -0
- package/src/services/api-tokens-service.js +117 -0
- package/src/services/audit-service.js +182 -0
- package/src/services/auth-service.js +164 -0
- package/src/services/auth-tokens-service.js +215 -0
- package/src/services/base-service.js +26 -0
- package/src/services/collections-service.js +249 -0
- package/src/services/core-services.js +32 -0
- package/src/services/fields-service.js +471 -0
- package/src/services/file-reconciliation-service.js +127 -0
- package/src/services/files-service.js +227 -0
- package/src/services/items-service.js +445 -0
- package/src/services/permissions-service.js +282 -0
- package/src/services/relations-service.js +455 -0
- package/src/services/roles-service.js +160 -0
- package/src/services/schema-access.js +7 -0
- package/src/services/service-registry.js +32 -0
- package/src/services/sessions-service.js +179 -0
- package/src/services/users-service.js +215 -0
- package/src/setup.js +66 -0
- package/src/storage/local-storage-driver.js +105 -0
- package/src/storage/s3-storage-driver.js +150 -0
- package/src/storage/storage-registry.js +39 -0
- package/src/transaction.js +46 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { BaseService } from './base-service.js';
|
|
4
|
+
|
|
5
|
+
function fileError(code, message) {
|
|
6
|
+
const error = new Error(message);
|
|
7
|
+
error.code = code;
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertFileManager(accountability) {
|
|
12
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
13
|
+
throw fileError('FORBIDDEN', 'File management requires administrator accountability in V1');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeFilename(value) {
|
|
17
|
+
if (typeof value !== 'string') throw fileError('INVALID_PAYLOAD', 'Download filename is required');
|
|
18
|
+
const filename = value.trim();
|
|
19
|
+
if (!filename || filename.length > 255 || /[\u0000-\u001f\u007f]/.test(filename)) {
|
|
20
|
+
throw fileError('INVALID_PAYLOAD', 'Download filename is invalid');
|
|
21
|
+
}
|
|
22
|
+
return filename;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeMimeType(value) {
|
|
26
|
+
if (value == null || value === '') return 'application/octet-stream';
|
|
27
|
+
const mimetype = String(value).trim().toLowerCase();
|
|
28
|
+
if (!mimetype || mimetype.length > 191 || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mimetype)) {
|
|
29
|
+
throw fileError('INVALID_PAYLOAD', 'File MIME type is invalid');
|
|
30
|
+
}
|
|
31
|
+
return mimetype;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function decodeJson(value) {
|
|
35
|
+
if (value == null || typeof value === 'object') return value ?? null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(value);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeRow(row) {
|
|
44
|
+
return row ? { ...row, metadata: decodeJson(row.metadata) } : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class FilesService extends BaseService {
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
super(options);
|
|
50
|
+
if (!options.storage) throw new Error('FilesService requires a storage registry');
|
|
51
|
+
this.storage = options.storage;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async action(event, payload) {
|
|
55
|
+
if (!this.emitter) return;
|
|
56
|
+
await this.emitter.action(event, payload, {
|
|
57
|
+
accountability: this.accountability,
|
|
58
|
+
requestId: this.requestId,
|
|
59
|
+
collection: 'yuncms_files',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async readMany() {
|
|
64
|
+
assertFileManager(this.accountability);
|
|
65
|
+
const [rows] = await this.database.query(
|
|
66
|
+
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
67
|
+
uploaded_by, uploaded_at, metadata
|
|
68
|
+
FROM yuncms_files
|
|
69
|
+
ORDER BY uploaded_at DESC, id DESC`,
|
|
70
|
+
);
|
|
71
|
+
return rows.map(normalizeRow);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async readOne(id) {
|
|
75
|
+
assertFileManager(this.accountability);
|
|
76
|
+
const [rows] = await this.database.query(
|
|
77
|
+
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
78
|
+
uploaded_by, uploaded_at, metadata
|
|
79
|
+
FROM yuncms_files
|
|
80
|
+
WHERE id = ?
|
|
81
|
+
LIMIT 1`,
|
|
82
|
+
[id],
|
|
83
|
+
);
|
|
84
|
+
return normalizeRow(rows[0]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async createOne({
|
|
88
|
+
contents,
|
|
89
|
+
filenameDownload,
|
|
90
|
+
title = null,
|
|
91
|
+
mimetype = 'application/octet-stream',
|
|
92
|
+
storage = 'local',
|
|
93
|
+
metadata = null,
|
|
94
|
+
} = {}) {
|
|
95
|
+
assertFileManager(this.accountability);
|
|
96
|
+
if (!Buffer.isBuffer(contents) && !(contents instanceof Uint8Array)) {
|
|
97
|
+
throw fileError('INVALID_FILE_CONTENT', 'File contents must be Buffer or Uint8Array');
|
|
98
|
+
}
|
|
99
|
+
if (contents.byteLength === 0) throw fileError('INVALID_FILE_CONTENT', 'Empty file uploads are not allowed');
|
|
100
|
+
|
|
101
|
+
const filename = normalizeFilename(filenameDownload);
|
|
102
|
+
const normalizedMime = normalizeMimeType(mimetype);
|
|
103
|
+
const driver = this.storage.get(storage);
|
|
104
|
+
const id = randomUUID();
|
|
105
|
+
const filenameDisk = id;
|
|
106
|
+
let stored = false;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await driver.put(filenameDisk, contents);
|
|
110
|
+
stored = true;
|
|
111
|
+
await this.database.query(
|
|
112
|
+
`INSERT INTO yuncms_files
|
|
113
|
+
(id, storage, filename_disk, filename_download, title, mimetype, filesize, uploaded_by, metadata)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
115
|
+
[
|
|
116
|
+
id,
|
|
117
|
+
storage,
|
|
118
|
+
filenameDisk,
|
|
119
|
+
filename,
|
|
120
|
+
title == null ? null : String(title).slice(0, 255),
|
|
121
|
+
normalizedMime,
|
|
122
|
+
contents.byteLength,
|
|
123
|
+
this.accountability.user ?? null,
|
|
124
|
+
metadata == null ? null : JSON.stringify(metadata),
|
|
125
|
+
],
|
|
126
|
+
);
|
|
127
|
+
const file = await this.readOne(id);
|
|
128
|
+
await this.action('files.create', {
|
|
129
|
+
key: id,
|
|
130
|
+
item: file,
|
|
131
|
+
});
|
|
132
|
+
return file;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (stored) {
|
|
135
|
+
try {
|
|
136
|
+
await driver.delete(filenameDisk);
|
|
137
|
+
} catch (cleanupError) {
|
|
138
|
+
error.cleanupError = cleanupError;
|
|
139
|
+
error.code ||= 'FILE_STORAGE_CLEANUP_FAILED';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async readContent(id) {
|
|
147
|
+
const file = await this.readOne(id);
|
|
148
|
+
if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
149
|
+
const driver = this.storage.get(file.storage);
|
|
150
|
+
const contents = await driver.get(file.filename_disk);
|
|
151
|
+
return { file, contents };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async updateOne(id, patch = {}) {
|
|
155
|
+
assertFileManager(this.accountability);
|
|
156
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
157
|
+
throw fileError('INVALID_PAYLOAD', 'File metadata patch must be an object');
|
|
158
|
+
}
|
|
159
|
+
const keys = Object.keys(patch);
|
|
160
|
+
if (keys.length === 0 || keys.some((key) => !['filenameDownload', 'title', 'metadata'].includes(key))) {
|
|
161
|
+
throw fileError('INVALID_PAYLOAD', 'File update supports filenameDownload, title and metadata only');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const before = await this.readOne(id);
|
|
165
|
+
if (!before) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
166
|
+
|
|
167
|
+
const assignments = [];
|
|
168
|
+
const params = [];
|
|
169
|
+
if (Object.hasOwn(patch, 'filenameDownload')) {
|
|
170
|
+
assignments.push('filename_download = ?');
|
|
171
|
+
params.push(normalizeFilename(patch.filenameDownload));
|
|
172
|
+
}
|
|
173
|
+
if (Object.hasOwn(patch, 'title')) {
|
|
174
|
+
assignments.push('title = ?');
|
|
175
|
+
params.push(patch.title == null ? null : String(patch.title).slice(0, 255));
|
|
176
|
+
}
|
|
177
|
+
if (Object.hasOwn(patch, 'metadata')) {
|
|
178
|
+
assignments.push('metadata = ?');
|
|
179
|
+
params.push(patch.metadata == null ? null : JSON.stringify(patch.metadata));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
params.push(id);
|
|
183
|
+
const [result] = await this.database.query(
|
|
184
|
+
`UPDATE yuncms_files SET ${assignments.join(', ')} WHERE id = ?`,
|
|
185
|
+
params,
|
|
186
|
+
);
|
|
187
|
+
if (result.affectedRows !== 1) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
188
|
+
const file = await this.readOne(id);
|
|
189
|
+
await this.action('files.update', {
|
|
190
|
+
key: id,
|
|
191
|
+
before,
|
|
192
|
+
item: file,
|
|
193
|
+
changes: patch,
|
|
194
|
+
});
|
|
195
|
+
return file;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async deleteOne(id) {
|
|
199
|
+
assertFileManager(this.accountability);
|
|
200
|
+
const file = await this.readOne(id);
|
|
201
|
+
if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
202
|
+
|
|
203
|
+
const [result] = await this.database.query('DELETE FROM yuncms_files WHERE id = ?', [id]);
|
|
204
|
+
if (result.affectedRows !== 1) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
205
|
+
|
|
206
|
+
const driver = this.storage.get(file.storage);
|
|
207
|
+
try {
|
|
208
|
+
await driver.delete(file.filename_disk);
|
|
209
|
+
} catch (cleanupError) {
|
|
210
|
+
const error = fileError(
|
|
211
|
+
'FILE_STORAGE_CLEANUP_FAILED',
|
|
212
|
+
'File metadata was deleted but the storage object could not be removed',
|
|
213
|
+
);
|
|
214
|
+
error.cleanupError = cleanupError;
|
|
215
|
+
error.file = file;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await this.action('files.delete', {
|
|
220
|
+
key: id,
|
|
221
|
+
before: file,
|
|
222
|
+
});
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { normalizeFilename, normalizeMimeType };
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
4
|
+
import { enforcePermissionValidation } from '../permission-validation.js';
|
|
5
|
+
import {
|
|
6
|
+
compileFilter,
|
|
7
|
+
compileSelectFields,
|
|
8
|
+
compileSort,
|
|
9
|
+
parseItemsQuery,
|
|
10
|
+
} from '../query.js';
|
|
11
|
+
import { SchemaCache } from '../schema.js';
|
|
12
|
+
import { withTransaction } from '../transaction.js';
|
|
13
|
+
import { BaseService } from './base-service.js';
|
|
14
|
+
import { PermissionsService } from './permissions-service.js';
|
|
15
|
+
|
|
16
|
+
const defaultSchemaCache = new SchemaCache();
|
|
17
|
+
const MAX_BULK_VALIDATION_ROWS = 5000;
|
|
18
|
+
|
|
19
|
+
function serviceError(code, message, path = null) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
error.code = code;
|
|
22
|
+
if (path) error.path = path;
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseMetadata(value) {
|
|
27
|
+
if (value == null || typeof value === 'object') return value ?? {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(value);
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function schemaForFields(schema, allowedFields) {
|
|
36
|
+
if (allowedFields == null) return schema;
|
|
37
|
+
return {
|
|
38
|
+
...schema,
|
|
39
|
+
fields: Object.fromEntries(
|
|
40
|
+
allowedFields
|
|
41
|
+
.filter((field) => schema.fields[field])
|
|
42
|
+
.map((field) => [field, schema.fields[field]]),
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeFields(fields) {
|
|
48
|
+
if (fields == null) return null;
|
|
49
|
+
if (Array.isArray(fields)) return fields;
|
|
50
|
+
return String(fields).split(',').map((field) => field.trim()).filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function combineCompiledFilters(...filters) {
|
|
54
|
+
const active = filters.filter((filter) => filter?.sql);
|
|
55
|
+
if (active.length === 0) return { sql: '', params: [] };
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
sql: ` WHERE ${active.map((filter) => `(${filter.sql.replace(/^ WHERE /, '')})`).join(' AND ')}`,
|
|
59
|
+
params: active.flatMap((filter) => filter.params),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createCandidateRecord(schema, id, entries) {
|
|
64
|
+
const provided = Object.fromEntries(entries);
|
|
65
|
+
const candidate = {};
|
|
66
|
+
|
|
67
|
+
for (const fieldSchema of Object.values(schema.fields)) {
|
|
68
|
+
if (fieldSchema.field === schema.primary_key) {
|
|
69
|
+
candidate[fieldSchema.field] = id;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (Object.hasOwn(provided, fieldSchema.field)) {
|
|
73
|
+
candidate[fieldSchema.field] = provided[fieldSchema.field];
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const metadata = parseMetadata(fieldSchema.schema_metadata);
|
|
77
|
+
candidate[fieldSchema.field] = Object.hasOwn(metadata, 'defaultValue')
|
|
78
|
+
? metadata.defaultValue
|
|
79
|
+
: null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return candidate;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ItemsService extends BaseService {
|
|
86
|
+
constructor(collection, options = {}) {
|
|
87
|
+
super(options);
|
|
88
|
+
this.collection = assertIdentifier(collection, 'collection name');
|
|
89
|
+
this.schemaCache = options.schemaCache ?? defaultSchemaCache;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hookContext(extra = {}) {
|
|
93
|
+
return {
|
|
94
|
+
accountability: this.accountability,
|
|
95
|
+
collection: this.collection,
|
|
96
|
+
requestId: this.requestId,
|
|
97
|
+
...extra,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async filterMutation(event, payload, context = {}) {
|
|
102
|
+
if (!this.emitter) return payload;
|
|
103
|
+
return this.emitter.filter(event, payload, this.hookContext(context));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async actionMutation(event, payload, context = {}) {
|
|
107
|
+
if (!this.emitter) return;
|
|
108
|
+
await this.emitter.action(event, payload, this.hookContext(context));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getCollectionSchema(database = this.database) {
|
|
112
|
+
const snapshot = this.schema ?? await this.schemaCache.get(database);
|
|
113
|
+
const collectionSchema = snapshot?.collections?.[this.collection];
|
|
114
|
+
if (!collectionSchema) {
|
|
115
|
+
throw serviceError('COLLECTION_NOT_FOUND', `Unknown collection: ${this.collection}`);
|
|
116
|
+
}
|
|
117
|
+
return collectionSchema;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async resolvePermission(action) {
|
|
121
|
+
const permissions = new PermissionsService({
|
|
122
|
+
accountability: this.accountability,
|
|
123
|
+
database: this.database,
|
|
124
|
+
schema: this.schema,
|
|
125
|
+
schemaCache: this.schemaCache,
|
|
126
|
+
emitter: this.emitter,
|
|
127
|
+
logger: this.logger,
|
|
128
|
+
permissionCache: this.permissionCache,
|
|
129
|
+
requestId: this.requestId,
|
|
130
|
+
});
|
|
131
|
+
return permissions.resolve(action, this.collection);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
validatePayload(payload, schema, permission, { creating = false } = {}) {
|
|
135
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
136
|
+
throw serviceError('INVALID_PAYLOAD', 'Payload must be an object');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entries = Object.entries(payload);
|
|
140
|
+
for (const [field] of entries) {
|
|
141
|
+
const fieldSchema = schema.fields[field];
|
|
142
|
+
if (!fieldSchema) throw serviceError('INVALID_PAYLOAD', `Unknown field: ${field}`, field);
|
|
143
|
+
if (fieldSchema.readonly) throw serviceError('FIELD_READ_ONLY', `Field is read-only: ${field}`, field);
|
|
144
|
+
if (permission.fields && !permission.fields.includes(field)) {
|
|
145
|
+
throw serviceError('FORBIDDEN_FIELD', `Field is not allowed for ${permission.action}: ${field}`, field);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (creating) {
|
|
150
|
+
for (const fieldSchema of Object.values(schema.fields)) {
|
|
151
|
+
if (!fieldSchema.required || fieldSchema.field === schema.primary_key) continue;
|
|
152
|
+
if (Object.hasOwn(payload, fieldSchema.field)) continue;
|
|
153
|
+
const metadata = parseMetadata(fieldSchema.schema_metadata);
|
|
154
|
+
if (Object.hasOwn(metadata, 'defaultValue')) continue;
|
|
155
|
+
throw serviceError('REQUIRED_FIELD_MISSING', `Required field is missing: ${fieldSchema.field}`, fieldSchema.field);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return entries;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema) {
|
|
163
|
+
const permissionSql = compileFilter(permissionFilter, fullSchema);
|
|
164
|
+
const userSql = compileFilter(userFilter, userSchema);
|
|
165
|
+
return combineCompiledFilters(permissionSql, userSql);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async readMany(rawQuery = {}) {
|
|
169
|
+
return (await this.readManyWithMeta(rawQuery)).data;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async readManyWithMeta(rawQuery = {}) {
|
|
173
|
+
const schema = await this.getCollectionSchema();
|
|
174
|
+
const permission = await this.resolvePermission('read');
|
|
175
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
176
|
+
const query = parseItemsQuery(rawQuery);
|
|
177
|
+
const requestedFields = normalizeFields(query.fields);
|
|
178
|
+
const selected = compileSelectFields(requestedFields, accessSchema);
|
|
179
|
+
const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema);
|
|
180
|
+
const sortSql = compileSort(query.sort, accessSchema);
|
|
181
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
182
|
+
|
|
183
|
+
const [rows] = await this.database.query(
|
|
184
|
+
`SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
|
|
185
|
+
[...filter.params, query.limit, query.offset],
|
|
186
|
+
);
|
|
187
|
+
const [countRows] = await this.database.query(
|
|
188
|
+
`SELECT COUNT(*) AS total_count FROM ${table}${filter.sql}`,
|
|
189
|
+
filter.params,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
data: rows,
|
|
194
|
+
meta: {
|
|
195
|
+
total_count: Number(countRows?.[0]?.total_count ?? 0),
|
|
196
|
+
limit: query.limit,
|
|
197
|
+
offset: query.offset,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async readOne(id, { fields = null } = {}) {
|
|
203
|
+
const schema = await this.getCollectionSchema();
|
|
204
|
+
const permission = await this.resolvePermission('read');
|
|
205
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
206
|
+
const selected = compileSelectFields(normalizeFields(fields), accessSchema);
|
|
207
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
208
|
+
const primaryKey = schema.primary_key;
|
|
209
|
+
const filter = combineCompiledFilters(
|
|
210
|
+
compileFilter(permission.filter, schema),
|
|
211
|
+
compileFilter({ [primaryKey]: { _eq: id } }, schema),
|
|
212
|
+
);
|
|
213
|
+
const [rows] = await this.database.query(
|
|
214
|
+
`SELECT ${selected.sql} FROM ${table}${filter.sql} LIMIT 1`,
|
|
215
|
+
filter.params,
|
|
216
|
+
);
|
|
217
|
+
return rows[0] ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async returnCreatedOrUpdated(id, schema) {
|
|
221
|
+
try {
|
|
222
|
+
return await this.readOne(id);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code !== 'FORBIDDEN') throw error;
|
|
225
|
+
return { [schema.primary_key]: id };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async createOne(payload = {}) {
|
|
230
|
+
const schema = await this.getCollectionSchema();
|
|
231
|
+
const permission = await this.resolvePermission('create');
|
|
232
|
+
const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create' });
|
|
233
|
+
const entries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
234
|
+
const id = randomUUID();
|
|
235
|
+
const candidate = createCandidateRecord(schema, id, entries);
|
|
236
|
+
enforcePermissionValidation(candidate, permission.validation, schema);
|
|
237
|
+
|
|
238
|
+
const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
|
|
239
|
+
const fields = Object.keys(values);
|
|
240
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
241
|
+
|
|
242
|
+
await this.database.query(
|
|
243
|
+
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
|
|
244
|
+
VALUES (${fields.map(() => '?').join(', ')})`,
|
|
245
|
+
fields.map((field) => values[field]),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const record = await this.returnCreatedOrUpdated(id, schema);
|
|
249
|
+
await this.actionMutation('items.create', { key: id, item: record }, { operation: 'create' });
|
|
250
|
+
return record;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async createMany(payloads = []) {
|
|
254
|
+
if (!Array.isArray(payloads) || payloads.length === 0) {
|
|
255
|
+
throw serviceError('INVALID_PAYLOAD', 'createMany requires a non-empty array');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const schema = await this.getCollectionSchema();
|
|
259
|
+
const permission = await this.resolvePermission('create');
|
|
260
|
+
const staged = [];
|
|
261
|
+
|
|
262
|
+
for (const payload of payloads) {
|
|
263
|
+
const filteredPayload = await this.filterMutation('items.create', payload, {
|
|
264
|
+
operation: 'create',
|
|
265
|
+
bulk: true,
|
|
266
|
+
});
|
|
267
|
+
const entries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
268
|
+
const id = randomUUID();
|
|
269
|
+
const candidate = createCandidateRecord(schema, id, entries);
|
|
270
|
+
enforcePermissionValidation(candidate, permission.validation, schema);
|
|
271
|
+
staged.push({
|
|
272
|
+
id,
|
|
273
|
+
values: { [schema.primary_key]: id, ...Object.fromEntries(entries) },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await withTransaction(this.database, async (connection) => {
|
|
278
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
279
|
+
|
|
280
|
+
for (const entry of staged) {
|
|
281
|
+
const fields = Object.keys(entry.values);
|
|
282
|
+
await connection.query(
|
|
283
|
+
`INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
|
|
284
|
+
VALUES (${fields.map(() => '?').join(', ')})`,
|
|
285
|
+
fields.map((field) => entry.values[field]),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const records = [];
|
|
291
|
+
for (const entry of staged) {
|
|
292
|
+
const record = await this.returnCreatedOrUpdated(entry.id, schema);
|
|
293
|
+
records.push(record);
|
|
294
|
+
await this.actionMutation('items.create', { key: entry.id, item: record }, {
|
|
295
|
+
operation: 'create',
|
|
296
|
+
bulk: true,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return records;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async updateOne(id, payload = {}) {
|
|
303
|
+
const schema = await this.getCollectionSchema();
|
|
304
|
+
const permission = await this.resolvePermission('update');
|
|
305
|
+
const filteredPayload = await this.filterMutation('items.update', payload, {
|
|
306
|
+
operation: 'update',
|
|
307
|
+
key: id,
|
|
308
|
+
});
|
|
309
|
+
const entries = this.validatePayload(filteredPayload, schema, permission);
|
|
310
|
+
if (entries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
311
|
+
|
|
312
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
313
|
+
const filter = combineCompiledFilters(
|
|
314
|
+
compileFilter(permission.filter, schema),
|
|
315
|
+
compileFilter({ [schema.primary_key]: { _eq: id } }, schema),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (permission.validation) {
|
|
319
|
+
const [currentRows] = await this.database.query(
|
|
320
|
+
`SELECT * FROM ${table}${filter.sql} LIMIT 1`,
|
|
321
|
+
filter.params,
|
|
322
|
+
);
|
|
323
|
+
const current = currentRows[0];
|
|
324
|
+
if (!current) return null;
|
|
325
|
+
enforcePermissionValidation({ ...current, ...filteredPayload }, permission.validation, schema);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
329
|
+
const [result] = await this.database.query(
|
|
330
|
+
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
331
|
+
[...entries.map(([, value]) => value), ...filter.params],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (result.affectedRows === 0) return null;
|
|
335
|
+
const record = await this.returnCreatedOrUpdated(id, schema);
|
|
336
|
+
await this.actionMutation('items.update', {
|
|
337
|
+
key: id,
|
|
338
|
+
item: record,
|
|
339
|
+
changes: filteredPayload,
|
|
340
|
+
}, { operation: 'update' });
|
|
341
|
+
return record;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async updateMany(filterInput, payload = {}) {
|
|
345
|
+
if (!filterInput || typeof filterInput !== 'object' || Array.isArray(filterInput) || Object.keys(filterInput).length === 0) {
|
|
346
|
+
throw serviceError('FILTER_REQUIRED', 'updateMany requires an explicit non-empty filter');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const schema = await this.getCollectionSchema();
|
|
350
|
+
const permission = await this.resolvePermission('update');
|
|
351
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
352
|
+
const filteredPayload = await this.filterMutation('items.update', payload, {
|
|
353
|
+
operation: 'update',
|
|
354
|
+
bulk: true,
|
|
355
|
+
filter: filterInput,
|
|
356
|
+
});
|
|
357
|
+
const entries = this.validatePayload(filteredPayload, schema, permission);
|
|
358
|
+
if (entries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
359
|
+
const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema);
|
|
360
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
361
|
+
|
|
362
|
+
if (permission.validation) {
|
|
363
|
+
const [rows] = await this.database.query(
|
|
364
|
+
`SELECT * FROM ${table}${filter.sql} LIMIT ?`,
|
|
365
|
+
[...filter.params, MAX_BULK_VALIDATION_ROWS + 1],
|
|
366
|
+
);
|
|
367
|
+
if (rows.length > MAX_BULK_VALIDATION_ROWS) {
|
|
368
|
+
throw serviceError(
|
|
369
|
+
'VALIDATION_BULK_LIMIT',
|
|
370
|
+
`Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
for (const row of rows) {
|
|
374
|
+
enforcePermissionValidation({ ...row, ...filteredPayload }, permission.validation, schema);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
379
|
+
const [result] = await this.database.query(
|
|
380
|
+
`UPDATE ${table} SET ${setSql}${filter.sql}`,
|
|
381
|
+
[...entries.map(([, value]) => value), ...filter.params],
|
|
382
|
+
);
|
|
383
|
+
await this.actionMutation('items.update', {
|
|
384
|
+
filter: filterInput,
|
|
385
|
+
changes: filteredPayload,
|
|
386
|
+
affected: result.affectedRows,
|
|
387
|
+
}, { operation: 'update', bulk: true });
|
|
388
|
+
return result.affectedRows;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async deleteOne(id) {
|
|
392
|
+
const schema = await this.getCollectionSchema();
|
|
393
|
+
const permission = await this.resolvePermission('delete');
|
|
394
|
+
const filtered = await this.filterMutation('items.delete', { key: id }, {
|
|
395
|
+
operation: 'delete',
|
|
396
|
+
key: id,
|
|
397
|
+
});
|
|
398
|
+
const key = filtered?.key ?? id;
|
|
399
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
400
|
+
const filter = combineCompiledFilters(
|
|
401
|
+
compileFilter(permission.filter, schema),
|
|
402
|
+
compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
|
|
403
|
+
);
|
|
404
|
+
const [result] = await this.database.query(
|
|
405
|
+
`DELETE FROM ${table}${filter.sql}`,
|
|
406
|
+
filter.params,
|
|
407
|
+
);
|
|
408
|
+
const deleted = result.affectedRows > 0;
|
|
409
|
+
if (deleted) {
|
|
410
|
+
await this.actionMutation('items.delete', { key }, { operation: 'delete' });
|
|
411
|
+
}
|
|
412
|
+
return deleted;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async deleteMany(filterInput) {
|
|
416
|
+
if (!filterInput || typeof filterInput !== 'object' || Array.isArray(filterInput) || Object.keys(filterInput).length === 0) {
|
|
417
|
+
throw serviceError('FILTER_REQUIRED', 'deleteMany requires an explicit non-empty filter');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const schema = await this.getCollectionSchema();
|
|
421
|
+
const permission = await this.resolvePermission('delete');
|
|
422
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
423
|
+
const filtered = await this.filterMutation('items.delete', { filter: filterInput }, {
|
|
424
|
+
operation: 'delete',
|
|
425
|
+
bulk: true,
|
|
426
|
+
});
|
|
427
|
+
const effectiveFilter = filtered?.filter ?? filterInput;
|
|
428
|
+
if (!effectiveFilter || typeof effectiveFilter !== 'object' || Array.isArray(effectiveFilter) || Object.keys(effectiveFilter).length === 0) {
|
|
429
|
+
throw serviceError('FILTER_REQUIRED', 'deleteMany hook result must preserve a non-empty filter');
|
|
430
|
+
}
|
|
431
|
+
const filter = this.compileActionFilters(effectiveFilter, permission.filter, accessSchema, schema);
|
|
432
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
433
|
+
const [result] = await this.database.query(
|
|
434
|
+
`DELETE FROM ${table}${filter.sql}`,
|
|
435
|
+
filter.params,
|
|
436
|
+
);
|
|
437
|
+
await this.actionMutation('items.delete', {
|
|
438
|
+
filter: effectiveFilter,
|
|
439
|
+
affected: result.affectedRows,
|
|
440
|
+
}, { operation: 'delete', bulk: true });
|
|
441
|
+
return result.affectedRows;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|