@yunsoft/yuncms-core 0.1.0 → 0.1.2

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.
@@ -4,6 +4,7 @@ import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
5
5
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
6
6
  import { incrementSchemaVersion } from '../schema-version.js';
7
+ import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
7
8
  import { withConnectionTransaction } from '../transaction.js';
8
9
  import { BaseService } from './base-service.js';
9
10
  import { assertSchemaManager } from './schema-access.js';
@@ -21,11 +22,15 @@ function assertUserCollectionName(collection) {
21
22
  return collection;
22
23
  }
23
24
 
25
+ function invalidSchemaPayload(message) {
26
+ const error = new Error(message);
27
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
28
+ return error;
29
+ }
30
+
24
31
  function assertCollectionMetadataPatch(patch) {
25
32
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
26
- const error = new Error('Collection metadata patch must be an object');
27
- error.code = 'INVALID_SCHEMA_PAYLOAD';
28
- throw error;
33
+ throw invalidSchemaPayload('Collection metadata patch must be an object');
29
34
  }
30
35
  for (const key of Object.keys(patch)) {
31
36
  if (!COLLECTION_METADATA_KEYS.has(key)) {
@@ -35,12 +40,40 @@ function assertCollectionMetadataPatch(patch) {
35
40
  }
36
41
  }
37
42
  if (Object.keys(patch).length === 0) {
38
- const error = new Error('Collection metadata patch cannot be empty');
39
- error.code = 'INVALID_SCHEMA_PAYLOAD';
40
- throw error;
43
+ throw invalidSchemaPayload('Collection metadata patch cannot be empty');
44
+ }
45
+ for (const key of ['singleton', 'hidden']) {
46
+ if (Object.hasOwn(patch, key) && typeof patch[key] !== 'boolean') {
47
+ throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
48
+ }
49
+ }
50
+ if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
51
+ throw invalidSchemaPayload('Collection note must be a string or null');
52
+ }
53
+ if (Object.hasOwn(patch, 'metadata') && patch.metadata != null && (
54
+ typeof patch.metadata !== 'object' || Array.isArray(patch.metadata)
55
+ )) {
56
+ throw invalidSchemaPayload('Collection metadata must be an object or null');
41
57
  }
42
58
  }
43
59
 
60
+ function assertCollectionCreateMetadata(input) {
61
+ for (const key of ['singleton', 'hidden']) {
62
+ if (Object.hasOwn(input, key) && typeof input[key] !== 'boolean') {
63
+ throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
64
+ }
65
+ }
66
+ if (Object.hasOwn(input, 'note') && input.note != null && typeof input.note !== 'string') {
67
+ throw invalidSchemaPayload('Collection note must be a string or null');
68
+ }
69
+ if (Object.hasOwn(input, 'metadata') && input.metadata != null && (
70
+ typeof input.metadata !== 'object' || Array.isArray(input.metadata)
71
+ )) {
72
+ throw invalidSchemaPayload('Collection metadata must be an object or null');
73
+ }
74
+ normalizeCollectionSystemFields(input.systemFields);
75
+ }
76
+
44
77
  function temporaryDropName() {
45
78
  return `_yuncms_drop_${randomUUID().replaceAll('-', '').slice(0, 24)}`;
46
79
  }
