@yunsoft/yuncms-core 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,110 @@
1
+ const NOW_PATTERN = /^\$NOW(?:\(([+-]\d+)\s+(second|minute|hour|day|week|month|year)s?\))?$/i;
2
+ const MAX_ADJUSTMENT = 10_000;
3
+
4
+ function dynamicVariableError(code, message, path = null) {
5
+ const error = new Error(message);
6
+ error.code = code;
7
+ if (path) error.path = path;
8
+ return error;
9
+ }
10
+
11
+ function normalizedNow(value) {
12
+ const date = value == null ? new Date() : new Date(value);
13
+ if (Number.isNaN(date.getTime())) {
14
+ throw dynamicVariableError('INVALID_QUERY', 'Dynamic variable context contains an invalid current timestamp');
15
+ }
16
+ return date;
17
+ }
18
+
19
+ function adjustNow(now, amount, unit, path) {
20
+ if (!Number.isSafeInteger(amount) || Math.abs(amount) > MAX_ADJUSTMENT) {
21
+ throw dynamicVariableError(
22
+ 'INVALID_QUERY',
23
+ `$NOW adjustment must be a signed integer between -${MAX_ADJUSTMENT} and ${MAX_ADJUSTMENT}`,
24
+ path,
25
+ );
26
+ }
27
+
28
+ const result = new Date(now.getTime());
29
+ switch (unit.toLowerCase()) {
30
+ case 'second': result.setUTCSeconds(result.getUTCSeconds() + amount); break;
31
+ case 'minute': result.setUTCMinutes(result.getUTCMinutes() + amount); break;
32
+ case 'hour': result.setUTCHours(result.getUTCHours() + amount); break;
33
+ case 'day': result.setUTCDate(result.getUTCDate() + amount); break;
34
+ case 'week': result.setUTCDate(result.getUTCDate() + (amount * 7)); break;
35
+ case 'month': result.setUTCMonth(result.getUTCMonth() + amount); break;
36
+ case 'year': result.setUTCFullYear(result.getUTCFullYear() + amount); break;
37
+ default: throw dynamicVariableError('INVALID_QUERY', `Unsupported $NOW adjustment unit: ${unit}`, path);
38
+ }
39
+ if (Number.isNaN(result.getTime())) {
40
+ throw dynamicVariableError('INVALID_QUERY', '$NOW adjustment produced an invalid timestamp', path);
41
+ }
42
+ return result;
43
+ }
44
+
45
+ export function resolveDynamicVariable(value, context = {}, { allowUnresolved = false, path = null } = {}) {
46
+ if (typeof value !== 'string') return value;
47
+
48
+ if (value === '$CURRENT_USER' || value === '$CURRENT_ROLE') {
49
+ if (allowUnresolved) return value;
50
+ const key = value === '$CURRENT_USER' ? 'user' : 'role';
51
+ const resolved = context?.[key];
52
+ if (resolved == null || resolved === '') {
53
+ throw dynamicVariableError(
54
+ 'FORBIDDEN',
55
+ `${value} is unavailable for the current accountability context`,
56
+ path,
57
+ );
58
+ }
59
+ return String(resolved);
60
+ }
61
+
62
+ if (value === '$NOW' || value.toUpperCase().startsWith('$NOW(')) {
63
+ const match = value.match(NOW_PATTERN);
64
+ if (!match) {
65
+ throw dynamicVariableError(
66
+ 'INVALID_QUERY',
67
+ 'Invalid $NOW value; use $NOW or a signed adjustment such as $NOW(-1 day)',
68
+ path,
69
+ );
70
+ }
71
+ if (allowUnresolved) return value;
72
+ const now = normalizedNow(context?.now);
73
+ return match[1] ? adjustNow(now, Number(match[1]), match[2], path) : now;
74
+ }
75
+
76
+ if (value.startsWith('$CURRENT_USER.') || value.startsWith('$CURRENT_ROLE.')) {
77
+ throw dynamicVariableError(
78
+ 'INVALID_QUERY',
79
+ 'Nested current-user and current-role fields are not supported in YunCMS V1',
80
+ path,
81
+ );
82
+ }
83
+
84
+ return value;
85
+ }
86
+
87
+ export function resolveDynamicVariables(value, context = {}, options = {}) {
88
+ const path = options.path ?? 'filter';
89
+ if (Array.isArray(value)) {
90
+ return value.map((entry, index) => resolveDynamicVariables(entry, context, {
91
+ ...options,
92
+ path: `${path}.${index}`,
93
+ }));
94
+ }
95
+ if (value && typeof value === 'object' && !(value instanceof Date)) {
96
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
97
+ key,
98
+ resolveDynamicVariables(entry, context, { ...options, path: `${path}.${key}` }),
99
+ ]));
100
+ }
101
+ return resolveDynamicVariable(value, context, options);
102
+ }
103
+
104
+ export const DYNAMIC_FILTER_VALUES = Object.freeze([
105
+ '$CURRENT_USER',
106
+ '$CURRENT_ROLE',
107
+ '$NOW',
108
+ '$NOW(-1 day)',
109
+ '$NOW(+1 day)',
110
+ ]);
package/src/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.js';
2
2
  export { isCacheStore, MemoryCacheStore } from './cache.js';
3
+ export {
4
+ DYNAMIC_FILTER_VALUES,
5
+ resolveDynamicVariable,
6
+ resolveDynamicVariables,
7
+ } from './dynamic-variables.js';
8
+
3
9
  export {
4
10
  RedisClient,
5
11
  RedisCacheStore,
@@ -1,4 +1,5 @@
1
1
  import { compileFilter } from './query.js';
2
+ import { resolveDynamicVariables } from './dynamic-variables.js';
2
3
 
3
4
  function validationError(message, path = null) {
4
5
  const error = new Error(message);
@@ -8,13 +9,19 @@ function validationError(message, path = null) {
8
9
  }
9
10
 
10
11
  function compare(operator, actual, expected) {
12
+ const temporalExpected = expected instanceof Date ? expected.getTime() : null;
13
+ const temporalActual = temporalExpected == null
14
+ ? actual
15
+ : actual instanceof Date ? actual.getTime() : new Date(actual).getTime();
16
+ const comparableActual = temporalExpected == null ? actual : temporalActual;
17
+ const comparableExpected = temporalExpected == null ? expected : temporalExpected;
11
18
  switch (operator) {
12
- case '_eq': return actual === expected;
13
- case '_neq': return actual !== expected;
14
- case '_lt': return actual != null && actual < expected;
15
- case '_lte': return actual != null && actual <= expected;
16
- case '_gt': return actual != null && actual > expected;
17
- case '_gte': return actual != null && actual >= expected;
19
+ case '_eq': return comparableActual === comparableExpected;
20
+ case '_neq': return comparableActual !== comparableExpected;
21
+ case '_lt': return comparableActual != null && comparableActual < comparableExpected;
22
+ case '_lte': return comparableActual != null && comparableActual <= comparableExpected;
23
+ case '_gt': return comparableActual != null && comparableActual > comparableExpected;
24
+ case '_gte': return comparableActual != null && comparableActual >= comparableExpected;
18
25
  case '_in': return expected.some((candidate) => actual === candidate);
19
26
  case '_nin': return !expected.some((candidate) => actual === candidate);
20
27
  case '_null': return expected ? actual == null : actual != null;
@@ -47,17 +54,18 @@ function evaluateNode(record, node) {
47
54
 
48
55
  export function assertPermissionValidationRule(rule, schema) {
49
56
  if (rule == null) return null;
50
- compileFilter(rule, schema);
57
+ compileFilter(rule, schema, { allowUnresolvedDynamicVariables: true });
51
58
  return rule;
52
59
  }
53
60
 
54
- export function evaluatePermissionValidation(record, rule, schema) {
61
+ export function evaluatePermissionValidation(record, rule, schema, { dynamicVariables = {} } = {}) {
55
62
  if (rule == null) return true;
56
63
  assertPermissionValidationRule(rule, schema);
57
- return evaluateNode(record, rule);
64
+ const resolvedRule = resolveDynamicVariables(rule, dynamicVariables, { path: 'validation' });
65
+ return evaluateNode(record, resolvedRule);
58
66
  }
59
67
 
60
- export function enforcePermissionValidation(record, rule, schema, { path = 'validation' } = {}) {
61
- if (evaluatePermissionValidation(record, rule, schema)) return record;
68
+ export function enforcePermissionValidation(record, rule, schema, { path = 'validation', dynamicVariables = {} } = {}) {
69
+ if (evaluatePermissionValidation(record, rule, schema, { dynamicVariables })) return record;
62
70
  throw validationError('Record does not satisfy the permission validation rule', path);
63
71
  }
package/src/query.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { quoteIdentifier } from './identifier.js';
2
+ import { resolveDynamicVariables } from './dynamic-variables.js';
2
3
 
3
4
  const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset', 'search', 'aggregate', 'groupBy']);
4
5
  const FILTER_OPERATORS = new Set([
@@ -289,7 +290,16 @@ function compileFilterObject(filter, schema, path, limits, state, depth) {
289
290
 
290
291
  export function compileFilter(filter, schema, options = {}) {
291
292
  if (!filter) return { sql: '', params: [] };
292
- const limits = { ...QUERY_LIMITS, ...options };
293
- const compiled = compileFilterObject(filter, schema, 'filter', limits, { nodes: 0 }, 1);
293
+ const {
294
+ dynamicVariables = {},
295
+ allowUnresolvedDynamicVariables = false,
296
+ ...limitOptions
297
+ } = options;
298
+ const limits = { ...QUERY_LIMITS, ...limitOptions };
299
+ const resolvedFilter = resolveDynamicVariables(filter, dynamicVariables, {
300
+ allowUnresolved: allowUnresolvedDynamicVariables,
301
+ path: 'filter',
302
+ });
303
+ const compiled = compileFilterObject(resolvedFilter, schema, 'filter', limits, { nodes: 0 }, 1);
294
304
  return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
295
305
  }
@@ -419,7 +419,13 @@ async function readRowsByLookup({
419
419
  },
420
420
  };
421
421
  const selection = compileSelectFields([...new Set([...visibleFields, ...trustedInternal])], internalSchema);
422
- const permissionFilter = compileFilter(permission.filter, schema);
422
+ const permissionFilter = compileFilter(permission.filter, schema, {
423
+ dynamicVariables: {
424
+ user: service.accountability?.user ?? null,
425
+ role: service.accountability?.role ?? null,
426
+ now: new Date(),
427
+ },
428
+ });
423
429
  const table = quoteIdentifier(service.collection, 'collection name');
424
430
  const data = [];
425
431
 
@@ -38,6 +38,7 @@ function publicUser(user) {
38
38
  email: user.email,
39
39
  role: user.role ?? null,
40
40
  role_name: user.role_name ?? null,
41
+ admin: Boolean(user.role_admin ?? user.admin),
41
42
  status: user.status,
42
43
  email_verified_at: user.email_verified_at ?? null,
43
44
  };
@@ -191,6 +192,7 @@ export class AuthService extends BaseService {
191
192
  email: result.email,
192
193
  role: result.role,
193
194
  role_name: result.role_name ?? null,
195
+ admin: result.admin === true,
194
196
  },
195
197
  access_token: result.access_token,
196
198
  access_expires_at: result.access_expires_at,
@@ -59,6 +59,7 @@ function publicUser(user) {
59
59
  email: user.email,
60
60
  role: user.role ?? null,
61
61
  role_name: user.role_name ?? null,
62
+ admin: Boolean(user.role_admin ?? user.admin),
62
63
  status: user.status,
63
64
  email_verified_at: user.email_verified_at ?? null,
64
65
  };
@@ -101,7 +101,13 @@ export class FilesService extends BaseService {
101
101
  'System schema is required to enforce a filtered Files permission',
102
102
  );
103
103
  }
104
- const compiled = compileFilter(permission.filter, collectionSchema);
104
+ const compiled = compileFilter(permission.filter, collectionSchema, {
105
+ dynamicVariables: {
106
+ user: this.accountability.user,
107
+ role: this.accountability.role,
108
+ now: new Date(),
109
+ },
110
+ });
105
111
  sql = compiled.sql;
106
112
  params = [...compiled.params];
107
113
  }
@@ -65,7 +65,7 @@ function combineCompiledFilters(...filters) {
65
65
  };
66
66
  }
67
67
 
68
- function createCandidateRecord(schema, id, entries) {
68
+ function createCandidateRecord(schema, id, entries, now = new Date()) {
69
69
  const provided = Object.fromEntries(entries);
70
70
  const candidate = {};
71
71
 
@@ -80,7 +80,7 @@ function createCandidateRecord(schema, id, entries) {
80
80
  }
81
81
  const metadata = parseMetadata(fieldSchema.schema_metadata);
82
82
  if (metadata.defaultPreset === 'now') {
83
- candidate[fieldSchema.field] = new Date();
83
+ candidate[fieldSchema.field] = now;
84
84
  continue;
85
85
  }
86
86
  candidate[fieldSchema.field] = Object.hasOwn(metadata, 'defaultValue')
@@ -95,6 +95,38 @@ function mergeSystemEntries(entries, schema, accountability, operation, now = ne
95
95
  return [...entries, ...systemMutationEntries(schema, accountability, operation, now)];
96
96
  }
97
97
 
98
+ function databaseMutationValue(field, value, schema) {
99
+ if (value == null) return value;
100
+ const type = schema.fields[field]?.type;
101
+
102
+ if (type === 'json') {
103
+ if (typeof value === 'string') {
104
+ try {
105
+ JSON.parse(value);
106
+ return value;
107
+ } catch {
108
+ return JSON.stringify(value);
109
+ }
110
+ }
111
+ return JSON.stringify(value);
112
+ }
113
+
114
+ if (
115
+ (type === 'datetime' || type === 'timestamp')
116
+ && typeof value === 'string'
117
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value)
118
+ ) {
119
+ const date = new Date(value);
120
+ if (!Number.isNaN(date.getTime())) return date;
121
+ }
122
+
123
+ return value;
124
+ }
125
+
126
+ function databaseMutationParams(entries, schema) {
127
+ return entries.map(([field, value]) => databaseMutationValue(field, value, schema));
128
+ }
129
+
98
130
  export class ItemsService extends BaseService {
99
131
  constructor(collection, options = {}) {
100
132
  super(options);
@@ -150,6 +182,14 @@ export class ItemsService extends BaseService {
150
182
  return permissions.resolve(action, this.collection);
151
183
  }
152
184
 
185
+ dynamicVariables(now = new Date()) {
186
+ return {
187
+ user: this.accountability.user,
188
+ role: this.accountability.role,
189
+ now,
190
+ };
191
+ }
192
+
153
193
  validatePayload(payload, schema, permission, { creating = false } = {}) {
154
194
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
155
195
  throw serviceError('INVALID_PAYLOAD', 'Payload must be an object');
@@ -179,9 +219,9 @@ export class ItemsService extends BaseService {
179
219
  return entries;
180
220
  }
181
221
 
182
- compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null) {
183
- const permissionSql = compileFilter(permissionFilter, fullSchema);
184
- const userSql = compileFilter(userFilter, userSchema);
222
+ compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null, dynamicVariables = this.dynamicVariables()) {
223
+ const permissionSql = compileFilter(permissionFilter, fullSchema, { dynamicVariables });
224
+ const userSql = compileFilter(userFilter, userSchema, { dynamicVariables });
185
225
  const searchSql = compileSearch(search, userSchema);
186
226
  return combineCompiledFilters(permissionSql, userSql, searchSql);
187
227
  }
@@ -221,7 +261,14 @@ export class ItemsService extends BaseService {
221
261
  const query = await this.normalizeReadQuery(rawQuery);
222
262
  const permission = await this.resolvePermission('read');
223
263
  const accessSchema = schemaForFields(schema, permission.fields);
224
- const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search);
264
+ const filter = this.compileActionFilters(
265
+ query.filter,
266
+ permission.filter,
267
+ accessSchema,
268
+ schema,
269
+ query.search,
270
+ this.dynamicVariables(),
271
+ );
225
272
  const table = quoteIdentifier(this.collection, 'collection name');
226
273
  const aggregate = compileAggregate(query.aggregate, query.groupBy, accessSchema);
227
274
 
@@ -288,7 +335,8 @@ export class ItemsService extends BaseService {
288
335
  fields: { ...accessSchema.fields, [trustedLookupField]: schema.fields[trustedLookupField] },
289
336
  };
290
337
  const internalSelection = compileSelectFields([...visibleSelection.fields, trustedLookupField], internalSchema);
291
- const permissionFilter = compileFilter(permission.filter, schema);
338
+ const dynamicVariables = this.dynamicVariables();
339
+ const permissionFilter = compileFilter(permission.filter, schema, { dynamicVariables });
292
340
  const table = quoteIdentifier(this.collection, 'collection name');
293
341
  const data = [];
294
342
 
@@ -315,8 +363,9 @@ export class ItemsService extends BaseService {
315
363
  const selected = compileSelectFields(normalizeFields(fields), accessSchema);
316
364
  const table = quoteIdentifier(this.collection, 'collection name');
317
365
  const primaryKey = schema.primary_key;
366
+ const dynamicVariables = this.dynamicVariables();
318
367
  const filter = combineCompiledFilters(
319
- compileFilter(permission.filter, schema),
368
+ compileFilter(permission.filter, schema, { dynamicVariables }),
320
369
  compileFilter({ [primaryKey]: { _eq: id } }, schema),
321
370
  );
322
371
  const [rows] = await this.database.query(
@@ -342,17 +391,20 @@ export class ItemsService extends BaseService {
342
391
  const permission = await this.resolvePermission('create');
343
392
  const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create' });
344
393
  const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
345
- const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
394
+ const now = new Date();
395
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create', now);
346
396
  const id = randomUUID();
347
- const candidate = createCandidateRecord(schema, id, entries);
348
- enforcePermissionValidation(candidate, permission.validation, schema);
397
+ const candidate = createCandidateRecord(schema, id, entries, now);
398
+ enforcePermissionValidation(candidate, permission.validation, schema, {
399
+ dynamicVariables: this.dynamicVariables(now),
400
+ });
349
401
 
350
402
  const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
351
403
  const fields = Object.keys(values);
352
404
  const table = quoteIdentifier(this.collection, 'collection name');
353
405
  await this.database.query(
354
406
  `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
355
- fields.map((field) => values[field]),
407
+ databaseMutationParams(fields.map((field) => [field, values[field]]), schema),
356
408
  );
357
409
 
358
410
  const record = await this.returnCreatedOrUpdated(id, schema);
@@ -368,14 +420,17 @@ export class ItemsService extends BaseService {
368
420
  const schema = await this.getCollectionSchema();
369
421
  const permission = await this.resolvePermission('create');
370
422
  const staged = [];
423
+ const now = new Date();
371
424
 
372
425
  for (const payload of payloads) {
373
426
  const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create', bulk: true });
374
427
  const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
375
- const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
428
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create', now);
376
429
  const id = randomUUID();
377
- const candidate = createCandidateRecord(schema, id, entries);
378
- enforcePermissionValidation(candidate, permission.validation, schema);
430
+ const candidate = createCandidateRecord(schema, id, entries, now);
431
+ enforcePermissionValidation(candidate, permission.validation, schema, {
432
+ dynamicVariables: this.dynamicVariables(now),
433
+ });
379
434
  staged.push({ id, values: { [schema.primary_key]: id, ...Object.fromEntries(entries) } });
380
435
  }
381
436
 
@@ -385,7 +440,10 @@ export class ItemsService extends BaseService {
385
440
  const fields = Object.keys(entry.values);
386
441
  await connection.query(
387
442
  `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
388
- fields.map((field) => entry.values[field]),
443
+ databaseMutationParams(
444
+ fields.map((field) => [field, entry.values[field]]),
445
+ schema,
446
+ ),
389
447
  );
390
448
  }
391
449
  });
@@ -405,11 +463,13 @@ export class ItemsService extends BaseService {
405
463
  const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', key: id });
406
464
  const callerEntries = this.validatePayload(filteredPayload, schema, permission);
407
465
  if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
408
- const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
466
+ const now = new Date();
467
+ const dynamicVariables = this.dynamicVariables(now);
468
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update', now);
409
469
  const effectiveChanges = Object.fromEntries(entries);
410
470
  const table = quoteIdentifier(this.collection, 'collection name');
411
471
  const filter = combineCompiledFilters(
412
- compileFilter(permission.filter, schema),
472
+ compileFilter(permission.filter, schema, { dynamicVariables }),
413
473
  compileFilter({ [schema.primary_key]: { _eq: id } }, schema),
414
474
  );
415
475
 
@@ -417,13 +477,13 @@ export class ItemsService extends BaseService {
417
477
  const [currentRows] = await this.database.query(`SELECT * FROM ${table}${filter.sql} LIMIT 1`, filter.params);
418
478
  const current = currentRows[0];
419
479
  if (!current) return null;
420
- enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
480
+ enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema, { dynamicVariables });
421
481
  }
422
482
 
423
483
  const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
424
484
  const [result] = await this.database.query(
425
485
  `UPDATE ${table} SET ${setSql}${filter.sql}`,
426
- [...entries.map(([, value]) => value), ...filter.params],
486
+ [...databaseMutationParams(entries, schema), ...filter.params],
427
487
  );
428
488
  if (result.affectedRows === 0) return null;
429
489
  const record = await this.returnCreatedOrUpdated(id, schema);
@@ -442,9 +502,11 @@ export class ItemsService extends BaseService {
442
502
  const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', bulk: true, filter: filterInput });
443
503
  const callerEntries = this.validatePayload(filteredPayload, schema, permission);
444
504
  if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
445
- const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
505
+ const now = new Date();
506
+ const dynamicVariables = this.dynamicVariables(now);
507
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update', now);
446
508
  const effectiveChanges = Object.fromEntries(entries);
447
- const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema);
509
+ const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema, null, dynamicVariables);
448
510
  const table = quoteIdentifier(this.collection, 'collection name');
449
511
 
450
512
  if (permission.validation) {
@@ -455,13 +517,15 @@ export class ItemsService extends BaseService {
455
517
  if (rows.length > MAX_BULK_VALIDATION_ROWS) {
456
518
  throw serviceError('VALIDATION_BULK_LIMIT', `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`);
457
519
  }
458
- for (const row of rows) enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
520
+ for (const row of rows) {
521
+ enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema, { dynamicVariables });
522
+ }
459
523
  }
460
524
 
461
525
  const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
462
526
  const [result] = await this.database.query(
463
527
  `UPDATE ${table} SET ${setSql}${filter.sql}`,
464
- [...entries.map(([, value]) => value), ...filter.params],
528
+ [...databaseMutationParams(entries, schema), ...filter.params],
465
529
  );
466
530
  await this.actionMutation('items.update', { filter: filterInput, changes: effectiveChanges, affected: result.affectedRows }, { operation: 'update', bulk: true });
467
531
  return result.affectedRows;
@@ -473,8 +537,9 @@ export class ItemsService extends BaseService {
473
537
  const filtered = await this.filterMutation('items.delete', { key: id }, { operation: 'delete', key: id });
474
538
  const key = filtered?.key ?? id;
475
539
  const table = quoteIdentifier(this.collection, 'collection name');
540
+ const dynamicVariables = this.dynamicVariables();
476
541
  const filter = combineCompiledFilters(
477
- compileFilter(permission.filter, schema),
542
+ compileFilter(permission.filter, schema, { dynamicVariables }),
478
543
  compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
479
544
  );
480
545
  const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
@@ -223,7 +223,7 @@ export class PermissionsService extends BaseService {
223
223
  const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
224
224
  const filter = input.filter ?? null;
225
225
  const validation = input.validation ?? null;
226
- if (filter !== null) compileFilter(filter, collectionSchema);
226
+ if (filter !== null) compileFilter(filter, collectionSchema, { allowUnresolvedDynamicVariables: true });
227
227
  assertPermissionValidationRule(validation, collectionSchema);
228
228
 
229
229
  const [roleRows] = await this.database.query(
@@ -290,7 +290,7 @@ export class PermissionsService extends BaseService {
290
290
  params.push(fields == null ? null : JSON.stringify(fields));
291
291
  }
292
292
  if (Object.hasOwn(patch, 'filter')) {
293
- if (patch.filter != null) compileFilter(patch.filter, collectionSchema);
293
+ if (patch.filter != null) compileFilter(patch.filter, collectionSchema, { allowUnresolvedDynamicVariables: true });
294
294
  assignments.push('filter = ?');
295
295
  params.push(patch.filter == null ? null : JSON.stringify(patch.filter));
296
296
  }