spice-js 2.7.31 → 2.7.33
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.
|
@@ -32,7 +32,8 @@ var SDate = require("sonover-date"),
|
|
|
32
32
|
_mapping_concurrency = Symbol(),
|
|
33
33
|
_skip_mapped_serialization = Symbol(),
|
|
34
34
|
_mapping_dept_exempt = Symbol(),
|
|
35
|
-
_current_path = Symbol()
|
|
35
|
+
_current_path = Symbol(),
|
|
36
|
+
_failed_json_fields = Symbol();
|
|
36
37
|
var GLOBAL_MAPPING_CONCURRENCY = Math.max(1, Number(process.env.SPICE_MAPPING_GLOBAL_CONCURRENCY) || 32);
|
|
37
38
|
var activeGlobalMappings = 0;
|
|
38
39
|
var globalMappingWaiters = [];
|
|
@@ -71,7 +72,7 @@ function _acquireGlobalMappingSlot() {
|
|
|
71
72
|
}
|
|
72
73
|
function runWithConcurrency(_x, _x2, _x3) {
|
|
73
74
|
return _runWithConcurrency.apply(this, arguments);
|
|
74
|
-
}
|
|
75
|
+
}
|
|
75
76
|
function _runWithConcurrency() {
|
|
76
77
|
_runWithConcurrency = _asyncToGenerator(function* (items, concurrency, worker) {
|
|
77
78
|
var results = new Array(items.length);
|
|
@@ -97,6 +98,19 @@ function _runWithConcurrency() {
|
|
|
97
98
|
});
|
|
98
99
|
return _runWithConcurrency.apply(this, arguments);
|
|
99
100
|
}
|
|
101
|
+
var JSON_PARSE_FAILED = Symbol("json_parse_failed");
|
|
102
|
+
function safeParseJsonField(value, fieldName, modelType) {
|
|
103
|
+
if (value == null || value === "") return undefined;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(value);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.warn("SpiceModel(" + modelType + "): failed to parse field \"" + fieldName + "\" as JSON: " + e.message);
|
|
108
|
+
return JSON_PARSE_FAILED;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
//const _type = Symbol("type");
|
|
113
|
+
|
|
100
114
|
var that;
|
|
101
115
|
if (!Promise.allSettled) {
|
|
102
116
|
Promise.allSettled = promises => Promise.all(promises.map((promise, i) => promise.then(value => ({
|
|
@@ -135,6 +149,7 @@ class SpiceModel {
|
|
|
135
149
|
this[_skip_cache] = ((_args4 = args) == null ? void 0 : (_args4$args = _args4.args) == null ? void 0 : _args4$args.skip_cache) || false;
|
|
136
150
|
this[_level] = ((_args5 = args) == null ? void 0 : (_args5$args = _args5.args) == null ? void 0 : _args5$args._level) || 0;
|
|
137
151
|
this[_current_path] = ((_args6 = args) == null ? void 0 : (_args6$args = _args6.args) == null ? void 0 : _args6$args._current_path) || "";
|
|
152
|
+
this[_failed_json_fields] = [];
|
|
138
153
|
this[_columns] = ((_args7 = args) == null ? void 0 : (_args7$args = _args7.args) == null ? void 0 : _args7$args._columns) || "";
|
|
139
154
|
this[_hooks] = {
|
|
140
155
|
create: {
|
|
@@ -258,13 +273,33 @@ class SpiceModel {
|
|
|
258
273
|
case Array:
|
|
259
274
|
case "array":
|
|
260
275
|
{
|
|
261
|
-
|
|
276
|
+
if (_.isArray(args.args[i])) {
|
|
277
|
+
this[i] = args.args[i];
|
|
278
|
+
} else {
|
|
279
|
+
var parsed = safeParseJsonField(args.args[i], i, args.collection || this.type);
|
|
280
|
+
if (parsed === JSON_PARSE_FAILED) {
|
|
281
|
+
this[_failed_json_fields].push(i);
|
|
282
|
+
this[i] = [];
|
|
283
|
+
} else if (parsed !== undefined) {
|
|
284
|
+
this[i] = parsed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
262
287
|
break;
|
|
263
288
|
}
|
|
264
289
|
case Object:
|
|
265
290
|
case "object":
|
|
266
291
|
{
|
|
267
|
-
|
|
292
|
+
if (_.isObject(args.args[i])) {
|
|
293
|
+
this[i] = args.args[i];
|
|
294
|
+
} else {
|
|
295
|
+
var _parsed = safeParseJsonField(args.args[i], i, args.collection || this.type);
|
|
296
|
+
if (_parsed === JSON_PARSE_FAILED) {
|
|
297
|
+
this[_failed_json_fields].push(i);
|
|
298
|
+
this[i] = {};
|
|
299
|
+
} else if (_parsed !== undefined) {
|
|
300
|
+
this[i] = _parsed;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
268
303
|
break;
|
|
269
304
|
}
|
|
270
305
|
default:
|
|
@@ -712,17 +747,44 @@ class SpiceModel {
|
|
|
712
747
|
}
|
|
713
748
|
})();
|
|
714
749
|
}
|
|
715
|
-
query(query, scope) {
|
|
750
|
+
query(query, scope, options) {
|
|
716
751
|
var _this5 = this;
|
|
717
752
|
return _asyncToGenerator(function* () {
|
|
718
753
|
try {
|
|
719
|
-
var results = yield _this5.database.query(query, scope);
|
|
754
|
+
var results = yield _this5.database.query(query, scope, options);
|
|
720
755
|
return results;
|
|
721
756
|
} catch (error) {
|
|
722
757
|
throw error;
|
|
723
758
|
}
|
|
724
759
|
})();
|
|
725
760
|
}
|
|
761
|
+
getQueryOptions(args) {
|
|
762
|
+
if (args === void 0) {
|
|
763
|
+
args = {};
|
|
764
|
+
}
|
|
765
|
+
var scanConsistency = args.scan_consistency;
|
|
766
|
+
|
|
767
|
+
// Backward compatibility for callers that already use statement_consistent.
|
|
768
|
+
// The adapter accepts the SDK-style value as well as these snake_case keys.
|
|
769
|
+
if (scanConsistency === undefined && args.statement_consistent !== undefined) {
|
|
770
|
+
var legacyValue = args.statement_consistent;
|
|
771
|
+
var isRequestPlus = legacyValue === true || typeof legacyValue === "string" && ["true", "request_plus"].includes(legacyValue.toLowerCase());
|
|
772
|
+
scanConsistency = isRequestPlus ? "request_plus" : "not_bounded";
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
scan_consistency: scanConsistency,
|
|
776
|
+
consistent_with: args.consistent_with,
|
|
777
|
+
scan_wait: args.scan_wait
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
requiresFreshQuery(args) {
|
|
781
|
+
if (args === void 0) {
|
|
782
|
+
args = {};
|
|
783
|
+
}
|
|
784
|
+
var options = this.getQueryOptions(args);
|
|
785
|
+
var scanConsistency = options.scan_consistency;
|
|
786
|
+
return options.consistent_with !== undefined || scanConsistency === true || typeof scanConsistency === "string" && ["true", "request_plus"].includes(scanConsistency.toLowerCase());
|
|
787
|
+
}
|
|
726
788
|
getMulti(args) {
|
|
727
789
|
var _this6 = this;
|
|
728
790
|
return _asyncToGenerator(function* () {
|
|
@@ -821,6 +883,21 @@ class SpiceModel {
|
|
|
821
883
|
}
|
|
822
884
|
})();
|
|
823
885
|
}
|
|
886
|
+
get failedJsonFields() {
|
|
887
|
+
return this[_failed_json_fields] || [];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Refuse writes when a schema-typed object/array field contained invalid
|
|
891
|
+
// JSON, so a parse fallback can never overwrite valid stored data.
|
|
892
|
+
assertNoFailedJsonFields(operation) {
|
|
893
|
+
var failed = this[_failed_json_fields] || [];
|
|
894
|
+
if (failed.length > 0) {
|
|
895
|
+
var err = new Error(this.type + ": cannot " + operation + " because field(s) [" + failed.join(", ") + "] contained invalid JSON. Stored data was left untouched.");
|
|
896
|
+
err.status = 400;
|
|
897
|
+
err.failed_json_fields = [...failed];
|
|
898
|
+
throw err;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
824
901
|
update(args) {
|
|
825
902
|
var _this8 = this;
|
|
826
903
|
return _asyncToGenerator(function* () {
|
|
@@ -829,6 +906,7 @@ class SpiceModel {
|
|
|
829
906
|
var p = (_this8$_ctx = _this8[_ctx]) == null ? void 0 : _this8$_ctx.profiler;
|
|
830
907
|
var doUpdate = /*#__PURE__*/function () {
|
|
831
908
|
var _ref1 = _asyncToGenerator(function* () {
|
|
909
|
+
_this8.assertNoFailedJsonFields("update");
|
|
832
910
|
_this8.updated_at = new SDate().now();
|
|
833
911
|
var results = yield _this8.database.get(args.id);
|
|
834
912
|
var item_exist = yield _this8.exist(results);
|
|
@@ -899,6 +977,7 @@ class SpiceModel {
|
|
|
899
977
|
var p = (_this9$_ctx = _this9[_ctx]) == null ? void 0 : _this9$_ctx.profiler;
|
|
900
978
|
var doCreate = /*#__PURE__*/function () {
|
|
901
979
|
var _ref11 = _asyncToGenerator(function* () {
|
|
980
|
+
_this9.assertNoFailedJsonFields("create");
|
|
902
981
|
var form;
|
|
903
982
|
_this9.created_at = new SDate().now();
|
|
904
983
|
args = _.defaults(args, {
|
|
@@ -1409,9 +1488,9 @@ class SpiceModel {
|
|
|
1409
1488
|
if (args.skip_hooks !== true) {
|
|
1410
1489
|
yield _this10.run_hook(_this10, "list", "before");
|
|
1411
1490
|
}
|
|
1412
|
-
var cacheKey = "list::" + _this10.type + "::" + args._join + "::" + query + "::" + args.limit + "::" + args.offset + "::" + args.sort + "::" + args.do_count + "::" + args.statement_consistent + "::" + args.columns + "::" + args.is_full_text + "::" + args.is_custom_query;
|
|
1491
|
+
var cacheKey = "list::" + _this10.type + "::" + args._join + "::" + query + "::" + args.limit + "::" + args.offset + "::" + args.sort + "::" + args.do_count + "::" + args.query_scope + "::" + args.scan_consistency + "::" + args.statement_consistent + "::" + args.scan_wait + "::" + args.columns + "::" + args.is_full_text + "::" + args.is_custom_query;
|
|
1413
1492
|
var results;
|
|
1414
|
-
if (_this10.shouldUseCache(_this10.type)) {
|
|
1493
|
+
if (_this10.shouldUseCache(_this10.type) && !_this10.requiresFreshQuery(args)) {
|
|
1415
1494
|
var cached = yield _this10.getCacheProviderObject(_this10.type).get(cacheKey);
|
|
1416
1495
|
results = cached == null ? void 0 : cached.value;
|
|
1417
1496
|
var isEmpty = (cached == null ? void 0 : cached.value) === undefined;
|
|
@@ -1494,14 +1573,15 @@ class SpiceModel {
|
|
|
1494
1573
|
var p = (_this11$_ctx = _this11[_ctx]) == null ? void 0 : _this11$_ctx.profiler;
|
|
1495
1574
|
var doFetch = /*#__PURE__*/function () {
|
|
1496
1575
|
var _ref17 = _asyncToGenerator(function* () {
|
|
1576
|
+
var queryOptions = _this11.getQueryOptions(args);
|
|
1497
1577
|
if (args.is_custom_query === "true" && args.ids.length > 0) {
|
|
1498
1578
|
console.log("Fetching results from database", _this11.type, args, query, mappedNestings);
|
|
1499
|
-
return yield _this11.database.query(query);
|
|
1579
|
+
return yield _this11.database.query(query, args.query_scope, queryOptions);
|
|
1500
1580
|
} else if (args.is_full_text === "true") {
|
|
1501
1581
|
return yield _this11.database.full_text_search(_this11.type, query || "", args.limit, args.offset, args._join);
|
|
1502
1582
|
} else {
|
|
1503
1583
|
var correctedColumns = _this11.correctColumns(args.columns, mappedNestings);
|
|
1504
|
-
var result = yield _this11.database.search(_this11.type, correctedColumns || args.columns || "", query || "", args.limit, args.offset, args.sort, args.do_count, args.
|
|
1584
|
+
var result = yield _this11.database.search(_this11.type, correctedColumns || args.columns || "", query || "", args.limit, args.offset, args.sort, args.do_count, args.query_scope, args._join, queryOptions);
|
|
1505
1585
|
return result;
|
|
1506
1586
|
}
|
|
1507
1587
|
});
|
package/package.json
CHANGED
package/src/models/SpiceModel.js
CHANGED
|
@@ -27,7 +27,8 @@ var SDate = require("sonover-date"),
|
|
|
27
27
|
_mapping_concurrency = Symbol(),
|
|
28
28
|
_skip_mapped_serialization = Symbol(),
|
|
29
29
|
_mapping_dept_exempt = Symbol(),
|
|
30
|
-
_current_path = Symbol()
|
|
30
|
+
_current_path = Symbol(),
|
|
31
|
+
_failed_json_fields = Symbol();
|
|
31
32
|
|
|
32
33
|
const GLOBAL_MAPPING_CONCURRENCY = Math.max(
|
|
33
34
|
1,
|
|
@@ -82,6 +83,20 @@ async function runWithConcurrency(items, concurrency, worker) {
|
|
|
82
83
|
return results;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
const JSON_PARSE_FAILED = Symbol("json_parse_failed");
|
|
87
|
+
|
|
88
|
+
function safeParseJsonField(value, fieldName, modelType) {
|
|
89
|
+
if (value == null || value === "") return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(value);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`SpiceModel(${modelType}): failed to parse field "${fieldName}" as JSON: ${e.message}`,
|
|
95
|
+
);
|
|
96
|
+
return JSON_PARSE_FAILED;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
85
100
|
//const _type = Symbol("type");
|
|
86
101
|
|
|
87
102
|
let that;
|
|
@@ -144,6 +159,7 @@ export default class SpiceModel {
|
|
|
144
159
|
this[_skip_cache] = args?.args?.skip_cache || false;
|
|
145
160
|
this[_level] = args?.args?._level || 0;
|
|
146
161
|
this[_current_path] = args?.args?._current_path || "";
|
|
162
|
+
this[_failed_json_fields] = [];
|
|
147
163
|
this[_columns] = args?.args?._columns || "";
|
|
148
164
|
this[_hooks] = {
|
|
149
165
|
create: {
|
|
@@ -289,18 +305,40 @@ export default class SpiceModel {
|
|
|
289
305
|
}
|
|
290
306
|
case Array:
|
|
291
307
|
case "array": {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
308
|
+
if (_.isArray(args.args[i])) {
|
|
309
|
+
this[i] = args.args[i];
|
|
310
|
+
} else {
|
|
311
|
+
const parsed = safeParseJsonField(
|
|
312
|
+
args.args[i],
|
|
313
|
+
i,
|
|
314
|
+
args.collection || this.type,
|
|
315
|
+
);
|
|
316
|
+
if (parsed === JSON_PARSE_FAILED) {
|
|
317
|
+
this[_failed_json_fields].push(i);
|
|
318
|
+
this[i] = [];
|
|
319
|
+
} else if (parsed !== undefined) {
|
|
320
|
+
this[i] = parsed;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
296
323
|
break;
|
|
297
324
|
}
|
|
298
325
|
case Object:
|
|
299
326
|
case "object": {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
327
|
+
if (_.isObject(args.args[i])) {
|
|
328
|
+
this[i] = args.args[i];
|
|
329
|
+
} else {
|
|
330
|
+
const parsed = safeParseJsonField(
|
|
331
|
+
args.args[i],
|
|
332
|
+
i,
|
|
333
|
+
args.collection || this.type,
|
|
334
|
+
);
|
|
335
|
+
if (parsed === JSON_PARSE_FAILED) {
|
|
336
|
+
this[_failed_json_fields].push(i);
|
|
337
|
+
this[i] = {};
|
|
338
|
+
} else if (parsed !== undefined) {
|
|
339
|
+
this[i] = parsed;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
304
342
|
break;
|
|
305
343
|
}
|
|
306
344
|
default: {
|
|
@@ -783,15 +821,47 @@ export default class SpiceModel {
|
|
|
783
821
|
}
|
|
784
822
|
}
|
|
785
823
|
|
|
786
|
-
async query(query, scope) {
|
|
824
|
+
async query(query, scope, options) {
|
|
787
825
|
try {
|
|
788
|
-
let results = await this.database.query(query, scope);
|
|
826
|
+
let results = await this.database.query(query, scope, options);
|
|
789
827
|
return results;
|
|
790
828
|
} catch (error) {
|
|
791
829
|
throw error;
|
|
792
830
|
}
|
|
793
831
|
}
|
|
794
832
|
|
|
833
|
+
getQueryOptions(args = {}) {
|
|
834
|
+
let scanConsistency = args.scan_consistency;
|
|
835
|
+
|
|
836
|
+
// Backward compatibility for callers that already use statement_consistent.
|
|
837
|
+
// The adapter accepts the SDK-style value as well as these snake_case keys.
|
|
838
|
+
if (scanConsistency === undefined && args.statement_consistent !== undefined) {
|
|
839
|
+
const legacyValue = args.statement_consistent;
|
|
840
|
+
const isRequestPlus =
|
|
841
|
+
legacyValue === true ||
|
|
842
|
+
(typeof legacyValue === "string" &&
|
|
843
|
+
["true", "request_plus"].includes(legacyValue.toLowerCase()));
|
|
844
|
+
scanConsistency = isRequestPlus ? "request_plus" : "not_bounded";
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
scan_consistency: scanConsistency,
|
|
849
|
+
consistent_with: args.consistent_with,
|
|
850
|
+
scan_wait: args.scan_wait,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
requiresFreshQuery(args = {}) {
|
|
855
|
+
const options = this.getQueryOptions(args);
|
|
856
|
+
const scanConsistency = options.scan_consistency;
|
|
857
|
+
return (
|
|
858
|
+
options.consistent_with !== undefined ||
|
|
859
|
+
scanConsistency === true ||
|
|
860
|
+
(typeof scanConsistency === "string" &&
|
|
861
|
+
["true", "request_plus"].includes(scanConsistency.toLowerCase()))
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
795
865
|
async getMulti(args) {
|
|
796
866
|
// ⚡ Profiling: use track() for proper async context forking
|
|
797
867
|
const p = this[_ctx]?.profiler;
|
|
@@ -909,11 +979,32 @@ export default class SpiceModel {
|
|
|
909
979
|
}
|
|
910
980
|
}
|
|
911
981
|
|
|
982
|
+
get failedJsonFields() {
|
|
983
|
+
return this[_failed_json_fields] || [];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Refuse writes when a schema-typed object/array field contained invalid
|
|
987
|
+
// JSON, so a parse fallback can never overwrite valid stored data.
|
|
988
|
+
assertNoFailedJsonFields(operation) {
|
|
989
|
+
const failed = this[_failed_json_fields] || [];
|
|
990
|
+
if (failed.length > 0) {
|
|
991
|
+
const err = new Error(
|
|
992
|
+
`${this.type}: cannot ${operation} because field(s) [${failed.join(
|
|
993
|
+
", ",
|
|
994
|
+
)}] contained invalid JSON. Stored data was left untouched.`,
|
|
995
|
+
);
|
|
996
|
+
err.status = 400;
|
|
997
|
+
err.failed_json_fields = [...failed];
|
|
998
|
+
throw err;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
912
1002
|
async update(args) {
|
|
913
1003
|
// Profiling: use track() for proper async context forking
|
|
914
1004
|
const p = this[_ctx]?.profiler;
|
|
915
1005
|
|
|
916
1006
|
const doUpdate = async () => {
|
|
1007
|
+
this.assertNoFailedJsonFields("update");
|
|
917
1008
|
this.updated_at = new SDate().now();
|
|
918
1009
|
let results = await this.database.get(args.id);
|
|
919
1010
|
let item_exist = await this.exist(results);
|
|
@@ -993,6 +1084,7 @@ export default class SpiceModel {
|
|
|
993
1084
|
const p = this[_ctx]?.profiler;
|
|
994
1085
|
|
|
995
1086
|
const doCreate = async () => {
|
|
1087
|
+
this.assertNoFailedJsonFields("create");
|
|
996
1088
|
let form;
|
|
997
1089
|
this.created_at = new SDate().now();
|
|
998
1090
|
args = _.defaults(args, { id_prefix: this.type });
|
|
@@ -1569,10 +1661,10 @@ export default class SpiceModel {
|
|
|
1569
1661
|
await this.run_hook(this, "list", "before");
|
|
1570
1662
|
}
|
|
1571
1663
|
|
|
1572
|
-
const cacheKey = `list::${this.type}::${args._join}::${query}::${args.limit}::${args.offset}::${args.sort}::${args.do_count}::${args.statement_consistent}::${args.columns}::${args.is_full_text}::${args.is_custom_query}`;
|
|
1664
|
+
const cacheKey = `list::${this.type}::${args._join}::${query}::${args.limit}::${args.offset}::${args.sort}::${args.do_count}::${args.query_scope}::${args.scan_consistency}::${args.statement_consistent}::${args.scan_wait}::${args.columns}::${args.is_full_text}::${args.is_custom_query}`;
|
|
1573
1665
|
let results;
|
|
1574
1666
|
|
|
1575
|
-
if (this.shouldUseCache(this.type)) {
|
|
1667
|
+
if (this.shouldUseCache(this.type) && !this.requiresFreshQuery(args)) {
|
|
1576
1668
|
const cached = await this.getCacheProviderObject(this.type).get(
|
|
1577
1669
|
cacheKey,
|
|
1578
1670
|
);
|
|
@@ -1671,6 +1763,7 @@ export default class SpiceModel {
|
|
|
1671
1763
|
const p = this[_ctx]?.profiler;
|
|
1672
1764
|
|
|
1673
1765
|
const doFetch = async () => {
|
|
1766
|
+
const queryOptions = this.getQueryOptions(args);
|
|
1674
1767
|
if (args.is_custom_query === "true" && args.ids.length > 0) {
|
|
1675
1768
|
console.log(
|
|
1676
1769
|
"Fetching results from database",
|
|
@@ -1679,7 +1772,7 @@ export default class SpiceModel {
|
|
|
1679
1772
|
query,
|
|
1680
1773
|
mappedNestings,
|
|
1681
1774
|
);
|
|
1682
|
-
return await this.database.query(query);
|
|
1775
|
+
return await this.database.query(query, args.query_scope, queryOptions);
|
|
1683
1776
|
} else if (args.is_full_text === "true") {
|
|
1684
1777
|
return await this.database.full_text_search(
|
|
1685
1778
|
this.type,
|
|
@@ -1701,8 +1794,9 @@ export default class SpiceModel {
|
|
|
1701
1794
|
args.offset,
|
|
1702
1795
|
args.sort,
|
|
1703
1796
|
args.do_count,
|
|
1704
|
-
args.
|
|
1797
|
+
args.query_scope,
|
|
1705
1798
|
args._join,
|
|
1799
|
+
queryOptions,
|
|
1706
1800
|
);
|
|
1707
1801
|
return result;
|
|
1708
1802
|
}
|
|
@@ -44,6 +44,18 @@ class MockDatabase {
|
|
|
44
44
|
return updated;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Mock insert operation
|
|
49
|
+
* @param {string} id - Document ID
|
|
50
|
+
* @param {Object} data - Document data
|
|
51
|
+
* @returns {Promise<Object>} Inserted document
|
|
52
|
+
*/
|
|
53
|
+
async insert(id, data, ttl) {
|
|
54
|
+
const inserted = { ...data, id };
|
|
55
|
+
this.mockData.set(id, inserted);
|
|
56
|
+
return inserted;
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
/**
|
|
48
60
|
* Seed mock database with test data
|
|
49
61
|
* @param {string} id - Document ID
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { createTestModel } = require("../helpers/modelFactory");
|
|
2
|
+
|
|
3
|
+
describe("SpiceModel query consistency", () => {
|
|
4
|
+
test("keeps query scope separate from query consistency", async () => {
|
|
5
|
+
const model = createTestModel({ type: "application" });
|
|
6
|
+
model.database.search = jest.fn(async () => ({ data: [], total: null }));
|
|
7
|
+
|
|
8
|
+
await model.fetchResults(
|
|
9
|
+
{
|
|
10
|
+
ids: [],
|
|
11
|
+
query_scope: "cluster",
|
|
12
|
+
scan_consistency: "request_plus",
|
|
13
|
+
scan_wait: 2500,
|
|
14
|
+
_join: "",
|
|
15
|
+
},
|
|
16
|
+
"",
|
|
17
|
+
[],
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(model.database.search).toHaveBeenCalledWith(
|
|
21
|
+
"application",
|
|
22
|
+
"",
|
|
23
|
+
"",
|
|
24
|
+
undefined,
|
|
25
|
+
undefined,
|
|
26
|
+
undefined,
|
|
27
|
+
undefined,
|
|
28
|
+
"cluster",
|
|
29
|
+
"",
|
|
30
|
+
{
|
|
31
|
+
scan_consistency: "request_plus",
|
|
32
|
+
consistent_with: undefined,
|
|
33
|
+
scan_wait: 2500,
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test.each([true, "true", "request_plus"])(
|
|
39
|
+
"maps legacy statement_consistent=%s to request_plus",
|
|
40
|
+
(legacyValue) => {
|
|
41
|
+
const model = createTestModel({ type: "application" });
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
model.getQueryOptions({ statement_consistent: legacyValue }),
|
|
45
|
+
).toEqual(
|
|
46
|
+
expect.objectContaining({ scan_consistency: "request_plus" }),
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
test.each([false, "false"])(
|
|
52
|
+
"maps legacy statement_consistent=%s to not_bounded",
|
|
53
|
+
(legacyValue) => {
|
|
54
|
+
const model = createTestModel({ type: "application" });
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
model.getQueryOptions({ statement_consistent: legacyValue }),
|
|
58
|
+
).toEqual(
|
|
59
|
+
expect.objectContaining({ scan_consistency: "not_bounded" }),
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
test("explicit scan_consistency takes precedence over the legacy flag", () => {
|
|
65
|
+
const model = createTestModel({ type: "application" });
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
model.getQueryOptions({
|
|
69
|
+
scan_consistency: "not_bounded",
|
|
70
|
+
statement_consistent: true,
|
|
71
|
+
}),
|
|
72
|
+
).toEqual(expect.objectContaining({ scan_consistency: "not_bounded" }));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("strongly consistent reads bypass the model query cache", () => {
|
|
76
|
+
const model = createTestModel({ type: "application" });
|
|
77
|
+
|
|
78
|
+
expect(model.requiresFreshQuery({ scan_consistency: "request_plus" })).toBe(
|
|
79
|
+
true,
|
|
80
|
+
);
|
|
81
|
+
expect(model.requiresFreshQuery({ statement_consistent: true })).toBe(true);
|
|
82
|
+
expect(model.requiresFreshQuery({ consistent_with: {} })).toBe(true);
|
|
83
|
+
expect(model.requiresFreshQuery({ scan_consistency: "not_bounded" })).toBe(
|
|
84
|
+
false,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("custom queries receive the same query options", async () => {
|
|
89
|
+
const model = createTestModel({ type: "application" });
|
|
90
|
+
model.database.query = jest.fn(async () => ({ rows: [] }));
|
|
91
|
+
const mutationState = { token: "test-token" };
|
|
92
|
+
|
|
93
|
+
await model.fetchResults(
|
|
94
|
+
{
|
|
95
|
+
ids: ["application-1"],
|
|
96
|
+
is_custom_query: "true",
|
|
97
|
+
query_scope: "scope",
|
|
98
|
+
consistent_with: mutationState,
|
|
99
|
+
},
|
|
100
|
+
"SELECT 1",
|
|
101
|
+
[],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(model.database.query).toHaveBeenCalledWith(
|
|
105
|
+
"SELECT 1",
|
|
106
|
+
"scope",
|
|
107
|
+
{
|
|
108
|
+
scan_consistency: undefined,
|
|
109
|
+
consistent_with: mutationState,
|
|
110
|
+
scan_wait: undefined,
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -631,3 +631,255 @@ describe('SpiceModel - Critical Fixes for Empty/Null Values', () => {
|
|
|
631
631
|
});
|
|
632
632
|
});
|
|
633
633
|
});
|
|
634
|
+
|
|
635
|
+
describe('SpiceModel - Constructor JSON parse resilience', () => {
|
|
636
|
+
let warnSpy;
|
|
637
|
+
|
|
638
|
+
beforeEach(() => {
|
|
639
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
afterEach(() => {
|
|
643
|
+
warnSpy.mockRestore();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('should fall back to {} for HTML in object-typed field and finish init', () => {
|
|
647
|
+
const modifiersSpy = jest.spyOn(
|
|
648
|
+
SpiceModel.prototype,
|
|
649
|
+
'createModifiersFromProps'
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const model = createTestModel({
|
|
653
|
+
type: 'application',
|
|
654
|
+
props: {
|
|
655
|
+
name: { type: String },
|
|
656
|
+
metadata: { type: Object },
|
|
657
|
+
title: { type: String },
|
|
658
|
+
},
|
|
659
|
+
args: {
|
|
660
|
+
name: 'App One',
|
|
661
|
+
metadata: '<p style="color:red">broken</p>',
|
|
662
|
+
title: 'Still set',
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
expect(model.metadata).toEqual({});
|
|
667
|
+
expect(model.name).toBe('App One');
|
|
668
|
+
expect(model.title).toBe('Still set');
|
|
669
|
+
expect(modifiersSpy).toHaveBeenCalled();
|
|
670
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
671
|
+
expect.stringContaining('failed to parse field "metadata"')
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
modifiersSpy.mockRestore();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('should fall back to [] for invalid JSON in array-typed field', () => {
|
|
678
|
+
const model = createTestModel({
|
|
679
|
+
type: 'addition',
|
|
680
|
+
props: {
|
|
681
|
+
name: { type: String },
|
|
682
|
+
tags: { type: Array },
|
|
683
|
+
},
|
|
684
|
+
args: {
|
|
685
|
+
name: 'Addition One',
|
|
686
|
+
tags: '<p style="">not json</p>',
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(model.tags).toEqual([]);
|
|
691
|
+
expect(model.name).toBe('Addition One');
|
|
692
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
693
|
+
expect.stringContaining('failed to parse field "tags"')
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('should parse valid JSON strings for object and array fields', () => {
|
|
698
|
+
const model = createTestModel({
|
|
699
|
+
type: 'application',
|
|
700
|
+
props: {
|
|
701
|
+
metadata: { type: Object },
|
|
702
|
+
tags: { type: Array },
|
|
703
|
+
},
|
|
704
|
+
args: {
|
|
705
|
+
metadata: '{"foo":"bar"}',
|
|
706
|
+
tags: '["a","b"]',
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
expect(model.metadata).toEqual({ foo: 'bar' });
|
|
711
|
+
expect(model.tags).toEqual(['a', 'b']);
|
|
712
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
713
|
+
expect.stringContaining('failed to parse field')
|
|
714
|
+
);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('should pass through already-parsed object and array values', () => {
|
|
718
|
+
const metadata = { foo: 'bar' };
|
|
719
|
+
const tags = ['a', 'b'];
|
|
720
|
+
|
|
721
|
+
const model = createTestModel({
|
|
722
|
+
type: 'application',
|
|
723
|
+
props: {
|
|
724
|
+
metadata: { type: Object },
|
|
725
|
+
tags: { type: Array },
|
|
726
|
+
},
|
|
727
|
+
args: {
|
|
728
|
+
metadata,
|
|
729
|
+
tags,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
expect(model.metadata).toBe(metadata);
|
|
734
|
+
expect(model.tags).toBe(tags);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('should track failed fields on the model', () => {
|
|
738
|
+
const model = createTestModel({
|
|
739
|
+
type: 'application',
|
|
740
|
+
props: {
|
|
741
|
+
metadata: { type: Object },
|
|
742
|
+
tags: { type: Array },
|
|
743
|
+
},
|
|
744
|
+
args: {
|
|
745
|
+
metadata: '<p>bad</p>',
|
|
746
|
+
tags: 'also bad',
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
expect(model.failedJsonFields.sort()).toEqual(['metadata', 'tags']);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('should have no failed fields when all values parse', () => {
|
|
754
|
+
const model = createTestModel({
|
|
755
|
+
type: 'application',
|
|
756
|
+
props: {
|
|
757
|
+
metadata: { type: Object },
|
|
758
|
+
},
|
|
759
|
+
args: {
|
|
760
|
+
metadata: '{"foo":"bar"}',
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
expect(model.failedJsonFields).toEqual([]);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe('SpiceModel - Write protection for failed JSON fields', () => {
|
|
769
|
+
let warnSpy;
|
|
770
|
+
|
|
771
|
+
beforeEach(() => {
|
|
772
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
afterEach(() => {
|
|
776
|
+
warnSpy.mockRestore();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test('update should reject and preserve stored data when a field failed to parse', async () => {
|
|
780
|
+
const model = createTestModel({
|
|
781
|
+
type: 'application',
|
|
782
|
+
props: {
|
|
783
|
+
name: { type: String },
|
|
784
|
+
metadata: { type: Object },
|
|
785
|
+
},
|
|
786
|
+
args: {
|
|
787
|
+
name: 'Updated Name',
|
|
788
|
+
metadata: '<p style="color:red">broken html</p>',
|
|
789
|
+
skip_serialize: true,
|
|
790
|
+
skip_hooks: true,
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const storedDoc = {
|
|
795
|
+
id: 'application-1',
|
|
796
|
+
name: 'Original Name',
|
|
797
|
+
metadata: { important: 'data' },
|
|
798
|
+
};
|
|
799
|
+
seedDatabase(model, storedDoc);
|
|
800
|
+
jest.spyOn(model.database, 'update');
|
|
801
|
+
|
|
802
|
+
await expect(
|
|
803
|
+
model.update({ id: 'application-1', skip_serialize: true, skip_hooks: true })
|
|
804
|
+
).rejects.toThrow(/cannot update.*metadata.*invalid JSON/);
|
|
805
|
+
|
|
806
|
+
// No write must reach the database; stored data stays intact
|
|
807
|
+
expect(model.database.update).not.toHaveBeenCalled();
|
|
808
|
+
const stored = await model.database.get('application-1');
|
|
809
|
+
expect(stored.metadata).toEqual({ important: 'data' });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('update rejection should carry status 400 and the failed field names', async () => {
|
|
813
|
+
const model = createTestModel({
|
|
814
|
+
type: 'application',
|
|
815
|
+
props: {
|
|
816
|
+
metadata: { type: Object },
|
|
817
|
+
},
|
|
818
|
+
args: {
|
|
819
|
+
metadata: 'not json at all',
|
|
820
|
+
skip_serialize: true,
|
|
821
|
+
skip_hooks: true,
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
seedDatabase(model, { id: 'application-1', metadata: { keep: true } });
|
|
825
|
+
|
|
826
|
+
let caught;
|
|
827
|
+
try {
|
|
828
|
+
await model.update({ id: 'application-1', skip_serialize: true, skip_hooks: true });
|
|
829
|
+
} catch (e) {
|
|
830
|
+
caught = e;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
expect(caught).toBeDefined();
|
|
834
|
+
expect(caught.status).toBe(400);
|
|
835
|
+
expect(caught.failed_json_fields).toEqual(['metadata']);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('create should reject when a field failed to parse', async () => {
|
|
839
|
+
const model = createTestModel({
|
|
840
|
+
type: 'application',
|
|
841
|
+
props: {
|
|
842
|
+
metadata: { type: Object },
|
|
843
|
+
},
|
|
844
|
+
args: {
|
|
845
|
+
metadata: '<div>nope</div>',
|
|
846
|
+
skip_serialize: true,
|
|
847
|
+
skip_hooks: true,
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
jest.spyOn(model.database, 'insert');
|
|
851
|
+
|
|
852
|
+
await expect(
|
|
853
|
+
model.create({ skip_serialize: true, skip_hooks: true })
|
|
854
|
+
).rejects.toThrow(/cannot create.*metadata.*invalid JSON/);
|
|
855
|
+
expect(model.database.insert).not.toHaveBeenCalled();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('update should proceed normally when all fields parsed', async () => {
|
|
859
|
+
const model = createTestModel({
|
|
860
|
+
type: 'application',
|
|
861
|
+
props: {
|
|
862
|
+
name: { type: String },
|
|
863
|
+
metadata: { type: Object },
|
|
864
|
+
},
|
|
865
|
+
args: {
|
|
866
|
+
name: 'New Name',
|
|
867
|
+
metadata: '{"valid":"json"}',
|
|
868
|
+
skip_serialize: true,
|
|
869
|
+
skip_hooks: true,
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
seedDatabase(model, { id: 'application-1', name: 'Old', metadata: {} });
|
|
873
|
+
// setMonitor needs the real cache global, which test mocks don't provide
|
|
874
|
+
jest.spyOn(model, 'setMonitor').mockImplementation(() => {});
|
|
875
|
+
|
|
876
|
+
const result = await model.update({
|
|
877
|
+
id: 'application-1',
|
|
878
|
+
skip_serialize: true,
|
|
879
|
+
skip_hooks: true,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
expect(result.name).toBe('New Name');
|
|
883
|
+
expect(result.metadata).toEqual({ valid: 'json' });
|
|
884
|
+
});
|
|
885
|
+
});
|