@@ -59,6 +92,7 @@ export class CollectionsService extends BaseService {
59
92
 
60
93
  async createOne(input = {}) {
61
94
  assertSchemaManager(this.accountability);
95
+ assertCollectionCreateMetadata(input);
62
96
  const collection = assertUserCollectionName(input.collection);
63
97
  const primaryKey = input.primaryKey ?? 'id';
64
98
 
@@ -68,6 +102,8 @@ export class CollectionsService extends BaseService {
68
102
  throw error;
69
103
  }
70
104
 
105
+ const systemFields = compileCollectionSystemFields(collection, input.systemFields);
106
+
71
107
  return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
72
108
  const metadata = new SchemaMetadataRepository(connection);
73
109
  const existing = await metadata.readCollection(collection);
@@ -79,11 +115,16 @@ export class CollectionsService extends BaseService {
79
115
 
80
116
  const table = quoteIdentifier(collection, 'collection name');
81
117
  let tableCreated = false;
118
+ const physicalDefinitions = [
119
+ 'id CHAR(36) NOT NULL PRIMARY KEY',
120
+ ...systemFields.columns,
121
+ ...systemFields.constraints,
122
+ ];
82
123
 
83
124
  try {
84
125
  await connection.query(
85
126
  `CREATE TABLE ${table} (
86
- id CHAR(36) NOT NULL PRIMARY KEY
127
+ ${physicalDefinitions.join(',\n ')}
87
128
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
88
129
  );
89
130
  tableCreated = true;
@@ -95,7 +136,10 @@ export class CollectionsService extends BaseService {
95
136
  note: input.note ?? null,
96
137
  singleton: input.singleton === true,
97
138
  hidden: input.hidden === true,
98
- metadata: input.metadata ?? null,
139
+ metadata: {
140
+ ...(input.metadata ?? {}),
141
+ systemFields: systemFields.fields,
142
+ },
99
143
  });
100
144
 
101
145
  await metadata.createField({
@@ -108,6 +152,10 @@ export class CollectionsService extends BaseService {
108
152
  schemaMetadata: { primaryKey: true, length: 36 },
109
153
  });
110
154
 
155
+ for (const systemField of systemFields.metadata) {
156
+ await metadata.createField(systemField);
157
+ }
158
+
111
159
  const schemaVersion = await incrementSchemaVersion(connection);
112
160
  return { ...created, schemaVersion };
113
161
  });
@@ -10,6 +10,7 @@ import { ItemsService } from './items-service.js';
10
10
  import { PermissionsService } from './permissions-service.js';
11
11
  import { RelationsService } from './relations-service.js';
12
12
  import { RolesService } from './roles-service.js';
13
+ import { StudioSettingsService } from './studio-settings-service.js';
13
14
  import { UsersService } from './users-service.js';
14
15
  import { createServiceRegistry } from './service-registry.js';
15
16
 
@@ -28,5 +29,6 @@ export function createCoreServiceRegistry() {
28
29
  PermissionsService,
29
30
  FilesService,
30
31
  FileReconciliationService,
32
+ StudioSettingsService,
31
33
  });
32
34
  }
@@ -10,7 +10,15 @@ import { BaseService } from './base-service.js';
10
10
  import { assertSchemaManager } from './schema-access.js';
11
11
 
12
12
  const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
13
- const FIELD_PHYSICAL_KEYS = new Set(['required', 'defaultValue', 'removeDefault', 'indexed']);
13
+ const FIELD_PHYSICAL_KEYS = new Set([
14
+ 'required',
15
+ 'defaultValue',
16
+ 'removeDefault',
17
+ 'defaultPreset',
18
+ 'removeDefaultPreset',
19
+ 'autoUpdate',
20
+ 'indexed',
21
+ ]);
14
22
 
15
23
  function assertFieldName(field) {
16
24
  assertIdentifier(field, 'field name');
@@ -18,11 +26,15 @@ function assertFieldName(field) {
18
26
  return field;
19
27
  }
20
28
 
29
+ function invalidSchemaPayload(message) {
30
+ const error = new Error(message);
31
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
32
+ return error;
33
+ }
34
+
21
35
  function assertFieldMetadataPatch(patch) {
22
36
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
23
- const error = new Error('Field metadata patch must be an object');
24
- error.code = 'INVALID_SCHEMA_PAYLOAD';
25
- throw error;
37
+ throw invalidSchemaPayload('Field metadata patch must be an object');
26
38
  }
27
39
  for (const key of Object.keys(patch)) {
28
40
  if (!FIELD_METADATA_KEYS.has(key)) {
@@ -32,23 +44,17 @@ function assertFieldMetadataPatch(patch) {
32
44
  }
33
45
  }
34
46
  if (Object.keys(patch).length === 0) {
35
- const error = new Error('Field metadata patch cannot be empty');
36
- error.code = 'INVALID_SCHEMA_PAYLOAD';
37
- throw error;
47
+ throw invalidSchemaPayload('Field metadata patch cannot be empty');
38
48
  }
39
49
  }
40
50
 
41
51
  function assertPhysicalPatch(patch) {
42
52
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
43
- const error = new Error('Physical field patch must be an object');
44
- error.code = 'INVALID_SCHEMA_PAYLOAD';
45
- throw error;
53
+ throw invalidSchemaPayload('Physical field patch must be an object');
46
54
  }
47
55
  const keys = Object.keys(patch);
48
56
  if (keys.length === 0) {
49
- const error = new Error('Physical field patch cannot be empty');
50
- error.code = 'INVALID_SCHEMA_PAYLOAD';
51
- throw error;
57
+ throw invalidSchemaPayload('Physical field patch cannot be empty');
52
58
  }
53
59
  for (const key of keys) {
54
60
  if (!FIELD_PHYSICAL_KEYS.has(key)) {
@@ -58,24 +64,30 @@ function assertPhysicalPatch(patch) {
58
64
  }
59
65
  }
60
66
  if (Object.hasOwn(patch, 'required') && typeof patch.required !== 'boolean') {
61
- const error = new Error('required must be boolean');
62
- error.code = 'INVALID_SCHEMA_PAYLOAD';
63
- throw error;
67
+ throw invalidSchemaPayload('required must be boolean');
64
68
  }
65
69
  if (Object.hasOwn(patch, 'indexed') && typeof patch.indexed !== 'boolean') {
66
- const error = new Error('indexed must be boolean');
67
- error.code = 'INVALID_SCHEMA_PAYLOAD';
68
- throw error;
70
+ throw invalidSchemaPayload('indexed must be boolean');
69
71
  }
70
- if (Object.hasOwn(patch, 'removeDefault') && patch.removeDefault !== true) {
71
- const error = new Error('removeDefault must be true when provided');
72
- error.code = 'INVALID_SCHEMA_PAYLOAD';
73
- throw error;
72
+ if (Object.hasOwn(patch, 'autoUpdate') && typeof patch.autoUpdate !== 'boolean') {
73
+ throw invalidSchemaPayload('autoUpdate must be boolean');
74
+ }
75
+ for (const key of ['removeDefault', 'removeDefaultPreset']) {
76
+ if (Object.hasOwn(patch, key) && patch[key] !== true) {
77
+ throw invalidSchemaPayload(`${key} must be true when provided`);
78
+ }
79
+ }
80
+ if (Object.hasOwn(patch, 'defaultPreset') && patch.defaultPreset !== 'now') {
81
+ throw invalidSchemaPayload('defaultPreset currently supports only now');
82
+ }
83
+ if (Object.hasOwn(patch, 'defaultValue') && Object.hasOwn(patch, 'defaultPreset')) {
84
+ throw invalidSchemaPayload('defaultValue and defaultPreset cannot be used together');
74
85
  }
75
86
  if (Object.hasOwn(patch, 'defaultValue') && patch.removeDefault === true) {
76
- const error = new Error('defaultValue and removeDefault cannot be used together');
77
- error.code = 'INVALID_SCHEMA_PAYLOAD';
78
- throw error;
87
+ throw invalidSchemaPayload('defaultValue and removeDefault cannot be used together');
88
+ }
89
+ if (Object.hasOwn(patch, 'defaultPreset') && patch.removeDefaultPreset === true) {
90
+ throw invalidSchemaPayload('defaultPreset and removeDefaultPreset cannot be used together');
79
91
  }
80
92
  }
81
93
 
@@ -89,15 +101,26 @@ function parseSchemaMetadata(value) {
89
101
  }
90
102
  }
91
103
 
104
+ function assertFieldNotSystemManaged(field) {
105
+ if (parseSchemaMetadata(field?.schema_metadata).systemManaged === true) {
106
+ const error = new Error(`System-managed field cannot be changed directly: ${field.collection}.${field.field}`);
107
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
108
+ throw error;
109
+ }
110
+ }
111
+
92
112
  function fieldInputFromMetadata(field, schemaMetadata) {
93
113
  const input = {
94
114
  type: field.type,
95
115
  required: Boolean(field.required),
116
+ interface: field.interface ?? null,
96
117
  };
97
118
  if (schemaMetadata.length !== undefined && field.type === 'string') input.length = schemaMetadata.length;
98
119
  if (schemaMetadata.precision !== undefined && field.type === 'decimal') input.precision = schemaMetadata.precision;
99
120
  if (schemaMetadata.scale !== undefined && field.type === 'decimal') input.scale = schemaMetadata.scale;
100
121
  if (Object.hasOwn(schemaMetadata, 'defaultValue')) input.defaultValue = schemaMetadata.defaultValue;
122
+ if (schemaMetadata.defaultPreset !== undefined) input.defaultPreset = schemaMetadata.defaultPreset;
123
+ if (schemaMetadata.autoUpdate === true) input.autoUpdate = true;
101
124
  return input;
102
125
  }
103
126
 
@@ -246,6 +269,7 @@ export class FieldsService extends BaseService {
246
269
  error.code = 'FIELD_NOT_FOUND';
247
270
  throw error;
248
271
  }
272
+ assertFieldNotSystemManaged(existing);
249
273
 
250
274
  return withConnectionTransaction(connection, async () => {
251
275
  const updated = await metadata.updateFieldMetadata(collection, field, patch);
@@ -288,6 +312,7 @@ export class FieldsService extends BaseService {
288
312
  error.code = 'FIELD_NOT_FOUND';
289
313
  throw error;
290
314
  }
315
+ assertFieldNotSystemManaged(existing);
291
316
 
292
317
  const relation = relations.find((candidate) =>
293
318
  candidate.many_collection === collection && candidate.many_field === field);
@@ -301,8 +326,18 @@ export class FieldsService extends BaseService {
301
326
  const currentMetadata = parseSchemaMetadata(existing.schema_metadata);
302
327
  const currentInput = fieldInputFromMetadata(existing, currentMetadata);
303
328
  const nextInput = { ...currentInput, required: nextRequired };
329
+
304
330
  if (patch.removeDefault === true) delete nextInput.defaultValue;
305
- else if (Object.hasOwn(patch, 'defaultValue')) nextInput.defaultValue = patch.defaultValue;
331
+ if (patch.removeDefaultPreset === true) delete nextInput.defaultPreset;
332
+ if (Object.hasOwn(patch, 'defaultValue')) {
333
+ nextInput.defaultValue = patch.defaultValue;
334
+ delete nextInput.defaultPreset;
335
+ }
336
+ if (Object.hasOwn(patch, 'defaultPreset')) {
337
+ nextInput.defaultPreset = patch.defaultPreset;
338
+ delete nextInput.defaultValue;
339
+ }
340
+ if (Object.hasOwn(patch, 'autoUpdate')) nextInput.autoUpdate = patch.autoUpdate;
306
341
 
307
342
  const currentCompiled = compileFieldColumn(currentInput);
308
343
  const nextCompiled = compileFieldColumn(nextInput);
@@ -410,6 +445,7 @@ export class FieldsService extends BaseService {
410
445
  error.code = 'FIELD_NOT_FOUND';
411
446
  throw error;
412
447
  }
448
+ assertFieldNotSystemManaged(existing);
413
449
 
414
450
  const blockingRelation = relations.find((relation) =>
415
451
  (relation.many_collection === collection && relation.many_field === field) ||
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { BaseService } from './base-service.js';
4
+ import { resolveSystemResourceAccess } from './system-resource-access.js';
4
5
 
5
6
  function fileError(code, message) {
6
7
  const error = new Error(message);
@@ -8,11 +9,6 @@ function fileError(code, message) {
8
9
  return error;
9
10
  }
10
11
 
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
12
  function normalizeFilename(value) {
17
13
  if (typeof value !== 'string') throw fileError('INVALID_PAYLOAD', 'Download filename is required');
18
14
  const filename = value.trim();
@@ -60,28 +56,32 @@ export class FilesService extends BaseService {
60
56
  });
61
57
  }
62
58
 
63
- async readMany() {
64
- assertFileManager(this.accountability);
59
+ async #readOneUnsafe(id) {
65
60
  const [rows] = await this.database.query(
66
61
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
67
62
  uploaded_by, uploaded_at, metadata
68
63
  FROM yuncms_files
69
- ORDER BY uploaded_at DESC, id DESC`,
64
+ WHERE id = ?
65
+ LIMIT 1`,
66
+ [id],
70
67
  );
71
- return rows.map(normalizeRow);
68
+ return normalizeRow(rows[0]);
72
69
  }
73
70
 
74
- async readOne(id) {
75
- assertFileManager(this.accountability);
71
+ async readMany() {
72
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
76
73
  const [rows] = await this.database.query(
77
74
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
78
75
  uploaded_by, uploaded_at, metadata
79
76
  FROM yuncms_files
80
- WHERE id = ?
81
- LIMIT 1`,
82
- [id],
77
+ ORDER BY uploaded_at DESC, id DESC`,
83
78
  );
84
- return normalizeRow(rows[0]);
79
+ return rows.map(normalizeRow);
80
+ }
81
+
82
+ async readOne(id) {
83
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
84
+ return this.#readOneUnsafe(id);
85
85
  }
86
86
 
87
87
  async createOne({
@@ -92,7 +92,7 @@ export class FilesService extends BaseService {
92
92
  storage = 'local',
93
93
  metadata = null,
94
94
  } = {}) {
95
- assertFileManager(this.accountability);
95
+ await resolveSystemResourceAccess(this, 'create', 'yuncms_files');
96
96
  if (!Buffer.isBuffer(contents) && !(contents instanceof Uint8Array)) {
97
97
  throw fileError('INVALID_FILE_CONTENT', 'File contents must be Buffer or Uint8Array');
98
98
  }
@@ -124,7 +124,7 @@ export class FilesService extends BaseService {
124
124
  metadata == null ? null : JSON.stringify(metadata),
125
125
  ],
126
126
  );
127
- const file = await this.readOne(id);
127
+ const file = await this.#readOneUnsafe(id);
128
128
  await this.action('files.create', {
129
129
  key: id,
130
130
  item: file,
@@ -144,7 +144,8 @@ export class FilesService extends BaseService {
144
144
  }
145
145
 
146
146
  async readContent(id) {
147
- const file = await this.readOne(id);
147
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
148
+ const file = await this.#readOneUnsafe(id);
148
149
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
149
150
  const driver = this.storage.get(file.storage);
150
151
  const contents = await driver.get(file.filename_disk);
@@ -152,7 +153,7 @@ export class FilesService extends BaseService {
152
153
  }
153
154
 
154
155
  async updateOne(id, patch = {}) {
155
- assertFileManager(this.accountability);
156
+ await resolveSystemResourceAccess(this, 'update', 'yuncms_files');
156
157
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
157
158
  throw fileError('INVALID_PAYLOAD', 'File metadata patch must be an object');
158
159
  }
@@ -161,7 +162,7 @@ export class FilesService extends BaseService {
161
162
  throw fileError('INVALID_PAYLOAD', 'File update supports filenameDownload, title and metadata only');
162
163
  }
163
164
 
164
- const before = await this.readOne(id);
165
+ const before = await this.#readOneUnsafe(id);
165
166
  if (!before) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
166
167
 
167
168
  const assignments = [];
@@ -185,7 +186,7 @@ export class FilesService extends BaseService {
185
186
  params,
186
187
  );
187
188
  if (result.affectedRows !== 1) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
188
- const file = await this.readOne(id);
189
+ const file = await this.#readOneUnsafe(id);
189
190
  await this.action('files.update', {
190
191
  key: id,
191
192
  before,
@@ -196,8 +197,8 @@ export class FilesService extends BaseService {
196
197
  }
197
198
 
198
199
  async deleteOne(id) {
199
- assertFileManager(this.accountability);
200
- const file = await this.readOne(id);
200
+ await resolveSystemResourceAccess(this, 'delete', 'yuncms_files');
201
+ const file = await this.#readOneUnsafe(id);
201
202
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
202
203
 
203
204
  const [result] = await this.database.query('DELETE FROM yuncms_files WHERE id = ?', [id]);
@@ -9,6 +9,7 @@ import {
9
9
  parseItemsQuery,
10
10
  } from '../query.js';
11
11
  import { SchemaCache } from '../schema.js';
12
+ import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
12
13
  import { withTransaction } from '../transaction.js';
13
14
  import { BaseService } from './base-service.js';
14
15
  import { PermissionsService } from './permissions-service.js';
@@ -74,6 +75,10 @@ function createCandidateRecord(schema, id, entries) {
74
75
  continue;
75
76
  }
76
77
  const metadata = parseMetadata(fieldSchema.schema_metadata);
78
+ if (metadata.defaultPreset === 'now') {
79
+ candidate[fieldSchema.field] = new Date();
80
+ continue;
81
+ }
77
82
  candidate[fieldSchema.field] = Object.hasOwn(metadata, 'defaultValue')
78
83
  ? metadata.defaultValue
79
84
  : null;
@@ -82,6 +87,10 @@ function createCandidateRecord(schema, id, entries) {
82
87
  return candidate;
83
88
  }
84
89
 
90
+ function mergeSystemEntries(entries, schema, accountability, operation, now = new Date()) {
91
+ return [...entries, ...systemMutationEntries(schema, accountability, operation, now)];
92
+ }
93
+
85
94
  export class ItemsService extends BaseService {
86
95
  constructor(collection, options = {}) {
87
96
  super(options);
@@ -114,6 +123,12 @@ export class ItemsService extends BaseService {
114
123
  if (!collectionSchema) {
115
124
  throw serviceError('COLLECTION_NOT_FOUND', `Unknown collection: ${this.collection}`);
116
125
  }
126
+ if (collectionSchema.system) {
127
+ throw serviceError(
128
+ 'SYSTEM_COLLECTION_USE_DEDICATED_SERVICE',
129
+ `System collection ${this.collection} must be accessed through its dedicated service`,
130
+ );
131
+ }
117
132
  return collectionSchema;
118
133
  }
119
134
 
@@ -150,8 +165,9 @@ export class ItemsService extends BaseService {
150
165
  for (const fieldSchema of Object.values(schema.fields)) {
151
166
  if (!fieldSchema.required || fieldSchema.field === schema.primary_key) continue;
152
167
  if (Object.hasOwn(payload, fieldSchema.field)) continue;
168
+ if (isSystemManagedField(fieldSchema)) continue;
153
169
  const metadata = parseMetadata(fieldSchema.schema_metadata);
154
- if (Object.hasOwn(metadata, 'defaultValue')) continue;
170
+ if (Object.hasOwn(metadata, 'defaultValue') || metadata.defaultPreset != null) continue;
155
171
  throw serviceError('REQUIRED_FIELD_MISSING', `Required field is missing: ${fieldSchema.field}`, fieldSchema.field);
156
172
  }
157
173
  }
@@ -230,7 +246,8 @@ export class ItemsService extends BaseService {
230
246
  const schema = await this.getCollectionSchema();
231
247
  const permission = await this.resolvePermission('create');
232
248
  const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create' });
233
- const entries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
249
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
250
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
234
251
  const id = randomUUID();
235
252
  const candidate = createCandidateRecord(schema, id, entries);
236
253
  enforcePermissionValidation(candidate, permission.validation, schema);
@@ -264,7 +281,8 @@ export class ItemsService extends BaseService {
264
281
  operation: 'create',
265
282
  bulk: true,
266
283
  });
267
- const entries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
284
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
285
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
268
286
  const id = randomUUID();
269
287
  const candidate = createCandidateRecord(schema, id, entries);
270
288
  enforcePermissionValidation(candidate, permission.validation, schema);
@@ -306,8 +324,10 @@ export class ItemsService extends BaseService {
306
324
  operation: 'update',
307
325
  key: id,
308
326
  });
309
- const entries = this.validatePayload(filteredPayload, schema, permission);
310
- if (entries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
327
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission);
328
+ if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
329
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
330
+ const effectiveChanges = Object.fromEntries(entries);
311
331
 
312
332
  const table = quoteIdentifier(this.collection, 'collection name');
313
333
  const filter = combineCompiledFilters(
@@ -322,7 +342,7 @@ export class ItemsService extends BaseService {
322
342
  );
323
343
  const current = currentRows[0];
324
344
  if (!current) return null;
325
- enforcePermissionValidation({ ...current, ...filteredPayload }, permission.validation, schema);
345
+ enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
326
346
  }
327
347
 
328
348
  const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
@@ -336,7 +356,7 @@ export class ItemsService extends BaseService {
336
356
  await this.actionMutation('items.update', {
337
357
  key: id,
338
358
  item: record,
339
- changes: filteredPayload,
359
+ changes: effectiveChanges,
340
360
  }, { operation: 'update' });
341
361
  return record;
342
362
  }
@@ -354,8 +374,10 @@ export class ItemsService extends BaseService {
354
374
  bulk: true,
355
375
  filter: filterInput,
356
376
  });
357
- const entries = this.validatePayload(filteredPayload, schema, permission);
358
- if (entries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
377
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission);
378
+ if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
379
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
380
+ const effectiveChanges = Object.fromEntries(entries);
359
381
  const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema);
360
382
  const table = quoteIdentifier(this.collection, 'collection name');
361
383
 
@@ -371,7 +393,7 @@ export class ItemsService extends BaseService {
371
393
  );
372
394
  }
373
395
  for (const row of rows) {
374
- enforcePermissionValidation({ ...row, ...filteredPayload }, permission.validation, schema);
396
+ enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
375
397
  }
376
398
  }
377
399
 
@@ -382,7 +404,7 @@ export class ItemsService extends BaseService {
382
404
  );
383
405
  await this.actionMutation('items.update', {
384
406
  filter: filterInput,
385
- changes: filteredPayload,
407
+ changes: effectiveChanges,
386
408
  affected: result.affectedRows,
387
409
  }, { operation: 'update', bulk: true });
388
410
  return result.affectedRows;
@@ -442,4 +464,4 @@ export class ItemsService extends BaseService {
442
464
  }
443
465
  }
444
466
 
445
- export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
467
+ export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };