spice-js 2.7.36 → 2.7.39

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/build/index.js CHANGED
@@ -189,6 +189,10 @@ class Spice {
189
189
  return spice;
190
190
  } catch (e) {
191
191
  console.log(e.stack);
192
+ // A failed init leaves no usable application (no config, no database,
193
+ // no routes). Surface it so the host process can exit instead of
194
+ // listening on a port with nothing behind it.
195
+ throw e;
192
196
  }
193
197
  })();
194
198
  }
@@ -25,11 +25,22 @@ function _load() {
25
25
  console.log('APP Bootstrap directory', app_bootstrap_path); */
26
26
  console.log("Loading Ordered Files", ordered_modules);
27
27
  for (var file of ordered_modules) {
28
- var mod = require(_path.default.join(bootstrap_path, file));
29
- if (_lodash.default.isFunction(mod)) {
30
- yield mod();
28
+ try {
29
+ var mod = require(_path.default.join(bootstrap_path, file));
30
+ if (_lodash.default.isFunction(mod)) {
31
+ yield mod();
32
+ }
33
+ console.log("loaded", file);
34
+ } catch (e) {
35
+ // The ordered modules are the foundation: database, models, logger.
36
+ // Nothing loaded after them can work without them, so swallowing the
37
+ // error here produced a half-booted process (no routes, no models,
38
+ // no retry) that still looked alive. Mark it and let init() rethrow
39
+ // so the host application can exit and be restarted.
40
+ console.log("Failed to load core module \"" + file + "\"");
41
+ e.spice_core_module = file;
42
+ throw e;
31
43
  }
32
- console.log("loaded", file);
33
44
  }
34
45
  var paths = [bootstrap_path, app_bootstrap_path];
35
46
  for (var loadder_path of paths) {
@@ -52,6 +63,9 @@ function _load() {
52
63
  }
53
64
  } catch (e) {
54
65
  console.log(e.stack);
66
+ if (e.spice_core_module) {
67
+ throw e;
68
+ }
55
69
  }
56
70
  });
57
71
  return _load.apply(this, arguments);
@@ -6,6 +6,7 @@ var _2 = require("..");
6
6
  var _ResourceLifecycleTriggered = _interopRequireDefault(require("../events/events/ResourceLifecycleTriggered"));
7
7
  var _Security = require("../utility/Security");
8
8
  var _fix = require("../utility/fix");
9
+ var _propertyDefaults = require("../utility/propertyDefaults");
9
10
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
10
11
  function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) { ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } } return n; }, _extends.apply(null, arguments); }
11
12
  function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); }
@@ -922,7 +923,7 @@ class SpiceModel {
922
923
  };
923
924
  var form;
924
925
  if (args.skip_write_serialize != true && args.skip_serialize != true) {
925
- cover_obj.new = yield _this8.do_serialize(_this8, "write", cover_obj.new, args);
926
+ cover_obj.new = yield _this8.do_serialize(_this8, "write", cover_obj.old, args);
926
927
  }
927
928
  if (args.skip_hooks != true) {
928
929
  yield _this8.run_hook(cover_obj, "update", "before", results);
@@ -2200,29 +2201,14 @@ class SpiceModel {
2200
2201
  if (!originalIsArray) {
2201
2202
  data = [data];
2202
2203
  }
2203
-
2204
- // Compute the defaults from properties using reduce.
2205
- var defaults = Object.keys(_this16.props).reduce((acc, key) => {
2206
- var _this16$props$key, _this16$props$key$def;
2207
- var def = (_this16$props$key = _this16.props[key]) == null ? void 0 : (_this16$props$key$def = _this16$props$key.defaults) == null ? void 0 : _this16$props$key$def[type];
2208
- if (def !== undefined) {
2209
- acc[key] = _.isFunction(def) ? def({
2210
- old_data: data,
2211
- new_data: old_data
2212
- }) : def;
2213
- }
2214
- return acc;
2215
- }, {});
2216
-
2217
- // Merge defaults into each object.
2218
- data = data.map(item => _.defaults(item, defaults));
2204
+ data = yield (0, _propertyDefaults.applyPropertyDefaults)(data, _this16.props, type, old_data, _this16[_ctx]);
2219
2205
 
2220
2206
  // If type is "read", clean the data by omitting certain props.
2221
2207
  if (type === "read") {
2222
2208
  // Collect hidden properties from schema.
2223
2209
  var hiddenProps = Object.keys(_this16.props).filter(key => {
2224
- var _this16$props$key2;
2225
- return (_this16$props$key2 = _this16.props[key]) == null ? void 0 : _this16$props$key2.hide;
2210
+ var _this16$props$key;
2211
+ return (_this16$props$key = _this16.props[key]) == null ? void 0 : _this16$props$key.hide;
2226
2212
  });
2227
2213
  // Combine default props to remove.
2228
2214
  var propsToClean = ["deleted", "type", "collection", ...path_to_be_removed, ...hiddenProps];
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ exports.__esModule = true;
4
+ exports.applyPropertyDefaults = applyPropertyDefaults;
5
+ exports.createPropertyDefaultFunctionContext = createPropertyDefaultFunctionContext;
6
+ var _lodash = _interopRequireDefault(require("lodash"));
7
+ var _sonoverDate = _interopRequireDefault(require("sonover-date"));
8
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
9
+ function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); }
10
+ function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; }
11
+ // sonover-date is Spice.js' declared date dependency and exposes its Moment instance.
12
+ var moment = new _sonoverDate.default().moment;
13
+ function createPropertyDefaultFunctionContext(_temp) {
14
+ var {
15
+ record,
16
+ oldData,
17
+ user,
18
+ setting,
19
+ ctx
20
+ } = _temp === void 0 ? {} : _temp;
21
+ var runtime = global.spice;
22
+ var _util = {
23
+ moment,
24
+ _: _lodash.default,
25
+ user,
26
+ spice: runtime
27
+ };
28
+ return {
29
+ old_data: oldData,
30
+ new_data: record,
31
+ user,
32
+ setting,
33
+ ctx,
34
+ moment,
35
+ _: _lodash.default,
36
+ spice: runtime,
37
+ _util
38
+ };
39
+ }
40
+
41
+ /** Apply schema defaults after mapping, without replacing supplied values. */
42
+ function applyPropertyDefaults(_x, _x2, _x3, _x4, _x5) {
43
+ return _applyPropertyDefaults.apply(this, arguments);
44
+ }
45
+ function _applyPropertyDefaults() {
46
+ _applyPropertyDefaults = _asyncToGenerator(function* (data, props, type, oldData, ctx) {
47
+ // Compute native defaults before merging, as the original serializer did.
48
+ var nativeDefaults = new Map();
49
+ for (var [key, property] of Object.entries(props)) {
50
+ var _property$defaults;
51
+ var value = property == null ? void 0 : (_property$defaults = property.defaults) == null ? void 0 : _property$defaults[type];
52
+ if (typeof value === "function") {
53
+ nativeDefaults.set(key, yield value({
54
+ old_data: data,
55
+ new_data: oldData
56
+ }));
57
+ }
58
+ }
59
+ // Match lodash.defaults' handling of null/undefined records in the previous serializer.
60
+ data = data.map(item => _lodash.default.defaults(item));
61
+ var _loop = function* _loop(_key, _property) {
62
+ var _property$defaults2;
63
+ var value = _property == null ? void 0 : (_property$defaults2 = _property.defaults) == null ? void 0 : _property$defaults2[type];
64
+ if (value === undefined) return "continue";
65
+
66
+ // Native schema functions retain their existing batch argument contract.
67
+ if (typeof value === "function") {
68
+ var result = nativeDefaults.get(_key);
69
+ for (var item of data) {
70
+ if (item[_key] === undefined) item[_key] = _lodash.default.cloneDeep(result);
71
+ }
72
+ return "continue";
73
+ }
74
+
75
+ // Only explicitly configured functions are executable. Literal strings stay literal.
76
+ if (_property["default_type_" + type] === "function") {
77
+ var missing = data.some(item => item[_key] === undefined);
78
+ if (!missing) return "continue";
79
+ if (typeof value !== "string") {
80
+ throw new TypeError("Default function for " + _key + " must be a function string.");
81
+ }
82
+ var fn = eval("(" + value + ")");
83
+ if (typeof fn !== "function") {
84
+ throw new TypeError("Default function for " + _key + " must evaluate to a function.");
85
+ }
86
+ for (var [index, _item] of data.entries()) {
87
+ var _ctx$state;
88
+ if (_item[_key] !== undefined) continue;
89
+ _item[_key] = yield fn(_item, createPropertyDefaultFunctionContext({
90
+ record: _item,
91
+ oldData: Array.isArray(oldData) ? oldData[index] : oldData,
92
+ user: ctx == null ? void 0 : (_ctx$state = ctx.state) == null ? void 0 : _ctx$state.user,
93
+ setting: type,
94
+ ctx
95
+ }));
96
+ }
97
+ return "continue";
98
+ }
99
+ for (var _item2 of data) {
100
+ if (_item2[_key] === undefined) _item2[_key] = _lodash.default.cloneDeep(value);
101
+ }
102
+ };
103
+ for (var [_key, _property] of Object.entries(props)) {
104
+ var _ret = yield* _loop(_key, _property);
105
+ if (_ret === "continue") continue;
106
+ }
107
+ return data;
108
+ });
109
+ return _applyPropertyDefaults.apply(this, arguments);
110
+ }
@@ -0,0 +1,44 @@
1
+ # Property defaults during serialization
2
+
3
+ `SpiceModel.do_serialize` calls `applyPropertyDefaults` after mapping, before hidden fields are removed on reads. The helper processes `defaults.read` or `defaults.write` according to the serialization mode.
4
+
5
+ Literal defaults apply only to undefined fields. Existing values, including null, false, zero and empty strings, are preserved. Objects and arrays are cloned per record.
6
+
7
+ A schema administrator can configure a serialized function:
8
+
9
+ ```js
10
+ stage: {
11
+ type: "string",
12
+ default_type_write: "function",
13
+ defaults: {
14
+ write: 'async (record, { old_data, user }) => record.urgent ? "priority" : "review"'
15
+ }
16
+ }
17
+ ```
18
+
19
+ Only strings with `default_type_read` / `default_type_write` equal to `function` are evaluated. Untagged strings, including function-looking text, remain literal. Configured functions run once per record missing that field and are awaited. Their arguments are:
20
+
21
+ - `record`: the current mapped record.
22
+ - `context.old_data`: the prior record (corresponding index for batch input).
23
+ - `context.new_data`: the current record.
24
+ - `context.user`: `ctx.state.user`, when present.
25
+ - `context.setting`: `read` or `write`.
26
+ - `context.ctx`: the request context, when present.
27
+ - `context._`: Lodash.
28
+ - `context.moment`: Moment.
29
+ - `context.spice`: the server runtime, including registered models, schemas, and services.
30
+ - `context._util`: the workflow-style helper object `{ _, moment, user, spice }`.
31
+
32
+ During an update, `old_data` is the stored record before changes and `new_data`
33
+ is the current record after submitted changes have been merged. The first
34
+ `record` argument and `new_data` are the same object. During create,
35
+ `old_data` is empty. A default function runs only when its own property is
36
+ undefined; use a hook or modifier when a field must be recomputed on every update.
37
+
38
+ Invalid function source or execution errors propagate through serialization, preventing function text from being persisted as a value. Functions are trusted schema code, with the same server privileges as other schema logic.
39
+
40
+ Native JavaScript schema functions retain the existing batch contract: `fn({ old_data: currentRecords, new_data: priorData })`. These historical argument names are preserved for compatibility. Native functions are evaluated before defaults are merged, and async results are now awaited.
41
+
42
+ Frontend Read Default functions also run in new-record forms. Use synchronous functions and the shared `(record, { user, setting, _, moment, _util })` arguments for those. `spice`, `ctx`, `old_data`, and `new_data` are server-only.
43
+
44
+ Run `npm test -- --runInBand tests/utility/propertyDefaults.test.js` for serialization integration and default-function regressions. The application must consume this updated Spice.js version for editor-authored server function defaults to work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spice-js",
3
- "version": "2.7.36",
3
+ "version": "2.7.39",
4
4
  "description": "spice",
5
5
  "main": "build/index.js",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -166,6 +166,10 @@ export default class Spice {
166
166
  return spice;
167
167
  } catch (e) {
168
168
  console.log(e.stack);
169
+ // A failed init leaves no usable application (no config, no database,
170
+ // no routes). Surface it so the host process can exit instead of
171
+ // listening on a port with nothing behind it.
172
+ throw e;
169
173
  }
170
174
  }
171
175
  }
@@ -26,11 +26,22 @@ export async function load() {
26
26
  console.log('APP Bootstrap directory', app_bootstrap_path); */
27
27
  console.log("Loading Ordered Files", ordered_modules);
28
28
  for (let file of ordered_modules) {
29
- let mod = require(path.join(bootstrap_path, file));
30
- if (_.isFunction(mod)) {
31
- await mod();
29
+ try {
30
+ let mod = require(path.join(bootstrap_path, file));
31
+ if (_.isFunction(mod)) {
32
+ await mod();
33
+ }
34
+ console.log("loaded", file);
35
+ } catch (e) {
36
+ // The ordered modules are the foundation: database, models, logger.
37
+ // Nothing loaded after them can work without them, so swallowing the
38
+ // error here produced a half-booted process (no routes, no models,
39
+ // no retry) that still looked alive. Mark it and let init() rethrow
40
+ // so the host application can exit and be restarted.
41
+ console.log(`Failed to load core module "${file}"`);
42
+ e.spice_core_module = file;
43
+ throw e;
32
44
  }
33
- console.log("loaded", file);
34
45
  }
35
46
 
36
47
  let paths = [bootstrap_path, app_bootstrap_path];
@@ -60,6 +71,9 @@ export async function load() {
60
71
  }
61
72
  } catch (e) {
62
73
  console.log(e.stack);
74
+ if (e.spice_core_module) {
75
+ throw e;
76
+ }
63
77
  }
64
78
  }
65
79
  //load();
@@ -6,6 +6,7 @@ var regeneratorRuntime = require("regenerator-runtime");
6
6
  import ResourceLifecycleTriggered from "../events/events/ResourceLifecycleTriggered";
7
7
  import { hasSQLInjection } from "../utility/Security";
8
8
  import { fixCollection } from "../utility/fix";
9
+ import { applyPropertyDefaults } from "../utility/propertyDefaults";
9
10
 
10
11
  var SDate = require("sonover-date"),
11
12
  UUID = require("uuid"),
@@ -1024,7 +1025,7 @@ export default class SpiceModel {
1024
1025
  cover_obj.new = await this.do_serialize(
1025
1026
  this,
1026
1027
  "write",
1027
- cover_obj.new,
1028
+ cover_obj.old,
1028
1029
  args,
1029
1030
  );
1030
1031
  }
@@ -2567,20 +2568,7 @@ export default class SpiceModel {
2567
2568
  data = [data];
2568
2569
  }
2569
2570
 
2570
- // Compute the defaults from properties using reduce.
2571
- const defaults = Object.keys(this.props).reduce((acc, key) => {
2572
- const def = this.props[key]?.defaults?.[type];
2573
- if (def !== undefined) {
2574
- acc[key] =
2575
- _.isFunction(def) ?
2576
- def({ old_data: data, new_data: old_data })
2577
- : def;
2578
- }
2579
- return acc;
2580
- }, {});
2581
-
2582
- // Merge defaults into each object.
2583
- data = data.map((item) => _.defaults(item, defaults));
2571
+ data = await applyPropertyDefaults(data, this.props, type, old_data, this[_ctx]);
2584
2572
 
2585
2573
  // If type is "read", clean the data by omitting certain props.
2586
2574
  if (type === "read") {
@@ -0,0 +1,86 @@
1
+ import _ from "lodash";
2
+ import SonoverDate from "sonover-date";
3
+
4
+ // sonover-date is Spice.js' declared date dependency and exposes its Moment instance.
5
+ const moment = new SonoverDate().moment;
6
+
7
+ export function createPropertyDefaultFunctionContext({
8
+ record,
9
+ oldData,
10
+ user,
11
+ setting,
12
+ ctx,
13
+ } = {}) {
14
+ const runtime = global.spice;
15
+ const _util = { moment, _, user, spice: runtime };
16
+ return {
17
+ old_data: oldData,
18
+ new_data: record,
19
+ user,
20
+ setting,
21
+ ctx,
22
+ moment,
23
+ _,
24
+ spice: runtime,
25
+ _util,
26
+ };
27
+ }
28
+
29
+ /** Apply schema defaults after mapping, without replacing supplied values. */
30
+ export async function applyPropertyDefaults(data, props, type, oldData, ctx) {
31
+ // Compute native defaults before merging, as the original serializer did.
32
+ const nativeDefaults = new Map();
33
+ for (const [key, property] of Object.entries(props)) {
34
+ const value = property?.defaults?.[type];
35
+ if (typeof value === "function") {
36
+ nativeDefaults.set(key, await value({ old_data: data, new_data: oldData }));
37
+ }
38
+ }
39
+ // Match lodash.defaults' handling of null/undefined records in the previous serializer.
40
+ data = data.map((item) => _.defaults(item));
41
+ for (const [key, property] of Object.entries(props)) {
42
+ const value = property?.defaults?.[type];
43
+ if (value === undefined) continue;
44
+
45
+ // Native schema functions retain their existing batch argument contract.
46
+ if (typeof value === "function") {
47
+ const result = nativeDefaults.get(key);
48
+ for (const item of data) {
49
+ if (item[key] === undefined) item[key] = _.cloneDeep(result);
50
+ }
51
+ continue;
52
+ }
53
+
54
+ // Only explicitly configured functions are executable. Literal strings stay literal.
55
+ if (property[`default_type_${type}`] === "function") {
56
+ const missing = data.some((item) => item[key] === undefined);
57
+ if (!missing) continue;
58
+ if (typeof value !== "string") {
59
+ throw new TypeError(`Default function for ${key} must be a function string.`);
60
+ }
61
+ const fn = eval(`(${value})`);
62
+ if (typeof fn !== "function") {
63
+ throw new TypeError(`Default function for ${key} must evaluate to a function.`);
64
+ }
65
+ for (const [index, item] of data.entries()) {
66
+ if (item[key] !== undefined) continue;
67
+ item[key] = await fn(
68
+ item,
69
+ createPropertyDefaultFunctionContext({
70
+ record: item,
71
+ oldData: Array.isArray(oldData) ? oldData[index] : oldData,
72
+ user: ctx?.state?.user,
73
+ setting: type,
74
+ ctx,
75
+ }),
76
+ );
77
+ }
78
+ continue;
79
+ }
80
+
81
+ for (const item of data) {
82
+ if (item[key] === undefined) item[key] = _.cloneDeep(value);
83
+ }
84
+ }
85
+ return data;
86
+ }
@@ -0,0 +1,157 @@
1
+ const {
2
+ applyPropertyDefaults,
3
+ createPropertyDefaultFunctionContext,
4
+ } = require('../../src/utility/propertyDefaults');
5
+ const { createTestModel, seedDatabase } = require('../helpers/modelFactory');
6
+
7
+ describe('property defaults', () => {
8
+ test('retains serialization of empty records', async () => {
9
+ const result = await applyPropertyDefaults([null, undefined], {
10
+ stage: { defaults: { write: 'review' } },
11
+ }, 'write');
12
+ expect(result).toEqual([{ stage: 'review' }, { stage: 'review' }]);
13
+ });
14
+
15
+ test('direct lookup values and falsy defaults survive write serialization', async () => {
16
+ const model = createTestModel({ props: {
17
+ stage: { type: 'string', default_type_write: 'property_value', defaults: { write: 'stage-review' } },
18
+ count: { type: 'number', defaults: { write: 0 } },
19
+ enabled: { type: 'boolean', defaults: { write: false } },
20
+ text: { type: 'string', defaults: { write: '' } },
21
+ } });
22
+ const result = await model.do_serialize({}, 'write', {}, {});
23
+ expect(result).toEqual({ stage: 'stage-review', count: 0, enabled: false, text: '' });
24
+ });
25
+
26
+ test.each(['read', 'write'])('evaluates saved %s function strings per missing record', async (type) => {
27
+ const model = createTestModel({ props: {
28
+ stage: { type: 'string', [`default_type_${type}`]: 'function', defaults: {
29
+ [type]: 'async (record) => record.urgent ? "priority" : "review"',
30
+ } },
31
+ } });
32
+ const result = await model.do_serialize([
33
+ { urgent: true }, { urgent: false }, { stage: 'done' }, { stage: null },
34
+ ], type, {}, {});
35
+ expect(result.map((record) => record.stage)).toEqual(['priority', 'review', 'done', null]);
36
+ });
37
+
38
+ test('passes record, old data and context to configured write functions', async () => {
39
+ const oldData = [{ stage: 'old-a' }, { stage: 'old-b' }];
40
+ const props = { stage: { default_type_write: 'function', defaults: {
41
+ write: '(record, { old_data, user, setting }) => `${old_data.stage}-${user.id}-${setting}`',
42
+ } } };
43
+ const result = await applyPropertyDefaults([{}, {}], props, 'write', oldData, { state: { user: { id: 'u1' } } });
44
+ expect(result.map((record) => record.stage)).toEqual(['old-a-u1-write', 'old-b-u1-write']);
45
+ });
46
+
47
+ test('update functions receive the stored record as old_data and merged changes as new_data', async () => {
48
+ const model = createTestModel({
49
+ type: 'ticket',
50
+ args: { status: 'approved' },
51
+ props: {
52
+ status: { type: 'string' },
53
+ status_change: {
54
+ type: 'object',
55
+ default_type_write: 'function',
56
+ defaults: {
57
+ write: `(record, { old_data, new_data }) => ({
58
+ from: old_data.status,
59
+ to: new_data.status,
60
+ sameNewRecord: record === new_data,
61
+ })`,
62
+ },
63
+ },
64
+ },
65
+ });
66
+ seedDatabase(model, { id: 'ticket-1', status: 'pending' });
67
+ jest.spyOn(model, 'setMonitor').mockImplementation(() => {});
68
+
69
+ const result = await model.update({
70
+ id: 'ticket-1',
71
+ skip_hooks: true,
72
+ skip_read_serialize: true,
73
+ });
74
+
75
+ expect(result.status_change).toEqual({
76
+ from: 'pending',
77
+ to: 'approved',
78
+ sameNewRecord: true,
79
+ });
80
+ });
81
+
82
+ test('provides lodash, moment and the workflow-style utility context', async () => {
83
+ const props = { due: { default_type_write: 'function', defaults: {
84
+ write: `(record, { _, moment, _util, spice }) => ({
85
+ owner: _.get(record, "owner.id"),
86
+ date: moment("2026-09-06").add(2, "days").format("YYYY-MM-DD"),
87
+ sameLodash: _util._ === _,
88
+ sameMoment: _util.moment === moment,
89
+ user: _util.user.id,
90
+ sameSpice: _util.spice === spice,
91
+ })`,
92
+ } } };
93
+ const result = await applyPropertyDefaults(
94
+ [{ owner: { id: 'u2' } }],
95
+ props,
96
+ 'write',
97
+ {},
98
+ { state: { user: { id: 'u1' } } },
99
+ );
100
+ expect(result[0].due).toEqual({
101
+ owner: 'u2',
102
+ date: '2026-09-08',
103
+ sameLodash: true,
104
+ sameMoment: true,
105
+ user: 'u1',
106
+ sameSpice: true,
107
+ });
108
+ });
109
+
110
+ test('exposes direct and _util helper aliases from the same context', () => {
111
+ const context = createPropertyDefaultFunctionContext({ user: { id: 'u1' } });
112
+ expect(context._util._).toBe(context._);
113
+ expect(context._util.moment).toBe(context.moment);
114
+ expect(context._util.user).toBe(context.user);
115
+ expect(context._util.spice).toBe(context.spice);
116
+ });
117
+
118
+ test('never executes literal strings that look like functions', async () => {
119
+ const code = '() => { throw new Error("must not run"); }';
120
+ const result = await applyPropertyDefaults([{}], { stage: { defaults: { write: code } } }, 'write');
121
+ expect(result[0].stage).toBe(code);
122
+ });
123
+
124
+ test('retains the native schema function batch contract', async () => {
125
+ const records = [{}, { stage: false }];
126
+ const oldData = { previous: true };
127
+ const native = jest.fn(async () => 'review');
128
+ await applyPropertyDefaults(records, { stage: { defaults: { write: native } } }, 'write', oldData);
129
+ expect(native).toHaveBeenCalledTimes(1);
130
+ expect(native).toHaveBeenCalledWith({ old_data: records, new_data: oldData });
131
+ expect(records).toEqual([{ stage: 'review' }, { stage: false }]);
132
+ });
133
+
134
+ test('does not run configured functions when the value is supplied', async () => {
135
+ const records = [{ stage: '' }, { stage: 0 }, { stage: false }, { stage: null }];
136
+ await applyPropertyDefaults(records, { stage: { default_type_write: 'function', defaults: {
137
+ write: '() => { throw new Error("must not run"); }',
138
+ } } }, 'write');
139
+ expect(records.map((record) => record.stage)).toEqual(['', 0, false, null]);
140
+ });
141
+
142
+ test('rejects invalid functions instead of saving their source as a default', async () => {
143
+ for (const write of ['not valid javascript', '42', { code: '' }]) {
144
+ await expect(applyPropertyDefaults([{}], { stage: {
145
+ default_type_write: 'function', defaults: { write },
146
+ } }, 'write')).rejects.toThrow();
147
+ }
148
+ });
149
+
150
+ test('clones array and object defaults for each record', async () => {
151
+ const value = { data: [] };
152
+ const result = await applyPropertyDefaults([{}, {}], { details: { defaults: { write: value } } }, 'write');
153
+ result[0].details.data.push('one');
154
+ expect(result[1].details).toEqual({ data: [] });
155
+ expect(value).toEqual({ data: [] });
156
+ });
157
+ });